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 @@
GitHub主页(欢迎star❤):
https://github.com/xunbu/docutranslate
-
交流QQ群: 1047781902
version:{{ version ? 'v' + version : '' }}
{{ baseUrl }}