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:
240
bot/api.py
240
bot/api.py
@@ -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
292
bot/config.py
Normal 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
353
bot/config_manager.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user