Ctrl K
ComunidadGuías…

src/webui/services/generation.py

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

Archivo original ↗Preguntar a KiBot
"""AI 生成服务:连接测试 / 世界 / 角色 / 文本生成。"""

from __future__ import annotations

import logging
import json
import re
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable

from src.engine.language import DEFAULT_LANGUAGE, normalize_language
from src.generation import creator
from src.llm.openai_transport import OpenAIChatTransport
from src.rules.rule_system import RuleSystem
from src.webui.config_update import connection_test_timeout

logger = logging.getLogger("trpg")


@dataclass(frozen=True)
class ConnectionDependencies:
    llm_client: Any | None
    config_state: Callable[[], dict[str, Any]]


@dataclass(frozen=True)
class GenerationDependencies:
    llm_client: Any | None
    llm_configuration_error: Callable[[str], dict[str, Any] | None]
    worlds_dir: Path
    lorebook_store: Any
    rules_dir: Path
    registry: Any
    parse_game_key: Callable[[str], tuple[str, ...]]
    get_instance: Callable[[tuple[str, ...]], Any | None]
    load_rule_by_id: Callable[[str, str], RuleSystem | None]
    load_rule_for_game: Callable[[Any], RuleSystem | None]
    character_gen_max_tokens: int
    text_gen_max_tokens: int


def _model_list_url(base_url: str, api_format: str = "openai") -> str:
    url = base_url.rstrip("/")
    if url.endswith("/chat/completions"):
        return f"{url[:-len('/chat/completions')]}/models"
    if url.endswith("/messages"):
        url = url[:-len("/messages")]
    if url.endswith("/models"):
        return url
    if (api_format or "openai").strip().lower() == "anthropic" and not url.endswith("/v1"):
        return f"{url}/v1/models"
    return f"{url}/models"


def _extract_model_ids(payload: Any) -> list[str]:
    if isinstance(payload, dict):
        raw_models = payload.get("data")
        if not isinstance(raw_models, list):
            raw_models = payload.get("models")
    else:
        raw_models = payload
    if not isinstance(raw_models, list):
        return []

    models: list[str] = []
    seen: set[str] = set()
    for item in raw_models:
        if isinstance(item, dict):
            model = str(item.get("id") or item.get("name") or item.get("model") or "").strip()
        else:
            model = str(item or "").strip()
        if not model or model in seen:
            continue
        seen.add(model)
        models.append(model[:160])
        if len(models) >= 300:
            break
    return sorted(models, key=str.casefold)


async def list_models(
    dependencies: ConnectionDependencies,
    base_url: str,
    api_key: str,
    proxy_url: str = "",
    api_format: str = "openai",
) -> dict[str, Any]:
    """读取 OpenAI / Anthropic 兼容接口的模型目录。"""
    import aiohttp

    url = _model_list_url(base_url, api_format)
    anthropic = (api_format or "openai").strip().lower() == "anthropic"
    headers = (
        {"x-api-key": api_key, "anthropic-version": "2023-06-01"}
        if anthropic else {"Authorization": f"Bearer {api_key}"}
    )
    try:
        if not dependencies.llm_client:
            return {"ok": False, "error": "LLM 客户端未初始化", "models": []}
        session = await dependencies.llm_client._get_session()
        active_proxy = proxy_url or dependencies.llm_client.proxy_url
        request_kwargs = {"proxy": active_proxy} if active_proxy else {}
        async with session.get(
            url,
            headers=headers,
            timeout=aiohttp.ClientTimeout(
                total=connection_test_timeout(dependencies.config_state())
            ),
            **request_kwargs,
        ) as response:
            if response.status != 200:
                error_text = await response.text()
                return {"ok": False, "error": f"HTTP {response.status}: {error_text[:200]}", "models": []}
            try:
                payload = await response.json()
            except (aiohttp.ContentTypeError, json.JSONDecodeError):
                return {"ok": False, "error": "模型列表响应不是有效 JSON", "models": []}
            models = _extract_model_ids(payload)
            if not models:
                return {"ok": False, "error": "接口未返回可识别的模型列表", "models": []}
            return {"ok": True, "models": models, "count": len(models)}
    except Exception as exc:
        return {"ok": False, "error": str(exc), "models": []}


async def test_connection(
    dependencies: ConnectionDependencies,
    base_url: str,
    api_key: str,
    model: str,
    proxy_url: str = "",
    api_format: str = "openai",
) -> dict[str, Any]:
    """测试 LLM API 连接是否正常。"""
    import aiohttp
    if (api_format or "openai").strip().lower() == "anthropic":
        return await _test_anthropic_connection(
            dependencies, base_url, api_key, model, proxy_url,
        )
    return await _test_openai_connection(
        dependencies, base_url, api_key, model, proxy_url,
    )


async def _test_openai_connection(
    dependencies: ConnectionDependencies,
    base_url: str,
    api_key: str,
    model: str,
    proxy_url: str = "",
) -> dict[str, Any]:
    import aiohttp
    url = base_url.rstrip("/")
    if not url.endswith("/chat/completions"):
        url += "/chat/completions"

    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {api_key}",
    }
    body = {
        "model": model,
        "messages": [{"role": "user", "content": "回复:OK"}],
        "max_tokens": 10,
    }

    start = time.time()
    try:
        if not dependencies.llm_client:
            return {"ok": False, "error": "LLM 客户端未初始化", "elapsed": 0}
        session = await dependencies.llm_client._get_session()
        active_proxy = proxy_url or dependencies.llm_client.proxy_url
        request_kwargs = {"proxy": active_proxy} if active_proxy else {}
        async with OpenAIChatTransport().post(
            session, url, json=body, headers=headers,
            timeout=aiohttp.ClientTimeout(
                total=connection_test_timeout(dependencies.config_state())
            ),
            **request_kwargs,
        ) as resp:
            return await _parse_connection_test_response(resp, start)
    except Exception as e:
        return {"ok": False, "error": str(e), "elapsed": round(time.time() - start, 2)}


async def _test_anthropic_connection(
    dependencies: ConnectionDependencies,
    base_url: str,
    api_key: str,
    model: str,
    proxy_url: str = "",
) -> dict[str, Any]:
    import aiohttp
    from src.llm.client import _anthropic_messages_url, _anthropic_text_content

    url = _anthropic_messages_url(base_url)
    headers = {
        "Content-Type": "application/json",
        "x-api-key": api_key,
        "anthropic-version": "2023-06-01",
    }
    body = {
        "model": model,
        "messages": [{"role": "user", "content": "回复:OK"}],
        "max_tokens": 10,
    }

    start = time.time()
    try:
        if not dependencies.llm_client:
            return {"ok": False, "error": "LLM 客户端未初始化", "elapsed": 0}
        session = await dependencies.llm_client._get_session()
        active_proxy = proxy_url or dependencies.llm_client.proxy_url
        request_kwargs = {"proxy": active_proxy} if active_proxy else {}
        async with session.post(
            url, json=body, headers=headers,
            timeout=aiohttp.ClientTimeout(
                total=connection_test_timeout(dependencies.config_state())
            ),
            **request_kwargs,
        ) as resp:
            elapsed = round(time.time() - start, 2)
            if resp.status == 200:
                data = await resp.json()
                usage = data.get("usage", {})
                tokens = int(usage.get("input_tokens", 0) or 0) + int(usage.get("output_tokens", 0) or 0)
                return {
                    "ok": True,
                    "elapsed": elapsed,
                    "response": _anthropic_text_content(data).strip(),
                    "tokens": tokens,
                }
            error_text = await resp.text()
            return {
                "ok": False,
                "error": f"HTTP {resp.status}: {error_text[:200]}",
                "elapsed": elapsed,
            }
    except Exception as e:
        return {"ok": False, "error": str(e), "elapsed": round(time.time() - start, 2)}


async def _parse_connection_test_response(resp, start: float) -> dict[str, Any]:
    elapsed = round(time.time() - start, 2)
    if resp.status == 200:
        data = await resp.json()
        content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
        return {
            "ok": True,
            "elapsed": elapsed,
            "response": content.strip(),
            "tokens": data.get("usage", {}).get("total_tokens", 0),
        }
    error_text = await resp.text()
    return {
        "ok": False,
        "error": f"HTTP {resp.status}: {error_text[:200]}",
        "elapsed": elapsed,
    }


async def generate_world(
    dependencies: GenerationDependencies,
    prompt: str,
    rule_id: str = "",
    language: str = DEFAULT_LANGUAGE,
) -> dict[str, Any]:
    """使用 AI 根据用户描述生成完整世界模板。"""
    if config_error := dependencies.llm_configuration_error(language):
        return config_error
    try:
        return await _gen_world(
            dependencies, prompt, rule_id, normalize_language(language),
        )
    except Exception as e:
        logger.exception("AI 生成世界失败")
        return {"ok": False, "error": str(e)}


async def _gen_world(
    dependencies: GenerationDependencies,
    prompt: str,
    rule_id: str,
    language: str,
) -> dict[str, Any]:
    return await creator.generate_world(
        dependencies.llm_client, prompt, rule_id or "freeform_fantasy",
        worlds_dir=dependencies.worlds_dir,
        lorebook_store=dependencies.lorebook_store,
        max_tokens=dependencies.character_gen_max_tokens,
        language=language,
    )


def _slug(value: str) -> str:
    raw = re.sub(r"[^A-Za-z0-9_-]+", "_", value.strip())[:32].strip("_")
    return raw or "custom"


async def generate_rule(
    dependencies: GenerationDependencies,
    prompt: str,
    source_rule_id: str = "",
    language: str = DEFAULT_LANGUAGE,
) -> dict[str, Any]:
    """按母版规则生成并保存一套 AI 自定义规则。"""
    if config_error := dependencies.llm_configuration_error(language):
        return config_error
    prompt = (prompt or "").strip()
    if not prompt:
        return {"ok": False, "error": "请输入规则题材描述"}
    source_rule_id = (source_rule_id or "freeform_fantasy").strip()
    source_path = RuleSystem.path_for(
        dependencies.rules_dir, source_rule_id, language,
    )
    if not source_path.exists():
        return {"ok": False, "error": f"母版规则不存在: {source_rule_id}"}
    try:
        language = normalize_language(language)
        source_rule = json.loads(source_path.read_text(encoding="utf-8"))
        rule_id = f"ai_rule_{_slug(prompt)}_{int(time.time())}"
        data = await creator.generate_rule(
            dependencies.llm_client,
            prompt,
            source_rule=source_rule,
            source_rule_id=source_rule_id,
            rule_id=rule_id,
            max_tokens=max(dependencies.character_gen_max_tokens, 4096),
            language=language,
        )
        if not data:
            return {"ok": False, "error": "AI 返回规则 JSON 解析失败,请重试"}
        data["rule_id"] = rule_id
        data["custom"] = True
        data["source_rule_id"] = source_rule_id
        dependencies.rules_dir.mkdir(parents=True, exist_ok=True)
        target = RuleSystem.path_for(dependencies.rules_dir, rule_id)
        tmp = target.with_suffix(".json.tmp")
        tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
        # 读取一次确保 JSON 与规则系统兼容;不兼容则不落正式文件。
        RuleSystem.load(tmp)
        tmp.replace(target)
        return {
            "ok": True,
            "rule_id": rule_id,
            "rule_name": data.get("rule_name", rule_id),
            "description": data.get("description", ""),
            "source_rule_id": source_rule_id,
            "rule": data,
        }
    except Exception as e:
        logger.exception("AI 生成规则失败")
        return {"ok": False, "error": str(e)}


async def generate_character(
    dependencies: GenerationDependencies,
    prompt: str,
    game_key: str = "",
    rule_id: str = "",
    language: str = DEFAULT_LANGUAGE,
) -> dict[str, Any]:
    """使用 AI 根据用户描述生成角色卡。"""
    if config_error := dependencies.llm_configuration_error(language):
        return config_error
    try:
        resolved_language = normalize_language(language)
        rule = dependencies.load_rule_by_id(rule_id, resolved_language)
        if game_key:
            inst = dependencies.get_instance(
                dependencies.parse_game_key(game_key)
            )
            if inst:
                resolved_language = normalize_language(language or getattr(inst, "language", DEFAULT_LANGUAGE))
                rule = rule or dependencies.load_rule_for_game(inst)
        data = await creator.generate_character(
            dependencies.llm_client,
            prompt,
            game_key,
            dependencies.registry,
            rule=rule,
            max_tokens=dependencies.character_gen_max_tokens,
            language=resolved_language,
        )
        if not data:
            return {"ok": False, "error": "AI 返回内容解析失败,请重试"}
        return {"ok": True, "character": data}
    except Exception as e:
        logger.exception("AI 生成角色失败")
        return {"ok": False, "error": str(e)}


async def generate_text(
    dependencies: GenerationDependencies,
    prompt: str,
    system_hint: str = "",
    language: str = DEFAULT_LANGUAGE,
) -> dict[str, Any]:
    """轻量文字生成:直接发 prompt 给 LLM,返回原始文本,不解析 JSON。"""
    if config_error := dependencies.llm_configuration_error(language):
        return config_error
    llm_client = dependencies.llm_client
    if llm_client is None:
        return {"ok": False, "error": "LLM 客户端未初始化"}
    language = normalize_language(language)
    system = system_hint or (
        "You are a TRPG character and setting assistant. Answer briefly and practically in natural English. Do not output JSON."
        if language == "en"
        else "你是一个TRPG角色设定助手。根据用户描述,生成简短实用的回答。不要输出JSON,直接输出文字。"
    )
    try:
        response = await llm_client.call(
            system_prompt=system,
            user_message=prompt,
            temperature=0.7,
            max_tokens=dependencies.text_gen_max_tokens,
        )
        text = response.narration or response.content
        return {"ok": True, "text": text.strip()}
    except Exception as e:
        logger.exception("文字生成失败")
        return {"ok": False, "error": str(e)}