Ctrl K
ComunidadGuías…

src/webui/composition.py

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

Archivo original ↗Preguntar a KiBot
"""Application composition for DiceFrame's WebUI runtime.

This module owns construction of model/storage subsystems and the WebAPI facade.
It deliberately does not read environment variables, start listeners, or mutate
the aiohttp application lifecycle.
"""

from __future__ import annotations

import logging
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from src.ai_providers import resolve_provider
from src.assistant_agent_limits import DEFAULT_TASK_MAX_TOKENS
from src.generation_limits import DEFAULT_TEXT_GEN_MAX_TOKENS
from src.asr import AsrService
from src.common_factory import TRPGSubsystems, create_trpg_subsystems
from src.imagegen import ImageGenerationService
from src.llm.client import ProviderConfig
from src.migrations.config import DEFAULT_NARRATIVE_MAX_TOKENS
from src.network_proxy import effective_proxy_url
from src.tts import SpeechService
from src.webui.api import WebAPI

logger = logging.getLogger("trpg")


@dataclass(frozen=True)
class RuntimePaths:
    """Filesystem locations required to assemble the WebUI runtime."""

    data_dir: Path
    prompts_dir: Path
    rules_dir: Path
    worlds_dir: Path
    adventures_dir: Path


@dataclass(frozen=True)
class RuntimeFactories:
    """Replaceable constructors used by the composition root."""

    create_subsystems: Callable[..., TRPGSubsystems] = create_trpg_subsystems
    create_web_api: Callable[..., WebAPI] = WebAPI
    create_speech: Callable[..., Any] = SpeechService
    create_asr: Callable[..., Any] = AsrService
    create_imagegen: Callable[..., Any] = ImageGenerationService


class RuntimeComposition:
    """Assemble replaceable runtime components from explicit dependencies."""

    def __init__(
        self,
        *,
        paths: RuntimePaths,
        state: dict,
        save_config: Callable[[], None],
        factories: RuntimeFactories | None = None,
    ) -> None:
        self.paths = paths
        self.state = state
        self.save_config = save_config
        self.factories = factories or RuntimeFactories()

    def build_subsystems(
        self,
        reuse: TRPGSubsystems | None = None,
        config: dict | None = None,
    ) -> TRPGSubsystems:
        runtime_config = self.state if config is None else config
        main_provider = resolve_provider(
            runtime_config,
            runtime_config.get("llm_provider_ref", ""),
        )
        if main_provider:
            main_base_url = main_provider["base_url"]
            main_api_key = main_provider["api_key"]
            main_api_format = main_provider["api_format"]
        else:
            main_base_url = ""
            main_api_key = ""
            main_api_format = "openai"
        providers = [
            ProviderConfig(
                provider_name="default",
                base_url=main_base_url,
                api_key=main_api_key,
                model_name=runtime_config["model"],
                api_format=main_api_format,
            )
        ]
        for index in (1, 2):
            if not runtime_config.get(f"fallback{index}_enabled"):
                continue
            fallback_provider = resolve_provider(
                runtime_config,
                runtime_config.get(f"fallback{index}_provider_ref", ""),
            )
            if not fallback_provider:
                continue
            fallback_base_url = fallback_provider["base_url"]
            fallback_model = runtime_config.get(f"fallback{index}_model", "")
            if not (fallback_base_url and fallback_model):
                continue
            fallback_api_key = fallback_provider["api_key"]
            fallback_api_format = fallback_provider["api_format"]
            providers.append(
                ProviderConfig(
                    provider_name=f"fallback{index}",
                    base_url=fallback_base_url,
                    api_key=fallback_api_key,
                    model_name=fallback_model,
                    api_format=fallback_api_format,
                    fallback=True,
                )
            )

        embedding_provider = resolve_provider(
            runtime_config,
            runtime_config.get("embedding_provider_ref", ""),
        )
        if embedding_provider:
            embedding_base_url = embedding_provider["base_url"]
            embedding_api_key = embedding_provider["api_key"]
        else:
            embedding_base_url = ""
            embedding_api_key = ""
        embedding_enabled = bool(
            runtime_config.get("embedding_enabled", False) and embedding_base_url
        )
        check_route = resolve_provider(runtime_config, runtime_config.get("check_provider_ref", ""))
        check_model = str(runtime_config.get("check_model") or "").strip()
        check_provider = (
            ProviderConfig("check", check_route["base_url"], check_route["api_key"], check_model, api_format=check_route["api_format"])
            if check_route and check_route["base_url"] and check_model else None
        )
        # 启动 / 热重载诊断:旧配置可能在磁盘上保留"已启用却解析不出客户端"
        # 的组合(不走配置保存校验)。只记录引用与模型名,不记录任何凭据;
        # 该组合会在回合推进时被拒绝并返回 CHECK_MODEL_NOT_CONFIGURED。
        if bool(runtime_config.get("check_model_enabled", False)) and check_provider is None:
            logger.warning(
                "独立检定模型已启用但配置无效(服务商引用、地址或模型名缺失),回合推进将被拒绝:"
                " check_provider_ref=%r check_model=%r",
                str(runtime_config.get("check_provider_ref") or ""),
                check_model,
            )
        return self.factories.create_subsystems(
            data_dir=self.paths.data_dir,
            prompts_dir=self.paths.prompts_dir,
            rules_dir=self.paths.rules_dir,
            worlds_dir=self.paths.worlds_dir,
            adventures_dir=self.paths.adventures_dir,
            providers=providers,
            default_provider="default",
            check_model_enabled=bool(runtime_config.get("check_model_enabled", False)),
            check_provider=check_provider,
            generation_options=runtime_config.get("llm_generation_options", {}),
            context_recent_rounds=runtime_config.get("context_recent_rounds", 0),
            embedding_enabled=embedding_enabled,
            embedding_base_url=embedding_base_url,
            embedding_api_key=embedding_api_key,
            embedding_model=runtime_config.get(
                "embedding_model", "nomic-embed-text"
            ),
            embedding_max_input=int(
                runtime_config.get("embedding_max_input", 0)
            ),
            proxy_url=effective_proxy_url(
                bool(runtime_config.get("proxy_enabled")),
                runtime_config.get("proxy_url", ""),
            ),
            narrative_max_tokens=int(
                runtime_config.get(
                    "narrative_max_tokens", DEFAULT_NARRATIVE_MAX_TOKENS
                )
            ),
            character_gen_max_tokens=int(
                runtime_config.get("character_gen_max_tokens", 4096)
            ),
            summary_max_tokens=int(runtime_config.get("summary_max_tokens", 1024)),
            brief_max_tokens=int(runtime_config.get("brief_max_tokens", 1024)),
            analysis_max_tokens=int(runtime_config.get("analysis_max_tokens", 1024)),
            model_request_timeout_seconds=float(
                runtime_config.get("model_request_timeout_seconds", 120)
            ),
            reuse=reuse,
        )

    @staticmethod
    def config_with_resolved_api_refs(config: dict) -> dict:
        """Resolve shared providers into capability-specific runtime keys."""
        resolved = dict(config)
        # Always overwrite these internal fields: residual public settings are never credentials.
        for capability in ("tts", "asr", "imagegen"):
            provider = resolve_provider(config, config.get(f"{capability}_provider_ref", ""))
            if capability == "imagegen" and provider and provider["api_format"] != "openai":
                provider = None
            resolved[f"{capability}_base_url"] = provider["base_url"] if provider else ""
            resolved[f"{capability}_api_key"] = provider["api_key"] if provider else ""
            if provider and provider["base_url"]:
                continue
            # Unconfigured installations must still start so the owner can configure them.
            if capability == "tts" and config.get("tts_provider") in {"openai-compatible", "gpt-sovits"}:
                resolved["tts_provider"] = "browser"
            elif capability == "asr":
                resolved["asr_provider"] = "disabled"
            elif capability == "imagegen":
                resolved["imagegen_enabled"] = False
        return resolved

    def make_api(
        self,
        subsystems: TRPGSubsystems,
        plugin_host: Any = None,
        config: dict | None = None,
        hub_client: Any = None,
    ) -> WebAPI:
        runtime_config = self.state if config is None else config
        api_config = self.config_with_resolved_api_refs(runtime_config)
        proxy_url = effective_proxy_url(
            bool(runtime_config.get("proxy_enabled")),
            runtime_config.get("proxy_url", ""),
        )
        speech_service = self.factories.create_speech(
            api_config,
            self.paths.data_dir / "tts-cache",
            proxy_url=proxy_url,
        )
        asr_service = self.factories.create_asr(api_config, proxy_url=proxy_url)
        imagegen_service = self.factories.create_imagegen(
            api_config,
            self.paths.data_dir / "generated-images",
            proxy_url=proxy_url,
        )
        return self.factories.create_web_api(
            registry=subsystems.registry,
            lorebook=subsystems.lorebook_store,
            memory=subsystems.memory_store,
            rules_dir=self.paths.rules_dir,
            handler=subsystems.handler,
            llm_client=subsystems.llm_client,
            worlds_dir=self.paths.worlds_dir,
            adventures_dir=self.paths.adventures_dir,
            character_gen_max_tokens=int(
                runtime_config.get("character_gen_max_tokens", 4096)
            ),
            text_gen_max_tokens=int(runtime_config.get("text_gen_max_tokens", DEFAULT_TEXT_GEN_MAX_TOKENS)),
            assistant_agent_max_tokens=int(runtime_config.get("assistant_agent_max_tokens", 4096)),
            assistant_agent_task_max_tokens=int(runtime_config.get("assistant_agent_task_max_tokens", DEFAULT_TASK_MAX_TOKENS)),
            plugin_host=plugin_host,
            hub_client=hub_client,
            speech_service=speech_service,
            asr_service=asr_service,
            imagegen_service=imagegen_service,
            ruleset_registry=getattr(subsystems, "ruleset_registry", None),
            content_cache_dir=self.paths.data_dir / "content-cache",
            content_mirrors=getattr(plugin_host, "mirrors", None),
            config_state=self.state,
            save_config=self.save_config,
        )

    @staticmethod
    def activate_api_runtime(subsystems: TRPGSubsystems, api: WebAPI) -> None:
        handler = getattr(subsystems, "handler", None)
        if handler is not None and hasattr(handler, "set_image_generation_service"):
            handler.set_image_generation_service(getattr(api, "_imagegen", None))