Ctrl K
ComunidadGuías…

src/common_factory.py

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

Archivo original ↗Preguntar a KiBot
"""TRPG 子系统工厂 —— WebUI 和插件宿主共用初始化逻辑。"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from src.commands.game_handler import GameHandler
from src.engine.game_instance import GameRegistry
from src.llm.client import LLMClient, ProviderConfig
from src.lorebook.bootstrap import seed_builtin_worlds
from src.lorebook.matcher import KeywordMatcher
from src.lorebook.store import LorebookStore
from src.migrations.world_identity import migrate_legacy_world_ids
from src.memory.delta import MemoryStore
from src.rulesets.builtin import build_default_ruleset_registry
from src.rulesets.registry import RulesetRuntimeRegistry

logger = logging.getLogger("trpg")


@dataclass
class TRPGSubsystems:
    registry: GameRegistry
    llm_client: LLMClient
    lorebook_store: LorebookStore
    lorebook_matcher: KeywordMatcher
    memory_store: MemoryStore
    ruleset_registry: RulesetRuntimeRegistry
    handler: GameHandler
    check_llm_client: LLMClient | None = None


async def close_model_clients(runtime: Any, *, keep: Any = None) -> None:
    """Close each owned model client once; keep clients reused by a new runtime."""
    retained = {id(getattr(keep, name, None)) for name in ("llm_client", "check_llm_client")}
    closed = set()
    for name in ("llm_client", "check_llm_client"):
        client = getattr(runtime, name, None)
        if client is None or id(client) in closed or id(client) in retained:
            continue
        closed.add(id(client))
        try:
            await client.close()
        except Exception:
            logger.warning("关闭模型客户端失败: %s", name, exc_info=True)


def create_trpg_subsystems(
    data_dir: Path,
    prompts_dir: Path,
    rules_dir: Path,
    worlds_dir: Path,
    providers: list[ProviderConfig],
    default_provider: str,
    *,
    adventures_dir: Path | None = None,
    embedding_enabled: bool = False,
    embedding_base_url: str = "",
    embedding_api_key: str = "",
    embedding_model: str = "",
    embedding_max_input: int = 0,
    proxy_url: str = "",
    auto_recover: bool = False,
    narrative_max_tokens: int = 4096,
    character_gen_max_tokens: int = 4096,
    summary_max_tokens: int = 4096,
    brief_max_tokens: int = 4096,
    analysis_max_tokens: int = 4096,
    model_request_timeout_seconds: float = 120,
    generation_options: dict[str, Any] | None = None,
    context_recent_rounds: int = 0,
    check_model_enabled: bool = False,
    check_provider: ProviderConfig | None = None,
    reuse: TRPGSubsystems | None = None,
) -> TRPGSubsystems:
    if reuse is None:
        converted = migrate_legacy_world_ids(data_dir, worlds_dir)
        if converted:
            logger.info('已迁移 %d 个旧世界来源标识 / Upgraded legacy world identities', len(converted))
    save_dir = data_dir / "saves"

    registry = reuse.registry if reuse is not None else GameRegistry(save_dir)

    llm_client = LLMClient(
        providers=providers,
        default=default_provider,
        proxy_url=proxy_url,
        request_timeout_seconds=model_request_timeout_seconds,
        generation_options=generation_options,
    )
    check_llm_client = (
        LLMClient(
            providers=[check_provider], default=check_provider.provider_name,
            proxy_url=proxy_url, request_timeout_seconds=model_request_timeout_seconds,
        )
        if check_model_enabled and check_provider is not None else None
    )

    if reuse is not None:
        lorebook_store = reuse.lorebook_store
        lorebook_matcher = reuse.lorebook_matcher
        memory_store = reuse.memory_store
    else:
        lorebook_store = LorebookStore(data_dir / "lorebook.db")
        lorebook_store.open()
        seed_builtin_worlds(lorebook_store, worlds_dir)
        lorebook_matcher = KeywordMatcher()

        memory_store = MemoryStore(data_dir / "memory.db")
        memory_store.open()

    embedding_client = None
    if embedding_enabled and embedding_base_url:
        from src.memory.embedding import EmbeddingClient
        emb_key = embedding_api_key
        embedding_client = EmbeddingClient(
            embedding_base_url, emb_key, embedding_model or "nomic-embed-text",
            max_input_override=embedding_max_input,
            proxy_url=proxy_url,
        )

    ruleset_registry = build_default_ruleset_registry(adventures_dir)
    handler = GameHandler(
        registry=registry,
        llm_client=llm_client,
        lorebook_matcher=lorebook_matcher,
        lorebook_store=lorebook_store,
        memory_store=memory_store,
        prompts_dir=prompts_dir,
        rules_dir=rules_dir,
        worlds_dir=worlds_dir,
        ruleset_registry=ruleset_registry,
        narrative_max_tokens=narrative_max_tokens,
        summary_max_tokens=summary_max_tokens,
        brief_max_tokens=brief_max_tokens,
        analysis_max_tokens=analysis_max_tokens,
        context_recent_rounds=context_recent_rounds,
        check_model_enabled=check_model_enabled,
        check_llm_client=check_llm_client,
    )
    # 仅在新运行时已经完整构建后切换 embedding 客户端,避免构建失败时
    # 破坏仍在服务请求的旧运行时。GameRegistry 与数据库连接始终复用。
    memory_store.embedding_client = embedding_client

    return TRPGSubsystems(
        registry=registry,
        llm_client=llm_client,
        lorebook_store=lorebook_store,
        lorebook_matcher=lorebook_matcher,
        memory_store=memory_store,
        ruleset_registry=ruleset_registry,
        handler=handler,
        check_llm_client=check_llm_client,
    )