Ctrl K
ComunidadGuías…

web_server.py

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

Archivo original ↗Preguntar a KiBot
from pathlib import Path
from dataclasses import replace

import asyncio
import logging
import os
import signal
import sys

sys.path.insert(0, str(Path(__file__).parent))
if __name__ == "__main__":
    from src.docker_launcher.data_versions import prepare_version_data
    prepare_version_data(Path(__file__).resolve().parent, os.environ)

from aiohttp import web
from src.runtime_env import load_project_env
from src.runtime_asyncio import install_runtime_exception_handler
from src.runtime_logging import RETENTION_DAYS, configure_runtime_logging

load_project_env(Path(__file__).resolve().with_name(".env"))

from src.ai_providers import is_llm_config_ready
from src.common_factory import TRPGSubsystems, create_trpg_subsystems
from src.migrations.config import (
    DEFAULT_NARRATIVE_MAX_TOKENS,
    migrate_generation_defaults as _migrate_generation_defaults,
)
from src.tts import SpeechService
from src.asr import AsrService
from src.imagegen import ImageGenerationService
from src.webui.api import WebAPI
from src.webui.services.launcher_promotion import promote_pending_launcher
from src.webui.composition import (
    RuntimeComposition,
    RuntimeFactories,
    RuntimePaths as CompositionPaths,
)
from src.webui.application import ApplicationDependencies, create_app
from src.webui.runtime_config import (
    ConfigStore,
    RuntimePaths,
)
from src.webui.host_credentials import HostCredentials
from src.webui.bootstrap import (
    BootstrapDependencies,
    BootstrapPaths,
    WebUIBootstrap,
)
from src.webui.access_control import WebAccessControl
from src.web_transport.lifecycle import run_with_graceful_shutdown
from src.web_transport.listeners import (
    build_listener_plan,
    internal_api_base,
    internal_api_listener,
    resolve_internal_listener,
    start_listeners,
)
from src.webui.config_controller import (
    ConfigController,
    ConfigControllerDependencies,
)
from src.version import MIN_CLIENT_VERSION, __version__

logger = logging.getLogger("trpg")
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s")


ROOT = Path(__file__).parent
RUNTIME_PATHS = RuntimePaths.from_root(ROOT, os.environ)
CONFIG_STORE = ConfigStore(RUNTIME_PATHS, os.environ, logger=logger)
DATA_DIR = RUNTIME_PATHS.data_dir
DATA_DIR.mkdir(parents=True, exist_ok=True)
# 更新候选进程由旧启动器启动:趁它还在就把包里的新启动器提升到安装根目录,
# 这样即使旧启动器的 HTTPS 健康检查失败回滚,下次启动也已经是修复版。
# 非便携版(没有 TRPG_INSTALL_ROOT)与无更新信号时是纯 no-op。
promote_pending_launcher(DATA_DIR, os.environ.get("TRPG_INSTALL_ROOT", ""), log=logger)
CONFIG_FILE = RUNTIME_PATHS.config_file
SECRETS_FILE = RUNTIME_PATHS.secrets_file
ACCESS_TOKEN_FILE = RUNTIME_PATHS.access_token_file


def _quarantine_invalid_json(path: Path) -> Path | None:
    return CONFIG_STORE.quarantine_invalid_json(path)


def _load_json_object(path: Path, label: str) -> dict:
    return CONFIG_STORE.load_json_object(path, label)


RUNTIME_CONFIG = CONFIG_STORE.load()
STATE = RUNTIME_CONFIG.state
HOST = RUNTIME_CONFIG.host
PORT = RUNTIME_CONFIG.port
TRANSPORT = RUNTIME_CONFIG.transport
# 监听计划在创建 app 之前就定好:插件/内部客户端用的 API 基址必须指向一个
# 本机真的能连上的监听器(具体地址就用它自己,通配地址用同族回环)。
LISTENER_PLAN, LISTENER_PLAN_WARNINGS = build_listener_plan(
    hosts=RUNTIME_CONFIG.hosts,
    port=PORT,
    transport=TRANSPORT,
    http_port=RUNTIME_CONFIG.http_port,
    https_port=RUNTIME_CONFIG.https_port,
)
INTERNAL_LISTENER = internal_api_listener(LISTENER_PLAN)
if INTERNAL_LISTENER is not None:
    TRANSPORT = replace(
        TRANSPORT,
        endpoint=replace(
            TRANSPORT.endpoint,
            scheme=INTERNAL_LISTENER.scheme,
            port=INTERNAL_LISTENER.port,
            local_host=INTERNAL_LISTENER.connect_host(),
        ),
    )
WEB_CORS_ENV_VALUE = RUNTIME_CONFIG.cors_env_value
WEB_CORS_CONFIG_VALUE = RUNTIME_CONFIG.cors_config_value
WEB_CORS_ORIGINS = RUNTIME_CONFIG.cors_origins

PROMPTS_DIR = RUNTIME_PATHS.prompts_dir
BUILTIN_RULES_DIR = RUNTIME_PATHS.builtin_rules_dir
BUILTIN_WORLDS_DIR = RUNTIME_PATHS.builtin_worlds_dir
BUILTIN_ADVENTURES_DIR = RUNTIME_PATHS.builtin_adventures_dir
RULES_DIR = RUNTIME_PATHS.rules_dir
WORLDS_DIR = RUNTIME_PATHS.worlds_dir
ADVENTURES_DIR = RUNTIME_PATHS.adventures_dir
STATIC_V2_DIR = RUNTIME_PATHS.static_v2_dir

def _atomic_write_json(path: Path, data: dict) -> None:
    CONFIG_STORE.atomic_write_json(path, data)


def _mask_secret(value: str) -> dict:
    return CONFIG_STORE.mask_secret(value)


def _public_config() -> dict:
    # 版本元数据是组合层事实而非配置状态,在这里注入而不是写进 ConfigStore:
    # 公开的 /api/config 是客户端(移动端)登录前唯一可匿名探测的端点,
    # 客户端据此做双向版本兼容提示(App 过旧 / 服务器过旧)。
    public = CONFIG_STORE.public_view(RUNTIME_CONFIG)
    public["server_version"] = __version__
    public["min_client_version"] = MIN_CLIENT_VERSION
    return public


def save_config():
    CONFIG_STORE.save(STATE)


def _legacy_plugin_bot_token() -> str:
    return _host_credentials().legacy_plugin_bot_token()


def _ensure_bot_token() -> str:
    return _host_credentials().ensure_bot_token()


def _write_access_token_file(password: str) -> None:
    _host_credentials().write_access_token_file(password)


def _delete_access_token_file() -> None:
    _host_credentials().delete_access_token_file()


def _read_access_token_file() -> str:
    return _host_credentials().read_access_token_file()


def _generate_initial_access_password() -> None:
    _host_credentials().generate_initial_access_password()


def _host_credentials() -> HostCredentials:
    return HostCredentials(
        state=STATE,
        data_dir=CONFIG_FILE.parent,
        plugin_data_dir=DATA_DIR,
        access_token_file=ACCESS_TOKEN_FILE,
        environ=os.environ,
        save_config=save_config,
        logger=logger,
    )


def _build_subsystems(
    reuse: TRPGSubsystems | None = None,
    config: dict | None = None,
) -> TRPGSubsystems:
    return _runtime_composition().build_subsystems(reuse=reuse, config=config)


def _config_with_resolved_api_refs(config: dict) -> dict:
    return RuntimeComposition.config_with_resolved_api_refs(config)


def _make_api(subsystems: TRPGSubsystems, plugin_host=None, config: dict | None = None, hub_client=None) -> WebAPI:
    return _runtime_composition().make_api(
        subsystems,
        plugin_host=plugin_host,
        config=config,
        hub_client=hub_client,
    )


def _activate_api_runtime(subsystems: TRPGSubsystems, api: WebAPI) -> None:
    RuntimeComposition.activate_api_runtime(subsystems, api)


def _runtime_composition() -> RuntimeComposition:
    """Build the composition boundary from current compatibility globals."""
    return RuntimeComposition(
        paths=CompositionPaths(
            data_dir=DATA_DIR,
            prompts_dir=PROMPTS_DIR,
            rules_dir=RULES_DIR,
            worlds_dir=WORLDS_DIR,
            adventures_dir=ADVENTURES_DIR,
        ),
        state=STATE,
        save_config=save_config,
        factories=RuntimeFactories(
            create_subsystems=create_trpg_subsystems,
            create_web_api=WebAPI,
            create_speech=SpeechService,
            create_asr=AsrService,
            create_imagegen=ImageGenerationService,
        ),
    )


BOOTSTRAP = WebUIBootstrap(
    BootstrapDependencies(
        paths=BootstrapPaths(
            root=ROOT,
            data_dir=DATA_DIR,
            builtin_rules_dir=BUILTIN_RULES_DIR,
            builtin_worlds_dir=BUILTIN_WORLDS_DIR,
            builtin_adventures_dir=BUILTIN_ADVENTURES_DIR,
            rules_dir=RULES_DIR,
            worlds_dir=WORLDS_DIR,
            adventures_dir=ADVENTURES_DIR,
        ),
        state=STATE,
        environ=os.environ,
        transport=TRANSPORT,
        generation_defaults_migrated=RUNTIME_CONFIG.generation_defaults_migrated,
        credentials=_host_credentials,
        save_config=save_config,
        build_subsystems=_build_subsystems,
        make_api=_make_api,
        activate_api_runtime=_activate_api_runtime,
    ),
    logger=logger,
)

ACCESS_CONTROL = WebAccessControl(STATE)
auth_middleware = ACCESS_CONTROL.middleware

CONFIG_CONTROLLER = ConfigController(
    ConfigControllerDependencies(
        state=STATE,
        environ=os.environ,
        cors_env_value=WEB_CORS_ENV_VALUE,
        public_config=lambda: _public_config(),
        save_config=lambda: save_config(),
        ensure_bot_token=lambda: _ensure_bot_token(),
        delete_access_token_file=lambda: _delete_access_token_file(),
        build_subsystems=lambda *args, **kwargs: _build_subsystems(*args, **kwargs),
        make_api=lambda *args, **kwargs: _make_api(*args, **kwargs),
        activate_api_runtime=lambda *args, **kwargs: _activate_api_runtime(
            *args, **kwargs
        ),
    ),
    logger=logger,
)
api_config_get = CONFIG_CONTROLLER.get
api_config_post = CONFIG_CONTROLLER.post
_apply_config_update = CONFIG_CONTROLLER.apply_update
api_bot_token_post = CONFIG_CONTROLLER.bot_token_post
_is_safe_external_url = CONFIG_CONTROLLER.is_safe_external_url
api_test_connection = CONFIG_CONTROLLER.test_connection
api_config_provider_models_post = CONFIG_CONTROLLER.provider_models_post
_proxy_from_test_body = CONFIG_CONTROLLER.proxy_from_test_body
api_test_embedding = CONFIG_CONTROLLER.test_embedding
api_test_proxy = CONFIG_CONTROLLER.test_proxy


def _application_dependencies() -> ApplicationDependencies:
    return ApplicationDependencies(
        data_dir=DATA_DIR,
        host_data_dir=CONFIG_FILE.parent,
        static_v2_dir=STATIC_V2_DIR,
        cors_origins=WEB_CORS_ORIGINS,
        transport=TRANSPORT,
        config_state=STATE,
        save_config=save_config,
        on_startup=BOOTSTRAP.on_startup,
        on_cleanup=BOOTSTRAP.on_cleanup,
        auth_middleware=auth_middleware,
        config_get=api_config_get,
        config_post=api_config_post,
        bot_token_post=api_bot_token_post,
        provider_models_post=api_config_provider_models_post,
        test_connection=api_test_connection,
        test_embedding=api_test_embedding,
        test_proxy=api_test_proxy,
    )


app = create_app(_application_dependencies())


async def _register_application_actions(application: web.Application) -> None:
    CONFIG_CONTROLLER.attach_agent_actions(application, application['api'])


app.on_startup.append(_register_application_actions)


async def _serve(loop: asyncio.AbstractEventLoop) -> bool:
    """按配置启动所有监听器;返回是否需要重启进程。

    HTTP 与 HTTPS 可以同时监听(TRPG_WEB_HTTP_PORT / TRPG_WEB_HTTPS_PORT),
    多地址(TRPG_WEB_HOSTS)则每个地址一个 site。计划构建与单个地址失败
    处理都在 web_transport.listeners 里,未启动任何监听器时按原语义抛错退出。
    """

    # 地址在进 bootstrap 之前就打印:这是用户最关心的一行,不能沉到
    # on_startup 的一屏初始化日志下面。个别地址随后绑定失败会有 ERROR 日志。
    for item in LISTENER_PLAN:
        print(f"DiceFrame WebUI: {item.url()}  (host={item.host})")
    for message in LISTENER_PLAN_WARNINGS:
        logger.warning("%s", message)

    runner = web.AppRunner(app)
    await runner.setup()
    try:
        started, failures = await start_listeners(runner, LISTENER_PLAN, TRANSPORT)
        if not started:
            raise OSError(failures[0] if failures else "没有可用的监听地址")
        await _align_internal_api(started)
        stop_event = asyncio.Event()
        try:
            loop.add_signal_handler(signal.SIGINT, stop_event.set)
            loop.add_signal_handler(signal.SIGTERM, stop_event.set)
        except NotImplementedError:
            # Windows 的事件循环不支持 add_signal_handler;停止信号由
            # run_with_graceful_shutdown 统一处理(Ctrl+C 与重启接口都走那里)。
            pass
        await stop_event.wait()
    finally:
        await runner.cleanup()
    return bool(app["runtime_control"]["restart_requested"])


async def _align_internal_api(started: list) -> None:
    """监听器真正起来后复核内部 API 地址,必要时改到确实在监听的那个。"""

    listener, warning = resolve_internal_listener(LISTENER_PLAN, started)
    if warning:
        logger.warning("%s", warning)
    if listener is None:
        return
    if listener.tls:
        logger.warning(
            "内部 API 使用 HTTPS 监听器 %s:插件客户端不做 TLS 信任配置,"
            "自签名或证书与地址不匹配时插件将无法连接;建议设置 TRPG_WEB_HTTP_PORT "
            "提供一个明文内部端口",
            listener.address,
        )
    plugin_host = app.get("plugin_host")
    if plugin_host is None:
        return
    base = internal_api_base(listener)
    if plugin_host.base_env.get("TRPG_API_BASE") != base:
        plugin_host.base_env["TRPG_API_BASE"] = base
        logger.info("插件 API 基址已调整为 %s", base)
        # 已启动的插件在 spawn 时就快照了旧地址,只有重启进程才能拿到新基址。
        restarted = await plugin_host.restart_api_consumers()
        if restarted:
            logger.info("已重启 %d 个使用内部 API 的插件以应用新基址", restarted)


if __name__ == "__main__":
    runtime_log_path = configure_runtime_logging(CONFIG_FILE.parent)
    logger.info("运行日志写入 %s(保留 %s 天)", runtime_log_path, RETENTION_DAYS)
    if TRANSPORT.degraded_error:
        logger.critical("%s", TRANSPORT.degraded_error)
    if not is_llm_config_ready(STATE):
        print("请在 WebUI 的 AI 服务商与模型配置中设置主模型。")
    runtime_loop = asyncio.new_event_loop()
    install_runtime_exception_handler(runtime_loop)
    serve_task = runtime_loop.create_task(_serve(runtime_loop))
    try:
        restart_requested = run_with_graceful_shutdown(
            runtime_loop,
            serve_task,
            lambda: bool(app["runtime_control"]["restart_requested"]),
        )
    finally:
        runtime_loop.close()
    if restart_requested:
        logger.info("DiceFrame 清理完成,正在重新启动")
        os.execv(sys.executable, [sys.executable, *sys.argv])