"""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)}