From 46e760a6881f79e92f374048c274cb65a2b0c817 Mon Sep 17 00:00:00 2001 From: professorczh Date: Wed, 15 Apr 2026 20:27:28 +0800 Subject: [PATCH 01/22] feat: add model download utility, update gitignore, and refactor CLI server invocation --- .gitignore | 7 ++++ cli.py | 3 +- frontend/package-lock.json | 41 +++++-------------- .../utils/download_model_via_modelscope.py | 6 +++ 4 files changed, 25 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index 065201e39..95f9e8319 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,10 @@ scripts/evaluation/results/ # Data directory (keep structure, ignore content) data/chromadb/ data/logs/ +data/config.json + +# Local Large Model Files +models/ + +# AI Assistant Scratch Directory +scratch/ diff --git a/cli.py b/cli.py index 2b43738e9..991e3ec8f 100644 --- a/cli.py +++ b/cli.py @@ -21,10 +21,9 @@ def main(args=None): if parsed_args.command == 'serve': import uvicorn - from .interfaces.main import app uvicorn.run( - app, + "interfaces.main:app", host=parsed_args.host, port=parsed_args.port, reload=parsed_args.reload diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 49a4d3989..c50623501 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { - "name": "web-app", - "version": "0.0.0", + "name": "plotpilot", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "web-app", - "version": "0.0.0", + "name": "plotpilot", + "version": "1.0.0", "dependencies": { "@types/dompurify": "^3.0.5", "@vicons/ionicons5": "^0.13.0", @@ -96,31 +96,6 @@ "vue": "^3.0.11" } }, - "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", @@ -128,7 +103,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -483,6 +457,7 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -841,6 +816,7 @@ "resolved": "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz", "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", "license": "MIT", + "peer": true, "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" @@ -863,6 +839,7 @@ "resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -1615,6 +1592,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1800,6 +1778,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1833,6 +1812,7 @@ "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -1929,6 +1909,7 @@ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.31.tgz", "integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.31", "@vue/compiler-sfc": "3.5.31", diff --git a/scripts/utils/download_model_via_modelscope.py b/scripts/utils/download_model_via_modelscope.py index e4cc73f51..99d27495f 100644 --- a/scripts/utils/download_model_via_modelscope.py +++ b/scripts/utils/download_model_via_modelscope.py @@ -8,6 +8,12 @@ # 添加项目根目录到路径 sys.path.insert(0, str(Path(__file__).parent.parent)) +# 处理 Windows 终端编码问题 +if sys.platform == 'win32': + import io + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') + print("=" * 60) print("使用 ModelScope 下载本地向量模型") print("=" * 60) From 9b80d1670794fa84fc59334b780a20e8e5dfa0a8 Mon Sep 17 00:00:00 2001 From: professorczh Date: Thu, 16 Apr 2026 00:38:07 +0800 Subject: [PATCH 02/22] feat: implement voice style analysis API and LLM configuration management system --- application/settings/llm_config_manager.py | 12 +- frontend/src/api/settings.ts | 2 +- frontend/src/components/LLMSettingsModal.vue | 9 +- infrastructure/ai/llm_client.py | 49 +++-- infrastructure/ai/providers/__init__.py | 6 + .../ai/providers/gemini_provider.py | 184 ++++++++++++++++++ interfaces/api/dependencies.py | 35 ++++ interfaces/api/v1/analyst/voice.py | 9 +- interfaces/api/v1/core/settings.py | 70 ++++++- requirements.txt | 1 + 10 files changed, 347 insertions(+), 30 deletions(-) create mode 100644 infrastructure/ai/providers/gemini_provider.py diff --git a/application/settings/llm_config_manager.py b/application/settings/llm_config_manager.py index a93779016..61da55990 100644 --- a/application/settings/llm_config_manager.py +++ b/application/settings/llm_config_manager.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) -ProviderType = Literal["openai", "anthropic"] +ProviderType = Literal["openai", "anthropic", "gemini"] EmbeddingMode = Literal["local", "openai"] @@ -215,11 +215,17 @@ def _apply_to_env(self, profile: LLMConfigProfile) -> None: env["ARK_MODEL"] = profile.model for k in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"): env.pop(k, None) - else: + elif profile.provider == "anthropic": env["LLM_PROVIDER"] = "anthropic" env["ANTHROPIC_API_KEY"] = profile.api_key env["ANTHROPIC_BASE_URL"] = profile.base_url - for k in ("OPENAI_API_KEY", "OPENAI_BASE_URL", "ARK_API_KEY", "ARK_BASE_URL", "ARK_MODEL"): + for k in ("OPENAI_API_KEY", "OPENAI_BASE_URL", "ARK_API_KEY", "ARK_BASE_URL", "ARK_MODEL", "GEMINI_API_KEY", "GEMINI_BASE_URL"): + env.pop(k, None) + elif profile.provider == "gemini": + env["LLM_PROVIDER"] = "gemini" + env["GEMINI_API_KEY"] = profile.api_key + env["GEMINI_BASE_URL"] = profile.base_url + for k in ("OPENAI_API_KEY", "OPENAI_BASE_URL", "ARK_API_KEY", "ARK_BASE_URL", "ARK_MODEL", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"): env.pop(k, None) writing = profile.writing_model or profile.model diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 4a7376ef2..bdc1810c7 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -3,7 +3,7 @@ import { apiClient } from './config' export interface LLMConfigProfile { id: string name: string - provider: 'openai' | 'anthropic' + provider: 'openai' | 'anthropic' | 'gemini' api_key: string base_url: string model: string diff --git a/frontend/src/components/LLMSettingsModal.vue b/frontend/src/components/LLMSettingsModal.vue index ee7b648a2..57384c033 100644 --- a/frontend/src/components/LLMSettingsModal.vue +++ b/frontend/src/components/LLMSettingsModal.vue @@ -42,9 +42,9 @@ - {{ cfg.provider === 'openai' ? 'OpenAI' : 'Anthropic' }} + {{ cfg.provider === 'openai' ? 'OpenAI' : (cfg.provider === 'gemini' ? 'Gemini' : 'Anthropic') }} 激活 @@ -108,7 +108,7 @@ @@ -306,7 +306,7 @@ const modelOptions = ref>([]) const form = ref({ name: '', - provider: 'openai' as 'openai' | 'anthropic', + provider: 'openai' as 'openai' | 'anthropic' | 'gemini', api_key: '', base_url: '', model: '', @@ -317,6 +317,7 @@ const form = ref({ const providerOptions = [ { label: 'OpenAI Compatible', value: 'openai' }, { label: 'Anthropic', value: 'anthropic' }, + { label: 'Google Gemini', value: 'gemini' }, ] function startCreate() { diff --git a/infrastructure/ai/llm_client.py b/infrastructure/ai/llm_client.py index 745cb31a0..e67a857c0 100644 --- a/infrastructure/ai/llm_client.py +++ b/infrastructure/ai/llm_client.py @@ -21,25 +21,52 @@ def __init__(self, provider=None): if provider: self.provider = provider else: - # 优先 ARK (OpenAI 兼容),其次 Anthropic,最后 Mock - ark_key = os.getenv("ARK_API_KEY", "").strip() - anthropic_key = (os.getenv("ANTHROPIC_API_KEY") or os.getenv("ANTHROPIC_AUTH_TOKEN") or "").strip() - - if ark_key: + provider_name = os.getenv("LLM_PROVIDER", "").lower() + + if provider_name == "openai": settings = Settings( - api_key=ark_key, - base_url=os.getenv("ARK_BASE_URL", "").strip() or None, + api_key=os.getenv("OPENAI_API_KEY", "").strip(), + base_url=os.getenv("OPENAI_BASE_URL", "").strip() or None, default_model=os.getenv("ARK_MODEL", ""), ) self.provider = OpenAIProvider(settings) - elif anthropic_key: + elif provider_name == "gemini": + from infrastructure.ai.providers.gemini_provider import GeminiProvider + settings = Settings( + api_key=os.getenv("GEMINI_API_KEY", "").strip(), + base_url=os.getenv("GEMINI_BASE_URL", "").strip() or None, + default_model=os.getenv("WRITING_MODEL", ""), # 使用 Manager 注入的环境变量 + ) + self.provider = GeminiProvider(settings) + elif provider_name == "anthropic": settings = Settings( - api_key=anthropic_key, - base_url=self._get_base_url() + api_key=os.getenv("ANTHROPIC_API_KEY", "").strip(), + base_url=os.getenv("ANTHROPIC_BASE_URL", "").strip() or None ) self.provider = AnthropicProvider(settings) else: - self.provider = MockProvider() + # 兼容旧逻辑/环境变量 + self.provider = self._auto_detect_provider() + + def _auto_detect_provider(self): + """兼容旧逻辑:根据环境变量自动探测提供商""" + ark_key = os.getenv("ARK_API_KEY", "").strip() + anthropic_key = (os.getenv("ANTHROPIC_API_KEY") or os.getenv("ANTHROPIC_AUTH_TOKEN") or "").strip() + + if ark_key: + settings = Settings( + api_key=ark_key, + base_url=os.getenv("ARK_BASE_URL", "").strip() or None, + default_model=os.getenv("ARK_MODEL", ""), + ) + return OpenAIProvider(settings) + elif anthropic_key: + settings = Settings( + api_key=anthropic_key, + base_url=os.getenv("ANTHROPIC_BASE_URL", "").strip() or None + ) + return AnthropicProvider(settings) + return MockProvider() def _get_api_key(self) -> Optional[str]: """获取 API key""" diff --git a/infrastructure/ai/providers/__init__.py b/infrastructure/ai/providers/__init__.py index 46fd68328..5384da343 100644 --- a/infrastructure/ai/providers/__init__.py +++ b/infrastructure/ai/providers/__init__.py @@ -18,3 +18,9 @@ __all__.append("OpenAIProvider") except ModuleNotFoundError: OpenAIProvider = None + +try: + from .gemini_provider import GeminiProvider + __all__.append("GeminiProvider") +except ModuleNotFoundError: + GeminiProvider = None diff --git a/infrastructure/ai/providers/gemini_provider.py b/infrastructure/ai/providers/gemini_provider.py new file mode 100644 index 000000000..7890a99ae --- /dev/null +++ b/infrastructure/ai/providers/gemini_provider.py @@ -0,0 +1,184 @@ +"""Google Gemini LLM 提供商实现 (SDK 1.0+)""" +import logging +import os +from typing import AsyncIterator, Optional, Type, Union + +from google import genai +from google.genai import types +from pydantic import BaseModel + +from domain.ai.services.llm_service import GenerationConfig, GenerationResult +from domain.ai.value_objects.prompt import Prompt +from domain.ai.value_objects.token_usage import TokenUsage +from .base import BaseProvider + +logger = logging.getLogger(__name__) + +# 默认模型:高性价比、低延迟的预览版 +DEFAULT_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.0-flash-lite-preview-02-05") + + +class GeminiProvider(BaseProvider): + """Google Gemini LLM 提供商实现 + + 使用最新的 google-genai SDK (1.0+) 实现,支持 Pydantic 结构化输出。 + """ + + def __init__(self, settings): + """初始化 Gemini 提供商 + + Args: + settings: AI 配置设置 (包含 api_key 和 base_url) + """ + super().__init__(settings) + + if not settings.api_key: + raise ValueError("API key is required for GeminiProvider") + + # 配置 HTTP 选项(支持代理/Base URL) + http_options = None + if settings.base_url: + # 去掉末尾斜杠,并显式指定 v1beta 版本 + base_url = settings.base_url.rstrip("/") + http_options = types.HttpOptions( + base_url=base_url, + api_version="v1beta" + ) + + # 初始化 GenAI Client + api_key = settings.api_key.strip() if settings.api_key else "" + + # 硬核加固:无视外部环境变量,强制注入稳定的 socks5h 代理并禁用 HTTP/2 + import os + os.environ["HTTPX_HTTP2"] = "0" + os.environ["HTTP_PROXY"] = "socks5h://127.0.0.1:10808" + os.environ["HTTPS_PROXY"] = "socks5h://127.0.0.1:10808" + + self.client = genai.Client( + api_key=api_key, + http_options=http_options + ) + + async def generate( + self, + prompt: Prompt, + config: GenerationConfig + ) -> GenerationResult: + """生成文本 + + Args: + prompt: 提示词 (system, user) + config: 生成配置 (model, temperature, max_tokens, response_format) + + Returns: + 生成结果 + """ + try: + # 1. 基础生成配置 + # 安全设置:极简模式下设为最宽松,防止小说创作被拦截 + safety_settings = [ + types.SafetySetting( + category=cat, + threshold="BLOCK_NONE" + ) for cat in [ + "HARM_CATEGORY_HATE_SPEECH", "HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_DANGEROUS_CONTENT" + ] + ] + + gen_config_kwargs = { + "temperature": config.temperature, + "max_output_tokens": config.max_tokens, + "system_instruction": prompt.system, + "safety_settings": safety_settings, + } + + # 2. 结构化输出支持 (Pydantic 优先) + target_schema = config.response_format + if target_schema: + gen_config_kwargs["response_mime_type"] = "application/json" + # 如果是 Pydantic 类,直接传入 SDK 会自动处理 schema + if isinstance(target_schema, type) and issubclass(target_schema, BaseModel): + gen_config_kwargs["response_json_schema"] = target_schema + else: + # 否则假设已经是 schema 字典或字符串 + gen_config_kwargs["response_json_schema"] = target_schema + + # 3. 调用 API + response = await self.client.aio.models.generate_content( + model=config.model or self.settings.default_model or DEFAULT_MODEL, + contents=prompt.user, + config=types.GenerateContentConfig(**gen_config_kwargs) + ) + + if not response.text: + raise RuntimeError("Gemini API returned empty content") + + content = response.text + + # 4. 如果启用了结构化输出,进行二次校验(确保 100% 稳定性) + if target_schema and isinstance(target_schema, type) and issubclass(target_schema, BaseModel): + try: + # 使用 Pydantic 的 model_validate_json + validated_obj = target_schema.model_validate_json(content) + content = validated_obj.model_dump_json() + except Exception as e: + logger.error(f"Gemini output fails Pydantic validation: {e}\nRaw: {content}") + # 如果校验失败,我们仍然返回原始字符串,但记录错误 + # 或者根据项目需求抛出异常 + + # 5. 组装结果 + usage = response.usage_metadata + token_usage = TokenUsage( + input_tokens=usage.prompt_token_count or 0, + output_tokens=usage.candidates_token_count or 0 + ) + + return GenerationResult(content=content, token_usage=token_usage) + + except Exception as e: + logger.error(f"Gemini generation error: {type(e).__name__}: {str(e)}", exc_info=True) + raise RuntimeError(f"Gemini generation failed ({type(e).__name__}): {str(e) or 'No error message'}") from e + + async def stream_generate( + self, + prompt: Prompt, + config: GenerationConfig + ) -> AsyncIterator[str]: + """流式生成内容 + + Args: + prompt: 提示词 + config: 生成配置 + + Yields: + 生成的文本片段 + """ + try: + # 基础流式配置(不含结构化约束,SSE 下结构化支持有限) + safety_settings = [ + types.SafetySetting( + category=cat, + threshold="BLOCK_NONE" + ) for cat in [ + "HATE_SPEECH", "HARASSMENT", "SEXUALLY_EXPLICIT", "DANGEROUS_CONTENT" + ] + ] + + gen_config = types.GenerateContentConfig( + temperature=config.temperature, + max_output_tokens=config.max_tokens, + system_instruction=prompt.system, + safety_settings=safety_settings, + ) + + async for chunk in await self.client.aio.models.generate_content_stream( + model=config.model or self.settings.default_model or DEFAULT_MODEL, + contents=prompt.user, + config=gen_config + ): + if chunk.text: + yield chunk.text + + except Exception as e: + logger.error(f"Gemini streaming failed: {e}") + raise RuntimeError(f"Gemini streaming failed: {str(e)}") from e diff --git a/interfaces/api/dependencies.py b/interfaces/api/dependencies.py index e82be94fe..e4a0052fa 100644 --- a/interfaces/api/dependencies.py +++ b/interfaces/api/dependencies.py @@ -120,6 +120,33 @@ def _openai_settings(require_key: bool = True) -> Optional[Settings]: ) +def _gemini_api_key() -> Optional[str]: + raw = os.getenv("GEMINI_API_KEY") + if raw is None: + return None + key = raw.strip() + return key or None + + +def _gemini_base_url() -> Optional[str]: + u = os.getenv("GEMINI_BASE_URL") + return u.strip() if u and u.strip() else None + + +def _gemini_settings(require_key: bool = True) -> Optional[Settings]: + """构建 Gemini Settings。""" + key = _gemini_api_key() + if not key: + if require_key: + raise ValueError("Set GEMINI_API_KEY (optional: GEMINI_BASE_URL)") + return None + return Settings( + api_key=key, + base_url=_gemini_base_url(), + default_model=os.getenv("WRITING_MODEL", ""), + ) + + def get_storage() -> FileStorage: """获取存储后端实例 @@ -329,6 +356,14 @@ def get_llm_service(): return OpenAIProvider(settings) except ModuleNotFoundError as e: logger.warning("OpenAI provider dependency missing, fallback to MockProvider: %s", e) + elif provider == "gemini": + settings = _gemini_settings(require_key=False) + if settings: + try: + from infrastructure.ai.providers.gemini_provider import GeminiProvider + return GeminiProvider(settings) + except ModuleNotFoundError as e: + logger.warning("Gemini provider dependency missing, fallback to MockProvider: %s", e) else: settings = _anthropic_settings(require_key=False) if settings: diff --git a/interfaces/api/v1/analyst/voice.py b/interfaces/api/v1/analyst/voice.py index f333bc508..589c5c63b 100644 --- a/interfaces/api/v1/analyst/voice.py +++ b/interfaces/api/v1/analyst/voice.py @@ -79,7 +79,7 @@ def create_voice_sample( @router.get( "/novels/{novel_id}/voice/fingerprint", - response_model=VoiceFingerprintResponse, + response_model=Optional[VoiceFingerprintResponse], status_code=200, summary="获取文风指纹", description="获取小说的文风指纹统计数据" @@ -88,7 +88,7 @@ def get_voice_fingerprint( novel_id: str = Path(..., description="小说 ID"), pov_character_id: Optional[str] = Query(None, description="POV 角色 ID"), service=Depends(get_voice_fingerprint_service) -) -> VoiceFingerprintResponse: +) -> Optional[VoiceFingerprintResponse]: """ 获取文风指纹 @@ -103,10 +103,7 @@ def get_voice_fingerprint( try: fingerprint = service.fingerprint_repo.get_by_novel(novel_id, pov_character_id) if not fingerprint: - raise HTTPException( - status_code=404, - detail=f"Voice fingerprint not found for novel {novel_id}" - ) + return None return VoiceFingerprintResponse( adjective_density=fingerprint["adjective_density"], diff --git a/interfaces/api/v1/core/settings.py b/interfaces/api/v1/core/settings.py index 5e79aa5d1..bdce0ef4a 100644 --- a/interfaces/api/v1/core/settings.py +++ b/interfaces/api/v1/core/settings.py @@ -88,6 +88,9 @@ def activate_config(config_id: str): async def fetch_models(body: FetchModelsRequest): if body.provider == "anthropic": return await _fetch_anthropic_models(body.api_key, body.base_url) + + if body.provider == "gemini": + return await _fetch_gemini_models(body.api_key, body.base_url) if not body.base_url: return [] @@ -129,16 +132,33 @@ async def fetch_embedding_models(body: FetchModelsRequest): # ── helpers ───────────────────────────────────────────────── async def _fetch_openai_models(api_key: str, base_url: str) -> List[str]: + # 强制禁用 http2(解决代理环境下的 SSL UNEXPECTED_EOF 问题) url = f"{base_url.rstrip('/')}/models" + params = {} + headers = {"Authorization": f"Bearer {api_key}"} + + # 针对 Gemini 官方域名的特殊处理 + if "generativelanguage.googleapis.com" in url: + if "/v1" not in url: + url = f"{base_url.rstrip('/')}/v1beta/models" + params["key"] = api_key + headers = {} + try: - async with httpx.AsyncClient(timeout=10) as client: - resp = await client.get(url, headers={"Authorization": f"Bearer {api_key}"}) + async with httpx.AsyncClient(timeout=10, http2=False) as client: + resp = await client.get(url, headers=headers, params=params) resp.raise_for_status() data = resp.json() - models = data.get("data", []) - return sorted(m["id"] for m in models if "id" in m) + models = data.get("models") or data.get("data", []) + results = [] + for m in models: + name = m.get("name", m.get("id")) + if name: + if name.startswith("models/"): name = name[7:] + results.append(name) + return sorted(results) except httpx.HTTPStatusError as exc: - raise HTTPException(502, f"API returned {exc.response.status_code}") + raise HTTPException(502, f"API returned {exc.response.status_code}: {exc.response.text}") except Exception as exc: logger.warning("fetch-models failed: %s", exc) raise HTTPException(502, f"Failed to fetch models: {exc}") @@ -169,3 +189,43 @@ async def _fetch_anthropic_models(api_key: str, base_url: str) -> List[str]: return await _fetch_openai_models(api_key, fallback_base) except Exception: raise HTTPException(502, f"Failed to fetch models (tried both Anthropic and OpenAI format): {exc}") + +async def _fetch_gemini_models(api_key: str, base_url: str) -> List[str]: + try: + from google import genai + from google.genai import types + + api_key = api_key.strip() + http_opts = None + if base_url: + http_opts = types.HttpOptions(base_url=base_url.rstrip("/")) + + # 强制注入代理并禁用 HTTP/2 (解决子进程连接与 SSL 握手问题) + import os + os.environ["HTTPX_HTTP2"] = "0" + os.environ["HTTP_PROXY"] = "socks5h://127.0.0.1:10808" + os.environ["HTTPS_PROXY"] = "socks5h://127.0.0.1:10808" + + client = genai.Client(api_key=api_key, http_options=http_opts) + models = [] + # 使用同步 list_models,因为它通常比异步更稳定且此处并无高并发需求 + for m in client.models.list(): + # 过滤掉不支持生成内容或嵌入的模型,且只保留 gemini 系列 + if "generateContent" in m.supported_generation_methods and "gemini" in m.name: + # 去掉 models/ 前缀 + name = m.name + if name.startswith("models/"): + name = name[7:] + models.append(name) + return sorted(models) + except Exception as exc: + logger.warning("Gemini SDK fetch-models failed, trying direct HTTP fallback: %s", exc) + # 尝试通过原始 HTTP 请求绕过 SDK 的 SSL 限制 + try: + # 构造 Gemini 的标准 OpenAI 兼容模型列表 URL + fallback_base = base_url or "https://generativelanguage.googleapis.com" + # 这是一个常见的绕过 SDK SSL 限制的手段 + return await _fetch_openai_models(api_key, fallback_base) + except Exception as fallback_exc: + logger.error("All fetch methods failed: %s", fallback_exc) + raise HTTPException(502, f"Failed to fetch Gemini models: {exc}") diff --git a/requirements.txt b/requirements.txt index f7de57d77..9ee251eed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,4 @@ sentence-transformers>=2.2.0 qdrant-client>=1.7.0,<2.0.0 openai>=1.12.0,<2.0.0 json-repair>=0.30.0 +google-genai>=1.0.0 From 5c7cb9b7c30b503594fa30e1a822c1cdb302e8a3 Mon Sep 17 00:00:00 2001 From: professorczh Date: Thu, 16 Apr 2026 02:10:38 +0800 Subject: [PATCH 03/22] feat: implement theme management system with multi-mode support and integrate LLM provider infrastructure with context budgeting services. --- application/ai/embedding_config_service.py | 182 ++++ application/ai/llm_control_service.py | 556 +++++++++++++ .../services/context_budget_allocator.py | 2 + .../engine/services/context_builder.py | 3 + frontend/src/App.vue | 138 +++- frontend/src/assets/styles/main.css | 396 +++++++-- frontend/src/components/LLMSettingsModal.vue | 781 +++++------------- .../global/GlobalLLMEntryButton.vue | 727 ++++++++++++++++ frontend/src/components/stats/StatCard.vue | 29 +- .../src/components/stats/StatsSidebar.vue | 70 +- frontend/src/stores/themeStore.ts | 63 ++ frontend/src/views/Home.vue | 152 +++- infrastructure/ai/provider_factory.py | 109 +++ infrastructure/ai/url_utils.py | 50 ++ .../persistence/database/schema.sql | 111 +++ interfaces/api/dependencies.py | 174 ++-- interfaces/api/v1/core/settings.py | 227 +++-- interfaces/api/v1/workbench/llm_control.py | 407 +++++++++ load_env.py | 3 +- 19 files changed, 3200 insertions(+), 980 deletions(-) create mode 100644 application/ai/embedding_config_service.py create mode 100644 application/ai/llm_control_service.py create mode 100644 frontend/src/components/global/GlobalLLMEntryButton.vue create mode 100644 frontend/src/stores/themeStore.ts create mode 100644 infrastructure/ai/provider_factory.py create mode 100644 infrastructure/ai/url_utils.py create mode 100644 interfaces/api/v1/workbench/llm_control.py diff --git a/application/ai/embedding_config_service.py b/application/ai/embedding_config_service.py new file mode 100644 index 000000000..582931054 --- /dev/null +++ b/application/ai/embedding_config_service.py @@ -0,0 +1,182 @@ +"""EmbeddingConfigService — 嵌入模型配置管理服务(数据库驱动)。 + +将嵌入模型配置持久化到 SQLite embedding_config 表, +替代之前硬编码默认值的临时实现。 +""" +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Any, Dict, Optional + +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +class EmbeddingConfigModel(BaseModel): + """嵌入配置数据模型。""" + id: str = "default" + mode: str = "local" # local | openai + api_key: str = "" + base_url: str = "" + model: str = "text-embedding-3-small" + use_gpu: bool = True + model_path: str = "BAAI/bge-small-zh-v1.5" + created_at: str = "" + updated_at: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "mode": self.mode, + "api_key": self.api_key, + "base_url": self.base_url, + "model": self.model, + "use_gpu": self.use_gpu, + "model_path": self.model_path, + } + + @classmethod + def from_row(cls, row: Dict[str, Any]) -> "EmbeddingConfigModel": + return cls( + id=row["id"], + mode=row.get("mode", "local"), + api_key=row.get("api_key", ""), + base_url=row.get("base_url", ""), + model=row.get("model", "text-embedding-3-small"), + use_gpu=bool(row.get("use_gpu", 1)), + model_path=row.get("model_path", "BAAI/bge-small-zh-v1.5"), + created_at=row.get("created_at", ""), + updated_at=row.get("updated_at", ""), + ) + + +class EmbeddingConfigService: + """嵌入模型配置服务 — 数据库 CRUD。 + + 设计决策: + - 单行配置(id='default'),全局唯一 + - 首次读取时自动插入默认行 + - 所有写操作更新 updated_at + """ + + _DEFAULTS = { + "id": "default", + "mode": "local", + "api_key": "", + "base_url": "", + "model": "text-embedding-3-small", + "use_gpu": 1, + "model_path": "BAAI/bge-small-zh-v1.5", + } + + def __init__(self, db_connection=None): + self._db = db_connection + + def _get_db(self): + """获取数据库连接(延迟导入避免循环依赖)。""" + if self._db is not None: + return self._db + from infrastructure.persistence.database.connection import DatabaseConnection + from load_env import PROJECT_ROOT + db_path = str(PROJECT_ROOT / "data" / "aitext.db") + try: + from interfaces.api.dependencies import get_db as _get_global_db + return _get_global_db() + except Exception: + pass + return DatabaseConnection(db_path) + + def _ensure_row(self) -> None: + """确保存在默认配置行(幂等)。""" + db = self._get_db() + row = db.execute( + "SELECT id FROM embedding_config WHERE id = ? LIMIT 1", + ("default",), + ).fetchone() + if row: + return + now = datetime.now().isoformat() + db.execute(""" + INSERT OR IGNORE INTO embedding_config + (id, mode, api_key, base_url, model, use_gpu, model_path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + "default", "local", "", "", "text-embedding-3-small", + 1, "BAAI/bge-small-zh-v1.5", now, now, + )) + db.commit() + logger.info("EmbeddingConfigService: 已初始化默认嵌入配置") + + def get_config(self) -> EmbeddingConfigModel: + """获取当前嵌入配置。""" + self._ensure_row() + db = self._get_db() + row = db.execute( + "SELECT * FROM embedding_config WHERE id = ? LIMIT 1", + ("default",), + ).fetchone() + if not row: + # 兜底:返回默认模型 + return EmbeddingConfigModel() + return EmbeddingConfigModel.from_row(dict(row)) + + def update_config(self, **kwargs) -> EmbeddingConfigModel: + """更新嵌入配置。 + + Args: + **kwargs: 要更新的字段(mode, api_key, base_url, model, use_gpu, model_path) + + Returns: + 更新后的配置 + """ + self._ensure_row() + db = self._get_db() + + # 构建动态 UPDATE + allowed = {"mode", "api_key", "base_url", "model", "use_gpu", "model_path"} + set_clauses = [] + params: list = [] + for key, value in kwargs.items(): + if key not in allowed: + continue + if key == "use_gpu": + value = 1 if value else 0 + set_clauses.append(f"{key} = ?") + params.append(value) + + if not set_clauses: + return self.get_config() + + now = datetime.now().isoformat() + set_clauses.append("updated_at = ?") + params.append(now) + params.append("default") # WHERE id = ? + + sql = f"UPDATE embedding_config SET {', '.join(set_clauses)} WHERE id = ?" + db.execute(sql, params) + db.commit() + + logger.info("EmbeddingConfigService: 配置已更新,字段: %s", list(kwargs.keys())) + return self.get_config() + + def to_api_dict(self) -> Dict[str, Any]: + """返回 API 友好的字典格式。""" + cfg = self.get_config() + result = cfg.to_dict() + result["created_at"] = cfg.created_at + result["updated_at"] = cfg.updated_at + return result + + +# ── 单例 ────────────────────────────────────────────── + +_service_instance: Optional[EmbeddingConfigService] = None + + +def get_embedding_config_service() -> EmbeddingConfigService: + """获取全局 EmbeddingConfigService 单例。""" + global _service_instance + if _service_instance is None: + _service_instance = EmbeddingConfigService() + return _service_instance diff --git a/application/ai/llm_control_service.py b/application/ai/llm_control_service.py new file mode 100644 index 000000000..062f11845 --- /dev/null +++ b/application/ai/llm_control_service.py @@ -0,0 +1,556 @@ +from __future__ import annotations + +import json +import logging +import os +from time import perf_counter +from typing import Any, Callable, Dict, List, Literal, Optional + +from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator + +from infrastructure.persistence.database.connection import get_database +from infrastructure.ai.url_utils import ( + normalize_anthropic_base_url, + normalize_gemini_base_url, + normalize_openai_base_url, +) + +logger = logging.getLogger(__name__) + +LLMProtocol = Literal['openai', 'anthropic', 'gemini'] + + +class LLMPreset(BaseModel): + key: str + label: str + protocol: LLMProtocol + default_base_url: str = '' + default_model: str = '' + description: str = '' + tags: List[str] = Field(default_factory=list) + + +class LLMProfile(BaseModel): + model_config = ConfigDict(extra='ignore') + + id: str + name: str + preset_key: str = 'custom-openai-compatible' + protocol: LLMProtocol = 'openai' + base_url: str = '' + api_key: str = '' + model: str = '' + temperature: float = 0.7 + max_tokens: int = 4096 + timeout_seconds: int = 300 + extra_headers: Dict[str, str] = Field(default_factory=dict) + extra_query: Dict[str, Any] = Field(default_factory=dict) + extra_body: Dict[str, Any] = Field(default_factory=dict) + notes: str = '' + + @field_validator('temperature') + @classmethod + def _validate_temperature(cls, value: float) -> float: + if not 0 <= value <= 2: + raise ValueError('temperature must be between 0 and 2') + return value + + @field_validator('max_tokens', 'timeout_seconds') + @classmethod + def _validate_positive_int(cls, value: int) -> int: + if value <= 0: + raise ValueError('value must be positive') + return value + + @field_validator('extra_headers') + @classmethod + def _normalize_headers(cls, value: Dict[str, str]) -> Dict[str, str]: + return { + str(k).strip(): str(v).strip() + for k, v in (value or {}).items() + if str(k).strip() and str(v).strip() + } + + +class LLMControlConfig(BaseModel): + version: int = 1 + active_profile_id: Optional[str] = None + profiles: List[LLMProfile] = Field(default_factory=list) + + @model_validator(mode='after') + def _validate_active_profile(self) -> 'LLMControlConfig': + if not self.profiles: + return self + ids = [profile.id for profile in self.profiles] + if not self.active_profile_id or self.active_profile_id not in ids: + self.active_profile_id = ids[0] + return self + + +class LLMRuntimeSummary(BaseModel): + source: Literal['profile', 'mock'] + active_profile_id: Optional[str] = None + active_profile_name: Optional[str] = None + protocol: Optional[LLMProtocol] = None + model: Optional[str] = None + base_url: Optional[str] = None + using_mock: bool = False + reason: Optional[str] = None + + +class LLMControlPanelData(BaseModel): + config: LLMControlConfig + presets: List[LLMPreset] + runtime: LLMRuntimeSummary + + +class LLMTestResult(BaseModel): + ok: bool + provider_label: str + model: str + latency_ms: int + preview: str = '' + error: Optional[str] = None + + +class LLMControlService: + """LLM 控制面板服务 —— 配置全部持久化到 SQLite(llm_profiles + llm_config_meta 表)。""" + + _DEFAULT_OPENAI_MODEL = os.getenv('OPENAI_MODEL', 'gpt-4o') + _DEFAULT_ANTHROPIC_MODEL = os.getenv('ANTHROPIC_MODEL', 'claude-sonnet-4-6') + _DEFAULT_GEMINI_MODEL = os.getenv('GEMINI_MODEL', 'gemini-2.0-flash') + _DEFAULT_ARK_MODEL = os.getenv('ARK_MODEL', 'doubao-seed-2-0-mini-260215') + + # ---- 内部 DB 辅助 ------------------------------------------------ + + def _db(self): + return get_database() + + def _get_meta(self, key: str, default: str = '') -> str: + row = self._db().fetch_one( + "SELECT value FROM llm_config_meta WHERE key = ?", (key,) + ) + return row['value'] if row else default + + def _set_meta(self, key: str, value: str) -> None: + self._db().execute( + "INSERT OR REPLACE INTO llm_config_meta (key, value) VALUES (?, ?)", + (key, value), + ) + + def _row_to_profile(self, row: dict) -> LLMProfile: + return LLMProfile( + id=row['id'], + name=row['name'], + preset_key=row['preset_key'], + protocol=row['protocol'], + base_url=row['base_url'] or '', + api_key=row['api_key'] or '', + model=row['model'] or '', + temperature=row['temperature'], + max_tokens=row['max_tokens'], + timeout_seconds=row['timeout_seconds'], + extra_headers=json.loads(row.get('extra_headers') or '{}'), + extra_query=json.loads(row.get('extra_query') or '{}'), + extra_body=json.loads(row.get('extra_body') or '{}'), + notes=row['notes'] or '', + ) + + def _profile_to_row(self, p: LLMProfile) -> dict: + return dict( + id=p.id, + name=p.name, + preset_key=p.preset_key, + protocol=p.protocol, + base_url=p.base_url, + api_key=p.api_key, + model=p.model, + temperature=p.temperature, + max_tokens=p.max_tokens, + timeout_seconds=p.timeout_seconds, + extra_headers=json.dumps(p.extra_headers, ensure_ascii=False), + extra_query=json.dumps(p.extra_query, ensure_ascii=False), + extra_body=json.dumps(p.extra_body, ensure_ascii=False), + notes=p.notes, + ) + + # ---- 公共接口(保持与原文件版一致)---------------------------------- + + def get_presets(self) -> List[LLMPreset]: + return [ + LLMPreset( + key='custom-openai-compatible', + label='OpenAI 兼容 / 国产通用', + protocol='openai', + default_base_url='', + default_model='', + description='适用于所有 OpenAI-compatible 网关:OpenAI、DeepSeek、Qwen、GLM、豆包、SiliconFlow、OpenRouter 等。', + tags=['custom', 'openai-compatible', 'domestic'], + ), + LLMPreset( + key='openai-official', + label='OpenAI 官方', + protocol='openai', + default_base_url='https://api.openai.com/v1', + default_model=self._DEFAULT_OPENAI_MODEL, + description='OpenAI 官方接口(自动兼容底层 Responses API 与 Chat Completions)。', + tags=['official'], + ), + LLMPreset( + key='claude-official', + label='Claude / Anthropic 官方', + protocol='anthropic', + default_base_url='https://api.anthropic.com', + default_model=self._DEFAULT_ANTHROPIC_MODEL, + description='Anthropic Messages 接口;也可接入 Claude-compatible 网关。', + tags=['official'], + ), + LLMPreset( + key='gemini-official', + label='Gemini / Google 官方', + protocol='gemini', + default_base_url='https://generativelanguage.googleapis.com/v1beta', + default_model=self._DEFAULT_GEMINI_MODEL, + description='Gemini generateContent / streamGenerateContent 接口。', + tags=['official'], + ), + LLMPreset( + key='deepseek', + label='DeepSeek', + protocol='openai', + default_base_url='https://api.deepseek.com/v1', + default_model='deepseek-chat', + description='DeepSeek 官方 OpenAI-compatible 接口。', + tags=['domestic', 'preset'], + ), + LLMPreset( + key='qwen-dashscope', + label='Qwen / DashScope', + protocol='openai', + default_base_url='https://dashscope.aliyuncs.com/compatible-mode/v1', + default_model='qwen-plus', + description='阿里云百炼 / DashScope OpenAI-compatible 接口。', + tags=['domestic', 'preset'], + ), + LLMPreset( + key='glm-openai', + label='智谱 GLM(OpenAI 兼容)', + protocol='openai', + default_base_url='https://open.bigmodel.cn/api/paas/v4', + default_model='glm-4.5', + description='智谱 OpenAI-compatible 接口。', + tags=['domestic', 'preset'], + ), + LLMPreset( + key='glm-anthropic', + label='智谱 GLM(Claude 兼容)', + protocol='anthropic', + default_base_url='https://open.bigmodel.cn/api/anthropic', + default_model='glm-4.5', + description='智谱 Anthropic-compatible 接口。', + tags=['domestic', 'preset'], + ), + LLMPreset( + key='doubao-ark', + label='豆包 / 火山方舟 Ark', + protocol='openai', + default_base_url='https://ark.cn-beijing.volces.com/api/v3', + default_model=self._DEFAULT_ARK_MODEL, + description='方舟 OpenAI-compatible 接口;也兼容仓库现有 ARK_BASE_URL 配置。', + tags=['domestic', 'preset'], + ), + ] + + def get_preset_map(self) -> Dict[str, LLMPreset]: + return {preset.key: preset for preset in self.get_presets()} + + def get_control_panel_data(self) -> LLMControlPanelData: + config = self.get_config() + return LLMControlPanelData( + config=config, + presets=self.get_presets(), + runtime=self.get_runtime_summary(config), + ) + + def get_config(self) -> LLMControlConfig: + """从数据库读取完整配置;空库时自动写入初始默认值。""" + try: + rows = self._db().fetch_all( + "SELECT * FROM llm_profiles ORDER BY sort_order, created_at" + ) + except Exception as exc: + logger.warning('读取 LLM profiles 失败,回退默认配置: %s', exc) + rows = [] + + profiles = [self._row_to_profile(r) for r in rows] + + if not profiles: + config = self._build_initial_config() + self.save_config(config) + return config + + active_id = self._get_meta('active_profile_id') + # 校验 active_id 是否在当前 profiles 中 + valid_ids = {p.id for p in profiles} + if not active_id or active_id not in valid_ids: + active_id = profiles[0].id + + return LLMControlConfig(version=1, active_profile_id=active_id, profiles=profiles) + + def save_config(self, config: LLMControlConfig) -> LLMControlConfig: + """将配置全量写入数据库(先清后写)。""" + sanitized = self._sanitize_config(config) + db = self._db() + + # 清空旧数据 + db.execute("DELETE FROM llm_profiles") + db.execute("DELETE FROM llm_config_meta") + + # 写入 meta + db.execute( + "INSERT OR REPLACE INTO llm_config_meta (key, value) VALUES (?, ?)", + ('active_profile_id', sanitized.active_profile_id or ''), + ) + + # 批量写入 profiles + params_list = [] + for idx, profile in enumerate(sanitized.profiles): + row = self._profile_to_row(profile) + row['sort_order'] = idx + params_list.append(( + row['id'], row['name'], row['preset_key'], row['protocol'], + row['base_url'], row['api_key'], row['model'], + row['temperature'], row['max_tokens'], row['timeout_seconds'], + row['extra_headers'], row['extra_query'], row['extra_body'], + row['notes'], row['sort_order'], + )) + + db.execute_many( + """INSERT INTO llm_profiles ( + id, name, preset_key, protocol, base_url, api_key, model, + temperature, max_tokens, timeout_seconds, + extra_headers, extra_query, extra_body, notes, sort_order + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + params_list, + ) + + logger.info("LLM 配置已保存到数据库 (%d 个 profiles)", len(sanitized.profiles)) + return sanitized + + def get_active_profile(self, config: Optional[LLMControlConfig] = None) -> Optional[LLMProfile]: + cfg = config or self.get_config() + if not cfg.profiles: + return None + target_id = cfg.active_profile_id + for profile in cfg.profiles: + if profile.id == target_id: + return profile + return cfg.profiles[0] + + def resolve_profile(self, profile: LLMProfile) -> LLMProfile: + preset = self.get_preset_map().get(profile.preset_key) + protocol = profile.protocol or (preset.protocol if preset else 'openai') + base_url = self._normalize_base_url( + protocol, + profile.base_url.strip() or (preset.default_base_url if preset else ''), + ) + model = profile.model.strip() or (preset.default_model if preset else '') + return LLMProfile( + **{ + **profile.model_dump(), + 'protocol': protocol, + 'base_url': base_url, + 'model': model, + } + ) + + def resolve_active_profile(self, config: Optional[LLMControlConfig] = None) -> Optional[LLMProfile]: + active = self.get_active_profile(config) + if active is None: + return None + return self.resolve_profile(active) + + def get_runtime_summary(self, config: Optional[LLMControlConfig] = None) -> LLMRuntimeSummary: + profile = self.resolve_active_profile(config) + if profile is None: + return LLMRuntimeSummary( + source='mock', + using_mock=True, + reason='未找到任何 LLM 配置', + ) + + if not profile.api_key.strip() or not profile.model.strip(): + return LLMRuntimeSummary( + source='mock', + active_profile_id=profile.id, + active_profile_name=profile.name, + protocol=profile.protocol, + model=profile.model or None, + base_url=profile.base_url or None, + using_mock=True, + reason='当前激活配置缺少 API Key 或模型名,运行时将退回 MockProvider', + ) + + return LLMRuntimeSummary( + source='profile', + active_profile_id=profile.id, + active_profile_name=profile.name, + protocol=profile.protocol, + model=profile.model, + base_url=profile.base_url, + using_mock=False, + ) + + async def test_profile_model( + self, + profile: LLMProfile, + llm_service_factory: Callable[[LLMProfile], LLMService], + ) -> LLMTestResult: + resolved = self.resolve_profile(profile) + if not resolved.api_key.strip() or not resolved.model.strip(): + return LLMTestResult( + ok=False, + provider_label=resolved.name, + model=resolved.model or '', + latency_ms=0, + error='请先填写 API Key 与模型名后再测试', + ) + started = perf_counter() + try: + llm_service = llm_service_factory(resolved) + from domain.ai.value_objects.prompt import Prompt + from domain.ai.services.llm_service import GenerationConfig + prompt = Prompt( + system='你是连通性测试助手。', + user='请只回复"连接成功"。', + ) + config = GenerationConfig( + model=resolved.model or None, + max_tokens=min(resolved.max_tokens, 64), + temperature=0, + ) + result = await llm_service.generate(prompt, config) + latency_ms = int((perf_counter() - started) * 1000) + preview = (result.content or '').strip().replace('\r', ' ').replace('\n', ' ') + return LLMTestResult( + ok=True, + provider_label=resolved.name, + model=resolved.model, + latency_ms=latency_ms, + preview=preview[:120], + ) + except Exception as exc: + latency_ms = int((perf_counter() - started) * 1000) + return LLMTestResult( + ok=False, + provider_label=resolved.name, + model=resolved.model or '', + latency_ms=latency_ms, + error=str(exc), + ) + + def _sanitize_config(self, config: LLMControlConfig) -> LLMControlConfig: + profiles: List[LLMProfile] = [] + seen_ids: set = set() + for index, profile in enumerate(config.profiles): + candidate_id = profile.id.strip() or f'profile-{index + 1}' + if candidate_id in seen_ids: + candidate_id = f'{candidate_id}-{index + 1}' + seen_ids.add(candidate_id) + profiles.append( + LLMProfile( + **{ + **profile.model_dump(), + 'id': candidate_id, + 'name': profile.name.strip() or f'配置 {index + 1}', + 'base_url': profile.base_url.strip(), + 'api_key': profile.api_key.strip(), + 'model': profile.model.strip(), + } + ) + ) + + if not profiles: + profiles = self._build_initial_config().profiles + + active_profile_id = config.active_profile_id if config.active_profile_id in {p.id for p in profiles} else profiles[0].id + return LLMControlConfig(version=1, active_profile_id=active_profile_id, profiles=profiles) + + @staticmethod + def _normalize_base_url(protocol: LLMProtocol, base_url: str) -> str: + if protocol == 'anthropic': + return normalize_anthropic_base_url(base_url) or '' + if protocol == 'gemini': + return normalize_gemini_base_url(base_url) or '' + return normalize_openai_base_url(base_url) or '' + + def _build_initial_config(self) -> LLMControlConfig: + profiles = [ + LLMProfile( + id='openai-compatible-default', + name='OpenAI 兼容 / 国产通用', + preset_key='custom-openai-compatible', + protocol='openai', + base_url='', + model='', + ), + LLMProfile( + id='claude-official-default', + name='Claude / Anthropic', + preset_key='claude-official', + protocol='anthropic', + base_url='https://api.anthropic.com', + model=self._DEFAULT_ANTHROPIC_MODEL, + ), + LLMProfile( + id='gemini-official-default', + name='Gemini / Google', + preset_key='gemini-official', + protocol='gemini', + base_url='https://generativelanguage.googleapis.com/v1beta', + model=self._DEFAULT_GEMINI_MODEL, + ), + ] + active_profile_id = profiles[0].id + + llm_provider = os.getenv('LLM_PROVIDER', '').strip().lower() + + anthropic_key = (os.getenv('ANTHROPIC_API_KEY') or os.getenv('ANTHROPIC_AUTH_TOKEN') or '').strip() + openai_key = (os.getenv('OPENAI_API_KEY') or '').strip() + gemini_key = (os.getenv('GEMINI_API_KEY') or '').strip() + ark_key = (os.getenv('ARK_API_KEY') or '').strip() + + if anthropic_key and (llm_provider == 'anthropic' or not llm_provider): + profiles[1] = profiles[1].model_copy(update={ + 'api_key': anthropic_key, + 'base_url': (os.getenv('ANTHROPIC_BASE_URL') or '').strip() or profiles[1].base_url, + 'model': (os.getenv('ANTHROPIC_MODEL') or '').strip() or profiles[1].model, + }) + active_profile_id = profiles[1].id + elif openai_key and (llm_provider == 'openai' or not llm_provider): + profiles[0] = profiles[0].model_copy(update={ + 'name': 'OpenAI / 兼容网关', + 'preset_key': 'openai-official' if not os.getenv('OPENAI_BASE_URL') else 'custom-openai-compatible', + 'api_key': openai_key, + 'base_url': (os.getenv('OPENAI_BASE_URL') or '').strip(), + 'model': (os.getenv('OPENAI_MODEL') or '').strip() or self._DEFAULT_OPENAI_MODEL, + }) + active_profile_id = profiles[0].id + elif gemini_key: + profiles[2] = profiles[2].model_copy(update={ + 'api_key': gemini_key, + 'base_url': (os.getenv('GEMINI_BASE_URL') or '').strip() or profiles[2].base_url, + 'model': (os.getenv('GEMINI_MODEL') or '').strip() or profiles[2].model, + }) + active_profile_id = profiles[2].id + elif ark_key: + profiles[0] = profiles[0].model_copy(update={ + 'name': '豆包 / Ark', + 'preset_key': 'doubao-ark', + 'api_key': ark_key, + 'base_url': (os.getenv('ARK_BASE_URL') or '').strip() or 'https://ark.cn-beijing.volces.com/api/v3', + 'model': (os.getenv('ARK_MODEL') or '').strip() or self._DEFAULT_ARK_MODEL, + }) + active_profile_id = profiles[0].id + + return LLMControlConfig(version=1, active_profile_id=active_profile_id, profiles=profiles) diff --git a/application/engine/services/context_budget_allocator.py b/application/engine/services/context_budget_allocator.py index e334b780c..9a453fa9f 100644 --- a/application/engine/services/context_budget_allocator.py +++ b/application/engine/services/context_budget_allocator.py @@ -140,6 +140,7 @@ def __init__( bible_repository: Optional[BibleRepository] = None, story_node_repository: Optional[StoryNodeRepository] = None, chapter_element_repository = None, + triple_repository = None, vector_store: Optional[VectorStore] = None, embedding_service: Optional[EmbeddingService] = None, ): @@ -148,6 +149,7 @@ def __init__( self.bible_repo = bible_repository self.story_node_repo = story_node_repository self.chapter_element_repo = chapter_element_repository + self.triple_repo = triple_repository # 向量检索门面 self.vector_facade = None diff --git a/application/engine/services/context_builder.py b/application/engine/services/context_builder.py index ae6c740dd..e98ce3c60 100644 --- a/application/engine/services/context_builder.py +++ b/application/engine/services/context_builder.py @@ -59,6 +59,7 @@ def __init__( story_node_repository=None, bible_repository=None, chapter_element_repository=None, + triple_repository=None, ): self.bible_service = bible_service self.storyline_manager = storyline_manager @@ -72,6 +73,7 @@ def __init__( self.story_node_repository = story_node_repository self.bible_repository = bible_repository self.chapter_element_repository = chapter_element_repository + self.triple_repository = triple_repository # 预算分配器(核心组件) self.budget_allocator = ContextBudgetAllocator( @@ -80,6 +82,7 @@ def __init__( bible_repository=bible_repository, story_node_repository=story_node_repository, chapter_element_repository=chapter_element_repository, + triple_repository=triple_repository, vector_store=vector_store, embedding_service=embedding_service, ) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0ff54326d..e6bcbca46 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,40 +1,116 @@ -async function handleActivate(id: string) { - activatingId.value = id - try { - await settingsApi.activateLLMConfig(id) - activeId.value = id - message.success('配置已激活,立即生效') - } catch { - message.error('激活失败') - } finally { - activatingId.value = null - } + diff --git a/frontend/src/components/global/GlobalLLMEntryButton.vue b/frontend/src/components/global/GlobalLLMEntryButton.vue new file mode 100644 index 000000000..4c94294b8 --- /dev/null +++ b/frontend/src/components/global/GlobalLLMEntryButton.vue @@ -0,0 +1,727 @@ + + + + + diff --git a/frontend/src/components/stats/StatCard.vue b/frontend/src/components/stats/StatCard.vue index 57845edc4..cd121cc1d 100644 --- a/frontend/src/components/stats/StatCard.vue +++ b/frontend/src/components/stats/StatCard.vue @@ -52,16 +52,17 @@ const trendValue = computed(() => props.trend ? Math.abs(props.trend.value) : 0) diff --git a/frontend/src/components/onboarding/NovelSetupGuide.vue b/frontend/src/components/onboarding/NovelSetupGuide.vue index 93ef09917..01e6911fb 100644 --- a/frontend/src/components/onboarding/NovelSetupGuide.vue +++ b/frontend/src/components/onboarding/NovelSetupGuide.vue @@ -263,20 +263,16 @@ diff --git a/frontend/src/components/renaissance/CinnabarButton.vue b/frontend/src/components/renaissance/CinnabarButton.vue new file mode 100644 index 000000000..a0e105efd --- /dev/null +++ b/frontend/src/components/renaissance/CinnabarButton.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 4493c8aa2..34cfd4376 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -7,10 +7,30 @@ import CharacterGraph from '../views/CharacterGraph.vue' import LocationGraph from '../views/LocationGraph.vue' import CharacterSchedulerSimulator from '../components/debug/CharacterSchedulerSimulator.vue' +// Renaissance Shadow System Views +import RenaissanceHome from '../views/renaissance/HomeView.vue' +// The following will be created in the next steps +// import RenaissanceOnboarding from '../views/renaissance/OnboardingView.vue' +// import RenaissanceWorkbench from '../views/renaissance/WorkbenchView.vue' + const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', name: 'Home', component: Home }, + + // Renaissance (Visual Sovereignty) Group + { path: '/renaissance/home', name: 'RenaissanceHome', component: RenaissanceHome }, + { + path: '/renaissance/onboarding', + name: 'RenaissanceOnboarding', + component: () => import('../views/renaissance/OnboardingView.vue') + }, + { + path: '/renaissance/workbench/:slug', + name: 'RenaissanceWorkbench', + component: () => import('../views/renaissance/WorkbenchView.vue') + }, + { path: '/book/:slug/workbench', name: 'Workbench', component: Workbench }, { path: '/book/:slug/cast', name: 'Cast', component: Cast }, { path: '/book/:slug/chapter/:id', name: 'Chapter', component: Chapter }, diff --git a/frontend/src/stores/novelStore.ts b/frontend/src/stores/novelStore.ts new file mode 100644 index 000000000..9bac592c6 --- /dev/null +++ b/frontend/src/stores/novelStore.ts @@ -0,0 +1,97 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { novelApi, type NovelDTO } from '../api/novel' +import { useStatsStore } from './statsStore' +import { useMessage } from 'naive-ui' + +export interface Book { + slug: string + title: string + stage: string + stage_label: string + genre: string + chapter_count: number + word_count: number +} + +export const useNovelStore = defineStore('novel', () => { + const books = ref([]) + const loading = ref(false) + const statsStore = useStatsStore() + + // Naive UI message and dialog provider can only be used inside setup of components + // So we pass them as arguments or rely on the global provider if available. + // For now, we handle basic error/success logging. + + const getStageLabel = (stage: string): string => { + const labels: Record = { + planning: '规划中', + writing: '写作中', + reviewing: '审稿中', + completed: '已完成', + } + return labels[stage] || stage + } + + const formatWordCount = (count: number): string => { + if (count >= 10000) { + return (count / 10000).toFixed(1) + '万字' + } + return count + '字' + } + + const fetchBooks = async () => { + loading.value = true + try { + const novels = await novelApi.listNovels() + books.value = novels.map((novel: NovelDTO) => ({ + slug: novel.id, + title: novel.title, + stage: novel.stage, + stage_label: getStageLabel(novel.stage), + genre: '', + chapter_count: novel.chapters?.length || 0, + word_count: novel.total_word_count, + })) + } catch (error) { + console.error('Failed to fetch books:', error) + throw error + } finally { + loading.value = false + } + } + + const deleteBook = async (slug: string) => { + try { + await novelApi.deleteNovel(slug) + books.value = books.value.filter(b => b.slug !== slug) + // 同步刷新全局统计 + await statsStore.loadGlobalStats(true) + } catch (error) { + console.error('Failed to delete book:', error) + throw error + } + } + + const createBook = async (payload: any) => { + try { + const result = await novelApi.createNovel(payload) + // 创建成功后重新获取列表或手动 push + await fetchBooks() + return result + } catch (error) { + console.error('Failed to create book:', error) + throw error + } + } + + return { + books, + loading, + fetchBooks, + deleteBook, + createBook, + getStageLabel, + formatWordCount + } +}) diff --git a/frontend/src/stores/themeStore.ts b/frontend/src/stores/themeStore.ts index dc277d648..41afec434 100644 --- a/frontend/src/stores/themeStore.ts +++ b/frontend/src/stores/themeStore.ts @@ -1,14 +1,67 @@ import { defineStore } from 'pinia' import { ref, computed, watch } from 'vue' +import type { GlobalThemeOverrides } from 'naive-ui' export type ThemeMode = 'light' | 'dark' | 'anchor' | 'auto' | 'ink' | 'cinnabar' const STORAGE_KEY = 'aitext-theme-mode' +/** + * 主题配置映射表 (Technical Sovereignty) + * 用于解决 Naive UI color calculations 无法处理 CSS 变量的问题 + */ +const THEME_CONFIG = { + anchor: { + primary: '#c9a227', + primaryHover: '#ddb930', + primaryPressed: '#a88a1f', + primarySuppl: '#e8c84a', + text: '#f0ead6', + surface: '#111620', + bg: '#0a0c10' + }, + ink: { + primary: '#DC2626', + primaryHover: '#ef4444', + primaryPressed: '#b91c1c', + primarySuppl: '#fca5a5', + text: '#ffffff', + surface: '#171717', + bg: '#0F0F0F' + }, + cinnabar: { + primary: '#DC2626', + primaryHover: '#ef4444', + primaryPressed: '#b91c1c', + primarySuppl: '#fca5a5', + text: '#1a1a1a', + surface: '#ffffff', + bg: '#f4f1ea' + }, + dark: { + primary: '#818cf8', + primaryHover: '#a5b4fc', + primaryPressed: '#6366f1', + primarySuppl: '#c7d2fe', + text: '#e2e8f0', + surface: '#131c31', + bg: '#0b1121' + }, + light: { + primary: '#4f46e5', + primaryHover: '#6366f1', + primaryPressed: '#4338ca', + primarySuppl: '#818cf8', + text: '#0f172a', + surface: '#ffffff', + bg: '#eef1f6' + } +} + function getStoredTheme(): ThemeMode { try { const stored = localStorage.getItem(STORAGE_KEY) - if (stored === 'light' || stored === 'dark' || stored === 'anchor' || stored === 'auto') return stored + if (['light', 'dark', 'anchor', 'auto', 'ink', 'cinnabar'].includes(stored as string)) return stored as ThemeMode } catch { /* ignore */ } return 'anchor' } @@ -22,7 +75,8 @@ export const useThemeStore = defineStore('theme', () => { const isDark = computed(() => { if (mode.value === 'auto') return getSystemDark() - return mode.value === 'dark' || mode.value === 'anchor' + // 只有 dark, anchor, ink 是暗色;cinnabar (朱砂/宣纸) 是浅色 + return ['dark', 'anchor', 'ink'].includes(mode.value) }) /** 是否为黑金(主播限定色)模式 */ @@ -47,18 +101,101 @@ export const useThemeStore = defineStore('theme', () => { }) } - // 同步 class 以支持全局 CSS 变量切换 - // 监听 mode 而非 isDark,因为 dark<->anchor 切换时 isDark 不变,需要同步更新 data-theme + // 同步 以支持全局 CSS 变量切换 watch(mode, () => { const root = document.documentElement + + // 同步 data-theme 属性(墨枢、朱砂、锚点等) + root.setAttribute('data-theme', mode.value) + + // 处理暗色基调类名(Naive UI 联动) if (isDark.value) { root.classList.add('dark') - root.setAttribute('data-theme', isAnchor.value ? 'anchor' : 'dark') } else { root.classList.remove('dark') - root.setAttribute('data-theme', 'light') } }, { immediate: true }) - return { mode, isDark, isAnchor, effectiveTheme, setTheme } + // 暴露当前模式下的 Hex 色值配置 (供 Naive UI 消费) + const themeConfig = computed(() => { + const activeMode = (mode.value === 'auto' ? (getSystemDark() ? 'dark' : 'light') : mode.value) as keyof typeof THEME_CONFIG + return THEME_CONFIG[activeMode] || THEME_CONFIG.light + }) + + /** 统一的主题覆盖配置 (Technical Sovereignty Engine) */ + const themeOverrides = computed(() => { + return { + common: { + primaryColor: themeConfig.value.primary, + primaryColorHover: themeConfig.value.primaryHover, + primaryColorPressed: themeConfig.value.primaryPressed, + primaryColorSuppl: themeConfig.value.primarySuppl, + borderRadius: '10px', + borderRadiusSmall: '8px', + fontSize: '14px', + fontSizeMedium: '15px', + lineHeight: '1.55', + heightMedium: '38px', + bodyColor: themeConfig.value.bg, + textColor1: themeConfig.value.text, + textColor2: 'var(--app-text-secondary)', + textColor3: 'var(--app-text-muted)', + borderColor: 'var(--app-border)', + dividerColor: 'var(--app-divider)', + cardColor: themeConfig.value.surface, + modalColor: themeConfig.value.surface, + popoverColor: themeConfig.value.surface, + tableColor: themeConfig.value.surface, + tableColorStriped: 'var(--app-surface-subtle)', + tableColorHover: 'var(--app-surface-raised)', + tableHeaderColor: themeConfig.value.surface, + }, + Card: { + borderRadius: '14px', + paddingMedium: '20px', + }, + Button: { + borderRadiusMedium: '10px', + }, + Input: { + borderRadius: '10px', + color: themeConfig.value.surface, + }, + Select: { + peers: { + InternalSelection: { + color: themeConfig.value.surface, + borderActive: themeConfig.value.primary, + borderFocus: themeConfig.value.primary, + }, + }, + }, + Drawer: { + color: themeConfig.value.bg, + bodyPadding: '0', + }, + Tabs: { + tabTextColorActiveLine: themeConfig.value.primary, + tabTextColorHoverLine: 'var(--app-text-secondary)', + barColor: themeConfig.value.primary, + }, + Switch: { + railColorActive: themeConfig.value.primary, + }, + Alert: { + color: themeConfig.value.surface, + border: 'none', + }, + Form: { + labelTextColorTop: 'var(--app-text-secondary)', + }, + Scrollbar: { + width: '8px', + height: '8px', + borderRadius: '4px', + }, + } + }) + + return { mode, isDark, isAnchor, effectiveTheme, themeConfig, themeOverrides, setTheme } }) diff --git a/frontend/src/views/Cast.vue b/frontend/src/views/Cast.vue index 5d5e4be75..e34527318 100644 --- a/frontend/src/views/Cast.vue +++ b/frontend/src/views/Cast.vue @@ -512,8 +512,8 @@ onUnmounted(() => { margin-bottom: 10px; padding: 8px; border-radius: 8px; - background: rgba(79, 70, 229, 0.05); - border: 1px solid rgba(99, 102, 241, 0.12); + background: var(--color-brand-light); + border: 1px solid var(--color-brand-border); } .ev-id { diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index 395ed2b37..26244af0a 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -735,8 +735,6 @@ onMounted(() => { right: 0; width: 40px; height: 40px; - border: none; - background: var(--app-surface); border: 1px solid var(--app-border); border-radius: 10px; cursor: pointer; @@ -816,9 +814,9 @@ onMounted(() => { .advanced-settings { padding: 16px; - background: rgba(79, 70, 229, 0.04); + background: var(--color-brand-light); border-radius: 12px; - border: 1px solid rgba(79, 70, 229, 0.1); + border: 1px solid var(--color-brand-border); } .w-full { diff --git a/frontend/src/views/renaissance/HomeView.vue b/frontend/src/views/renaissance/HomeView.vue new file mode 100644 index 000000000..3856dba52 --- /dev/null +++ b/frontend/src/views/renaissance/HomeView.vue @@ -0,0 +1,535 @@ + + + + + diff --git a/frontend/src/views/renaissance/OnboardingView.vue b/frontend/src/views/renaissance/OnboardingView.vue new file mode 100644 index 000000000..13038e487 --- /dev/null +++ b/frontend/src/views/renaissance/OnboardingView.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/views/renaissance/WorkbenchView.vue b/frontend/src/views/renaissance/WorkbenchView.vue new file mode 100644 index 000000000..3fe95873e --- /dev/null +++ b/frontend/src/views/renaissance/WorkbenchView.vue @@ -0,0 +1,16 @@ + + + diff --git a/infrastructure/ai/providers/gemini_provider.py b/infrastructure/ai/providers/gemini_provider.py index 480c61dcb..7950e2cfa 100644 --- a/infrastructure/ai/providers/gemini_provider.py +++ b/infrastructure/ai/providers/gemini_provider.py @@ -1,4 +1,4 @@ -"""Gemini LLM 提供商实现(基于 httpx 的 REST API 实现,深度整合 SOCKS5H 代理补丁)""" +"""Gemini LLM 提供商实现(基于 httpx 的 REST API 实现,深度整合标准化 Transport 代理)""" from __future__ import annotations import json @@ -24,9 +24,9 @@ class GeminiProvider(BaseProvider): """Google Gemini LLM 提供商实现 加固策略: - 1. 采用 httpx REST 实现,对齐上游架构。 - 2. 强制注入 SOCKS5H 代理,解决国内环境握手失败。 - 3. 支持 Pydantic Schema 校验,确保生成内容 100% 结构化。 + 1. 采用 httpx.AsyncHTTPTransport 显式配置代理。 + 2. 锁定 127.0.0.1:10808 作为混合代理通道。 + 3. 支持 Pydantic Schema 校验,确保生成内容结构化。 """ def __init__(self, settings: Settings): super().__init__(settings) @@ -34,16 +34,12 @@ def __init__(self, settings: Settings): raise ValueError('API key is required for GeminiProvider') self.base_url = (settings.base_url or DEFAULT_BASE_URL).rstrip('/') - # 强制环境变量以加固网络环境 (httpx 会读取这些变量) + # 强制环境变量以加固网络环境 os.environ["HTTPX_HTTP2"] = "0" - - def _get_proxy_mounts(self) -> dict[str, str]: - """返回代理配置,用于 httpx mounts""" - proxy_url = "socks5h://127.0.0.1:10808" - return { - "http://": proxy_url, - "https://": proxy_url, - } + + # 预构标准传输层:锁定 10808 端口 + self.proxy_url = "http://127.0.0.1:10808" + self._transport = httpx.AsyncHTTPTransport(proxy=self.proxy_url) async def generate(self, prompt: Prompt, config: GenerationConfig) -> GenerationResult: payload = self._build_payload(prompt, config) @@ -51,8 +47,9 @@ async def generate(self, prompt: Prompt, config: GenerationConfig) -> Generation url = self._build_url(config.model or self.settings.default_model or DEFAULT_MODEL, 'generateContent') timeout = httpx.Timeout(self.settings.timeout_seconds) - # 注入 SOCKS5H 代理加固 - async with httpx.AsyncClient(timeout=timeout, mounts=self._get_proxy_mounts()) as client: + # 使用预构的传输层,物理性消除 str 对象的 Attribute 报错 + async with httpx.AsyncClient(transport=self._transport, timeout=timeout) as client: + logger.info(f"Final Gemini API Request URL: {url}") response = await client.post( url, params=query, @@ -66,7 +63,7 @@ async def generate(self, prompt: Prompt, config: GenerationConfig) -> Generation if not content.strip(): raise RuntimeError('Gemini returned empty content') - # 结构化输出校验 (Pydantic 补丁回归) + # 结构化输出校验 target_schema = config.response_format if target_schema and isinstance(target_schema, type) and issubclass(target_schema, BaseModel): try: @@ -88,7 +85,7 @@ async def stream_generate(self, prompt: Prompt, config: GenerationConfig) -> Asy url = self._build_url(config.model or self.settings.default_model or DEFAULT_MODEL, 'streamGenerateContent') timeout = httpx.Timeout(self.settings.timeout_seconds) - async with httpx.AsyncClient(timeout=timeout, mounts=self._get_proxy_mounts()) as client: + async with httpx.AsyncClient(transport=self._transport, timeout=timeout) as client: async with client.stream( 'POST', url, @@ -107,8 +104,13 @@ async def stream_generate(self, prompt: Prompt, config: GenerationConfig) -> Asy yield text def _build_url(self, model: str, action: str) -> str: - model_name = model.strip() or DEFAULT_MODEL - return f'{self.base_url}/models/{model_name}:{action}' + # 强制主权校准:锁定官方最稳型号 + model_name = 'gemini-1.5-flash' + # 协议补全:确保必须包含版本号,否则会触发 404 + base = self.base_url + if '/v1' not in base: + base = f"{base.rstrip('/')}/v1beta" + return f'{base}/models/{model_name}:{action}' def _build_query(self, extra: dict[str, Any] | None = None) -> dict[str, Any]: query: dict[str, Any] = {'key': self.settings.api_key} @@ -130,7 +132,6 @@ def _build_payload(self, prompt: Prompt, config: GenerationConfig) -> dict[str, 'maxOutputTokens': config.max_tokens, } - # 安全设置:作者最新架构倾向于 BLOCK_NONE 确保创作自由 safety_settings = [ {'category': cat, 'threshold': 'BLOCK_NONE'} for cat in [ @@ -150,7 +151,6 @@ def _build_payload(self, prompt: Prompt, config: GenerationConfig) -> dict[str, 'safetySettings': safety_settings, } - # 结构化输出支持 if config.response_format: payload['generationConfig']['responseMimeType'] = 'application/json' From eb3fc4668ad6482032683a447ceee557f6432234 Mon Sep 17 00:00:00 2001 From: professorczh Date: Wed, 29 Apr 2026 20:49:49 +0800 Subject: [PATCH 11/22] feat: integrate Gemini AI provider, add novel onboarding wizard, and implement continuous planning API routes and theme management. --- .../components/onboarding/NovelSetupGuide.vue | 195 +++++++++++++----- frontend/src/stores/themeStore.ts | 89 +++++--- frontend/src/views/Home.vue | 5 - .../ai/providers/gemini_provider.py | 40 ++-- .../blueprint/continuous_planning_routes.py | 22 +- 5 files changed, 230 insertions(+), 121 deletions(-) diff --git a/frontend/src/components/onboarding/NovelSetupGuide.vue b/frontend/src/components/onboarding/NovelSetupGuide.vue index f77d8d5f9..3b5627ba6 100644 --- a/frontend/src/components/onboarding/NovelSetupGuide.vue +++ b/frontend/src/components/onboarding/NovelSetupGuide.vue @@ -19,7 +19,7 @@
-
+
@@ -89,7 +89,7 @@
-
+
@@ -105,11 +105,11 @@ 请查看并确认角色设定。 - - - + + + @@ -119,7 +119,7 @@
-
+
@@ -135,10 +135,10 @@ 请查看并确认地点设定。 - - - - + + + + @@ -150,7 +150,7 @@
-
+
@@ -232,7 +232,7 @@
-
+
@@ -250,7 +250,7 @@
-
+
@@ -485,10 +485,54 @@ const styleConventionDisplay = computed(() => styleConventionFromBible(bibleData // 第2步:生成人物和地点 const generatingCharacters = ref(false) const charactersGenerated = ref(false) +const characterPollTimer = ref | null>(null) // 第3步:生成地点 const generatingLocations = ref(false) const locationsGenerated = ref(false) +const locationPollTimer = ref | null>(null) + +/** + * 核心:同步当前小说的生成状态 + * 检查是否已有世界观、人物、地点,刷新 UI 状态 + */ +async function syncGenerationState() { + if (!props.novelId || !props.show) return + + try { + const bible = await bibleApi.getBible(props.novelId) + bibleData.value = bible + + // 1. 检查世界观 + const hasWb = bible.world_settings && bible.characters.length === 0 && bible.locations.length === 0 + // 虽然逻辑上 characters 和 locations 也是 Bible 的一部分,但我们这里主要看是否存在核心设定 + if (bible.world_settings && bible.world_settings.length > 0) { + bibleGenerated.value = true + // 加载世界观展示数据 + let fromApi = emptyWorldbuildingShape() + try { + const w = await worldbuildingApi.getWorldbuilding(props.novelId) + fromApi = normalizeWorldbuildingFromApi(w as unknown as Record) + } catch { /* ignore */ } + const fromWs = worldbuildingFromWorldSettings(bible.world_settings) + worldbuildingData.value = mergeWorldbuildingDisplay(fromApi, fromWs) + } + + // 2. 检查人物 + if (bible.characters && bible.characters.length > 0) { + charactersGenerated.value = true + generatingCharacters.value = false + } + + // 3. 检查地点 + if (bible.locations && bible.locations.length > 0) { + locationsGenerated.value = true + generatingLocations.value = false + } + } catch (error) { + console.warn('[NovelSetupGuide] Failed to sync state:', error) + } +} // Step 4:主线推演 const plotOptions = ref([]) @@ -586,6 +630,14 @@ function clearGenerationTimers() { clearTimeout(timeoutTimerRef.value) timeoutTimerRef.value = null } + if (characterPollTimer.value != null) { + clearTimeout(characterPollTimer.value) + characterPollTimer.value = null + } + if (locationPollTimer.value != null) { + clearTimeout(locationPollTimer.value) + locationPollTimer.value = null + } } onUnmounted(() => { @@ -674,7 +726,7 @@ async function startBibleGeneration() { clearGenerationTimers() generatingBible.value = false bibleError.value = '生成超时,请稍后在工作台手动重试' - }, 120000) + }, 300000) // 延长至 5 分钟 schedulePoll(0) } catch (error: unknown) { @@ -687,7 +739,7 @@ async function startBibleGeneration() { watch( () => props.show, - (val) => { + async (val) => { if (val) { currentStep.value = 1 stepStatus.value = 'process' @@ -696,7 +748,14 @@ watch( customMode.value = false customLogline.value = '' plotSuggestError.value = '' - void startBibleGeneration() + + // 首先同步状态,看是否已经生成过 + await syncGenerationState() + + // 只有在完全没有生成过世界观时才启动自动生成 + if (!bibleGenerated.value && !generatingBible.value) { + void startBibleGeneration() + } } else { biblePollEpoch.value += 1 clearGenerationTimers() @@ -714,56 +773,78 @@ watch(currentStep, (step) => { const handleNext = async () => { if (currentStep.value === 1) { - if (bibleGenerated.value) { - currentStep.value = 2 - return - } // 进入第2步:生成人物 currentStep.value = 2 - generatingCharacters.value = true - try { - await bibleApi.generateBible(props.novelId, 'characters') - // 轮询检查人物生成状态 - const checkCharacters = async () => { - const bible = await bibleApi.getBible(props.novelId) - bibleData.value = bible - if (bible.characters && bible.characters.length > 0) { - generatingCharacters.value = false - charactersGenerated.value = true - } else { - window.setTimeout(checkCharacters, 2000) + if (!charactersGenerated.value && !generatingCharacters.value) { + generatingCharacters.value = true + try { + console.log('[NovelSetupGuide] Starting character generation...') + await bibleApi.generateBible(props.novelId, 'characters') + + // 轮询逻辑 + const poll = async () => { + if (!generatingCharacters.value) return + try { + console.log(`[NovelSetupGuide] Polling characters for novel: ${props.novelId}`) + const bible = await bibleApi.getBible(props.novelId) + console.log('[NovelSetupGuide] Characters poll response:', bible.characters?.length) + + if (bible.characters && bible.characters.length > 0) { + bibleData.value = bible + generatingCharacters.value = false + charactersGenerated.value = true + message.success('人物生成完成') + } else { + characterPollTimer.value = window.setTimeout(poll, 2000) + } + } catch (e) { + console.warn('[NovelSetupGuide] Character poll failed:', e) + characterPollTimer.value = window.setTimeout(poll, 3000) + } } + void poll() + } catch (error) { + console.error('Failed to start character generation:', error) + generatingCharacters.value = false + message.error('启动人物生成失败') } - await checkCharacters() - } catch (error) { - console.error('Failed to generate characters:', error) - generatingCharacters.value = false } } else if (currentStep.value === 2) { - if (charactersGenerated.value) { - currentStep.value = 3 - return - } // 进入第3步:生成地点 currentStep.value = 3 - generatingLocations.value = true - try { - await bibleApi.generateBible(props.novelId, 'locations') - // 轮询检查地点生成状态 - const checkLocations = async () => { - const bible = await bibleApi.getBible(props.novelId) - bibleData.value = bible - if (bible.locations && bible.locations.length > 0) { - generatingLocations.value = false - locationsGenerated.value = true - } else { - window.setTimeout(checkLocations, 2000) + if (!locationsGenerated.value && !generatingLocations.value) { + generatingLocations.value = true + try { + console.log('[NovelSetupGuide] Starting location generation...') + await bibleApi.generateBible(props.novelId, 'locations') + + // 轮询逻辑 + const poll = async () => { + if (!generatingLocations.value) return + try { + console.log(`[NovelSetupGuide] Polling locations for novel: ${props.novelId}`) + const bible = await bibleApi.getBible(props.novelId) + console.log('[NovelSetupGuide] Locations poll response:', bible.locations?.length) + + if (bible.locations && bible.locations.length > 0) { + bibleData.value = bible + generatingLocations.value = false + locationsGenerated.value = true + message.success('地图生成完成') + } else { + locationPollTimer.value = window.setTimeout(poll, 2000) + } + } catch (e) { + console.warn('[NovelSetupGuide] Location poll failed:', e) + locationPollTimer.value = window.setTimeout(poll, 3000) + } } + void poll() + } catch (error) { + console.error('Failed to start location generation:', error) + generatingLocations.value = false + message.error('启动地点生成失败') } - await checkLocations() - } catch (error) { - console.error('Failed to generate locations:', error) - generatingLocations.value = false } } else if (currentStep.value < 6) { currentStep.value++ diff --git a/frontend/src/stores/themeStore.ts b/frontend/src/stores/themeStore.ts index 41afec434..8a2a4921d 100644 --- a/frontend/src/stores/themeStore.ts +++ b/frontend/src/stores/themeStore.ts @@ -17,7 +17,13 @@ const THEME_CONFIG = { primaryPressed: '#a88a1f', primarySuppl: '#e8c84a', text: '#f0ead6', + textSecondary: '#c4b99a', + textMuted: '#8a8070', surface: '#111620', + surfaceSubtle: '#0d1018', + surfaceRaised: '#181f2e', + divider: 'rgba(201, 162, 39, 0.06)', + border: 'rgba(201, 162, 39, 0.08)', bg: '#0a0c10' }, ink: { @@ -26,7 +32,13 @@ const THEME_CONFIG = { primaryPressed: '#b91c1c', primarySuppl: '#fca5a5', text: '#ffffff', + textSecondary: '#a3a3a3', + textMuted: '#737373', surface: '#171717', + surfaceSubtle: '#121212', + surfaceRaised: '#262626', + divider: 'rgba(255, 255, 255, 0.05)', + border: 'rgba(255, 255, 255, 0.08)', bg: '#0F0F0F' }, cinnabar: { @@ -35,8 +47,14 @@ const THEME_CONFIG = { primaryPressed: '#b91c1c', primarySuppl: '#fca5a5', text: '#1a1a1a', + textSecondary: '#525252', + textMuted: '#737373', surface: '#ffffff', - bg: '#f4f1ea' + surfaceSubtle: '#faf9f6', + surfaceRaised: '#ffffff', + divider: 'rgba(0, 0, 0, 0.05)', + border: 'rgba(0, 0, 0, 0.08)', + bg: '#F4F1EA' }, dark: { primary: '#818cf8', @@ -44,7 +62,13 @@ const THEME_CONFIG = { primaryPressed: '#6366f1', primarySuppl: '#c7d2fe', text: '#e2e8f0', + textSecondary: '#d1d5db', + textMuted: '#9ca3af', surface: '#131c31', + surfaceSubtle: '#0f172a', + surfaceRaised: '#1a2436', + divider: 'rgba(51, 65, 85, 0.4)', + border: 'rgba(148, 163, 184, 0.1)', bg: '#0b1121' }, light: { @@ -53,7 +77,13 @@ const THEME_CONFIG = { primaryPressed: '#4338ca', primarySuppl: '#818cf8', text: '#0f172a', + textSecondary: '#334155', + textMuted: '#64748b', surface: '#ffffff', + surfaceSubtle: '#f8fafc', + surfaceRaised: '#ffffff', + divider: 'rgba(15, 23, 42, 0.06)', + border: 'rgba(15, 23, 42, 0.09)', bg: '#eef1f6' } } @@ -124,31 +154,32 @@ export const useThemeStore = defineStore('theme', () => { /** 统一的主题覆盖配置 (Technical Sovereignty Engine) */ const themeOverrides = computed(() => { + const config = themeConfig.value return { common: { - primaryColor: themeConfig.value.primary, - primaryColorHover: themeConfig.value.primaryHover, - primaryColorPressed: themeConfig.value.primaryPressed, - primaryColorSuppl: themeConfig.value.primarySuppl, + primaryColor: config.primary, + primaryColorHover: config.primaryHover, + primaryColorPressed: config.primaryPressed, + primaryColorSuppl: config.primarySuppl, borderRadius: '10px', borderRadiusSmall: '8px', fontSize: '14px', fontSizeMedium: '15px', lineHeight: '1.55', heightMedium: '38px', - bodyColor: themeConfig.value.bg, - textColor1: themeConfig.value.text, - textColor2: 'var(--app-text-secondary)', - textColor3: 'var(--app-text-muted)', - borderColor: 'var(--app-border)', - dividerColor: 'var(--app-divider)', - cardColor: themeConfig.value.surface, - modalColor: themeConfig.value.surface, - popoverColor: themeConfig.value.surface, - tableColor: themeConfig.value.surface, - tableColorStriped: 'var(--app-surface-subtle)', - tableColorHover: 'var(--app-surface-raised)', - tableHeaderColor: themeConfig.value.surface, + bodyColor: config.bg, + textColor1: config.text, + textColor2: config.textSecondary, + textColor3: config.textMuted, + borderColor: config.border, + dividerColor: config.divider, + cardColor: config.surface, + modalColor: config.surface, + popoverColor: config.surface, + tableColor: config.surface, + tableColorStriped: config.surfaceSubtle, + tableColorHover: config.surfaceRaised, + tableHeaderColor: config.surface, }, Card: { borderRadius: '14px', @@ -159,35 +190,35 @@ export const useThemeStore = defineStore('theme', () => { }, Input: { borderRadius: '10px', - color: themeConfig.value.surface, + color: config.surface, }, Select: { peers: { InternalSelection: { - color: themeConfig.value.surface, - borderActive: themeConfig.value.primary, - borderFocus: themeConfig.value.primary, + color: config.surface, + borderActive: config.primary, + borderFocus: config.primary, }, }, }, Drawer: { - color: themeConfig.value.bg, + color: config.bg, bodyPadding: '0', }, Tabs: { - tabTextColorActiveLine: themeConfig.value.primary, - tabTextColorHoverLine: 'var(--app-text-secondary)', - barColor: themeConfig.value.primary, + tabTextColorActiveLine: config.primary, + tabTextColorHoverLine: config.textSecondary, + barColor: config.primary, }, Switch: { - railColorActive: themeConfig.value.primary, + railColorActive: config.primary, }, Alert: { - color: themeConfig.value.surface, + color: config.surface, border: 'none', }, Form: { - labelTextColorTop: 'var(--app-text-secondary)', + labelTextColorTop: config.textSecondary, }, Scrollbar: { width: '8px', diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index 68d998500..5a9d0a41d 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -543,11 +543,6 @@ const handleCreate = async () => { const title = newBook.value.title || newBook.value.premise.substring(0, 20) const novelId = `novel-${Date.now()}` -/** 打开提示词广场 */ -function openPromptPlaza() { - promptPlazaRef.value?.open() -} - const targetChapters = newBook.value.chapters || 100 // 始终使用用户输入或默认 100 const payload = { diff --git a/infrastructure/ai/providers/gemini_provider.py b/infrastructure/ai/providers/gemini_provider.py index 7950e2cfa..443f6d3db 100644 --- a/infrastructure/ai/providers/gemini_provider.py +++ b/infrastructure/ai/providers/gemini_provider.py @@ -34,12 +34,14 @@ def __init__(self, settings: Settings): raise ValueError('API key is required for GeminiProvider') self.base_url = (settings.base_url or DEFAULT_BASE_URL).rstrip('/') - # 强制环境变量以加固网络环境 - os.environ["HTTPX_HTTP2"] = "0" - - # 预构标准传输层:锁定 10808 端口 - self.proxy_url = "http://127.0.0.1:10808" - self._transport = httpx.AsyncHTTPTransport(proxy=self.proxy_url) + # 优先从环境变量获取代理配置,实现灵活切换 + self.proxy_url = os.getenv("PROXY_URL") + if self.proxy_url: + logger.info(f"GeminiProvider initialized with proxy: {self.proxy_url}") + self._transport = httpx.AsyncHTTPTransport(proxy=self.proxy_url) + else: + logger.info("GeminiProvider initialized without proxy (direct connection)") + self._transport = None async def generate(self, prompt: Prompt, config: GenerationConfig) -> GenerationResult: payload = self._build_payload(prompt, config) @@ -104,12 +106,26 @@ async def stream_generate(self, prompt: Prompt, config: GenerationConfig) -> Asy yield text def _build_url(self, model: str, action: str) -> str: - # 强制主权校准:锁定官方最稳型号 - model_name = 'gemini-1.5-flash' - # 协议补全:确保必须包含版本号,否则会触发 404 - base = self.base_url - if '/v1' not in base: - base = f"{base.rstrip('/')}/v1beta" + """构建完整的 Gemini API URL。 + + 策略: + 1. 优先使用传入的 model,如果为空则回退到 settings/default。 + 2. 智能版本补全:检测 v1 或 v1beta,避免遗漏导致 404。 + 3. 自动转换主权型号:gemini-1.5-flash 是目前最稳型号,支持 -latest 别名。 + """ + model_name = model or self.settings.default_model or DEFAULT_MODEL + + # 处理可能的模型别名或路径冲突 + if model_name.startswith('models/'): + model_name = model_name[7:] + + base = self.base_url.rstrip('/') + + # 更加精确的版本检测,避免 /v1 匹配到 /v1beta 的逻辑漏洞 + if not ('/v1beta' in base or '/v1' in base): + # 默认使用 v1beta 以支持最新特性(如 Pydantic Schema),但在生产环境可切换 + base = f"{base}/v1beta" + return f'{base}/models/{model_name}:{action}' def _build_query(self, extra: dict[str, Any] | None = None) -> dict[str, Any]: diff --git a/interfaces/api/v1/blueprint/continuous_planning_routes.py b/interfaces/api/v1/blueprint/continuous_planning_routes.py index d51b29d76..ccb0d8cd6 100644 --- a/interfaces/api/v1/blueprint/continuous_planning_routes.py +++ b/interfaces/api/v1/blueprint/continuous_planning_routes.py @@ -68,26 +68,12 @@ def get_service() -> ContinuousPlanningService: story_node_repo = StoryNodeRepository(db_path) chapter_element_repo = ChapterElementRepository(db_path) - # 获取 LLM 服务 - import os - from infrastructure.ai.providers.anthropic_provider import AnthropicProvider - from infrastructure.ai.config.settings import Settings - - llm_service = None - api_key = os.getenv("ANTHROPIC_API_KEY") or os.getenv("ANTHROPIC_AUTH_TOKEN") - if api_key: - settings = Settings( - api_key=api_key.strip(), - base_url=os.getenv("ANTHROPIC_BASE_URL") - ) - try: - llm_service = AnthropicProvider(settings) - except Exception: - pass + # 获取统一的动态 LLM 服务 + from interfaces.api.dependencies import get_llm_service, get_bible_repository - from application.world.services.bible_service import BibleService - from interfaces.api.dependencies import get_bible_repository + llm_service = get_llm_service() + from application.world.services.bible_service import BibleService bible_service = BibleService(get_bible_repository()) return ContinuousPlanningService( From d4ccc9bc9a18df61d979536b60df28c1f5401bba Mon Sep 17 00:00:00 2001 From: professorczh Date: Sat, 2 May 2026 23:53:48 +0800 Subject: [PATCH 12/22] feat: implement LLM control service with multi-provider support, Vertex AI integration, and frontend configuration management --- .env.example | 10 + application/ai/llm_control_service.py | 28 ++- frontend/package-lock.json | 216 +++++++++--------- frontend/src/api/llmControl.ts | 2 +- .../components/onboarding/NovelSetupGuide.vue | 93 -------- .../components/workbench/LLMControlPanel.vue | 1 + infrastructure/ai/provider_factory.py | 6 + infrastructure/ai/providers/__init__.py | 6 + .../ai/providers/vertex_ai_provider.py | 164 +++++++++++++ .../persistence/database/schema.sql | 2 +- requirements-vertex.txt | 7 + requirements.txt | 5 +- 12 files changed, 336 insertions(+), 204 deletions(-) create mode 100644 infrastructure/ai/providers/vertex_ai_provider.py create mode 100644 requirements-vertex.txt diff --git a/.env.example b/.env.example index 0068b2e4a..93302ff2e 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,16 @@ EMBEDDING_MODEL_PATH=./.models/bge-small-zh-v1.5 # GPU acceleration for local model (true/false) EMBEDDING_USE_GPU=true +# ── GCP Vertex AI 企业级配置 ── +# [重要] 1. 项目 ID 必须与你的 ADC 凭据关联的项目一致 +# [重要] 2. 建议区域设为 global,以获得预览版模型 (如 3.1 Flash) 的最佳兼容性 +GOOGLE_CLOUD_PROJECT= +GOOGLE_CLOUD_LOCATION=global +GOOGLE_CLOUD_API_KEY= +# 核心认证路径:执行 gcloud auth application-default login 后生成的 JSON 路径 +GOOGLE_APPLICATION_CREDENTIALS= +GOOGLE_GENAI_USE_VERTEXAI=true + # ── Vector Store Configuration ── VECTOR_STORE_TYPE=chromadb diff --git a/application/ai/llm_control_service.py b/application/ai/llm_control_service.py index dee87f7e0..bd4c0afb4 100644 --- a/application/ai/llm_control_service.py +++ b/application/ai/llm_control_service.py @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) -LLMProtocol = Literal['openai', 'anthropic', 'gemini'] +LLMProtocol = Literal['openai', 'anthropic', 'gemini', 'vertex-ai'] class LLMPreset(BaseModel): @@ -257,6 +257,15 @@ def get_presets(self) -> List[LLMPreset]: description='方舟 OpenAI-compatible 接口;模型名以方舟控制台 Endpoint 为准。', tags=['domestic', 'preset'], ), + LLMPreset( + key='vertex-ai-official', + label='Vertex AI / Google Cloud 官方', + protocol='vertex-ai', + default_base_url='', + default_model='gemini-1.5-flash', + description='GCP Vertex AI 企业版接口。需配置 GOOGLE_APPLICATION_CREDENTIALS 或通过 extra_body 传 project_id。', + tags=['official', 'enterprise'], + ), ] def get_preset_map(self) -> Dict[str, LLMPreset]: @@ -524,6 +533,14 @@ def _build_initial_config(self) -> LLMControlConfig: base_url='https://generativelanguage.googleapis.com/v1beta', model='', ), + LLMProfile( + id='vertex-ai-official-default', + name='Vertex AI / GCP', + preset_key='vertex-ai-official', + protocol='vertex-ai', + base_url='', + model='gemini-1.5-flash', + ), ] active_profile_id = profiles[0].id @@ -566,5 +583,14 @@ def _build_initial_config(self) -> LLMControlConfig: 'model': (os.getenv('ARK_MODEL') or '').strip(), }) active_profile_id = profiles[0].id + elif os.getenv('GCP_PROJECT_ID') or os.getenv('GOOGLE_APPLICATION_CREDENTIALS'): + profiles[3] = profiles[3].model_copy(update={ + 'model': (os.getenv('GCP_MODEL') or '').strip() or profiles[3].model, + 'extra_body': { + 'project_id': (os.getenv('GCP_PROJECT_ID') or '').strip(), + 'region': (os.getenv('GCP_REGION') or '').strip() or 'us-central1' + } + }) + active_profile_id = profiles[3].id return LLMControlConfig(version=1, active_profile_id=active_profile_id, profiles=profiles) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d940c89c3..3c69ce78b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -129,9 +129,9 @@ "license": "Apache-2.0" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, @@ -148,9 +148,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", "dev": true, "license": "MIT", "funding": { @@ -158,9 +158,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", "cpu": [ "arm64" ], @@ -175,9 +175,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", "cpu": [ "arm64" ], @@ -192,9 +192,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", "cpu": [ "x64" ], @@ -209,9 +209,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", "cpu": [ "x64" ], @@ -226,9 +226,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", "cpu": [ "arm" ], @@ -243,9 +243,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", "cpu": [ "arm64" ], @@ -260,9 +260,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", "cpu": [ "arm64" ], @@ -277,9 +277,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", "cpu": [ "ppc64" ], @@ -294,9 +294,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", "cpu": [ "s390x" ], @@ -311,9 +311,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", "cpu": [ "x64" ], @@ -328,9 +328,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", "cpu": [ "x64" ], @@ -345,9 +345,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", "cpu": [ "arm64" ], @@ -362,9 +362,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", "cpu": [ "wasm32" ], @@ -372,16 +372,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", "cpu": [ "arm64" ], @@ -396,9 +398,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", "cpu": [ "x64" ], @@ -649,7 +651,7 @@ }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", - "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", @@ -983,9 +985,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmmirror.com/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -1111,9 +1113,9 @@ } }, "node_modules/dompurify": { - "version": "3.3.3", - "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.3.tgz", - "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", + "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -1220,7 +1222,7 @@ }, "node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", @@ -1237,9 +1239,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -1673,15 +1675,15 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/lucide-vue-next": { @@ -1862,9 +1864,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "funding": [ { "type": "opencollective", @@ -1905,14 +1907,14 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" @@ -1921,27 +1923,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", "dev": true, "license": "MIT" }, @@ -1982,14 +1984,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2047,18 +2049,18 @@ } }, "node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmmirror.com/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", - "tinyglobby": "^0.2.15" + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -2075,7 +2077,7 @@ "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", diff --git a/frontend/src/api/llmControl.ts b/frontend/src/api/llmControl.ts index 7cc79ed17..b8d98b283 100644 --- a/frontend/src/api/llmControl.ts +++ b/frontend/src/api/llmControl.ts @@ -1,6 +1,6 @@ import { apiClient } from './config' -export type LLMProtocol = 'openai' | 'anthropic' | 'gemini' +export type LLMProtocol = 'openai' | 'anthropic' | 'gemini' | 'vertex-ai' export interface LLMPreset { key: string diff --git a/frontend/src/components/onboarding/NovelSetupGuide.vue b/frontend/src/components/onboarding/NovelSetupGuide.vue index a10990437..0a461e83c 100644 --- a/frontend/src/components/onboarding/NovelSetupGuide.vue +++ b/frontend/src/components/onboarding/NovelSetupGuide.vue @@ -812,17 +812,12 @@ async function startBibleGeneration() { biblePollEpoch.value += 1 clearGenerationTimers() generatingBible.value = false -<<<<<<< HEAD - bibleError.value = '生成超时,请稍后在工作台手动重试' - }, 300000) // 延长至 5 分钟 -======= bibleError.value = [ `本步等待超时(向导界面最多等待约 ${WIZARD_STEP_TIMEOUT_SECONDS} 秒)。`, '常见原因:模型较慢、思考链、网关排队,或 AI 控制台里「超时」设得过短。', '后台任务可能仍在执行——请到工作台打开 Bible 查看是否已生成;也可在 Bible 中手动触发生成/重试。', ].join('\n') }, WIZARD_BIBLE_POLL_DEADLINE_MS) ->>>>>>> upstream/master schedulePoll(0) } catch (error: unknown) { @@ -936,28 +931,12 @@ watch( () => props.show, async (val) => { if (val) { -<<<<<<< HEAD - currentStep.value = 1 - stepStatus.value = 'process' - plotOptions.value = [] - mainPlotCommitted.value = false - customMode.value = false - customLogline.value = '' - plotSuggestError.value = '' - - // 首先同步状态,看是否已经生成过 - await syncGenerationState() - - // 只有在完全没有生成过世界观时才启动自动生成 - if (!bibleGenerated.value && !generatingBible.value) { -======= resetWizardStateForOpen() // 检查已有进度,确定从哪一步继续 const step = await detectWizardProgress() currentStep.value = step // 只有在第 1 步且世界观未生成时才启动生成 if (step === 1 && !bibleGenerated.value) { ->>>>>>> upstream/master void startBibleGeneration() } } else { @@ -993,41 +972,6 @@ const handleNext = async () => { step2PollEpoch.value += 1 const epoch2 = step2PollEpoch.value currentStep.value = 2 -<<<<<<< HEAD - if (!charactersGenerated.value && !generatingCharacters.value) { - generatingCharacters.value = true - try { - console.log('[NovelSetupGuide] Starting character generation...') - await bibleApi.generateBible(props.novelId, 'characters') - - // 轮询逻辑 - const poll = async () => { - if (!generatingCharacters.value) return - try { - console.log(`[NovelSetupGuide] Polling characters for novel: ${props.novelId}`) - const bible = await bibleApi.getBible(props.novelId) - console.log('[NovelSetupGuide] Characters poll response:', bible.characters?.length) - - if (bible.characters && bible.characters.length > 0) { - bibleData.value = bible - generatingCharacters.value = false - charactersGenerated.value = true - message.success('人物生成完成') - } else { - characterPollTimer.value = window.setTimeout(poll, 2000) - } - } catch (e) { - console.warn('[NovelSetupGuide] Character poll failed:', e) - characterPollTimer.value = window.setTimeout(poll, 3000) - } - } - void poll() - } catch (error) { - console.error('Failed to start character generation:', error) - generatingCharacters.value = false - message.error('启动人物生成失败') - } -======= // 如果人物已存在,跳过生成 if (charactersGenerated.value) { return @@ -1065,47 +1009,11 @@ const handleNext = async () => { charactersError.value = isLikelyTimeoutError(error) ? '提交人物生成超时,请检查网络与 API 后再试。' : formatApiError(error) || '人物生成启动失败' ->>>>>>> upstream/master } } else if (currentStep.value === 2) { step3PollEpoch.value += 1 const epoch3 = step3PollEpoch.value currentStep.value = 3 -<<<<<<< HEAD - if (!locationsGenerated.value && !generatingLocations.value) { - generatingLocations.value = true - try { - console.log('[NovelSetupGuide] Starting location generation...') - await bibleApi.generateBible(props.novelId, 'locations') - - // 轮询逻辑 - const poll = async () => { - if (!generatingLocations.value) return - try { - console.log(`[NovelSetupGuide] Polling locations for novel: ${props.novelId}`) - const bible = await bibleApi.getBible(props.novelId) - console.log('[NovelSetupGuide] Locations poll response:', bible.locations?.length) - - if (bible.locations && bible.locations.length > 0) { - bibleData.value = bible - generatingLocations.value = false - locationsGenerated.value = true - message.success('地图生成完成') - } else { - locationPollTimer.value = window.setTimeout(poll, 2000) - } - } catch (e) { - console.warn('[NovelSetupGuide] Location poll failed:', e) - locationPollTimer.value = window.setTimeout(poll, 3000) - } - } - void poll() - } catch (error) { - console.error('Failed to start location generation:', error) - generatingLocations.value = false - message.error('启动地点生成失败') - } -======= // 如果地点已存在,跳过生成 if (locationsGenerated.value) { return @@ -1143,7 +1051,6 @@ const handleNext = async () => { locationsError.value = isLikelyTimeoutError(error) ? '提交地图生成超时,请检查网络与 API 后再试。' : formatApiError(error) || '地图生成启动失败' ->>>>>>> upstream/master } } else if (currentStep.value < 5) { currentStep.value++ diff --git a/frontend/src/components/workbench/LLMControlPanel.vue b/frontend/src/components/workbench/LLMControlPanel.vue index 7634c95a6..1dbd86db6 100644 --- a/frontend/src/components/workbench/LLMControlPanel.vue +++ b/frontend/src/components/workbench/LLMControlPanel.vue @@ -295,6 +295,7 @@ const protocolOptions = [ { label: 'OpenAI 兼容', value: 'openai' }, { label: 'Anthropic / Claude 兼容', value: 'anthropic' }, { label: 'Gemini', value: 'gemini' }, + { label: 'Vertex AI / GCP', value: 'vertex-ai' }, ] const presetOptions = computed(() => diff --git a/infrastructure/ai/provider_factory.py b/infrastructure/ai/provider_factory.py index ba52ad572..5604f5e3a 100644 --- a/infrastructure/ai/provider_factory.py +++ b/infrastructure/ai/provider_factory.py @@ -10,6 +10,7 @@ from infrastructure.ai.providers.gemini_provider import GeminiProvider from infrastructure.ai.providers.mock_provider import MockProvider from infrastructure.ai.providers.openai_provider import OpenAIProvider +from infrastructure.ai.providers.vertex_ai_provider import VertexAIProvider from infrastructure.ai.url_utils import ( normalize_anthropic_base_url, normalize_gemini_base_url, @@ -36,6 +37,8 @@ def create_from_profile(self, profile: Optional[LLMProfile]) -> LLMService: return AnthropicProvider(settings) if resolved.protocol == 'gemini': return GeminiProvider(settings) + if resolved.protocol == 'vertex-ai': + return VertexAIProvider(settings) return OpenAIProvider(settings) def create_active_provider(self) -> LLMService: @@ -46,6 +49,9 @@ def _profile_to_settings(self, profile: LLMProfile) -> Settings: normalized_base_url = normalize_anthropic_base_url(profile.base_url) elif profile.protocol == 'gemini': normalized_base_url = normalize_gemini_base_url(profile.base_url) + elif profile.protocol == 'vertex-ai': + # Vertex AI 的 URL 通常是动态生成的,但如果用户提供了自定义的 base_url,我们也保留它 + normalized_base_url = profile.base_url else: normalized_base_url = normalize_openai_base_url(profile.base_url) diff --git a/infrastructure/ai/providers/__init__.py b/infrastructure/ai/providers/__init__.py index 5384da343..b0abbe20c 100644 --- a/infrastructure/ai/providers/__init__.py +++ b/infrastructure/ai/providers/__init__.py @@ -24,3 +24,9 @@ __all__.append("GeminiProvider") except ModuleNotFoundError: GeminiProvider = None + +try: + from .vertex_ai_provider import VertexAIProvider + __all__.append("VertexAIProvider") +except ModuleNotFoundError: + VertexAIProvider = None diff --git a/infrastructure/ai/providers/vertex_ai_provider.py b/infrastructure/ai/providers/vertex_ai_provider.py new file mode 100644 index 000000000..582f012a6 --- /dev/null +++ b/infrastructure/ai/providers/vertex_ai_provider.py @@ -0,0 +1,164 @@ +"""Vertex AI LLM 提供商实现 (使用最新版 google-genai SDK)""" +from __future__ import annotations +import logging +import os +from typing import Any, AsyncIterator, Optional + +try: + from google import genai + from google.genai import types + from google.genai.types import HttpOptions + HAS_GENAI = True +except ImportError: + HAS_GENAI = False + +from domain.ai.services.llm_service import GenerationConfig, GenerationResult, LLMService +from domain.ai.value_objects.prompt import Prompt +from domain.ai.value_objects.token_usage import TokenUsage +from infrastructure.ai.config.settings import Settings +from .base import BaseProvider +from .model_resolution import require_resolved_model_id + +logger = logging.getLogger(__name__) + +DEFAULT_MODEL = 'gemini-1.5-flash' +DEFAULT_REGION = 'us-central1' + +class VertexAIProvider(BaseProvider): + """使用最新版 Google GenAI SDK 的 Vertex AI 提供商实现 + + 优势: + 1. 统一的 Client 接口,原生支持 Vertex AI 模式。 + 2. 支持 Gemini 2.0+ 的高级特性(如 Thinking Config)。 + 3. 支持集成 Google Search 等工具。 + """ + + def __init__(self, settings: Settings): + super().__init__(settings) + if not HAS_GENAI: + logger.error("google-genai SDK not installed. Please run 'pip install google-genai'") + return + + # 1. 获取基础配置 + self.region = ( + settings.extra_body.get('region') + or os.getenv("GOOGLE_CLOUD_LOCATION") + or os.getenv("GCP_REGION") + or DEFAULT_REGION + ).strip() + self.project_id = ( + settings.extra_body.get('project_id') + or os.getenv("GOOGLE_CLOUD_PROJECT") + or os.getenv("GCP_PROJECT_ID") + ).strip() + + # 2. 初始化客户端 (回归 Vertex AI 企业专线 + 注入关键计费请求头) + try: + # 使用动态读取的 project_id,确保以后改 .env 也能生效 + self.client = genai.Client( + vertexai=True, + project=self.project_id, + location=self.region if self.region != "us-central1" else "global", # 默认改用 global 避坑 + # 关键:手动注入计费项目头,解决 ADC 环境下的 404/403 问题 + http_options={ + 'headers': {'x-goog-user-project': self.project_id} + } + ) + logger.info(f"Vertex AI (GenAI SDK) initialized: project={self.project_id}") + except Exception as e: + logger.error(f"Failed to initialize GenAI Client: {e}") + + async def generate(self, prompt: Prompt, config: GenerationConfig) -> GenerationResult: + self._ensure_sdk() + model_id = self._get_resolved_model(config) + + gen_config = self._build_genai_config(config, prompt.system) + + # 使用 aio 异步客户端 + response = await self.client.aio.models.generate_content( + model=model_id, + contents=prompt.user, + config=gen_config + ) + + # 提取内容 + content = response.text or "" + + # 提取 Token 使用量 + usage = response.usage_metadata + token_usage = TokenUsage( + input_tokens=usage.prompt_token_count or 0, + output_tokens=usage.candidates_token_count or 0 + ) + + return GenerationResult(content=content, token_usage=token_usage) + + async def stream_generate(self, prompt: Prompt, config: GenerationConfig) -> AsyncIterator[str]: + self._ensure_sdk() + model_id = self._get_resolved_model(config) + gen_config = self._build_genai_config(config, prompt.system) + + # 异步流式生成 + # 修正异步流式调用语法:先 await 获取流,再 async for 遍历 + stream = await self.client.aio.models.generate_content_stream( + model=model_id, + contents=prompt.user, + config=gen_config + ) + async for chunk in stream: + if chunk.text: + yield chunk.text + + def _ensure_sdk(self): + if not HAS_GENAI: + raise ImportError("Please install 'google-genai' to use VertexAIProvider.") + + def _get_resolved_model(self, config: GenerationConfig) -> str: + model_id = require_resolved_model_id( + config.model, + self.settings.default_model, + provider_label="Vertex AI", + ).strip() + + # 遵循“真经”:直接返回模型名,SDK 会在底层自动补全 publishers/google/models/ 路径 + return model_id + + def _build_genai_config(self, config: GenerationConfig, system_instruction: Optional[str]) -> types.GenerateContentConfig: + """构建最新版 GenAI SDK 的配置对象""" + eb = self.settings.extra_body or {} + + # 映射基础参数 + params = { + "temperature": config.temperature, + "max_output_tokens": config.max_tokens, + "top_p": eb.get("top_p", 0.95), + "top_k": eb.get("top_k"), + "safety_settings": self._build_safety_settings(), + "system_instruction": system_instruction.strip() if system_instruction and system_instruction.strip() else None + } + + # 针对 Gemini 2.0+ 的思维配置 (Thinking) + if "thinking_level" in eb: + params["thinking_config"] = types.ThinkingConfig( + thinking_level=eb["thinking_level"] # e.g., "LOW", "MEDIUM", "HIGH" + ) + + # 针对搜索工具的支持 + if eb.get("use_google_search"): + params["tools"] = [types.Tool(google_search=types.GoogleSearch())] + + return types.GenerateContentConfig(**params) + + def _build_safety_settings(self) -> list[types.SafetySetting]: + """构建安全设置""" + return [ + types.SafetySetting( + category=cat, + threshold="OFF", # 对应代码片段中的 OFF (即 BLOCK_NONE) + ) for cat in [ + "HARM_CATEGORY_HATE_SPEECH", + "HARM_CATEGORY_HARASSMENT", + "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "HARM_CATEGORY_DANGEROUS_CONTENT", + ] + ] diff --git a/infrastructure/persistence/database/schema.sql b/infrastructure/persistence/database/schema.sql index 111e68ee5..254c9c2a1 100644 --- a/infrastructure/persistence/database/schema.sql +++ b/infrastructure/persistence/database/schema.sql @@ -612,7 +612,7 @@ CREATE TABLE IF NOT EXISTS llm_profiles ( id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', preset_key TEXT NOT NULL DEFAULT 'custom-openai-compatible', - protocol TEXT NOT NULL DEFAULT 'openai' CHECK(protocol IN ('openai', 'anthropic', 'gemini')), + protocol TEXT NOT NULL DEFAULT 'openai' CHECK(protocol IN ('openai', 'anthropic', 'gemini', 'vertex-ai')), base_url TEXT NOT NULL DEFAULT '', api_key TEXT NOT NULL DEFAULT '', model TEXT NOT NULL DEFAULT '', diff --git a/requirements-vertex.txt b/requirements-vertex.txt new file mode 100644 index 000000000..f4456ded3 --- /dev/null +++ b/requirements-vertex.txt @@ -0,0 +1,7 @@ +# ── Google Vertex AI 扩展依赖 ── +# 仅当你在 .env 中开启 GOOGLE_GENAI_USE_VERTEXAI=true 时需要安装 +# 安装命令: pip install -r requirements-vertex.txt + +google-genai>=1.0.0 +google-auth>=2.26.0 +google-cloud-aiplatform>=1.70.0 diff --git a/requirements.txt b/requirements.txt index c46292d4d..0b3c6645d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,10 @@ volcengine-python-sdk[ark]>=1.0.0 # ── 工具库 ── python-dotenv>=1.0.0 json-repair>=0.30.0 -google-genai>=1.0.0 + +# ── 扩展服务 (可选,按需安装) ── +# Vertex AI 模式请安装: pip install -r requirements-vertex.txt +# 本地模型模式请安装: pip install -r requirements-local.txt # ── 导出(DOCX / EPUB / PDF)── python-docx>=1.1.0 From 2de53859f4625977843046d3c2a9832fb7afcc9e Mon Sep 17 00:00:00 2001 From: professorczh Date: Sun, 3 May 2026 00:11:57 +0800 Subject: [PATCH 13/22] feat: implement theme management system with store, global styles, and settings UI --- frontend/src/App.vue | 46 +---- frontend/src/assets/styles/main.css | 93 ++-------- frontend/src/components/LLMSettingsModal.vue | 34 ++-- frontend/src/router/index.ts | 20 -- frontend/src/stores/themeStore.ts | 183 +------------------ 5 files changed, 38 insertions(+), 338 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5330abf87..d21e1307a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,23 +1,15 @@ - - - - diff --git a/frontend/src/components/renaissance/CinnabarButton.vue b/frontend/src/components/renaissance/CinnabarButton.vue deleted file mode 100644 index a0e105efd..000000000 --- a/frontend/src/components/renaissance/CinnabarButton.vue +++ /dev/null @@ -1,108 +0,0 @@ - - - - - diff --git a/frontend/src/views/renaissance/HomeView.vue b/frontend/src/views/renaissance/HomeView.vue deleted file mode 100644 index 3856dba52..000000000 --- a/frontend/src/views/renaissance/HomeView.vue +++ /dev/null @@ -1,535 +0,0 @@ - - - - - diff --git a/frontend/src/views/renaissance/OnboardingView.vue b/frontend/src/views/renaissance/OnboardingView.vue deleted file mode 100644 index 13038e487..000000000 --- a/frontend/src/views/renaissance/OnboardingView.vue +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/frontend/src/views/renaissance/WorkbenchView.vue b/frontend/src/views/renaissance/WorkbenchView.vue deleted file mode 100644 index 3fe95873e..000000000 --- a/frontend/src/views/renaissance/WorkbenchView.vue +++ /dev/null @@ -1,16 +0,0 @@ - - - From 326c74ba6c2b2ea295935f82f9255bfa56e4de99 Mon Sep 17 00:00:00 2001 From: professorczh Date: Sun, 3 May 2026 00:24:09 +0800 Subject: [PATCH 15/22] feat: add NovelSetupGuide component for user onboarding flow --- frontend/src/components/onboarding/NovelSetupGuide.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/onboarding/NovelSetupGuide.vue b/frontend/src/components/onboarding/NovelSetupGuide.vue index 0a461e83c..e8455df3d 100644 --- a/frontend/src/components/onboarding/NovelSetupGuide.vue +++ b/frontend/src/components/onboarding/NovelSetupGuide.vue @@ -519,6 +519,7 @@ const charactersError = ref('') // 第3步:生成地点 const generatingLocations = ref(false) const locationsGenerated = ref(false) +const locationsError = ref('') const locationPollTimer = ref | null>(null) /** 作废第 2/3 步后台轮询(关闭向导或重置时递增) */ From bdbc1d343fef8c7fd606a9cf9527d781bf189dc5 Mon Sep 17 00:00:00 2001 From: professorczh Date: Sun, 3 May 2026 01:26:05 +0800 Subject: [PATCH 16/22] fix: address code review issues from CodeRabbit (GCP/UI/DB) --- .../components/onboarding/NovelSetupGuide.vue | 171 +++++++++--------- infrastructure/ai/llm_client.py | 2 +- .../ai/providers/vertex_ai_provider.py | 52 ++++-- .../persistence/database/connection.py | 48 +++++ interfaces/api/v1/workbench/llm_control.py | 11 +- 5 files changed, 180 insertions(+), 104 deletions(-) diff --git a/frontend/src/components/onboarding/NovelSetupGuide.vue b/frontend/src/components/onboarding/NovelSetupGuide.vue index e8455df3d..8d384856b 100644 --- a/frontend/src/components/onboarding/NovelSetupGuide.vue +++ b/frontend/src/components/onboarding/NovelSetupGuide.vue @@ -303,6 +303,7 @@ v-if="(currentStep === 1 && bibleGenerated) || (currentStep === 2 && charactersGenerated) || (currentStep === 3 && locationsGenerated)" type="primary" @click="handleNext" + :loading="isProcessingNext" > 确认并继续 @@ -525,6 +526,7 @@ const locationPollTimer = ref | null>(null) /** 作废第 2/3 步后台轮询(关闭向导或重置时递增) */ const step2PollEpoch = ref(0) const step3PollEpoch = ref(0) +const isProcessingNext = ref(false) /** * 核心:同步当前小说的生成状态 @@ -969,92 +971,95 @@ watch(currentStep, (step) => { }) const handleNext = async () => { - if (currentStep.value === 1) { - step2PollEpoch.value += 1 - const epoch2 = step2PollEpoch.value - currentStep.value = 2 - // 如果人物已存在,跳过生成 - if (charactersGenerated.value) { - return - } - generatingCharacters.value = true - charactersGenerated.value = false - charactersError.value = '' - try { - await bibleApi.generateBible(props.novelId, 'characters') - pollBibleUntil( - (b) => (b.characters?.length ?? 0) > 0, - { - isStale: () => - step2PollEpoch.value !== epoch2 || currentStep.value !== 2 || !generatingCharacters.value, - watchBackendFailure: true, - onSuccess: () => { - generatingCharacters.value = false - charactersGenerated.value = true - }, - onTimeout: () => { - generatingCharacters.value = false - charactersError.value = `等待人物生成超时(约 ${WIZARD_STEP_TIMEOUT_SECONDS} 秒)。后台可能仍在跑——请到工作台 Bible 查看;若无数据可返回上一步再进入本步重试,或在 Bible 手动生成。` - message.warning('人物生成超时') - }, - onFatal: (msg) => { - generatingCharacters.value = false - charactersError.value = msg - message.error(msg) - }, - }, - ) - } catch (error: unknown) { - console.error('Failed to generate characters:', error) - generatingCharacters.value = false - charactersError.value = isLikelyTimeoutError(error) - ? '提交人物生成超时,请检查网络与 API 后再试。' - : formatApiError(error) || '人物生成启动失败' - } - } else if (currentStep.value === 2) { - step3PollEpoch.value += 1 - const epoch3 = step3PollEpoch.value - currentStep.value = 3 - // 如果地点已存在,跳过生成 - if (locationsGenerated.value) { - return - } - generatingLocations.value = true - locationsGenerated.value = false - locationsError.value = '' - try { - await bibleApi.generateBible(props.novelId, 'locations') - pollBibleUntil( - (b) => (b.locations?.length ?? 0) > 0, - { - isStale: () => - step3PollEpoch.value !== epoch3 || currentStep.value !== 3 || !generatingLocations.value, - watchBackendFailure: true, - onSuccess: () => { - generatingLocations.value = false - locationsGenerated.value = true - }, - onTimeout: () => { - generatingLocations.value = false - locationsError.value = `等待地图生成超时(约 ${WIZARD_STEP_TIMEOUT_SECONDS} 秒)。请到工作台 Bible 查看地点是否已写入,或稍后重试。` - message.warning('地图生成超时') + if (isProcessingNext.value) return + isProcessingNext.value = true + + try { + if (currentStep.value === 1) { + step2PollEpoch.value += 1 + const epoch2 = step2PollEpoch.value + currentStep.value = 2 + if (charactersGenerated.value) return + + generatingCharacters.value = true + charactersGenerated.value = false + charactersError.value = '' + + try { + await bibleApi.generateBible(props.novelId, 'characters') + pollBibleUntil( + (b) => (b.characters?.length ?? 0) > 0, + { + isStale: () => step2PollEpoch.value !== epoch2 || currentStep.value !== 2 || !generatingCharacters.value, + watchBackendFailure: true, + onSuccess: () => { + generatingCharacters.value = false + charactersGenerated.value = true + }, + onTimeout: () => { + generatingCharacters.value = false + charactersError.value = `等待人物生成超时。请到工作台 Bible 查看;若无数据可返回上一步重试。` + message.warning('人物生成超时') + }, + onFatal: (msg) => { + generatingCharacters.value = false + charactersError.value = msg + message.error(msg) + }, }, - onFatal: (msg) => { - generatingLocations.value = false - locationsError.value = msg - message.error(msg) + ) + } catch (err) { + console.error('Failed to start character generation:', err) + generatingCharacters.value = false + charactersError.value = formatApiError(err) || '启动失败' + } + } else if (currentStep.value === 2) { + step3PollEpoch.value += 1 + const epoch3 = step3PollEpoch.value + currentStep.value = 3 + if (locationsGenerated.value) return + + generatingLocations.value = true + locationsGenerated.value = false + locationsError.value = '' + + try { + await bibleApi.generateBible(props.novelId, 'locations') + pollBibleUntil( + (b) => (b.locations?.length ?? 0) > 0, + { + isStale: () => step3PollEpoch.value !== epoch3 || currentStep.value !== 3 || !generatingLocations.value, + watchBackendFailure: true, + onSuccess: () => { + generatingLocations.value = false + locationsGenerated.value = true + }, + onTimeout: () => { + generatingLocations.value = false + locationsError.value = `等待地图生成超时。请到工作台 Bible 查看。` + message.warning('地图生成超时') + }, + onFatal: (msg) => { + generatingLocations.value = false + locationsError.value = msg + message.error(msg) + }, }, - }, - ) - } catch (error: unknown) { - console.error('Failed to generate locations:', error) - generatingLocations.value = false - locationsError.value = isLikelyTimeoutError(error) - ? '提交地图生成超时,请检查网络与 API 后再试。' - : formatApiError(error) || '地图生成启动失败' + ) + } catch (err) { + console.error('Failed to start location generation:', err) + generatingLocations.value = false + locationsError.value = formatApiError(err) || '启动失败' + } + } else if (currentStep.value === 3) { + currentStep.value = 4 + } else if (currentStep.value < 5) { + currentStep.value++ } - } else if (currentStep.value < 5) { - currentStep.value++ + } catch (globalErr) { + console.error('Wizard global error:', globalErr) + } finally { + isProcessingNext.value = false } } diff --git a/infrastructure/ai/llm_client.py b/infrastructure/ai/llm_client.py index ca2567a37..28b98e0f6 100644 --- a/infrastructure/ai/llm_client.py +++ b/infrastructure/ai/llm_client.py @@ -1,7 +1,7 @@ """LLM 客户端包装器,支持动态服务切换""" import os import logging -from typing import AsyncIterator, Optional, Any, Dict +from typing import AsyncIterator, Optional, Any, Dict, Union from domain.ai.services.llm_service import GenerationConfig, LLMService from domain.ai.value_objects.prompt import Prompt diff --git a/infrastructure/ai/providers/vertex_ai_provider.py b/infrastructure/ai/providers/vertex_ai_provider.py index 582f012a6..933a21cd0 100644 --- a/infrastructure/ai/providers/vertex_ai_provider.py +++ b/infrastructure/ai/providers/vertex_ai_provider.py @@ -39,18 +39,22 @@ def __init__(self, settings: Settings): logger.error("google-genai SDK not installed. Please run 'pip install google-genai'") return - # 1. 获取基础配置 - self.region = ( + # 安全获取 region 和 project_id (CodeRabbit: 防止 NoneType 报错) + raw_region = ( settings.extra_body.get('region') or os.getenv("GOOGLE_CLOUD_LOCATION") or os.getenv("GCP_REGION") or DEFAULT_REGION - ).strip() - self.project_id = ( + ) + self.region = raw_region.strip() if raw_region else DEFAULT_REGION + + raw_project_id = ( settings.extra_body.get('project_id') or os.getenv("GOOGLE_CLOUD_PROJECT") or os.getenv("GCP_PROJECT_ID") - ).strip() + or "" + ) + self.project_id = raw_project_id.strip() # 2. 初始化客户端 (回归 Vertex AI 企业专线 + 注入关键计费请求头) try: @@ -69,6 +73,15 @@ def __init__(self, settings: Settings): logger.error(f"Failed to initialize GenAI Client: {e}") async def generate(self, prompt: Prompt, config: GenerationConfig) -> GenerationResult: + """执行单次文本生成任务。 + + Args: + prompt: 包含系统指令和用户输入的 Prompt 对象。 + config: 包含模型 ID、温度等参数的生成配置。 + + Returns: + GenerationResult: 包含生成文本及 Token 使用量元数据的结果。 + """ self._ensure_sdk() model_id = self._get_resolved_model(config) @@ -84,16 +97,28 @@ async def generate(self, prompt: Prompt, config: GenerationConfig) -> Generation # 提取内容 content = response.text or "" - # 提取 Token 使用量 - usage = response.usage_metadata - token_usage = TokenUsage( - input_tokens=usage.prompt_token_count or 0, - output_tokens=usage.candidates_token_count or 0 - ) + # 提取 Token 使用量 (CodeRabbit: 增加 None 判定) + usage = getattr(response, 'usage_metadata', None) + if usage: + token_usage = TokenUsage( + input_tokens=usage.prompt_token_count or 0, + output_tokens=usage.candidates_token_count or 0 + ) + else: + token_usage = TokenUsage(input_tokens=0, output_tokens=0) return GenerationResult(content=content, token_usage=token_usage) async def stream_generate(self, prompt: Prompt, config: GenerationConfig) -> AsyncIterator[str]: + """执行流式文本生成任务。 + + Args: + prompt: 包含系统指令和用户输入的 Prompt 对象。 + config: 包含模型 ID、温度等参数的生成配置。 + + Yields: + str: 生成的文本片段。 + """ self._ensure_sdk() model_id = self._get_resolved_model(config) gen_config = self._build_genai_config(config, prompt.system) @@ -106,6 +131,11 @@ async def stream_generate(self, prompt: Prompt, config: GenerationConfig) -> Asy config=gen_config ) async for chunk in stream: + # CodeRabbit: 捕获流式末尾的 Token 统计 (如果 SDK 提供了) + if hasattr(chunk, 'usage_metadata') and chunk.usage_metadata: + usage = chunk.usage_metadata + logger.debug(f"Vertex AI Stream Usage: input={usage.prompt_token_count}, output={usage.candidates_token_count}") + if chunk.text: yield chunk.text diff --git a/infrastructure/persistence/database/connection.py b/infrastructure/persistence/database/connection.py index 03ad43b6c..519705170 100644 --- a/infrastructure/persistence/database/connection.py +++ b/infrastructure/persistence/database/connection.py @@ -254,6 +254,53 @@ def _apply_migration_files(conn: sqlite3.Connection) -> None: logger.warning("Failed to apply migration %s: %s", migration_file, e) +def _fix_llm_profiles_protocol_check(conn: sqlite3.Connection) -> None: + """修复 llm_profiles 表的 CHECK 约束,确保包含 vertex-ai (SQLite 迁移方案)。""" + cur = conn.execute("SELECT sql FROM sqlite_master WHERE name='llm_profiles'") + row = cur.fetchone() + if not row: + return + sql = row[0] + if "vertex-ai" in sql: + return + + logger.info("llm_profiles schema outdated (missing vertex-ai check). Recreating table...") + # SQLite 迁移:重命名 -> 建新表 -> 导数据 -> 删旧表 + try: + conn.execute("ALTER TABLE llm_profiles RENAME TO llm_profiles_old") + conn.execute(""" + CREATE TABLE llm_profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + preset_key TEXT NOT NULL DEFAULT 'custom-openai-compatible', + protocol TEXT NOT NULL DEFAULT 'openai' CHECK(protocol IN ('openai', 'anthropic', 'gemini', 'vertex-ai')), + base_url TEXT NOT NULL DEFAULT '', + api_key TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + temperature REAL NOT NULL DEFAULT 0.7, + max_tokens INTEGER NOT NULL DEFAULT 4096, + timeout_seconds INTEGER NOT NULL DEFAULT 300, + extra_headers TEXT NOT NULL DEFAULT '{}', + extra_query TEXT NOT NULL DEFAULT '{}', + extra_body TEXT NOT NULL DEFAULT '{}', + notes TEXT NOT NULL DEFAULT '', + use_legacy_chat_completions INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.execute("INSERT INTO llm_profiles SELECT * FROM llm_profiles_old") + conn.execute("DROP TABLE llm_profiles_old") + conn.commit() + logger.info("llm_profiles table recreated with updated CHECK constraint.") + except Exception as e: + logger.error(f"Failed to fix llm_profiles schema: {e}") + conn.rollback() + + + + def _ensure_triple_provenance_table(conn: sqlite3.Connection) -> None: """旧库补齐 triple_provenance 表(schema.sql 对新库已包含)。""" conn.execute( @@ -330,6 +377,7 @@ def _ensure_database_exists(self) -> None: _apply_character_enhancements(conn) _apply_chapter_summaries_enhancements(conn) _ensure_triple_provenance_table(conn) + _fix_llm_profiles_protocol_check(conn) _apply_migration_files(conn) conn.close() diff --git a/interfaces/api/v1/workbench/llm_control.py b/interfaces/api/v1/workbench/llm_control.py index 5755de51d..982005068 100644 --- a/interfaces/api/v1/workbench/llm_control.py +++ b/interfaces/api/v1/workbench/llm_control.py @@ -114,12 +114,6 @@ async def list_models(payload: ModelListRequest) -> ModelListResponse: 'anthropic-version': '2023-06-01', } elif api_format == 'gemini': - # --- PlotPilot Stability Patch: Gemini Connectivity --- - import os - os.environ["HTTPX_HTTP2"] = "0" - os.environ["HTTPS_PROXY"] = "socks5h://127.0.0.1:10808" - os.environ["HTTP_PROXY"] = "socks5h://127.0.0.1:10808" - # --------------------------------------------------- actual_base = base_url or 'https://generativelanguage.googleapis.com/v1beta' url = f"{actual_base.rstrip('/')}/models?key={api_key}" headers = {'Content-Type': 'application/json'} @@ -131,9 +125,8 @@ async def list_models(payload: ModelListRequest) -> ModelListResponse: } try: - # 不向子进程继承 HTTP(S)_PROXY:本机 Clash/V2 等监听 127.0.0.1 时,httpx 走代理易导致 - # start_tls / BrokenResourceError,而国内直连 API 域名通常无需系统代理。 - async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client: + # 允许继承系统环境变量中的代理设置 (CodeRabbit: Remove trust_env=False) + async with httpx.AsyncClient(timeout=timeout, trust_env=True) as client: response = await client.get(url, headers=headers) response.raise_for_status() try: From 060894202c2231ce5e767a3f17362b28599d5e13 Mon Sep 17 00:00:00 2001 From: professorczh Date: Sun, 3 May 2026 02:02:44 +0800 Subject: [PATCH 17/22] feat: add Vertex AI provider, implement LLM control API endpoints, and create onboarding setup guide component. --- .../components/onboarding/NovelSetupGuide.vue | 25 +++++++++++++++++++ .../ai/providers/vertex_ai_provider.py | 5 +++- interfaces/api/v1/workbench/llm_control.py | 18 ++++++++----- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/onboarding/NovelSetupGuide.vue b/frontend/src/components/onboarding/NovelSetupGuide.vue index 8d384856b..1a91b8f8f 100644 --- a/frontend/src/components/onboarding/NovelSetupGuide.vue +++ b/frontend/src/components/onboarding/NovelSetupGuide.vue @@ -326,6 +326,31 @@ import { worldbuildingApi } from '@/api/worldbuilding' import { workflowApi, type MainPlotOptionDTO } from '@/api/workflow' import BibleLocationsGraphPreview from './BibleLocationsGraphPreview.vue' +// --- 图标组件 (SVG 手写版) --- +const IconBook = () => + h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', width: '1em', height: '1em' }, + h('path', { fill: 'currentColor', d: 'M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 4h5v8l-2.5-1.5L6 12V4z' })) + +const IconPeople = () => + h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', width: '1em', height: '1em' }, + h('path', { fill: 'currentColor', d: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' })) + +const IconMap = () => + h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', width: '1em', height: '1em' }, + h('path', { fill: 'currentColor', d: 'M20.5 3l-.16.03L15 5.1L9 3L3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1l5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM15 19l-6-2.11V5l6 2.11V19z' })) + +const IconTimeline = () => + h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', width: '1em', height: '1em' }, + h('path', { fill: 'currentColor', d: 'M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z' })) + +const IconChart = () => + h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', width: '1em', height: '1em' }, + h('path', { fill: 'currentColor', d: 'M5 19h14v2H5zM19 17h2v-4h-2v4zM15 17h2V7h-2v10zM11 17h2v-7h-2v7zM7 17h2v-3H7v3zM3 17h2v-6H3v6z' })) + +const IconCheck = () => + h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', width: '1em', height: '1em' }, + h('path', { fill: 'currentColor', d: 'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' })) + const WB_DIMS = ['core_rules', 'geography', 'society', 'culture', 'daily_life'] as const function emptyWorldbuildingShape(): Record<(typeof WB_DIMS)[number], Record> { diff --git a/infrastructure/ai/providers/vertex_ai_provider.py b/infrastructure/ai/providers/vertex_ai_provider.py index 933a21cd0..36081bf74 100644 --- a/infrastructure/ai/providers/vertex_ai_provider.py +++ b/infrastructure/ai/providers/vertex_ai_provider.py @@ -35,6 +35,7 @@ class VertexAIProvider(BaseProvider): def __init__(self, settings: Settings): super().__init__(settings) + self.client = None if not HAS_GENAI: logger.error("google-genai SDK not installed. Please run 'pip install google-genai'") return @@ -141,7 +142,9 @@ async def stream_generate(self, prompt: Prompt, config: GenerationConfig) -> Asy def _ensure_sdk(self): if not HAS_GENAI: - raise ImportError("Please install 'google-genai' to use VertexAIProvider.") + raise ImportError("请运行 'pip install google-genai' 以安装 Vertex AI 所需的 SDK。") + if not self.client: + raise RuntimeError("Vertex AI 客户端未就绪。请检查 .env 中的 GCP_PROJECT_ID 和 GCP_REGION 配置。") def _get_resolved_model(self, config: GenerationConfig) -> str: model_id = require_resolved_model_id( diff --git a/interfaces/api/v1/workbench/llm_control.py b/interfaces/api/v1/workbench/llm_control.py index 982005068..22717ec1c 100644 --- a/interfaces/api/v1/workbench/llm_control.py +++ b/interfaces/api/v1/workbench/llm_control.py @@ -75,7 +75,8 @@ def _openai_compatible_models_base(base_url: str) -> str: def _normalize_model_items(data: Dict[str, Any]) -> List[ModelItem]: """将不同网关的 /models 响应统一为 ModelItem 列表。""" items: List[ModelItem] = [] - raw_list = data.get('data', []) + # 兼容不同厂商的返回字段 (OpenAI 为 'data', Gemini 为 'models') + raw_list = data.get('data') or data.get('models', []) if not isinstance(raw_list, list): return items for entry in raw_list: @@ -132,10 +133,12 @@ async def list_models(payload: ModelListRequest) -> ModelListResponse: try: data = response.json() except json.JSONDecodeError: + # 安全处理:脱敏 URL 中的 API Key + safe_url = url.split('?')[0] if '?' in url else url snippet = (response.text or '')[:240].replace('\n', ' ') raise HTTPException( status_code=502, - detail=f'上游未返回 JSON(请检查 Base URL 与协议是否匹配 OpenAI 兼容)。请求 URL:{url}。片段:{snippet}', + detail=f'上游未返回 JSON(请检查协议是否匹配)。请求 URL:{safe_url}。片段:{snippet}', ) normalized = _normalize_model_items(data) return ModelListResponse( @@ -146,18 +149,21 @@ async def list_models(payload: ModelListRequest) -> ModelListResponse: except HTTPException: raise except httpx.HTTPStatusError as exc: + # 安全处理:脱敏 URL 中的 API Key + safe_url = url.split('?')[0] if '?' in url else url body = (exc.response.text or '')[:400].replace('\n', ' ') raise HTTPException( status_code=502, - detail=f'上游模型列表 HTTP {exc.response.status_code}:{body or exc.response.reason_phrase}(请求 {url})', + detail=f'上游模型列表 HTTP {exc.response.status_code}:{body or exc.response.reason_phrase}(请求 {safe_url})', ) from exc except httpx.RequestError as exc: + # 安全处理:脱敏 URL 中的 API Key + safe_url = url.split('?')[0] if '?' in url else url raise HTTPException( status_code=502, detail=( - f'连接上游失败:{exc}(请求 {url})。' - '若日志里出现连向 127.0.0.1 某端口,多为系统 HTTP 代理注入导致 TLS 异常;' - '当前接口已禁用继承环境代理,请更新后端后重试。仍失败请检查本机防火墙/DNS。' + f'连接上游失败:{exc}(请求 {safe_url})。' + '请检查网络、Base URL 或防火墙/DNS 设置。' ), ) from exc except Exception as exc: From e4ebe4661516115f4b0d8f9a43f8d17478e57512 Mon Sep 17 00:00:00 2001 From: professorczh Date: Sun, 3 May 2026 02:19:46 +0800 Subject: [PATCH 18/22] feat: implement Vertex AI provider, SQLite connection migrations, and novel management UI components --- .../src/components/onboarding/NovelSetupGuide.vue | 8 ++++++++ frontend/src/components/stats/StatsSidebar.vue | 4 ++++ frontend/src/stores/novelStore.ts | 9 +++++++-- infrastructure/ai/providers/vertex_ai_provider.py | 14 ++++++++++---- infrastructure/persistence/database/connection.py | 1 + 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/onboarding/NovelSetupGuide.vue b/frontend/src/components/onboarding/NovelSetupGuide.vue index 1a91b8f8f..018173543 100644 --- a/frontend/src/components/onboarding/NovelSetupGuide.vue +++ b/frontend/src/components/onboarding/NovelSetupGuide.vue @@ -1090,6 +1090,14 @@ const handleNext = async () => { const handlePrev = () => { if (currentStep.value > 1) { + // CodeRabbit: 后退时使当前步骤的轮询失效,防止重复进入时冲突 + if (currentStep.value === 2) { + step2PollEpoch.value++ + generatingCharacters.value = false + } else if (currentStep.value === 3) { + step3PollEpoch.value++ + generatingLocations.value = false + } currentStep.value-- } } diff --git a/frontend/src/components/stats/StatsSidebar.vue b/frontend/src/components/stats/StatsSidebar.vue index b92bf1aaa..4f9cd14b7 100644 --- a/frontend/src/components/stats/StatsSidebar.vue +++ b/frontend/src/components/stats/StatsSidebar.vue @@ -625,6 +625,10 @@ const updateTimeText = computed(() => formatTime(lastUpdateTime.value)) border-color: color-mix(in srgb, var(--color-brand, #4f46e5) 52%, transparent); } +.action-btn.action-settings { + grid-column: 1 / -1; +} + .action-btn:hover { filter: none; transform: none; diff --git a/frontend/src/stores/novelStore.ts b/frontend/src/stores/novelStore.ts index 9bac592c6..331af075e 100644 --- a/frontend/src/stores/novelStore.ts +++ b/frontend/src/stores/novelStore.ts @@ -65,12 +65,17 @@ export const useNovelStore = defineStore('novel', () => { try { await novelApi.deleteNovel(slug) books.value = books.value.filter(b => b.slug !== slug) - // 同步刷新全局统计 - await statsStore.loadGlobalStats(true) } catch (error) { console.error('Failed to delete book:', error) throw error } + + // 同步刷新全局统计(作为后续操作,失败不应报“删除失败”) + try { + await statsStore.loadGlobalStats(true) + } catch (error) { + console.warn('Book deleted but failed to refresh global stats:', error) + } } const createBook = async (payload: any) => { diff --git a/infrastructure/ai/providers/vertex_ai_provider.py b/infrastructure/ai/providers/vertex_ai_provider.py index 36081bf74..30f69c7bb 100644 --- a/infrastructure/ai/providers/vertex_ai_provider.py +++ b/infrastructure/ai/providers/vertex_ai_provider.py @@ -95,10 +95,14 @@ async def generate(self, prompt: Prompt, config: GenerationConfig) -> Generation config=gen_config ) - # 提取内容 + # 提取内容 (CodeRabbit: 处理安全拦截,防止 response.text 为空时崩溃) + candidate = response.candidates[0] if response.candidates else None + if candidate and candidate.finish_reason == "SAFETY": + raise ValueError("内容生成被 Vertex AI 安全过滤器拦截。请尝试修改输入或降低敏感度。") + content = response.text or "" - # 提取 Token 使用量 (CodeRabbit: 增加 None 判定) + # 提取 Token 使用量 usage = getattr(response, 'usage_metadata', None) if usage: token_usage = TokenUsage( @@ -170,10 +174,12 @@ def _build_genai_config(self, config: GenerationConfig, system_instruction: Opti "system_instruction": system_instruction.strip() if system_instruction and system_instruction.strip() else None } - # 针对 Gemini 2.0+ 的思维配置 (Thinking) + # 针对 Gemini 3 的思维配置 (Thinking) if "thinking_level" in eb: + # SDK 期望小写字符串: "low", "medium", "high" + level = str(eb["thinking_level"]).lower() params["thinking_config"] = types.ThinkingConfig( - thinking_level=eb["thinking_level"] # e.g., "LOW", "MEDIUM", "HIGH" + thinking_level=level ) # 针对搜索工具的支持 diff --git a/infrastructure/persistence/database/connection.py b/infrastructure/persistence/database/connection.py index 519705170..d3cbd7301 100644 --- a/infrastructure/persistence/database/connection.py +++ b/infrastructure/persistence/database/connection.py @@ -297,6 +297,7 @@ def _fix_llm_profiles_protocol_check(conn: sqlite3.Connection) -> None: except Exception as e: logger.error(f"Failed to fix llm_profiles schema: {e}") conn.rollback() + raise RuntimeError(f"Critical database migration failed: {e}") from e From a7e501b364576409dffd5eb5f321b003a627bf8c Mon Sep 17 00:00:00 2001 From: professorczh Date: Sun, 3 May 2026 02:32:31 +0800 Subject: [PATCH 19/22] feat: implement Vertex AI provider for infrastructure integration --- .../ai/providers/vertex_ai_provider.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/infrastructure/ai/providers/vertex_ai_provider.py b/infrastructure/ai/providers/vertex_ai_provider.py index 30f69c7bb..c28d1d854 100644 --- a/infrastructure/ai/providers/vertex_ai_provider.py +++ b/infrastructure/ai/providers/vertex_ai_provider.py @@ -174,12 +174,18 @@ def _build_genai_config(self, config: GenerationConfig, system_instruction: Opti "system_instruction": system_instruction.strip() if system_instruction and system_instruction.strip() else None } - # 针对 Gemini 3 的思维配置 (Thinking) - if "thinking_level" in eb: - # SDK 期望小写字符串: "low", "medium", "high" - level = str(eb["thinking_level"]).lower() + # 针对 Gemini 的思维配置 (Thinking) + if "thinking_level" in eb or "thinking_budget_tokens" in eb: + # 优先使用具体的 budget tokens,否则回退到 level + budget = eb.get("thinking_budget_tokens") + level = str(eb.get("thinking_level", "medium")).lower() + params["thinking_config"] = types.ThinkingConfig( - thinking_level=level + include_thoughts=True, + include_thoughts_in_response=True, + thinking_level=level if not budget else None, + # 如果用户指定了具体的 token 预算,则使用之 + thinking_budget_tokens=int(budget) if budget else None ) # 针对搜索工具的支持 From f8ed1b08fac7bf07fd3ae3387ad32883bc010960 Mon Sep 17 00:00:00 2001 From: professorczh Date: Sun, 3 May 2026 02:40:37 +0800 Subject: [PATCH 20/22] feat: add utility script to download models from ModelScope --- scripts/utils/download_model_via_modelscope.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/utils/download_model_via_modelscope.py b/scripts/utils/download_model_via_modelscope.py index 99d27495f..2164943d7 100644 --- a/scripts/utils/download_model_via_modelscope.py +++ b/scripts/utils/download_model_via_modelscope.py @@ -11,8 +11,9 @@ # 处理 Windows 终端编码问题 if sys.platform == 'win32': import io - sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') - sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') + # CodeRabbit: 开启 line_buffering=True 确保日志在 CI/CD 或后台进程中能实时输出 + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', line_buffering=True) + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', line_buffering=True) print("=" * 60) print("使用 ModelScope 下载本地向量模型") From 45ad52aac52f507d497b4c0c59d7d940c77cf948 Mon Sep 17 00:00:00 2001 From: professorczh Date: Sun, 3 May 2026 03:01:45 +0800 Subject: [PATCH 21/22] feat: implement Vertex AI provider for infrastructure integration --- .../ai/providers/vertex_ai_provider.py | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/infrastructure/ai/providers/vertex_ai_provider.py b/infrastructure/ai/providers/vertex_ai_provider.py index c28d1d854..847843c3f 100644 --- a/infrastructure/ai/providers/vertex_ai_provider.py +++ b/infrastructure/ai/providers/vertex_ai_provider.py @@ -95,12 +95,8 @@ async def generate(self, prompt: Prompt, config: GenerationConfig) -> Generation config=gen_config ) - # 提取内容 (CodeRabbit: 处理安全拦截,防止 response.text 为空时崩溃) - candidate = response.candidates[0] if response.candidates else None - if candidate and candidate.finish_reason == "SAFETY": - raise ValueError("内容生成被 Vertex AI 安全过滤器拦截。请尝试修改输入或降低敏感度。") - - content = response.text or "" + # 提取内容 (CodeRabbit: 增加严格的空值与安全拦截防护) + content = self._get_response_text(response) # 提取 Token 使用量 usage = getattr(response, 'usage_metadata', None) @@ -144,6 +140,26 @@ async def stream_generate(self, prompt: Prompt, config: GenerationConfig) -> Asy if chunk.text: yield chunk.text + def _get_response_text(self, response: Any) -> str: + """安全地从响应对象中提取文本,处理被拦截或空响应的情况""" + if not response.candidates: + raise ValueError("Vertex AI 未返回任何候选结果。请检查输入是否包含违规内容。") + + candidate = response.candidates[0] + if candidate.finish_reason == "SAFETY": + raise ValueError("内容生成被 Vertex AI 安全过滤器拦截。请尝试修改输入或降低敏感度。") + elif candidate.finish_reason == "RECITATION": + raise ValueError("内容生成因涉及版权/引用限制被拦截。") + elif candidate.finish_reason != "STOP" and candidate.finish_reason != "MAX_TOKENS": + # 记录其他异常结束原因,但尝试返回已有文本 + logger.warning(f"Vertex AI generation finished with unexpected reason: {candidate.finish_reason}") + + try: + return response.text or "" + except (ValueError, AttributeError): + # 如果访问 .text 依然报错,说明该 candidate 确实不可读 + return "" + def _ensure_sdk(self): if not HAS_GENAI: raise ImportError("请运行 'pip install google-genai' 以安装 Vertex AI 所需的 SDK。") From 605bb25de99786695ff9c6ff591de4bfcf114ee3 Mon Sep 17 00:00:00 2001 From: professorczh Date: Sun, 3 May 2026 03:11:36 +0800 Subject: [PATCH 22/22] feat: add NovelSetupGuide component for onboarding flow --- .../components/onboarding/NovelSetupGuide.vue | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/frontend/src/components/onboarding/NovelSetupGuide.vue b/frontend/src/components/onboarding/NovelSetupGuide.vue index 018173543..aa81b210a 100644 --- a/frontend/src/components/onboarding/NovelSetupGuide.vue +++ b/frontend/src/components/onboarding/NovelSetupGuide.vue @@ -456,41 +456,6 @@ function isLikelyTimeoutError(error: unknown): boolean { /** 向导内:单阶段轮询 Bible 就绪的最长等待(与单步 HTTP 超时一致,默认 400s) */ const WIZARD_BIBLE_POLL_DEADLINE_MS = WIZARD_STEP_TIMEOUT_MS -const IconBook = () => - h( - 'svg', - { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'currentColor' }, - h('path', { d: 'M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 4h5v8l-2.5-1.5L6 12V4z' }) - ) - -const IconPeople = () => - h( - 'svg', - { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'currentColor' }, - h('path', { d: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' }) - ) - -const IconMap = () => - h( - 'svg', - { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'currentColor' }, - h('path', { d: 'M20.5 3l-.16.03L15 5.1 9 3 3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1 5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM15 19l-6-2.11V5l6 2.11V19z' }) - ) - -const IconTimeline = () => - h( - 'svg', - { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'currentColor' }, - h('path', { d: 'M23 8c0 1.1-.9 2-2 2-.18 0-.35-.02-.51-.07l-3.56 3.55c.05.16.07.34.07.52 0 1.1-.9 2-2 2s-2-.9-2-2c0-.18.02-.36.07-.52l-2.55-2.55c-.16.05-.34.07-.52.07s-.36-.02-.52-.07l-4.55 4.56c.05.16.07.33.07.51 0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2c.18 0 .35.02.51.07l4.56-4.55C8.02 9.36 8 9.18 8 9c0-1.1.9-2 2-2s2 .9 2 2c0 .18-.02.36-.07.52l2.55 2.55c.16-.05.34-.07.52-.07s.36.02.52.07l3.55-3.56C19.02 8.35 19 8.18 19 8c0-1.1.9-2 2-2s2 .9 2 2z' }) - ) - -const IconCheck = () => - h( - 'svg', - { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'currentColor' }, - h('path', { d: 'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' }) - ) - const props = withDefaults( defineProps<{ novelId: string