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."""