Implement comprehensive config system and clean up codebase

Major changes:
- Add Pydantic-based configuration system (bot/config.py, bot/config_manager.py)
- Add config.yaml with all service URLs, models, and feature flags
- Fix config.yaml path resolution in Docker (check /app/config.yaml first)
- Remove Fish Audio API integration (tested feature that didn't work)
- Remove hardcoded ERROR_WEBHOOK_URL, import from config instead
- Add missing Pydantic models (LogConfigUpdateRequest, LogFilterUpdateRequest)
- Enable Cheshire Cat memory system by default (USE_CHESHIRE_CAT=true)
- Add .env.example template with all required environment variables
- Add setup.sh script for user-friendly initialization
- Update docker-compose.yml with proper env file mounting
- Update .gitignore for config files and temporary files

Config system features:
- Static configuration from config.yaml
- Runtime overrides from config_runtime.yaml
- Environment variables for secrets (.env)
- Web UI integration via config_manager
- Graceful fallback to defaults

Secrets handling:
- Move ERROR_WEBHOOK_URL from hardcoded to .env
- Add .env.example with all placeholder values
- Document all required secrets
- Fish API key and voice ID removed from .env

Documentation:
- CONFIG_README.md - Configuration system guide
- CONFIG_SYSTEM_COMPLETE.md - Implementation summary
- FISH_API_REMOVAL_COMPLETE.md - Removal record
- SECRETS_CONFIGURED.md - Secrets setup record
- BOT_STARTUP_FIX.md - Pydantic model fixes
- MIGRATION_CHECKLIST.md - Setup checklist
- WEB_UI_INTEGRATION_COMPLETE.md - Web UI config guide
- Updated readmes/README.md with new features
This commit is contained in:
2026-02-15 19:51:00 +02:00
parent bb5067a89e
commit 8d09a8a52f
20 changed files with 2688 additions and 164 deletions

View File

@@ -170,6 +170,17 @@ class ServerConfigRequest(BaseModel):
class EvilMoodSetRequest(BaseModel):
mood: str
class LogConfigUpdateRequest(BaseModel):
component: Optional[str] = None
enabled: Optional[bool] = None
enabled_levels: Optional[List[str]] = None
class LogFilterUpdateRequest(BaseModel):
exclude_paths: Optional[List[str]] = None
exclude_status: Optional[List[int]] = None
include_slow_requests: Optional[bool] = True
slow_threshold_ms: Optional[int] = 1000
# ========== Routes ==========
@app.get("/")
def read_index():
@@ -206,6 +217,13 @@ async def set_mood_endpoint(data: MoodSetRequest):
from utils.moods import load_mood_description
globals.DM_MOOD_DESCRIPTION = load_mood_description(data.mood)
# Persist to config manager
try:
from config_manager import config_manager
config_manager.set("runtime.mood.dm_mood", data.mood, persist=True)
except Exception as e:
logger.warning(f"Failed to persist mood to config: {e}")
return {"status": "ok", "new_mood": data.mood}
@app.post("/mood/reset")
@@ -215,6 +233,13 @@ async def reset_mood_endpoint():
from utils.moods import load_mood_description
globals.DM_MOOD_DESCRIPTION = load_mood_description("neutral")
# Persist to config manager
try:
from config_manager import config_manager
config_manager.set("runtime.mood.dm_mood", "neutral", persist=True)
except Exception as e:
logger.warning(f"Failed to persist mood reset to config: {e}")
return {"status": "ok", "new_mood": "neutral"}
@app.post("/mood/calm")
@@ -224,6 +249,13 @@ def calm_miku_endpoint():
from utils.moods import load_mood_description
globals.DM_MOOD_DESCRIPTION = load_mood_description("neutral")
# Persist to config manager
try:
from config_manager import config_manager
config_manager.set("runtime.mood.dm_mood", "neutral", persist=True)
except Exception as e:
logger.warning(f"Failed to persist mood calm to config: {e}")
return {"status": "ok", "message": "Miku has been calmed down"}
# ========== Language Mode Management ==========
@@ -250,6 +282,14 @@ def toggle_language_mode():
model_used = globals.TEXT_MODEL
logger.info("Switched to English mode (using default model)")
# Persist via config manager
try:
from config_manager import config_manager
config_manager.set("discord.language_mode", new_mode, persist=True)
logger.info(f"💾 Language mode persisted to config_runtime.yaml")
except Exception as e:
logger.warning(f"Failed to persist language mode: {e}")
return {
"status": "ok",
"language_mode": new_mode,
@@ -402,6 +442,14 @@ def enable_bipolar_mode():
return {"status": "ok", "message": "Bipolar mode is already enabled", "bipolar_mode": True}
_enable()
# Persist to config manager
try:
from config_manager import config_manager
config_manager.set("runtime.bipolar_mode.enabled", True, persist=True)
except Exception as e:
logger.warning(f"Failed to persist bipolar mode enable to config: {e}")
return {"status": "ok", "message": "Bipolar mode enabled", "bipolar_mode": True}
@app.post("/bipolar-mode/disable")
@@ -414,6 +462,13 @@ def disable_bipolar_mode():
_disable()
# Persist to config manager
try:
from config_manager import config_manager
config_manager.set("runtime.bipolar_mode.enabled", False, persist=True)
except Exception as e:
logger.warning(f"Failed to persist bipolar mode disable to config: {e}")
# Optionally cleanup webhooks in background
if globals.client and globals.client.loop and globals.client.loop.is_running():
globals.client.loop.create_task(cleanup_webhooks(globals.client))
@@ -644,18 +699,15 @@ async def select_gpu(request: Request):
if gpu not in ["nvidia", "amd"]:
return {"status": "error", "message": "Invalid GPU selection. Must be 'nvidia' or 'amd'"}
gpu_state_file = os.path.join(os.path.dirname(__file__), "memory", "gpu_state.json")
try:
from datetime import datetime
state = {
"current_gpu": gpu,
"last_updated": datetime.now().isoformat()
}
with open(gpu_state_file, "w") as f:
json.dump(state, f, indent=2)
from config_manager import config_manager
success = config_manager.set_gpu(gpu)
logger.info(f"GPU Selection: Switched to {gpu.upper()} GPU")
return {"status": "ok", "message": f"Switched to {gpu.upper()} GPU", "gpu": gpu}
if success:
logger.info(f"GPU Selection: Switched to {gpu.upper()} GPU")
return {"status": "ok", "message": f"Switched to {gpu.upper()} GPU", "gpu": gpu}
else:
return {"status": "error", "message": "Failed to save GPU state"}
except Exception as e:
logger.error(f"GPU Selection Error: {e}")
return {"status": "error", "message": str(e)}
@@ -2415,18 +2467,164 @@ Be detailed but conversational. React to what you see with Miku's cheerful, play
}
)
# ========== Log Management API ==========
class LogConfigUpdateRequest(BaseModel):
component: Optional[str] = None
enabled: Optional[bool] = None
enabled_levels: Optional[List[str]] = None
class LogFilterUpdateRequest(BaseModel):
exclude_paths: Optional[List[str]] = None
exclude_status: Optional[List[int]] = None
include_slow_requests: Optional[bool] = None
slow_threshold_ms: Optional[int] = None
# ========== Configuration Management (New Unified System) ==========
@app.get("/config")
async def get_full_config():
"""
Get full configuration including static, runtime, and state.
Useful for debugging and config display in UI.
"""
try:
from config_manager import config_manager
full_config = config_manager.get_full_config()
return {
"success": True,
"config": full_config
}
except Exception as e:
logger.error(f"Failed to get config: {e}")
return {"success": False, "error": str(e)}
@app.get("/config/static")
async def get_static_config():
"""
Get static configuration from config.yaml.
These are default values that can be overridden at runtime.
"""
try:
from config_manager import config_manager
return {
"success": True,
"config": config_manager.static_config
}
except Exception as e:
logger.error(f"Failed to get static config: {e}")
return {"success": False, "error": str(e)}
@app.get("/config/runtime")
async def get_runtime_config():
"""
Get runtime configuration overrides.
These are values changed via Web UI that override config.yaml.
"""
try:
from config_manager import config_manager
return {
"success": True,
"config": config_manager.runtime_config,
"path": str(config_manager.runtime_config_path)
}
except Exception as e:
logger.error(f"Failed to get runtime config: {e}")
return {"success": False, "error": str(e)}
@app.post("/config/set")
async def set_config_value(request: Request):
"""
Set a configuration value with optional persistence.
Body: {
"key_path": "discord.language_mode", // Dot-separated path
"value": "japanese",
"persist": true // Save to config_runtime.yaml
}
"""
try:
data = await request.json()
key_path = data.get("key_path")
value = data.get("value")
persist = data.get("persist", True)
if not key_path:
return {"success": False, "error": "key_path is required"}
from config_manager import config_manager
config_manager.set(key_path, value, persist=persist)
# Update globals if needed
if key_path == "discord.language_mode":
globals.LANGUAGE_MODE = value
elif key_path == "autonomous.debug_mode":
globals.AUTONOMOUS_DEBUG = value
elif key_path == "voice.debug_mode":
globals.VOICE_DEBUG_MODE = value
elif key_path == "gpu.prefer_amd":
globals.PREFER_AMD_GPU = value
return {
"success": True,
"message": f"Set {key_path} = {value}",
"persisted": persist
}
except Exception as e:
logger.error(f"Failed to set config: {e}")
return {"success": False, "error": str(e)}
@app.post("/config/reset")
async def reset_config(request: Request):
"""
Reset configuration to defaults.
Body: {
"key_path": "discord.language_mode", // Optional: reset specific key
"persist": true // Remove from config_runtime.yaml
}
If key_path is omitted, resets all runtime config to defaults.
"""
try:
data = await request.json()
key_path = data.get("key_path")
persist = data.get("persist", True)
from config_manager import config_manager
config_manager.reset_to_defaults(key_path)
return {
"success": True,
"message": f"Reset {key_path or 'all config'} to defaults"
}
except Exception as e:
logger.error(f"Failed to reset config: {e}")
return {"success": False, "error": str(e)}
@app.post("/config/validate")
async def validate_config_endpoint():
"""
Validate current configuration.
Returns list of errors if validation fails.
"""
try:
from config_manager import config_manager
is_valid, errors = config_manager.validate_config()
return {
"success": is_valid,
"is_valid": is_valid,
"errors": errors
}
except Exception as e:
logger.error(f"Failed to validate config: {e}")
return {"success": False, "error": str(e)}
@app.get("/config/state")
async def get_config_state():
"""
Get runtime state (not persisted config).
These are transient values like current mood, evil mode, etc.
"""
try:
from config_manager import config_manager
return {
"success": True,
"state": config_manager.runtime_state
}
except Exception as e:
logger.error(f"Failed to get config state: {e}")
return {"success": False, "error": str(e)}
# ========== Logging Configuration (Existing System) ==========
@app.get("/api/log/config")
async def get_log_config():
"""Get current logging configuration."""

292
bot/config.py Normal file
View File

@@ -0,0 +1,292 @@
"""
Configuration management for Miku Discord Bot.
Uses Pydantic for type-safe configuration loading from:
- .env (secrets only)
- config.yaml (all other configuration)
"""
import os
from pathlib import Path
from typing import Any, Optional
from pydantic import BaseModel, Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# ============================================
# Pydantic Models for Configuration
# ============================================
class ServicesConfig(BaseModel):
"""External service endpoint configuration"""
url: str = "http://llama-swap:8080"
amd_url: str = "http://llama-swap-amd:8080"
class CheshireCatConfig(BaseModel):
"""Cheshire Cat AI memory system configuration"""
url: str = "http://cheshire-cat:80"
timeout_seconds: int = Field(default=120, ge=1, le=600)
enabled: bool = True
class FaceDetectorConfig(BaseModel):
"""Face detection service configuration"""
startup_timeout_seconds: int = Field(default=60, ge=10, le=300)
class ModelsConfig(BaseModel):
"""AI model configuration"""
text: str = "llama3.1"
vision: str = "vision"
evil: str = "darkidol"
japanese: str = "swallow"
class DiscordConfig(BaseModel):
"""Discord bot configuration"""
language_mode: str = Field(default="english", pattern="^(english|japanese)$")
api_port: int = Field(default=3939, ge=1024, le=65535)
class AutonomousConfig(BaseModel):
"""Autonomous system configuration"""
debug_mode: bool = False
class VoiceConfig(BaseModel):
"""Voice chat configuration"""
debug_mode: bool = False
class MemoryConfig(BaseModel):
"""Memory and logging configuration"""
log_dir: str = "/app/memory/logs"
conversation_history_length: int = Field(default=5, ge=1, le=50)
class ServerConfig(BaseModel):
"""Server settings"""
host: str = "0.0.0.0"
log_level: str = Field(default="critical", pattern="^(debug|info|warning|error|critical)$")
class GPUConfig(BaseModel):
"""GPU configuration"""
prefer_amd: bool = False
amd_models_enabled: bool = True
class AppConfig(BaseModel):
"""Main application configuration"""
services: ServicesConfig = Field(default_factory=ServicesConfig)
cheshire_cat: CheshireCatConfig = Field(default_factory=CheshireCatConfig)
face_detector: FaceDetectorConfig = Field(default_factory=FaceDetectorConfig)
models: ModelsConfig = Field(default_factory=ModelsConfig)
discord: DiscordConfig = Field(default_factory=DiscordConfig)
autonomous: AutonomousConfig = Field(default_factory=AutonomousConfig)
voice: VoiceConfig = Field(default_factory=VoiceConfig)
memory: MemoryConfig = Field(default_factory=MemoryConfig)
server: ServerConfig = Field(default_factory=ServerConfig)
gpu: GPUConfig = Field(default_factory=GPUConfig)
class Secrets(BaseSettings):
"""
Secrets loaded from environment variables (.env file)
These are sensitive values that should never be committed to git
"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_prefix="", # No prefix for env vars
extra="ignore" # Ignore extra env vars
)
# Discord
discord_bot_token: str = Field(..., description="Discord bot token")
# API Keys
cheshire_cat_api_key: str = Field(default="", description="Cheshire Cat API key (empty if no auth)")
# Error Reporting
error_webhook_url: Optional[str] = Field(default=None, description="Discord webhook for error notifications")
# Owner
owner_user_id: int = Field(default=209381657369772032, description="Bot owner Discord user ID")
# ============================================
# Configuration Loader
# ============================================
def load_config(config_path: str = None) -> AppConfig:
"""
Load configuration from YAML file.
Args:
config_path: Path to config.yaml (defaults to ../config.yaml from bot directory)
Returns:
AppConfig instance
"""
import yaml
if config_path is None:
# Default: try Docker path first, then fall back to relative path
# In Docker, config.yaml is mounted at /app/config.yaml
docker_config = Path("/app/config.yaml")
if docker_config.exists():
config_path = docker_config
else:
# Not in Docker, go up one level from bot/ directory
config_path = Path(__file__).parent.parent / "config.yaml"
config_file = Path(config_path)
if not config_file.exists():
# Fall back to default config if file doesn't exist
print(f"⚠️ Config file not found: {config_file}")
print("Using default configuration")
return AppConfig()
with open(config_file, "r") as f:
config_data = yaml.safe_load(f) or {}
return AppConfig(**config_data)
def load_secrets() -> Secrets:
"""
Load secrets from environment variables (.env file).
Returns:
Secrets instance
"""
return Secrets()
# ============================================
# Unified Configuration Instance
# ============================================
# Load configuration at module import time
CONFIG = load_config()
SECRETS = load_secrets()
# ============================================
# Config Manager Integration
# ============================================
# Import config_manager for unified configuration with Web UI support
try:
from config_manager import config_manager
HAS_CONFIG_MANAGER = True
except ImportError:
# Fallback if config_manager is not yet imported
HAS_CONFIG_MANAGER = False
config_manager = None
# ============================================
# Backward Compatibility Globals
# ============================================
# These provide a transition path from globals.py to config.py
# These now support runtime overrides via config_manager
# TODO: Gradually migrate all code to use CONFIG/SECRETS directly
# Legacy globals (for backward compatibility)
# These now support runtime overrides via config_manager
def _get_config_value(static_value: Any, key_path: str, default: Any = None) -> Any:
"""Get configuration value with config_manager fallback."""
if HAS_CONFIG_MANAGER and config_manager:
runtime_value = config_manager.get(key_path)
return runtime_value if runtime_value is not None else static_value
return static_value
def _get_config_state(static_value: Any, state_key: str) -> Any:
"""Get configuration state from config_manager."""
if HAS_CONFIG_MANAGER and config_manager:
state_value = config_manager.get_state(state_key)
return state_value if state_value is not None else static_value
return static_value
# Service URLs
DISCORD_BOT_TOKEN = SECRETS.discord_bot_token
CHESHIRE_CAT_API_KEY = SECRETS.cheshire_cat_api_key
CHESHIRE_CAT_URL = _get_config_value(CONFIG.cheshire_cat.url, "services.cheshire_cat.url", "http://cheshire-cat:80")
USE_CHESHIRE_CAT = _get_config_value(CONFIG.cheshire_cat.enabled, "services.cheshire_cat.enabled", True)
CHESHIRE_CAT_TIMEOUT = _get_config_value(CONFIG.cheshire_cat.timeout_seconds, "services.cheshire_cat.timeout_seconds", 120)
LLAMA_URL = _get_config_value(CONFIG.services.url, "services.llama.url", "http://llama-swap:8080")
LLAMA_AMD_URL = _get_config_value(CONFIG.services.amd_url, "services.llama.amd_url", "http://llama-swap-amd:8080")
TEXT_MODEL = _get_config_value(CONFIG.models.text, "models.text", "llama3.1")
VISION_MODEL = _get_config_value(CONFIG.models.vision, "models.vision", "vision")
EVIL_TEXT_MODEL = _get_config_value(CONFIG.models.evil, "models.evil", "darkidol")
JAPANESE_TEXT_MODEL = _get_config_value(CONFIG.models.japanese, "models.japanese", "swallow")
OWNER_USER_ID = SECRETS.owner_user_id
AUTONOMOUS_DEBUG = _get_config_value(CONFIG.autonomous.debug_mode, "autonomous.debug_mode", False)
VOICE_DEBUG_MODE = _get_config_value(CONFIG.voice.debug_mode, "voice.debug_mode", False)
LANGUAGE_MODE = _get_config_value(CONFIG.discord.language_mode, "discord.language_mode", "english")
LOG_DIR = _get_config_value(CONFIG.memory.log_dir, "memory.log_dir", "/app/memory/logs")
PREFER_AMD_GPU = _get_config_value(CONFIG.gpu.prefer_amd, "gpu.prefer_amd", False)
AMD_MODELS_ENABLED = _get_config_value(CONFIG.gpu.amd_models_enabled, "gpu.amd_models_enabled", True)
ERROR_WEBHOOK_URL = SECRETS.error_webhook_url
# ============================================
# Validation & Health Check
# ============================================
def validate_config() -> tuple[bool, list[str]]:
"""
Validate that all required configuration is present.
Returns:
Tuple of (is_valid, list_of_errors)
"""
errors = []
# Check secrets
if not SECRETS.discord_bot_token or SECRETS.discord_bot_token == "your_discord_bot_token_here":
errors.append("DISCORD_BOT_TOKEN not set or using placeholder value")
# Validate Cheshire Cat config
if CONFIG.cheshire_cat.enabled and not CONFIG.cheshire_cat.url:
errors.append("Cheshire Cat enabled but URL not configured")
return len(errors) == 0, errors
def print_config_summary():
"""Print a summary of current configuration (without secrets)"""
print("\n" + "="*60)
print("🎵 Miku Bot Configuration Summary")
print("="*60)
print(f"\n📊 Configuration loaded from: config.yaml")
print(f"🔐 Secrets loaded from: .env")
print(f"\n🤖 Models:")
print(f" - Text: {CONFIG.models.text}")
print(f" - Vision: {CONFIG.models.vision}")
print(f" - Evil: {CONFIG.models.evil}")
print(f" - Japanese: {CONFIG.models.japanese}")
print(f"\n🔗 Services:")
print(f" - Llama: {CONFIG.services.url}")
print(f" - Llama AMD: {CONFIG.services.amd_url}")
print(f" - Cheshire Cat: {CONFIG.cheshire_cat.url} (enabled: {CONFIG.cheshire_cat.enabled})")
print(f"\n⚙️ Settings:")
print(f" - Language Mode: {CONFIG.discord.language_mode}")
print(f" - Autonomous Debug: {CONFIG.autonomous.debug_mode}")
print(f" - Voice Debug: {CONFIG.voice.debug_mode}")
print(f" - Prefer AMD GPU: {CONFIG.gpu.prefer_amd}")
print(f"\n📝 Secrets: {'✅ Loaded' if SECRETS.discord_bot_token else '❌ Missing'}")
print("\n" + "="*60 + "\n")
# Auto-validate on import
is_valid, validation_errors = validate_config()
if not is_valid:
print("❌ Configuration Validation Failed:")
for error in validation_errors:
print(f" - {error}")
print("\nPlease check your .env file and try again.")
# Note: We don't exit here because the bot might be started in a different context
# The calling code should check validate_config() if needed

353
bot/config_manager.py Normal file
View File

@@ -0,0 +1,353 @@
"""
Unified Configuration Manager for Miku Discord Bot.
Handles:
- Static configuration from config.yaml
- Runtime overrides from Web UI
- Per-server configuration
- Priority system: Runtime > Static > Defaults
- Persistence of runtime changes
"""
import json
import os
from pathlib import Path
from typing import Any, Dict, Optional, Union
from datetime import datetime
import yaml
from config import CONFIG, SECRETS
from utils.logger import get_logger
logger = get_logger('config_manager')
class ConfigManager:
"""
Unified configuration manager with runtime overrides.
Priority:
1. Runtime overrides (from Web UI, API, CLI)
2. Static config (from config.yaml)
3. Hardcoded defaults (fallback)
"""
def __init__(self, config_path: Optional[str] = None):
"""Initialize configuration manager."""
self.config_path = Path(config_path) if config_path else Path(__file__).parent.parent / "config.yaml"
self.runtime_config_path = Path(__file__).parent.parent / "config_runtime.yaml"
# Memory directory for server configs and state
self.memory_dir = Path(__file__).parent / "memory"
self.memory_dir.mkdir(exist_ok=True)
# Load configurations
self.static_config: Dict = self._load_static_config()
self.runtime_config: Dict = self._load_runtime_config()
# Runtime state (not persisted)
self.runtime_state: Dict = {
"dm_mood": "neutral",
"evil_mode": False,
"bipolar_mode": False,
"language_mode": "english",
"current_gpu": "nvidia",
}
# Load persisted state
self._load_runtime_state()
logger.info("✅ ConfigManager initialized")
def _load_static_config(self) -> Dict:
"""Load static configuration from config.yaml."""
if not self.config_path.exists():
logger.warning(f"⚠️ config.yaml not found: {self.config_path}")
return {}
try:
with open(self.config_path, "r") as f:
config = yaml.safe_load(f) or {}
logger.debug(f"✅ Loaded static config from {self.config_path}")
return config
except Exception as e:
logger.error(f"❌ Failed to load config.yaml: {e}")
return {}
def _load_runtime_config(self) -> Dict:
"""Load runtime overrides from config_runtime.yaml."""
if not self.runtime_config_path.exists():
logger.debug(" config_runtime.yaml not found (no overrides)")
return {}
try:
with open(self.runtime_config_path, "r") as f:
config = yaml.safe_load(f) or {}
logger.debug(f"✅ Loaded runtime config from {self.runtime_config_path}")
return config
except Exception as e:
logger.error(f"❌ Failed to load config_runtime.yaml: {e}")
return {}
def _load_runtime_state(self):
"""Load runtime state from memory files."""
# Load GPU state
gpu_state_file = self.memory_dir / "gpu_state.json"
try:
if gpu_state_file.exists():
with open(gpu_state_file, "r") as f:
gpu_state = json.load(f)
self.runtime_state["current_gpu"] = gpu_state.get("current_gpu", "nvidia")
logger.debug(f"✅ Loaded GPU state: {self.runtime_state['current_gpu']}")
except Exception as e:
logger.error(f"❌ Failed to load GPU state: {e}")
def get(self, key_path: str, default: Any = None) -> Any:
"""
Get configuration value with priority system.
Args:
key_path: Dot-separated path (e.g., "discord.language_mode")
default: Fallback value if not found
Returns:
Configuration value (runtime > static > default)
"""
# Try runtime config first
value = self._get_nested_value(self.runtime_config, key_path)
if value is not None:
logger.debug(f"⚡ Runtime config: {key_path} = {value}")
return value
# Try static config second
value = self._get_nested_value(self.static_config, key_path)
if value is not None:
logger.debug(f"📄 Static config: {key_path} = {value}")
return value
# Return default
logger.debug(f"⚙️ Default value: {key_path} = {default}")
return default
def _get_nested_value(self, config: Dict, key_path: str) -> Any:
"""Get nested value from config using dot notation."""
keys = key_path.split(".")
value = config
for key in keys:
if isinstance(value, dict) and key in value:
value = value[key]
else:
return None
return value
def set(self, key_path: str, value: Any, persist: bool = True):
"""
Set configuration value.
Args:
key_path: Dot-separated path (e.g., "discord.language_mode")
value: New value to set
persist: Whether to save to config_runtime.yaml
"""
# Set in runtime config
keys = key_path.split(".")
config = self.runtime_config
for key in keys[:-1]:
if key not in config:
config[key] = {}
config = config[key]
config[keys[-1]] = value
logger.info(f"✅ Config set: {key_path} = {value}")
# Persist if requested
if persist:
self.save_runtime_config()
def save_runtime_config(self):
"""Save runtime configuration to config_runtime.yaml."""
try:
with open(self.runtime_config_path, "w") as f:
yaml.dump(self.runtime_config, f, default_flow_style=False)
logger.info(f"💾 Saved runtime config to {self.runtime_config_path}")
except Exception as e:
logger.error(f"❌ Failed to save runtime config: {e}")
def reset_to_defaults(self, key_path: Optional[str] = None):
"""
Reset configuration to defaults.
Args:
key_path: Specific key to reset, or None to reset all runtime config
"""
if key_path:
# Remove specific key from runtime config
self._remove_nested_key(self.runtime_config, key_path)
logger.info(f"🔄 Reset {key_path} to default")
else:
# Clear all runtime config
self.runtime_config = {}
logger.info("🔄 Reset all config to defaults")
self.save_runtime_config()
def _remove_nested_key(self, config: Dict, key_path: str):
"""Remove nested key from config."""
keys = key_path.split(".")
obj = config
for key in keys[:-1]:
if isinstance(obj, dict) and key in obj:
obj = obj[key]
else:
return
if isinstance(obj, dict) and keys[-1] in obj:
del obj[keys[-1]]
# ========== Runtime State Management ==========
def get_state(self, key: str, default: Any = None) -> Any:
"""Get runtime state value (not persisted to config)."""
return self.runtime_state.get(key, default)
def set_state(self, key: str, value: Any):
"""Set runtime state value."""
self.runtime_state[key] = value
logger.debug(f"📊 State: {key} = {value}")
# ========== Server Configuration ==========
def get_server_config(self, guild_id: int) -> Dict:
"""Get configuration for a specific server."""
server_config_file = self.memory_dir / "servers_config.json"
try:
if server_config_file.exists():
with open(server_config_file, "r") as f:
all_servers = json.load(f)
return all_servers.get(str(guild_id), {})
except Exception as e:
logger.error(f"❌ Failed to load server config: {e}")
return {}
def set_server_config(self, guild_id: int, config: Dict):
"""Set configuration for a specific server."""
server_config_file = self.memory_dir / "servers_config.json"
try:
# Load existing config
all_servers = {}
if server_config_file.exists():
with open(server_config_file, "r") as f:
all_servers = json.load(f)
# Update server config
all_servers[str(guild_id)] = {
**all_servers.get(str(guild_id), {}),
**config,
"last_updated": datetime.now().isoformat()
}
# Save
with open(server_config_file, "w") as f:
json.dump(all_servers, f, indent=2)
logger.info(f"💾 Saved server config for {guild_id}")
except Exception as e:
logger.error(f"❌ Failed to save server config: {e}")
# ========== GPU State ==========
def get_gpu(self) -> str:
"""Get current GPU selection."""
return self.get_state("current_gpu", "nvidia")
def set_gpu(self, gpu: str):
"""Set current GPU selection and persist."""
gpu = gpu.lower()
if gpu not in ["nvidia", "amd"]:
logger.warning(f"⚠️ Invalid GPU: {gpu}")
return False
# Update state
self.set_state("current_gpu", gpu)
# Persist to file
gpu_state_file = self.memory_dir / "gpu_state.json"
try:
state = {
"current_gpu": gpu,
"last_updated": datetime.now().isoformat()
}
with open(gpu_state_file, "w") as f:
json.dump(state, f, indent=2)
logger.info(f"💾 Saved GPU state: {gpu}")
return True
except Exception as e:
logger.error(f"❌ Failed to save GPU state: {e}")
return False
# ========== Configuration Export ==========
def get_full_config(self) -> Dict:
"""
Get full configuration (merged static + runtime).
Useful for API responses and debugging.
"""
return {
"static": self.static_config,
"runtime": self.runtime_config,
"state": self.runtime_state,
"merged": self._merge_configs(self.static_config, self.runtime_config)
}
def _merge_configs(self, base: Dict, override: Dict) -> Dict:
"""Deep merge two dictionaries."""
result = base.copy()
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = self._merge_configs(result[key], value)
else:
result[key] = value
return result
# ========== Validation ==========
def validate_config(self) -> tuple[bool, list[str]]:
"""
Validate current configuration.
Returns:
Tuple of (is_valid, list_of_errors)
"""
errors = []
# Check required secrets
if not SECRETS.discord_bot_token or SECRETS.discord_bot_token.startswith("your_"):
errors.append("DISCORD_BOT_TOKEN not set or using placeholder")
# Validate language mode
language = self.get("discord.language_mode", "english")
if language not in ["english", "japanese"]:
errors.append(f"Invalid language_mode: {language}")
# Validate GPU
gpu = self.get_gpu()
if gpu not in ["nvidia", "amd"]:
errors.append(f"Invalid GPU selection: {gpu}")
return len(errors) == 0, errors
# ========== Global Instance ==========
# Create global config manager instance
config_manager = ConfigManager()

View File

@@ -31,17 +31,13 @@ OWNER_USER_ID = int(os.getenv("OWNER_USER_ID", "209381657369772032")) # Bot own
# Cheshire Cat AI integration (Phase 3)
CHESHIRE_CAT_URL = os.getenv("CHESHIRE_CAT_URL", "http://cheshire-cat:80")
USE_CHESHIRE_CAT = os.getenv("USE_CHESHIRE_CAT", "false").lower() == "true"
USE_CHESHIRE_CAT = os.getenv("USE_CHESHIRE_CAT", "true").lower() == "true" # Default enabled for memory system
CHESHIRE_CAT_API_KEY = os.getenv("CHESHIRE_CAT_API_KEY", "") # Empty = no auth
CHESHIRE_CAT_TIMEOUT = int(os.getenv("CHESHIRE_CAT_TIMEOUT", "120")) # Seconds
# Language mode for Miku (english or japanese)
LANGUAGE_MODE = "english" # Can be "english" or "japanese"
# Fish.audio TTS settings
FISH_API_KEY = os.getenv("FISH_API_KEY", "478d263d8c094e0c8993aae3e9cf9159")
MIKU_VOICE_ID = os.getenv("MIKU_VOICE_ID", "b28b79555e8c4904ac4d048c36e716b7")
# Set up Discord client
intents = discord.Intents.default()
intents.message_content = True

View File

@@ -23,3 +23,6 @@ torch
PyNaCl>=1.5.0
websockets>=12.0
discord-ext-voice-recv
pydantic>=2.0.0
pydantic-settings>=2.0.0
pyyaml>=6.0

View File

@@ -1,122 +0,0 @@
#!/usr/bin/env python3
"""
Test script for Fish.audio TTS API
Usage: python test_fish_tts.py "Your text here"
"""
import sys
import os
import requests
def test_fish_tts(text: str, output_file: str = "test_output.mp3"):
"""
Test Fish.audio TTS API with given text
Args:
text: Text to convert to speech
output_file: Output audio file path
"""
# Get credentials from environment or globals
try:
import globals
api_key = globals.FISH_API_KEY
voice_id = globals.MIKU_VOICE_ID
except:
api_key = os.getenv("FISH_API_KEY")
voice_id = os.getenv("MIKU_VOICE_ID")
if not api_key or not voice_id:
print("❌ Error: FISH_API_KEY or MIKU_VOICE_ID not set!")
print("Please set them in your environment or globals.py")
return False
print(f"🎤 Testing Fish.audio TTS...")
print(f"📝 Text: {text}")
print(f"🎵 Voice ID: {voice_id[:8]}...")
print(f"<EFBFBD> API Key: {api_key[:8]}...{api_key[-4:]} (length: {len(api_key)})")
print(f"<EFBFBD>💾 Output: {output_file}")
print()
# API endpoint
url = "https://api.fish.audio/v1/tts"
# Headers
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"model": "s1" # Recommended model
}
# Request payload
payload = {
"text": text,
"reference_id": voice_id,
"format": "mp3",
"latency": "balanced",
"temperature": 0.9,
"normalize": True
}
try:
print("⏳ Sending request to Fish.audio API...")
response = requests.post(url, json=payload, headers=headers, timeout=30)
if response.status_code == 200:
# Save audio file
with open(output_file, "wb") as f:
f.write(response.content)
file_size = len(response.content)
print(f"✅ Success! Audio generated ({file_size:,} bytes)")
print(f"🎵 Saved to: {output_file}")
print()
print(f"▶️ Play with: mpg123 {output_file}")
print(f" or just open the file in your media player")
return True
else:
print(f"❌ Error {response.status_code}: {response.text}")
if response.status_code == 402:
print()
print("💡 Troubleshooting tips for 402 error:")
print(" 1. Go to https://fish.audio/app/api-keys/")
print(" 2. Make sure you're using the 'Secret Key' (not just the Key ID)")
print(" 3. Try deleting and creating a new API key")
print(" 4. Check your balance at https://fish.audio/app/billing/")
print(" 5. Make sure you have sufficient credits for this request")
elif response.status_code == 401:
print()
print("💡 Authentication failed:")
print(" - Double-check your API key is correct")
print(" - Make sure there are no extra spaces or quotes")
print(f" - Your key length is {len(api_key)} characters")
elif response.status_code == 422:
print()
print("💡 Invalid parameters:")
print(" - Check if the voice model ID is correct")
print(" - Verify the model exists at https://fish.audio/")
return False
except requests.exceptions.Timeout:
print("❌ Request timed out. Please try again.")
return False
except Exception as e:
print(f"❌ Error: {e}")
return False
def main():
if len(sys.argv) < 2:
print("Usage: python test_fish_tts.py \"Your text here\"")
print()
print("Example:")
print(' python test_fish_tts.py "Hello! I am Hatsune Miku!"')
sys.exit(1)
text = " ".join(sys.argv[1:])
success = test_fish_tts(text)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()

View File

@@ -8,8 +8,8 @@ from utils.logger import get_logger
logger = get_logger('error_handler')
# Webhook URL for error notifications
ERROR_WEBHOOK_URL = "https://discord.com/api/webhooks/1462216811293708522/4kdGenpxZFsP0z3VBgebYENODKmcRrmEzoIwCN81jCirnAxuU2YvxGgwGCNBb6TInA9Z"
# Import from config system
from config import ERROR_WEBHOOK_URL
# User-friendly error message that Miku will say
MIKU_ERROR_MESSAGE = "Someone tell Koko-nii there is a problem with my AI."