From 9d8eacf0b40fab1ee7850e529d30ea6f4ef641af Mon Sep 17 00:00:00 2001 From: r-earth-or Date: Wed, 15 Apr 2026 13:57:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=89=8D=E7=AB=AF=E4=B8=8D=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E6=A8=A1=E5=9E=8Bapi-key=20=E9=9A=90=E8=97=8FGitHub?= =?UTF-8?q?=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docutranslate/app.py | 31 ++- docutranslate/core/factory.py | 7 +- docutranslate/core/model_presets.py | 42 ++++ docutranslate/core/schemas.py | 14 +- docutranslate/environment.py | 144 ++++++++++++ docutranslate/static/i18nData.json | 12 + docutranslate/static/index.html | 330 ++++++---------------------- run.bat | 35 +++ 8 files changed, 331 insertions(+), 284 deletions(-) create mode 100644 docutranslate/core/model_presets.py create mode 100644 run.bat diff --git a/docutranslate/app.py b/docutranslate/app.py index 091d1d3..35718ee 100644 --- a/docutranslate/app.py +++ b/docutranslate/app.py @@ -56,9 +56,16 @@ from pydantic import ( from docutranslate import __version__ from docutranslate.agents.glossary_agent import GlossaryAgentConfig +from docutranslate.core.model_presets import apply_model_preset_to_payload from docutranslate.core.schemas import TranslatePayload, MarkdownWorkflowParams, TextWorkflowParams, JsonWorkflowParams, \ XlsxWorkflowParams, DocxWorkflowParams, SrtWorkflowParams, EpubWorkflowParams, HtmlWorkflowParams, \ AssWorkflowParams, PPTXWorkflowParams +from docutranslate.environment import ( + DOCUTRANSLATE_RPM, + DOCUTRANSLATE_TPM, + get_default_model_preset, + get_public_model_presets, +) from docutranslate.exporter.md.types import ConvertEngineType # --- 核心代码 Imports --- from docutranslate.global_values.conditional_import import DOCLING_EXIST @@ -1341,6 +1348,11 @@ async def _start_translation_task( file_contents: bytes, original_filename: str, ): + try: + payload = apply_model_preset_to_payload(payload) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + # --- 新增: Auto 工作流路由逻辑 --- if payload.workflow_type == "auto": detected_type = get_workflow_type_from_filename(original_filename) @@ -2210,6 +2222,7 @@ async def service_get_app_version(): async def service_flat_translate( request: Request, file: UploadFile = File(..., description="要翻译的文件"), + model_preset: str = Form("", description="服务端模型预设ID"), model_id: str = Form("", description="模型ID (例如: gpt-4o, glm-4-air),当 skip_translate=False 时必填"), base_url: Optional[str] = Form("", description="LLM API 基础 URL (如不填则依赖环境变量或默认值,当 skip_translate=False 时必填)"), api_key: str = Form("xx", description="API Key (默认xx)"), @@ -2307,6 +2320,7 @@ async def service_flat_translate( payload_dict = { # --- 基础参数 --- "workflow_type": workflow_type, + "model_preset": model_preset, "base_url": base_url, "api_key": api_key, "model_id": model_id, @@ -2389,6 +2403,7 @@ async def service_flat_translate( try: # 使用 TypeAdapter 进行多态校验,将扁平字典转为嵌套的 TranslatePayload 对象 payload_obj = TypeAdapter(TranslatePayload).validate_python(payload_dict) + payload_obj = apply_model_preset_to_payload(payload_obj) except Exception as e: raise HTTPException(status_code=400, detail=f"参数配置校验失败: {str(e)}") @@ -2474,18 +2489,10 @@ async def service_flat_translate( @app.get("/api/config", tags=["Config"], summary="获取服务端环境变量默认配置") async def get_config(): - """返回由服务端环境变量预设的前端默认配置,不含敏感信息(API key 仅返回是否存在)。""" - from docutranslate.environment import ( - DOCUTRANSLATE_BASE_URL, - DOCUTRANSLATE_API_KEY, - DOCUTRANSLATE_MODEL_ID, - DOCUTRANSLATE_RPM, - DOCUTRANSLATE_TPM, - ) + """返回前端可用的模型预设列表与全局默认配置,不包含敏感信息。""" return JSONResponse({ - "base_url": DOCUTRANSLATE_BASE_URL, - "api_key": DOCUTRANSLATE_API_KEY, - "model_id": DOCUTRANSLATE_MODEL_ID, + "model_presets": get_public_model_presets(), + "default_model_preset": get_default_model_preset(), "rpm": DOCUTRANSLATE_RPM, "tpm": DOCUTRANSLATE_TPM, }) @@ -2578,4 +2585,4 @@ def run_app(host=None, port: int | None = None, enable_CORS=False, if __name__ == "__main__": - run_app() \ No newline at end of file + run_app() diff --git a/docutranslate/core/factory.py b/docutranslate/core/factory.py index 79e3d19..e1745ec 100644 --- a/docutranslate/core/factory.py +++ b/docutranslate/core/factory.py @@ -5,6 +5,7 @@ import logging from docutranslate.agents.glossary_agent import GlossaryAgentConfig +from docutranslate.core.model_presets import apply_model_preset_to_payload from docutranslate.core.schemas import TranslatePayload, MarkdownWorkflowParams, TextWorkflowParams, JsonWorkflowParams, \ XlsxWorkflowParams, DocxWorkflowParams, SrtWorkflowParams, EpubWorkflowParams, HtmlWorkflowParams, \ AssWorkflowParams, PPTXWorkflowParams @@ -48,6 +49,8 @@ def create_workflow_from_payload(payload: TranslatePayload, logger: logging.Logg """ 根据扁平化的 Payload 配置对象,构建并返回对应的 Workflow 实例。 """ + payload = apply_model_preset_to_payload(payload) + if logger is None: logger = logging.getLogger("docutranslate.factory") @@ -115,7 +118,7 @@ def create_workflow_from_payload(payload: TranslatePayload, logger: logging.Logg for param_type, (TransConf, WorkConf, WorkClass, ExpConf) in mapping.items(): if isinstance(payload, param_type): # 提取通用 Translator 参数 - dump_exclude = {"workflow_type"} + dump_exclude = {"workflow_type", "model_preset"} # 特定类型的特殊参数需要保留,例如 json_paths, insert_mode 等 # model_dump 会自动包含定义在 param_type 中的所有字段 translator_args = payload.model_dump(exclude=dump_exclude, exclude_none=True) @@ -140,4 +143,4 @@ def create_workflow_from_payload(payload: TranslatePayload, logger: logging.Logg return WorkClass(config=workflow_config) - raise ValueError(f"未知的 Payload 类型: {type(payload)}") \ No newline at end of file + raise ValueError(f"未知的 Payload 类型: {type(payload)}") diff --git a/docutranslate/core/model_presets.py b/docutranslate/core/model_presets.py new file mode 100644 index 0000000..336b7d2 --- /dev/null +++ b/docutranslate/core/model_presets.py @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2025 QinHan +# SPDX-License-Identifier: MPL-2.0 + +from typing import Any + +from pydantic import TypeAdapter + +from docutranslate.core.schemas import TranslatePayload +from docutranslate.environment import resolve_model_preset + + +def apply_model_preset_to_payload_data( + payload_data: dict[str, Any], +) -> dict[str, Any]: + if payload_data.get("skip_translate"): + return payload_data + + model_preset = str(payload_data.get("model_preset") or "").strip() + if not model_preset: + return payload_data + + preset = resolve_model_preset(model_preset) + hydrated = dict(payload_data) + hydrated["base_url"] = preset["base_url"] + hydrated["api_key"] = preset["api_key"] + hydrated["model_id"] = preset["model_id"] + hydrated["provider"] = preset.get("provider") + + if hydrated.get("rpm") in (None, "") and preset.get("rpm") is not None: + hydrated["rpm"] = preset["rpm"] + if hydrated.get("tpm") in (None, "") and preset.get("tpm") is not None: + hydrated["tpm"] = preset["tpm"] + + return hydrated + + +def apply_model_preset_to_payload(payload: TranslatePayload) -> TranslatePayload: + payload_data = payload.model_dump() + hydrated_data = apply_model_preset_to_payload_data(payload_data) + if hydrated_data == payload_data: + return payload + return TypeAdapter(TranslatePayload).validate_python(hydrated_data) diff --git a/docutranslate/core/schemas.py b/docutranslate/core/schemas.py index 4b3ddf8..eb58860 100644 --- a/docutranslate/core/schemas.py +++ b/docutranslate/core/schemas.py @@ -99,6 +99,11 @@ class BaseWorkflowParams(BaseModel): default=False, description="是否跳过翻译步骤。如果为True,则仅执行文档解析和格式转换。", ) + model_preset: Optional[str] = Field( + default="", + description="服务端模型预设ID。设置后会由服务端从环境变量中注入模型配置。", + examples=["default"], + ) # 修改: 默认值改为 "" base_url: Optional[str] = Field( default="", @@ -196,14 +201,17 @@ class BaseWorkflowParams(BaseModel): if isinstance(values, dict): if not values.get("skip_translate"): + has_model_preset = bool(str(values.get("model_preset") or "").strip()) # 如果是空字符串 "" (即默认值),not "" 为 True,会触发错误,符合预期 - if not (values.get("base_url") or values.get("baseurl")): + if not has_model_preset and not ( + values.get("base_url") or values.get("baseurl") + ): # Auto 模式在校验前不强制要求 base_url if values.get("workflow_type") != "auto": raise ValueError( "当 `skip_translate` 为 `False` 时, `base_url` 或 `baseurl` 字段是必须的。" ) - if not values.get("model_id"): + if not has_model_preset and not values.get("model_id"): if values.get("workflow_type") != "auto": raise ValueError( "当 `skip_translate` 为 `False` 时, `model_id` 字段是必须的。" @@ -469,4 +477,4 @@ TranslatePayload = Annotated[ PPTXWorkflowParams, ], Field(discriminator="workflow_type"), -] \ No newline at end of file +] diff --git a/docutranslate/environment.py b/docutranslate/environment.py index d624630..b87356a 100644 --- a/docutranslate/environment.py +++ b/docutranslate/environment.py @@ -4,7 +4,10 @@ 集中管理所有环境变量。 所有 os.getenv() 调用应在此处统一声明,其他模块从这里导入。 """ +import json import os +from functools import lru_cache +from typing import Any from dotenv import load_dotenv @@ -39,3 +42,144 @@ DOCUTRANSLATE_RPM: int | None = int(_rpm_str) if _rpm_str.strip() else None # 默认 TPM 限制 (Tokens Per Minute),不设置则不限制 _tpm_str = os.getenv("DOCUTRANSLATE_TPM", "") DOCUTRANSLATE_TPM: int | None = int(_tpm_str) if _tpm_str.strip() else None + +# 模型预设配置(JSON 字符串) +DOCUTRANSLATE_MODEL_PRESETS: str = os.getenv("DOCUTRANSLATE_MODEL_PRESETS", "").strip() + +# 前端默认选中的模型预设 ID +DOCUTRANSLATE_DEFAULT_MODEL_PRESET: str = os.getenv( + "DOCUTRANSLATE_DEFAULT_MODEL_PRESET", "" +).strip() + +# 兼容旧版单模型配置时的展示名称 +DOCUTRANSLATE_DEFAULT_MODEL_PRESET_LABEL: str = os.getenv( + "DOCUTRANSLATE_DEFAULT_MODEL_PRESET_LABEL", "环境默认模型" +).strip() + + +def _parse_optional_int(value: Any) -> int | None: + if value is None: + return None + if isinstance(value, int): + return value + text = str(value).strip() + return int(text) if text else None + + +def _clean_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def _normalize_model_preset(preset_id: str, raw: dict[str, Any]) -> dict[str, Any]: + base_url = _clean_text(raw.get("base_url", "")) + model_id = _clean_text(raw.get("model_id", "")) + if not base_url or not model_id: + raise ValueError( + f"模型预设 '{preset_id}' 缺少必要字段 `base_url` 或 `model_id`。" + ) + + api_key_env = _clean_text(raw.get("api_key_env", "")) + api_key = os.getenv(api_key_env, "").strip() if api_key_env else _clean_text( + raw.get("api_key", "") + ) + if not api_key: + api_key = DOCUTRANSLATE_API_KEY.strip() + + provider = _clean_text(raw.get("provider", "")) or None + description = _clean_text(raw.get("description", "")) or None + + return { + "id": preset_id, + "label": _clean_text(raw.get("label") or raw.get("name") or preset_id), + "description": description, + "base_url": base_url, + "api_key": api_key or "xx", + "model_id": model_id, + "provider": provider, + "rpm": _parse_optional_int(raw.get("rpm", DOCUTRANSLATE_RPM)), + "tpm": _parse_optional_int(raw.get("tpm", DOCUTRANSLATE_TPM)), + } + + +@lru_cache(maxsize=1) +def get_model_presets() -> dict[str, dict[str, Any]]: + presets: dict[str, dict[str, Any]] = {} + + if DOCUTRANSLATE_MODEL_PRESETS: + parsed = json.loads(DOCUTRANSLATE_MODEL_PRESETS) + if isinstance(parsed, list): + for item in parsed: + if not isinstance(item, dict): + raise ValueError("DOCUTRANSLATE_MODEL_PRESETS 列表项必须是对象。") + preset_id = str(item.get("id") or item.get("name") or "").strip() + if not preset_id: + raise ValueError( + "DOCUTRANSLATE_MODEL_PRESETS 的列表项必须包含 `id` 或 `name`。" + ) + presets[preset_id] = _normalize_model_preset(preset_id, item) + elif isinstance(parsed, dict): + for preset_id, item in parsed.items(): + if not isinstance(item, dict): + raise ValueError("DOCUTRANSLATE_MODEL_PRESETS 对象成员必须是对象。") + normalized_id = str(preset_id).strip() + if not normalized_id: + raise ValueError( + "DOCUTRANSLATE_MODEL_PRESETS 的对象键不能是空字符串。" + ) + presets[normalized_id] = _normalize_model_preset(normalized_id, item) + else: + raise ValueError( + "DOCUTRANSLATE_MODEL_PRESETS 必须是 JSON 对象或 JSON 数组。" + ) + + if not presets and DOCUTRANSLATE_BASE_URL.strip() and DOCUTRANSLATE_MODEL_ID.strip(): + presets["default"] = { + "id": "default", + "label": DOCUTRANSLATE_DEFAULT_MODEL_PRESET_LABEL, + "description": None, + "base_url": DOCUTRANSLATE_BASE_URL.strip(), + "api_key": DOCUTRANSLATE_API_KEY.strip() or "xx", + "model_id": DOCUTRANSLATE_MODEL_ID.strip(), + "provider": None, + "rpm": DOCUTRANSLATE_RPM, + "tpm": DOCUTRANSLATE_TPM, + } + + return presets + + +def get_default_model_preset() -> str | None: + presets = get_model_presets() + if not presets: + return None + if DOCUTRANSLATE_DEFAULT_MODEL_PRESET: + if DOCUTRANSLATE_DEFAULT_MODEL_PRESET not in presets: + raise ValueError( + "DOCUTRANSLATE_DEFAULT_MODEL_PRESET 指向了不存在的模型预设。" + ) + return DOCUTRANSLATE_DEFAULT_MODEL_PRESET + return next(iter(presets)) + + +def get_public_model_presets() -> list[dict[str, str]]: + public_presets: list[dict[str, str]] = [] + for preset_id, preset in get_model_presets().items(): + item = {"id": preset_id, "label": preset["label"]} + if preset.get("description"): + item["description"] = preset["description"] + public_presets.append(item) + return public_presets + + +def resolve_model_preset(preset_id: str) -> dict[str, Any]: + preset_key = str(preset_id or "").strip() + if not preset_key: + raise ValueError("模型预设不能为空。") + + presets = get_model_presets() + if preset_key not in presets: + raise ValueError(f"未找到模型预设 '{preset_key}'。") + + return dict(presets[preset_key]) diff --git a/docutranslate/static/i18nData.json b/docutranslate/static/i18nData.json index 30521b3..c660636 100644 --- a/docutranslate/static/i18nData.json +++ b/docutranslate/static/i18nData.json @@ -74,6 +74,10 @@ "codeOcrLabel": "代码识别", "aiSettingsTitleText": "翻译模型", "skipTranslationLabel": "跳过翻译", + "modelPresetLabel": "模型预设", + "modelPresetPlaceholder": "请选择模型预设", + "modelPresetEmpty": "请先在服务端环境变量中配置模型预设", + "modelPresetRuntimeHint": "运行时将从服务端环境变量读取供应商、模型端点与 API Key。", "platformLabel": "选择平台", "platformCustom": "自定义接口", "baseUrlLabel": "API 地址 (Base URL)", @@ -247,6 +251,10 @@ "codeOcrLabel": "Code Recognition", "aiSettingsTitleText": "Translation Model", "skipTranslationLabel": "Skip Translation", + "modelPresetLabel": "Model Preset", + "modelPresetPlaceholder": "Select a model preset", + "modelPresetEmpty": "Configure model presets in server environment variables first", + "modelPresetRuntimeHint": "Provider, endpoint, and API key will be loaded from server environment variables at runtime.", "platformLabel": "Select Platform", "platformCustom": "Custom API", "baseUrlLabel": "API Address (Base URL)", @@ -420,6 +428,10 @@ "codeOcrLabel": "Nhận dạng mã (Code)", "aiSettingsTitleText": "Mô hình dịch", "skipTranslationLabel": "Bỏ qua dịch thuật", + "modelPresetLabel": "Mẫu mô hình", + "modelPresetPlaceholder": "Chọn mẫu mô hình", + "modelPresetEmpty": "Hãy cấu hình sẵn mẫu mô hình trong biến môi trường phía máy chủ", + "modelPresetRuntimeHint": "Nhà cung cấp, endpoint và API Key sẽ được đọc từ biến môi trường phía máy chủ khi chạy.", "platformLabel": "Chọn nền tảng", "platformCustom": "API tùy chỉnh", "baseUrlLabel": "Địa chỉ API (Base URL)", diff --git a/docutranslate/static/index.html b/docutranslate/static/index.html index 4a307e8..0be3e2a 100644 --- a/docutranslate/static/index.html +++ b/docutranslate/static/index.html @@ -225,16 +225,6 @@

DocuTranslate

-
- - -
@@ -536,17 +526,12 @@
- + :t="t">
-

GitHub主页(欢迎star❤):
https://github.com/xunbu/docutranslate -

-

交流QQ群: 1047781902

version:{{ version ? 'v' + version : '' }}

@@ -882,58 +863,6 @@
- - - -
@@ -1066,149 +995,31 @@ } }; - const KNOWN_PLATFORMS = [ - {val: "custom", label: "platformCustom", provider: "default"}, - {val: "https://api.302.ai/v1", label: "302.AI", provider: ""}, - {val: "https://api.openai.com/v1", label: "OpenAI", provider: "default"}, - { - val: "https://generativelanguage.googleapis.com/v1beta/openai/", - label: "Gemini", - provider: "google" - }, - {val: "https://api.deepseek.com/v1", label: "DeepSeek", provider: ""}, - { - val: "https://dashscope.aliyuncs.com/compatible-mode/v1", - label: "阿里云百炼(DashScope)", - provider: "aliyuncs" - }, - { - val: "https://ark.cn-beijing.volces.com/api/v3", - label: "火山引擎(volces)", - provider: "volces" - }, - {val: "https://api.siliconflow.cn/v1", label: "硅基流动(siliconflow CN)", provider: "siliconflow"}, - {val: "https://open.bigmodel.cn/api/paas/v4", label: "智谱AI(bigmodel CN)", provider: "bigmodel"}, - {val: "https://www.dmxapi.cn/v1", label: "DMXAPI_CN", provider: ""}, - {val: "https://www.dmxapi.com/v1", label: "DMXAPI_GLOBAL", provider: ""}, - {val: "https://ai.juguang.chat/v1", label: "聚光AI(juguang CN)", provider: ""}, - {val: "https://openrouter.ai/api/v1", label: "OpenRouter", provider: ""}, - {val: "http://127.0.0.1:1234/v1", label: "LM Studio", provider: ""}, - {val: "http://127.0.0.1:11434/v1", label: "Ollama", provider: "ollama"} - ]; - - const PlatformSelector = { - props: ['platform', 'baseUrl', 'apiKey', 'modelId', 'provider', 't', 'prefix', 'invalidApiKey', 'invalidBaseUrl', 'invalidModelId'], + const ModelPresetSelector = { + props: ['modelPreset', 'presets', 't', 'invalidModelPreset'], template: `
-
- - -
- -
- - -
- -
Base URL: {{ baseUrl }}
-
- - -
- -
- - -
-
-
- - + + +
{{ t('modelPresetRuntimeHint') }}
+
{{ t('modelPresetEmpty') }}
`, setup(props, {emit}) { - const showPass = ref(false); - const platforms = KNOWN_PLATFORMS; - - // ProviderType Literal values - const providers = [ - "default", "ollama", "bigmodel", "aliyuncs", "volces", "google", "siliconflow" - ]; - - const apiHrefMap = { - "https://api.302.ai/v1": ["https://share.302.ai/BgRLAe", "apiHrefInfo302ai"], - "https://openrouter.ai/api/v1": ["https://openrouter.ai/settings/keys", null], - "https://api.openai.com/v1": ["https://platform.openai.com/api-keys", null], - "https://api.deepseek.com/v1": ["https://platform.deepseek.com/api_keys", null], - "https://open.bigmodel.cn/api/paas/v4": ["https://open.bigmodel.cn/usercenter/apikeys", null], - "https://dashscope.aliyuncs.com/compatible-mode/v1": ["https://bailian.console.aliyun.com/?tab=model#/api-key", null], - "https://ark.cn-beijing.volces.com/api/v3": ["https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D", null], - "https://api.siliconflow.cn/v1": ["https://cloud.siliconflow.cn/account/ak", null], - "https://ai.juguang.chat/v1": ["https://ai.juguang.chat/console/token", null], - "https://www.dmxapi.cn/v1": ["https://www.dmxapi.cn/token", null], - "https://www.dmxapi.com/v1": ["https://www.dmxapi.com/console/token", null], - "https://generativelanguage.googleapis.com/v1beta/openai/": ["https://aistudio.google.com/u/0/apikey", null] - }; - const apiHref = computed(() => apiHrefMap[props.baseUrl]); - - const save = (key, val) => localStorage.setItem(key, val); - const handlePlatformChange = (val) => { - emit('update:platform', val); - save(`${props.prefix}_last_platform`, val); - - // Determine provider based on platform - const selected = platforms.find(p => p.val === val); - if (val === 'custom') { - emit('update:provider', 'default'); - } else if (selected) { - emit('update:provider', selected.provider); - } - }; - const handleBaseUrlChange = (val) => { - emit('update:baseUrl', val); - emit('clearError', 'base_url'); - if (props.platform === 'custom') save(`${props.prefix}_custom_base_url`, val); - }; - const handleApiKeyChange = (val) => { - emit('update:apiKey', val); - emit('clearError', 'api_key'); - save(`${props.prefix}_${props.platform}_apikey`, val); - }; - const handleModelChange = (val) => { - emit('update:modelId', val); - emit('clearError', 'model_id'); - save(`${props.prefix}_${props.platform}_model_id`, val); - }; - const handleProviderChange = (val) => { - emit('update:provider', val); - save(`${props.prefix}_${props.platform}_provider`, val); + const handlePresetChange = (val) => { + emit('update:modelPreset', val); + emit('clearError', 'model_preset'); + localStorage.setItem('translator_model_preset', val); }; return { - showPass, - platforms, - providers, - apiHref, - handlePlatformChange, - handleBaseUrlChange, - handleApiKeyChange, - handleModelChange, - handleProviderChange + handlePresetChange }; } }; @@ -1234,7 +1045,7 @@ ]; createApp({ - components: {SliderControl, PlatformSelector}, + components: {SliderControl, ModelPresetSelector}, setup() { const version = ref(""); const currentLang = ref(localStorage.getItem('ui_language') || 'zh'); @@ -1243,6 +1054,8 @@ const tasks = ref([]); const enginList = ref([]); const defaultParams = reactive({}); + const modelPresets = ref([]); + const defaultModelPreset = ref(''); // Refs for DOM elements const glossaryInput = ref(null); @@ -1257,9 +1070,7 @@ // Validation State const errors = reactive({ - model_id: false, - api_key: false, - base_url: false, + model_preset: false, mineru_token: false, mineru_deploy_base_url: false, custom_to_lang: false, @@ -1289,11 +1100,7 @@ formula_ocr: true, code_ocr: true, skip_translate: false, - platform: 'https://api.302.ai/v1', - base_url: '', - api_key: '', - model_id: '', - provider: 'api.openai.com', // Default provider + model_preset: '', system_proxy_enable: false, force_json: false, to_lang: 'Simplified Chinese', @@ -1336,6 +1143,10 @@ const v = localStorage.getItem(k); return (v === null || v === '' || v === 'null') ? null : Number(v); }; + const validPresetIds = modelPresets.value.map(p => p.id); + const fallbackPreset = validPresetIds.includes(defaultModelPreset.value) + ? defaultModelPreset.value + : (validPresetIds[0] || ''); form.workflow_type = get('translator_last_workflow', 'docx'); form.auto_workflow_enabled = getBool('translator_auto_workflow_enabled', true); @@ -1356,7 +1167,7 @@ form.formula_ocr = getBool('translator_formula_ocr', true); form.code_ocr = getBool('translator_code_ocr', true); form.skip_translate = getBool('translator_skip_translate', false); - form.platform = get('translator_platform_last_platform', 'custom'); + form.model_preset = get('translator_model_preset', fallbackPreset); form.system_proxy_enable = getBool('translator_system_proxy_enable', false); form.force_json = getBool('translator_force_json', false); form.to_lang = get('translator_to_lang', 'Simplified Chinese'); @@ -1370,13 +1181,8 @@ form.rpm = getNumOrNull('rpm'); // Load RPM form.tpm = getNumOrNull('tpm'); // Load TPM - // Determine Provider - const platObj = KNOWN_PLATFORMS.find(p => p.val === form.platform); - if (form.platform === 'custom') { - // 修正:读取组件实际保存的 Key (translator_platform_custom_provider) - form.provider = get('translator_platform_custom_provider', 'default'); - } else { - form.provider = platObj ? platObj.provider : ''; + if (!validPresetIds.includes(form.model_preset)) { + form.model_preset = fallbackPreset; } // Restore workflow specific params @@ -1387,9 +1193,6 @@ workflowParams.txt.segment_mode = get('translator_txt_segment_mode', 'line'); workflowParams.xlsx.translate_regions = get('translator_xlsx_translate_regions', ''); workflowParams.json.json_paths = get('translator_json_paths', ''); - - // Trigger platform updates to load API keys/models - updatePlatformParams(form.platform, 'translator_platform', form); }; // --- 新增:专门用于将当前 form 数据全部写入 localStorage 的函数 --- @@ -1419,7 +1222,7 @@ s('translator_formula_ocr', f.formula_ocr); s('translator_code_ocr', f.code_ocr); s('translator_skip_translate', f.skip_translate); - s('translator_platform_last_platform', f.platform); + s('translator_model_preset', f.model_preset); s('translator_system_proxy_enable', f.system_proxy_enable); s('translator_force_json', f.force_json); s('translator_to_lang', f.to_lang); @@ -1435,12 +1238,6 @@ s('rpm', f.rpm || ''); s('tpm', f.tpm || ''); - // 平台相关 (API Key 等) - s(`translator_platform_${f.platform}_apikey`, f.api_key); - s(`translator_platform_${f.platform}_model_id`, f.model_id); - s('translator_provider', f.provider); - if (f.platform === 'custom') s('translator_platform_custom_base_url', f.base_url); - // 2. 自动循环保存所有具体工作流参数 (txt, docx, xlsx...) for (const [wfType, params] of Object.entries(workflowParams)) { for (const [key, val] of Object.entries(params)) { @@ -1449,17 +1246,6 @@ } }; - const updatePlatformParams = (plat, prefix, target) => { - const get = (k) => localStorage.getItem(k) || ''; - target.api_key = get(`${prefix}_${plat}_apikey`); - target.model_id = get(`${prefix}_${plat}_model_id`); - target.base_url = plat === 'custom' ? get(`${prefix}_custom_base_url`) : plat; - }; - - watch(() => form.platform, (n) => { - updatePlatformParams(n, 'translator_platform', form); - }); - const t = (k) => { const dict = i18nData.value[currentLang.value] || i18nData.value['zh'] || {}; return dict[k] || k; @@ -1467,6 +1253,15 @@ const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1); const saveSetting = (k, v) => localStorage.setItem(k, v); const saveSettingArray = (k, v) => localStorage.setItem(k, JSON.stringify(v)); + const syncModelPresetSelection = () => { + const validPresetIds = modelPresets.value.map(p => p.id); + const fallbackPreset = validPresetIds.includes(defaultModelPreset.value) + ? defaultModelPreset.value + : (validPresetIds[0] || ''); + if (!validPresetIds.includes(form.model_preset)) { + form.model_preset = fallbackPreset; + } + }; const saveWorkflowParam = (keySuffix) => { const wf = form.workflow_type; @@ -1642,10 +1437,7 @@ // Clone basic form const basePayload = { skip_translate: form.skip_translate, - base_url: emptyToNull(form.base_url), - api_key: form.api_key || "", - model_id: emptyToNull(form.model_id), - provider: emptyToNull(form.provider), // Add provider + model_preset: emptyToNull(form.model_preset), to_lang: form.to_lang === 'custom' ? form.custom_to_lang : form.to_lang, thinking: form.thinking, chunk_size: Number(form.chunk_size), @@ -1718,12 +1510,8 @@ Object.keys(errors).forEach(k => errors[k] = false); if (!form.skip_translate) { - if (!form.model_id) { - errors.model_id = true; - isValid = false; - } - if (form.platform === 'custom' && !form.base_url) { - errors.base_url = true; + if (!form.model_preset) { + errors.model_preset = true; isValid = false; } if (form.to_lang === 'custom' && !form.custom_to_lang) { @@ -2062,6 +1850,10 @@ const data = JSON.parse(ev.target.result); if (data.form) Object.assign(form, data.form); if (data.workflowParams) Object.assign(workflowParams, data.workflowParams); + ['platform', 'base_url', 'api_key', 'model_id', 'provider'].forEach((key) => { + if (key in form) delete form[key]; + }); + syncModelPresetSelection(); saveAllSettings(); alert(t('configImportSuccess')); } catch (err) { @@ -2110,6 +1902,10 @@ projectContributeBtn: "项目协作", workflowTitle: "选择工作流", autoWorkflowLabel: "自动选择工作流", + modelPresetLabel: "模型预设", + modelPresetPlaceholder: "请选择模型预设", + modelPresetEmpty: "请先在服务端环境变量中配置模型预设", + modelPresetRuntimeHint: "运行时将从服务端环境变量读取供应商、模型端点与 API Key。", workflowOptionPptx: "PPTX 演示文稿", pptxSettingsTitleText: "PPTX 设置", mineruDeployServerUrlLabel: "Server URL", @@ -2123,6 +1919,10 @@ tutorialBtn: "Tutorial", projectContributeBtn: "Contribute", workflowTitle: "Select Workflow", + modelPresetLabel: "Model Preset", + modelPresetPlaceholder: "Select a model preset", + modelPresetEmpty: "Configure model presets in server environment variables first", + modelPresetRuntimeHint: "Provider, endpoint, and API key will be loaded from server environment variables at runtime.", workflowOptionPptx: "PPTX Presentation", pptxSettingsTitleText: "PPTX Settings", mineruDeployServerUrlLabel: "Server URL", @@ -2145,15 +1945,10 @@ enginList.value = await enginRes.json(); Object.assign(defaultParams, await paramsRes.json()); const envConfig = await configRes.json().catch(() => ({})); - // 将服务端环境变量作为 localStorage 未设置时的回退默认值 - if (envConfig.base_url && !localStorage.getItem('translator_platform_custom_base_url')) { - localStorage.setItem('translator_platform_custom_base_url', envConfig.base_url); - } - if (envConfig.api_key && !localStorage.getItem('translator_platform_custom_apikey')) { - localStorage.setItem('translator_platform_custom_apikey', envConfig.api_key); - } - if (envConfig.model_id && !localStorage.getItem('translator_platform_custom_model_id')) { - localStorage.setItem('translator_platform_custom_model_id', envConfig.model_id); + modelPresets.value = Array.isArray(envConfig.model_presets) ? envConfig.model_presets : []; + defaultModelPreset.value = envConfig.default_model_preset || (modelPresets.value[0]?.id || ''); + if (defaultModelPreset.value && !localStorage.getItem('translator_model_preset')) { + localStorage.setItem('translator_model_preset', defaultModelPreset.value); } if (envConfig.rpm != null && !localStorage.getItem('rpm')) { localStorage.setItem('rpm', String(envConfig.rpm)); @@ -2195,6 +1990,7 @@ return { version, currentLang, i18nData, glossaryData, glossaryCount, tasks, enginList, defaultParams, + modelPresets, form, workflowParams, showMineruToken, previewMode, syncScrollEnabled, showIdentityOption, errors, clearError, t, createNewTask, removeTask, handleTaskFileSelect, handleTaskFileDrop, toggleTaskState, diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..f896351 --- /dev/null +++ b/run.bat @@ -0,0 +1,35 @@ +@echo off +setlocal + +REM 切换到脚本所在目录 +cd /d "%~dp0" + +REM 检查 .venv 是否存在 +if not exist ".venv\Scripts\activate.bat" ( + echo [ERROR] 未找到 .venv\Scripts\activate.bat + pause + exit /b 1 +) + +REM 激活虚拟环境 +call ".venv\Scripts\activate.bat" + +REM 如果传了参数,就用参数;否则默认用当前目录 +if "%~1"=="" ( + python docutranslate/cli.py -i --host 0.0.0.0 +) else ( + python docutranslate/cli.py -i "%~1" +) + +set EXIT_CODE=%ERRORLEVEL% + +REM 退出虚拟环境 +call deactivate >nul 2>nul + +if not "%EXIT_CODE%"=="0" ( + echo. + echo [ERROR] 程序退出,返回码: %EXIT_CODE% + pause +) + +exit /b %EXIT_CODE% \ No newline at end of file