Ctrl K
ComunidadGuías…

src/webui/config_update.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""运行时配置更新的纯校验与归一化。

先在副本上完成全部校验,再由 composition root 一次性提交,避免无效请求
把全局 STATE 留在半更新状态。
"""

from __future__ import annotations

from src.game_preferences import validate_preferences

from dataclasses import dataclass
from typing import Any

from src.network_proxy import effective_proxy_url, is_supported_proxy_url
from src.ai_providers import (
    PROVIDER_REF_KEYS,
    UNSUPPORTED_AI_CONFIG_KEYS,
    is_provider_secret_key,
    normalize_ai_providers,
    reconcile_model_capability_routes,
    resolve_provider,
    strip_dangling_provider_refs,
    strip_orphan_provider_secrets,
)
from src.asr.contracts import SUPPORTED_ASR_PROVIDER_IDS
from src.imagegen.contracts import (
    IMAGE_PROVIDER_IDS,
    PROMPT_TEMPLATE_VARIABLE_RE,
    PROMPT_TEMPLATE_VARIABLES,
)
from src.tts.contracts import SUPPORTED_PROVIDER_IDS
from src.webui.access_password import hash_access_password
from src.webui.cors import invalid_cors_origins, normalize_cors_origins
from src.llm.generation_options import validate_generation_options, validate_recent_rounds
from src.assistant_agent_limits import MIN_TASK_MAX_TOKENS, MAX_TASK_MAX_TOKENS

SECRET_CONFIG_KEYS = frozenset({
    "access_token", "bot_token", "napcat_token",
})
STRING_CONFIG_KEYS = frozenset({
    "model", "embedding_model", "web_cors_origins",
    "fallback1_model", "fallback2_model", "check_model",
    "public_base_url", "napcat_host", "napcat_connection_id",
    "tts_model", "tts_default_voice", "tts_gm_voice", "tts_player_voice",
    "asr_model",
    "imagegen_model", "imagegen_square_size",
    "imagegen_landscape_size", "imagegen_quality",
    *PROVIDER_REF_KEYS,
})
CONFIG_KEYS = (
    "game_defaults",
    "llm_generation_options", "context_recent_rounds", "check_model_enabled", "check_model",
    "model", "web_port", "web_cors_origins", "embedding_enabled",
    "embedding_model", "embedding_max_input",
    "fallback1_enabled", "fallback1_model",
    "fallback2_enabled", "fallback2_model",
    "narrative_max_tokens", "character_gen_max_tokens", "summary_max_tokens", "brief_max_tokens",
    "analysis_max_tokens", "text_gen_max_tokens", "assistant_agent_max_tokens", "assistant_agent_task_max_tokens", "proxy_enabled", "proxy_url", "public_base_url", "access_token",
    "qq_bot_enabled", "napcat_host", "napcat_port", "napcat_token", "napcat_heartbeat_sec",
    "napcat_reconnect_delay_sec", "napcat_action_timeout_sec", "napcat_reply_delay_min_sec",
    "napcat_reply_delay_max_sec", "napcat_command_dedup_window_sec", "napcat_connection_id",
    "napcat_chat_filter_enabled", "napcat_show_dropped_logs", "napcat_group_list_mode", "napcat_group_list",
    "napcat_private_list_mode", "napcat_private_list", "napcat_blocked_users", "napcat_block_official_bots",
    "update_channel",
    "tts_provider", "tts_model", "tts_audio_format",
    "tts_default_voice", "tts_gm_voice", "tts_player_voice", "tts_timeout_seconds", "tts_cache_mb",
    "asr_provider", "asr_model", "asr_timeout_seconds",
    "imagegen_enabled", "imagegen_auto_scene", "imagegen_manual_scene", "imagegen_auto_storyboard", "imagegen_auto_use_manual_prompt", "imagegen_provider",
    "imagegen_model", "imagegen_square_size", "imagegen_landscape_size",
    "imagegen_quality", "imagegen_style_prefix", "imagegen_manual_rules", "imagegen_manual_prompt", "imagegen_auto_rules", "imagegen_auto_prompt", "imagegen_timeout_seconds", "test_timeout_seconds",
    "economy_auto_reward_enabled", "economy_auto_reward_gold_cap",
    "model_request_timeout_seconds",
    "ai_providers", *PROVIDER_REF_KEYS,
)
MODEL_RUNTIME_CONFIG_KEYS = frozenset({
    "llm_generation_options", "context_recent_rounds", "check_model_enabled", "check_provider_ref", "check_model",
    "model",
    "embedding_enabled", "embedding_model", "embedding_max_input",
    "fallback1_enabled", "fallback1_model",
    "fallback2_enabled", "fallback2_model",
    "narrative_max_tokens", "summary_max_tokens", "brief_max_tokens", "analysis_max_tokens",
    "model_request_timeout_seconds",
    "proxy_enabled", "proxy_url",
    "ai_providers", "llm_provider_ref", "fallback1_provider_ref", "fallback2_provider_ref",
    "embedding_provider_ref",
})
API_RUNTIME_CONFIG_KEYS = frozenset({
    "character_gen_max_tokens", "text_gen_max_tokens", "assistant_agent_max_tokens", "assistant_agent_task_max_tokens",
    "tts_provider", "tts_model", "tts_audio_format",
    "tts_default_voice", "tts_gm_voice", "tts_player_voice", "tts_timeout_seconds", "tts_cache_mb",
    "asr_provider", "asr_model", "asr_timeout_seconds",
    "imagegen_enabled", "imagegen_auto_scene", "imagegen_manual_scene", "imagegen_auto_storyboard", "imagegen_auto_use_manual_prompt", "imagegen_provider",
    "imagegen_model", "imagegen_square_size", "imagegen_landscape_size",
    "imagegen_quality", "imagegen_style_prefix", "imagegen_manual_rules", "imagegen_manual_prompt", "imagegen_auto_rules", "imagegen_auto_prompt", "imagegen_timeout_seconds",
    "ai_providers", "tts_provider_ref", "asr_provider_ref", "imagegen_provider_ref",
})


def connection_test_timeout(config: dict[str, Any]) -> float:
    try:
        value = float(config.get("test_timeout_seconds", 30))
    except (TypeError, ValueError):
        value = 30
    return max(5.0, min(value, 300.0))


def provider_runtime_changed(changed_keys: frozenset[str]) -> bool:
    """服务商库或其 secret 有变动:影响所有引用它的能力,两组运行时都要重建。"""
    return any(is_provider_secret_key(key) for key in changed_keys)


def _degrade_speech_engines_lost_provider(candidate: dict[str, Any], refs_before: dict[str, str]) -> None:
    """解除服务商绑定时停用依赖它的能力,不允许残留直填配置恢复连接。"""
    tts_ref_cleared = bool(refs_before.get("tts_provider_ref")) and not str(candidate.get("tts_provider_ref") or "").strip()
    if (tts_ref_cleared
            and candidate.get("tts_provider") in {"openai-compatible", "gpt-sovits"}):
        candidate["tts_provider"] = "browser"
    asr_ref_cleared = bool(refs_before.get("asr_provider_ref")) and not str(candidate.get("asr_provider_ref") or "").strip()
    if (asr_ref_cleared
            and candidate.get("asr_provider") == "openai-compatible"):
        candidate["asr_provider"] = "disabled"

    imagegen_ref_cleared = bool(refs_before.get("imagegen_provider_ref")) and not str(
        candidate.get("imagegen_provider_ref") or ""
    ).strip()
    if imagegen_ref_cleared:
        candidate["imagegen_enabled"] = False
    for capability in ("fallback1", "fallback2", "embedding", "check"):
        ref_key = f"{capability}_provider_ref"
        if refs_before.get(ref_key) and not candidate.get(ref_key):
            candidate["check_model_enabled" if capability == "check" else f"{capability}_enabled"] = False
BOT_CONFIG_MAP = {
    "qq_bot_enabled": "enabled",
    "napcat_host": "host",
    "napcat_port": "port",
    "napcat_token": "token",
    "napcat_heartbeat_sec": "heartbeat_sec",
    "napcat_reconnect_delay_sec": "reconnect_delay_sec",
    "napcat_action_timeout_sec": "action_timeout_sec",
    "napcat_reply_delay_min_sec": "reply_delay_min_sec",
    "napcat_reply_delay_max_sec": "reply_delay_max_sec",
    "napcat_command_dedup_window_sec": "command_dedup_window_sec",
    "napcat_connection_id": "connection_id",
    "napcat_chat_filter_enabled": "chat_filter_enabled",
    "napcat_show_dropped_logs": "show_dropped_logs",
    "napcat_group_list_mode": "group_list_mode",
    "napcat_group_list": "group_list",
    "napcat_private_list_mode": "private_list_mode",
    "napcat_private_list": "private_list",
    "napcat_blocked_users": "blocked_users",
    "napcat_block_official_bots": "block_official_bots",
}


@dataclass(frozen=True)
class PreparedConfigUpdate:
    state: dict[str, Any]
    changed_keys: frozenset[str]
    access_password_changed: bool
    error: str = ""
    warnings: tuple[str, ...] = ()


def clean_text_value(value: Any) -> str:
    return value.strip() if isinstance(value, str) else ""


def normalize_api_format(value: Any) -> str:
    return "anthropic" if clean_text_value(value).lower() == "anthropic" else "openai"


def prepare_config_update(current: dict[str, Any], body: dict[str, Any]) -> PreparedConfigUpdate:
    unsupported = sorted(set(body) & UNSUPPORTED_AI_CONFIG_KEYS)
    if unsupported:
        return PreparedConfigUpdate(
            dict(current), frozenset(), False,
            "unsupported AI config fields: " + ", ".join(unsupported)
            + "; use ai_providers and *_provider_ref",
        )
    candidate = {key: value for key, value in current.items() if key not in UNSUPPORTED_AI_CONFIG_KEYS}
    changed_keys = frozenset(set(body).intersection(CONFIG_KEYS)
                             | {key for key in body
                                if is_provider_secret_key(key) and clean_text_value(body[key])})
    access_password_changed = bool(clean_text_value(body.get("access_token")))
    # 服务商 secret 是动态键(ai_provider_key_<id>),不在 CONFIG_KEYS 白名单内,
    # 与固定 secret 同语义:非空才写入,空值保留旧值。
    for key in body:
        if is_provider_secret_key(key):
            value = clean_text_value(body[key])
            if value:
                candidate[key] = value
    try:
        for key in CONFIG_KEYS:
            if key not in body:
                continue
            raw = body[key]
            if key == "game_defaults":
                candidate[key] = validate_preferences(raw)
                continue
            if key == "check_model_enabled":
                if not isinstance(raw, bool):
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "check_model_enabled must be a boolean")
                candidate[key] = raw
                continue
            if key in {"llm_generation_options", "context_recent_rounds"}:
                try:
                    candidate[key] = (validate_generation_options(raw) if key == "llm_generation_options" else validate_recent_rounds(raw))
                except ValueError as exc:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, str(exc))
                continue
            if key in SECRET_CONFIG_KEYS:
                value = clean_text_value(raw)
                if value:
                    candidate[key] = hash_access_password(value) if key == "access_token" else value
                continue
            if key == "web_cors_origins":
                invalid = invalid_cors_origins(raw)
                if invalid:
                    return PreparedConfigUpdate(
                        candidate,
                        changed_keys,
                        access_password_changed,
                        "跨域来源必须是完整的 http(s) Origin,不能使用 *、路径或查询参数",
                    )
                candidate[key] = normalize_cors_origins(raw)
            elif key == "ai_providers":
                candidate[key] = normalize_ai_providers(raw)
            elif key in STRING_CONFIG_KEYS:
                candidate[key] = clean_text_value(raw)
            elif key == "assistant_agent_task_max_tokens":
                if isinstance(raw, bool) or not isinstance(raw, int):
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "assistant_agent_task_max_tokens must be an integer")
                if not MIN_TASK_MAX_TOKENS <= raw <= MAX_TASK_MAX_TOKENS:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, f"assistant_agent_task_max_tokens must be between {MIN_TASK_MAX_TOKENS} and {MAX_TASK_MAX_TOKENS}")
                candidate[key] = raw
            elif key.endswith("_max_tokens"):
                if isinstance(raw, bool):
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, f"{key} must be an integer")
                candidate[key] = max(1, int(raw))
            elif key == "proxy_url":
                proxy_url = str(raw or "").strip()
                if proxy_url and not is_supported_proxy_url(proxy_url):
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "代理地址仅支持 http:// 或 https://")
                candidate[key] = proxy_url
            elif key in {
                "proxy_enabled", "qq_bot_enabled", "napcat_chat_filter_enabled",
                "napcat_show_dropped_logs", "napcat_block_official_bots",
                "imagegen_enabled", "imagegen_auto_scene", "imagegen_manual_scene", "imagegen_auto_storyboard", "imagegen_auto_use_manual_prompt",
                "economy_auto_reward_enabled",
            }:
                candidate[key] = bool(raw)
            elif key == "economy_auto_reward_gold_cap":
                cap_value = int(raw)
                if cap_value < 1:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "奖励自动结算金币上限必须大于 0")
                candidate[key] = cap_value
            elif key in {
                "napcat_heartbeat_sec", "napcat_reconnect_delay_sec",
                "napcat_action_timeout_sec", "napcat_command_dedup_window_sec",
            }:
                numeric_value = float(raw)
                if numeric_value <= 0:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "NapCat 时间参数必须大于 0")
                candidate[key] = numeric_value
            elif key in {"napcat_reply_delay_min_sec", "napcat_reply_delay_max_sec"}:
                delay_value = float(raw)
                if delay_value < 0:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "NapCat 回复延迟不能小于 0")
                candidate[key] = delay_value
            elif key in {"napcat_group_list_mode", "napcat_private_list_mode"}:
                candidate[key] = "blacklist" if raw == "blacklist" else "whitelist"
            elif key in {"napcat_group_list", "napcat_private_list", "napcat_blocked_users"}:
                values = raw if isinstance(raw, list) else []
                candidate[key] = list(dict.fromkeys(str(item).strip() for item in values if str(item).strip()))
            elif key == "update_channel":
                candidate[key] = "preview" if str(raw).strip() == "preview" else "stable"
            elif key == "tts_provider":
                provider = str(raw or "").strip()
                if provider not in SUPPORTED_PROVIDER_IDS:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "TTS Provider 无效")
                candidate[key] = provider
            elif key == "tts_audio_format":
                audio_format = str(raw or "").strip().lower()
                if audio_format not in {"mp3", "opus", "aac", "flac", "wav", "pcm"}:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "TTS 音频格式无效")
                candidate[key] = audio_format
            elif key == "tts_timeout_seconds":
                timeout = float(raw)
                if not 5 <= timeout <= 300:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "TTS 超时必须在 5–300 秒之间")
                candidate[key] = timeout
            elif key == "tts_cache_mb":
                cache_mb = int(raw)
                if not 16 <= cache_mb <= 2048:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "TTS 缓存必须在 16–2048 MB 之间")
                candidate[key] = cache_mb
            elif key == "asr_provider":
                provider = str(raw or "").strip()
                if provider not in SUPPORTED_ASR_PROVIDER_IDS:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "ASR Provider 无效")
                candidate[key] = provider
            elif key == "asr_timeout_seconds":
                timeout = float(raw)
                if not 5 <= timeout <= 300:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "ASR 超时必须在 5–300 秒之间")
                candidate[key] = timeout
            elif key == "imagegen_provider":
                provider = str(raw or "").strip()
                if provider not in IMAGE_PROVIDER_IDS:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "图像生成 Provider 无效")
                candidate[key] = provider
            elif key in {"imagegen_style_prefix", "imagegen_manual_rules", "imagegen_manual_prompt", "imagegen_auto_rules", "imagegen_auto_prompt"}:
                value = clean_text_value(raw)
                if len(value) > 12000:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "图像提示词配置不能超过 12000 个字符")
                if any(
                    token not in PROMPT_TEMPLATE_VARIABLES
                    for token in PROMPT_TEMPLATE_VARIABLE_RE.findall(value)
                ):
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "图像提示词包含不支持的变量")
                candidate[key] = value
            elif key == "imagegen_timeout_seconds":
                timeout = float(raw)
                if not 5 <= timeout <= 300:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "图像生成超时必须在 5–300 秒之间")
                candidate[key] = timeout
            elif key == "test_timeout_seconds":
                timeout = float(raw)
                if not 5 <= timeout <= 300:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "连接测试超时必须在 5–300 秒之间")
                candidate[key] = timeout
            elif key == "model_request_timeout_seconds":
                timeout = float(raw)
                if not 10 <= timeout <= 600:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "模型请求超时必须在 10–600 秒之间")
                candidate[key] = timeout
            elif key == "napcat_port":
                port = int(raw)
                if not 1 <= port <= 65535:
                    return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "NapCat 端口无效")
                candidate[key] = port
            else:
                candidate[key] = raw
    except (TypeError, ValueError):
        return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "配置字段格式无效")

    # 服务商删除后同步清理孤儿 secret 与悬空引用,保持 STATE 自洽。
    refs_before = {key: str(candidate.get(key) or "").strip() for key in PROVIDER_REF_KEYS}
    strip_orphan_provider_secrets(candidate)
    strip_dangling_provider_refs(candidate)
    cleared_model_routes = reconcile_model_capability_routes(candidate)
    _degrade_speech_engines_lost_provider(candidate, refs_before)

    # 候选状态整体校验:启用独立检定时,服务商引用、地址与模型名必须同时有效,
    # 否则拒绝保存——不允许"开关开着、客户端构建不出来"的配置进入运行时,
    # 把失败推迟到回合推进时才暴露(曾导致推进 500 / 伪成功)。
    if candidate.get("check_model_enabled"):
        check_route = resolve_provider(candidate, candidate.get("check_provider_ref", ""))
        check_model = str(candidate.get("check_model") or "").strip()
        if not (check_route and check_route["base_url"] and check_model):
            return PreparedConfigUpdate(
                candidate,
                changed_keys,
                access_password_changed,
                "独立检定模型需要有效的服务商和模型名,请在设置中补全后保存,或关闭独立检定模型",
            )

    imagegen_ref = str(candidate.get("imagegen_provider_ref") or "").strip()
    if imagegen_ref:
        imagegen_provider = next(
            (entry for entry in candidate.get("ai_providers") or [] if entry.get("id") == imagegen_ref),
            None,
        )
        if imagegen_provider and imagegen_provider.get("api_format") != "openai":
            return PreparedConfigUpdate(
                candidate,
                changed_keys,
                access_password_changed,
                "图像生成仅支持 OpenAI 兼容服务商",
            )

    active_proxy = effective_proxy_url(bool(candidate.get("proxy_enabled")), candidate.get("proxy_url", ""))
    if candidate.get("proxy_enabled") and not active_proxy:
        return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "已启用代理,但代理地址为空")
    if active_proxy and not is_supported_proxy_url(active_proxy):
        return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "代理地址仅支持 http:// 或 https://")
    if float(candidate.get("napcat_reply_delay_max_sec", 0)) < float(candidate.get("napcat_reply_delay_min_sec", 0)):
        return PreparedConfigUpdate(candidate, changed_keys, access_password_changed, "NapCat 回复延迟上限不能小于下限")
    return PreparedConfigUpdate(
        candidate,
        changed_keys,
        access_password_changed,
        warnings=tuple(
            f"{label}已解除:所选模型的手动能力不是该用途所需类型"
            for label in cleared_model_routes
        ),
    )


def bot_plugin_changes(body: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
    return {
        plugin_key: state[legacy_key]
        for legacy_key, plugin_key in BOT_CONFIG_MAP.items()
        if legacy_key in body and legacy_key in state
    }