349 lines
13 KiB
Python
349 lines
13 KiB
Python
"""
|
|
配置管理
|
|
|
|
- config.toml: 运行时配置
|
|
- config.defaults.toml: 默认配置基线
|
|
"""
|
|
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
from typing import Any, Dict
|
|
import tomllib
|
|
|
|
from app.core.logger import logger
|
|
|
|
DEFAULT_CONFIG_FILE = Path(__file__).parent.parent.parent / "config.defaults.toml"
|
|
|
|
|
|
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""深度合并字典: override 覆盖 base."""
|
|
if not isinstance(base, dict):
|
|
return deepcopy(override) if isinstance(override, dict) else deepcopy(base)
|
|
|
|
result = deepcopy(base)
|
|
if not isinstance(override, dict):
|
|
return result
|
|
|
|
for key, val in override.items():
|
|
if isinstance(val, dict) and isinstance(result.get(key), dict):
|
|
result[key] = _deep_merge(result[key], val)
|
|
else:
|
|
result[key] = val
|
|
return result
|
|
|
|
|
|
def _migrate_deprecated_config(
|
|
config: Dict[str, Any], valid_sections: set
|
|
) -> tuple[Dict[str, Any], set]:
|
|
"""
|
|
迁移废弃的配置节到新配置结构
|
|
|
|
Returns:
|
|
(迁移后的配置, 废弃的配置节集合)
|
|
"""
|
|
# 配置映射规则:旧配置 -> 新配置
|
|
MIGRATION_MAP = {
|
|
# grok.* -> 对应的新配置节
|
|
"grok.temporary": "app.temporary",
|
|
"grok.disable_memory": "app.disable_memory",
|
|
"grok.stream": "app.stream",
|
|
"grok.thinking": "app.thinking",
|
|
"grok.dynamic_statsig": "app.dynamic_statsig",
|
|
"grok.filter_tags": "app.filter_tags",
|
|
"chat.capture_enabled": "app.chat_capture_enabled",
|
|
"chat.capture_file": "app.chat_capture_file",
|
|
"grok.timeout": "voice.timeout",
|
|
"grok.base_proxy_url": "proxy.base_proxy_url",
|
|
"grok.asset_proxy_url": "proxy.asset_proxy_url",
|
|
"network.base_proxy_url": "proxy.base_proxy_url",
|
|
"network.asset_proxy_url": "proxy.asset_proxy_url",
|
|
"grok.cf_clearance": "proxy.cf_clearance",
|
|
"grok.browser": "proxy.browser",
|
|
"grok.user_agent": "proxy.user_agent",
|
|
"security.cf_clearance": "proxy.cf_clearance",
|
|
"security.browser": "proxy.browser",
|
|
"security.user_agent": "proxy.user_agent",
|
|
"grok.max_retry": "retry.max_retry",
|
|
"grok.retry_status_codes": "retry.retry_status_codes",
|
|
"grok.retry_backoff_base": "retry.retry_backoff_base",
|
|
"grok.retry_backoff_factor": "retry.retry_backoff_factor",
|
|
"grok.retry_backoff_max": "retry.retry_backoff_max",
|
|
"grok.retry_budget": "retry.retry_budget",
|
|
"grok.video_idle_timeout": "video.stream_timeout",
|
|
"grok.image_ws_nsfw": "image.nsfw",
|
|
"grok.image_ws_blocked_seconds": "image.final_timeout",
|
|
"grok.image_ws_final_min_bytes": "image.final_min_bytes",
|
|
"grok.image_ws_medium_min_bytes": "image.medium_min_bytes",
|
|
# legacy sections
|
|
"network.base_proxy_url": "proxy.base_proxy_url",
|
|
"network.asset_proxy_url": "proxy.asset_proxy_url",
|
|
"network.timeout": [
|
|
"chat.timeout",
|
|
"image.timeout",
|
|
"video.timeout",
|
|
"voice.timeout",
|
|
],
|
|
"security.cf_clearance": "proxy.cf_clearance",
|
|
"security.browser": "proxy.browser",
|
|
"security.user_agent": "proxy.user_agent",
|
|
"timeout.stream_idle_timeout": [
|
|
"chat.stream_timeout",
|
|
"image.stream_timeout",
|
|
"video.stream_timeout",
|
|
],
|
|
"timeout.video_idle_timeout": "video.stream_timeout",
|
|
"image.image_ws_nsfw": "image.nsfw",
|
|
"image.image_ws_blocked_seconds": "image.final_timeout",
|
|
"image.image_ws_final_min_bytes": "image.final_min_bytes",
|
|
"image.image_ws_medium_min_bytes": "image.medium_min_bytes",
|
|
"performance.assets_max_concurrent": [
|
|
"asset.upload_concurrent",
|
|
"asset.download_concurrent",
|
|
"asset.list_concurrent",
|
|
"asset.delete_concurrent",
|
|
],
|
|
"performance.assets_delete_batch_size": "asset.delete_batch_size",
|
|
"performance.assets_batch_size": "asset.list_batch_size",
|
|
"performance.media_max_concurrent": ["chat.concurrent", "video.concurrent"],
|
|
"performance.usage_max_concurrent": "usage.concurrent",
|
|
"performance.usage_batch_size": "usage.batch_size",
|
|
"performance.nsfw_max_concurrent": "nsfw.concurrent",
|
|
"performance.nsfw_batch_size": "nsfw.batch_size",
|
|
}
|
|
|
|
deprecated_sections = set(config.keys()) - valid_sections
|
|
if not deprecated_sections:
|
|
return config, set()
|
|
|
|
result = {k: deepcopy(v) for k, v in config.items() if k in valid_sections}
|
|
migrated_count = 0
|
|
|
|
# 处理废弃配置节或旧配置键
|
|
for old_section, old_values in config.items():
|
|
if not isinstance(old_values, dict):
|
|
continue
|
|
for old_key, old_value in old_values.items():
|
|
old_path = f"{old_section}.{old_key}"
|
|
new_paths = MIGRATION_MAP.get(old_path)
|
|
if not new_paths:
|
|
continue
|
|
if isinstance(new_paths, str):
|
|
new_paths = [new_paths]
|
|
for new_path in new_paths:
|
|
try:
|
|
new_section, new_key = new_path.split(".", 1)
|
|
if new_section not in result:
|
|
result[new_section] = {}
|
|
if new_key not in result[new_section]:
|
|
result[new_section][new_key] = old_value
|
|
migrated_count += 1
|
|
logger.debug(
|
|
f"Migrated config: {old_path} -> {new_path} = {old_value}"
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Skip config migration for {old_path}: {e}"
|
|
)
|
|
continue
|
|
if isinstance(result.get(old_section), dict):
|
|
result[old_section].pop(old_key, None)
|
|
|
|
# 兼容旧 chat.* 配置键迁移到 app.*
|
|
legacy_chat_map = {
|
|
"temporary": "temporary",
|
|
"disable_memory": "disable_memory",
|
|
"stream": "stream",
|
|
"thinking": "thinking",
|
|
"dynamic_statsig": "dynamic_statsig",
|
|
"filter_tags": "filter_tags",
|
|
"capture_enabled": "chat_capture_enabled",
|
|
"capture_file": "chat_capture_file",
|
|
}
|
|
chat_section = config.get("chat")
|
|
if isinstance(chat_section, dict):
|
|
app_section = result.setdefault("app", {})
|
|
for old_key, new_key in legacy_chat_map.items():
|
|
if old_key in chat_section:
|
|
if new_key not in app_section:
|
|
app_section[new_key] = chat_section[old_key]
|
|
if isinstance(result.get("chat"), dict):
|
|
result["chat"].pop(old_key, None)
|
|
migrated_count += 1
|
|
logger.debug(
|
|
f"Migrated config: chat.{old_key} -> app.{new_key} = {chat_section[old_key]}"
|
|
)
|
|
|
|
if migrated_count > 0:
|
|
logger.info(
|
|
f"Migrated {migrated_count} config items from deprecated/legacy sections"
|
|
)
|
|
|
|
return result, deprecated_sections
|
|
|
|
|
|
def _load_defaults() -> Dict[str, Any]:
|
|
"""加载默认配置文件"""
|
|
if not DEFAULT_CONFIG_FILE.exists():
|
|
return {}
|
|
try:
|
|
with DEFAULT_CONFIG_FILE.open("rb") as f:
|
|
return tomllib.load(f)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load defaults from {DEFAULT_CONFIG_FILE}: {e}")
|
|
return {}
|
|
|
|
|
|
class Config:
|
|
"""配置管理器"""
|
|
|
|
_instance = None
|
|
_config = {}
|
|
|
|
def __init__(self):
|
|
self._config = {}
|
|
self._defaults = {}
|
|
self._code_defaults = {}
|
|
self._defaults_loaded = False
|
|
|
|
def register_defaults(self, defaults: Dict[str, Any]):
|
|
"""注册代码中定义的默认值"""
|
|
self._code_defaults = _deep_merge(self._code_defaults, defaults)
|
|
|
|
def _ensure_defaults(self):
|
|
if self._defaults_loaded:
|
|
return
|
|
file_defaults = _load_defaults()
|
|
# 合并文件默认值和代码默认值(代码默认值优先级更低)
|
|
self._defaults = _deep_merge(self._code_defaults, file_defaults)
|
|
self._defaults_loaded = True
|
|
|
|
async def load(self):
|
|
"""显式加载配置"""
|
|
try:
|
|
from app.core.storage import get_storage, LocalStorage
|
|
|
|
self._ensure_defaults()
|
|
|
|
storage = get_storage()
|
|
config_data = await storage.load_config()
|
|
from_remote = True
|
|
|
|
# 从本地 data/config.toml 初始化后端
|
|
if config_data is None:
|
|
local_storage = LocalStorage()
|
|
from_remote = False
|
|
try:
|
|
# 尝试读取本地配置
|
|
config_data = await local_storage.load_config()
|
|
except Exception as e:
|
|
logger.info(f"Failed to auto-init config from local: {e}")
|
|
config_data = {}
|
|
|
|
config_data = config_data or {}
|
|
|
|
# 检查是否有废弃的配置节
|
|
valid_sections = set(self._defaults.keys())
|
|
config_data, deprecated_sections = _migrate_deprecated_config(
|
|
config_data, valid_sections
|
|
)
|
|
if deprecated_sections:
|
|
logger.info(
|
|
f"Cleaned deprecated config sections: {deprecated_sections}"
|
|
)
|
|
|
|
merged = _deep_merge(self._defaults, config_data)
|
|
|
|
# 自动回填缺失配置到存储
|
|
# 或迁移了配置后需要更新
|
|
should_persist = (
|
|
(not from_remote) or (merged != config_data) or deprecated_sections
|
|
)
|
|
if should_persist:
|
|
async with storage.acquire_lock("config_save", timeout=10):
|
|
await storage.save_config(merged)
|
|
if not from_remote:
|
|
logger.info(
|
|
f"Initialized remote storage ({storage.__class__.__name__}) with config baseline."
|
|
)
|
|
if deprecated_sections:
|
|
logger.info("Configuration automatically migrated and cleaned.")
|
|
|
|
self._config = merged
|
|
except Exception as e:
|
|
logger.error(f"Error loading config: {e}")
|
|
self._config = {}
|
|
|
|
def get(self, key: str, default: Any = None) -> Any:
|
|
"""
|
|
获取配置值
|
|
|
|
Args:
|
|
key: 配置键,格式 "section.key"
|
|
default: 默认值
|
|
"""
|
|
if "." in key:
|
|
try:
|
|
section, attr = key.split(".", 1)
|
|
return self._config.get(section, {}).get(attr, default)
|
|
except (ValueError, AttributeError):
|
|
return default
|
|
|
|
return self._config.get(key, default)
|
|
|
|
async def update(self, new_config: dict):
|
|
"""更新配置"""
|
|
from app.core.storage import get_storage
|
|
|
|
storage = get_storage()
|
|
async with storage.acquire_lock("config_save", timeout=10):
|
|
self._ensure_defaults()
|
|
previous = deepcopy(self._config or {})
|
|
base = _deep_merge(self._defaults, self._config or {})
|
|
merged = _deep_merge(base, new_config or {})
|
|
await storage.save_config(merged)
|
|
self._config = merged
|
|
self._apply_runtime_updates(previous, self._config)
|
|
|
|
def _apply_runtime_updates(self, previous: Dict[str, Any], current: Dict[str, Any]):
|
|
"""配置变更后立即应用可热更新的运行时设置。"""
|
|
prev_app = previous.get("app") if isinstance(previous, dict) else {}
|
|
curr_app = current.get("app") if isinstance(current, dict) else {}
|
|
prev_app = prev_app if isinstance(prev_app, dict) else {}
|
|
curr_app = curr_app if isinstance(curr_app, dict) else {}
|
|
|
|
log_related_keys = {
|
|
"app_log_enabled",
|
|
"chat_capture_enabled",
|
|
"chat_capture_file",
|
|
}
|
|
if any(prev_app.get(key) != curr_app.get(key) for key in log_related_keys):
|
|
try:
|
|
from app.core.logger import setup_logging
|
|
import os
|
|
|
|
setup_logging(
|
|
level=os.getenv("LOG_LEVEL", "INFO"),
|
|
json_console=False,
|
|
file_logging=bool(curr_app.get("app_log_enabled", True)),
|
|
)
|
|
logger.info("Applied runtime log configuration update.")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to hot-apply log configuration: {e}")
|
|
|
|
|
|
# 全局配置实例
|
|
config = Config()
|
|
|
|
|
|
def get_config(key: str, default: Any = None) -> Any:
|
|
"""获取配置"""
|
|
return config.get(key, default)
|
|
|
|
|
|
def register_defaults(defaults: Dict[str, Any]):
|
|
"""注册默认配置"""
|
|
config.register_defaults(defaults)
|
|
|
|
|
|
__all__ = ["Config", "config", "get_config", "register_defaults"]
|