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:
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
|
||||
Reference in New Issue
Block a user