feat:前端不显示模型api-key

隐藏GitHub链接
This commit is contained in:
r-earth-or
2026-04-15 13:57:05 +08:00
parent 47a3e9126a
commit 9d8eacf0b4
8 changed files with 331 additions and 284 deletions

View File

@@ -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,
})

View File

@@ -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)

View File

@@ -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)

View File

@@ -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` 字段是必须的。"

View File

@@ -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])

View File

@@ -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)",

View File

@@ -225,16 +225,6 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center">
<h4 class="mb-0 me-3 fw-bold" :title="t('pageTitle')">DocuTranslate</h4>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-info" data-bs-toggle="modal"
data-bs-target="#tutorialModal">
<i class="bi bi-question-circle-fill me-1"></i><span>{{ t('tutorialBtn') }}</span>
</button>
<button type="button" class="btn btn-sm btn-outline-warning" data-bs-toggle="modal"
data-bs-target="#contributorsModal">
<i class="bi bi-people-fill me-1"></i><span>{{ t('projectContributeBtn') }}</span>
</button>
</div>
</div>
</div>
@@ -536,17 +526,12 @@
</div>
<div v-show="!form.skip_translate">
<platform-selector
v-model:platform="form.platform"
v-model:base-url="form.base_url"
v-model:api-key="form.api_key"
v-model:model-id="form.model_id"
v-model:provider="form.provider"
:invalid-api-key="errors.api_key"
:invalid-base-url="errors.base_url"
:invalid-model-id="errors.model_id"
<model-preset-selector
v-model:model-preset="form.model_preset"
:presets="modelPresets"
:invalid-model-preset="errors.model_preset"
@clear-error="clearError"
:t="t" prefix="translator_platform"></platform-selector>
:t="t"></model-preset-selector>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch"
@@ -717,10 +702,6 @@
<!-- Project Info -->
<div class="mt-4 text-center text-muted small project-info">
<p class="bi bi-github mb-2"> GitHub主页(欢迎star❤): <br/><a
href="https://github.com/xunbu/docutranslate" target="_blank">https://github.com/xunbu/docutranslate</a>
</p>
<p class="bi bi-tencent-qq mb-2"> 交流QQ群: 1047781902 </p>
<p class="bi mb-0">version:<span>{{ version ? 'v' + version : '' }}</span></p>
</div>
</div>
@@ -882,58 +863,6 @@
</div>
</div>
<div class="modal fade" id="tutorialModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title"><i
class="bi bi-book-half me-2"></i>{{ t('tutorialModalTitle') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" v-html="t('tutorialModalBody')"></div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">{{ t('tutorialUnderstandBtn')
}}
</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="contributorsModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title"><i
class="bi bi-heart-fill me-2 text-danger"></i>{{ t('contributorsModalTitle') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>{{ t('contributorsPara1') }}</p>
<p>{{ t('contributorsPara2') }}</p>
<div class="alert alert-success mt-4" role="alert">
<p>{{ t('contributorsWelcome') }}</p>
<hr>
<p class="mb-0">
<a href="https://github.com/xunbu/docutranslate" target="_blank"
class="btn btn-info btn-sm ms-2"><i
class="bi bi-github me-1"></i><span>{{ t('contributorsGithub') }}</span></a>
<a href="https://github.com/xunbu/docutranslate/pulls" target="_blank"
class="btn btn-success btn-sm ms-2"><i
class="bi bi-git me-1"></i><span>{{ t('contributorsPR') }}</span></a>
<a href="https://github.com/xunbu/docutranslate/issues" target="_blank"
class="btn btn-warning btn-sm ms-2"><i
class="bi bi-bug-fill me-1"></i><span>{{ t('contributorsIssue') }}</span></a>
</p>
<hr>
<p>{{ t('contributorsQQ') }}</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('closeBtn') }}</button>
</div>
</div>
</div>
</div>
<!-- Preview Offcanvas -->
<div class="offcanvas offcanvas-end" tabindex="-1" id="previewOffcanvas" ref="previewOffcanvas">
<div class="offcanvas-header border-bottom">
@@ -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: `
<div>
<div class="mb-2">
<label class="form-label">{{ t('platformLabel') }}</label>
<select class="form-select" :value="platform" @change="handlePlatformChange($event.target.value)">
<option v-for="p in platforms" :value="p.val">{{ p.val === 'custom' ? t(p.label) : p.label }}</option>
</select>
</div>
<div class="mb-3" v-if="platform === 'custom'">
<label class="form-label">{{ t('providerLabel') }}</label>
<select class="form-select" :value="provider" @change="handleProviderChange($event.target.value)">
<option v-for="prov in providers" :value="prov">{{ prov }}</option>
</select>
</div>
<div class="form-text mb-3">Base URL: <code ref="baseUrlDisplay">{{ baseUrl }}</code></div>
<div class="mb-3" v-if="platform === 'custom'">
<label class="form-label">{{ t('baseUrlLabel') }}</label>
<input type="url" class="form-control" :class="{'is-invalid': invalidBaseUrl}"
:value="baseUrl" @input="handleBaseUrlChange($event.target.value)" required
placeholder="OpenAi Compatible URL">
</div>
<div class="mb-3">
<label class="form-label">API Key <a v-if="apiHref" :href="apiHref[0]" target="_blank" class="ms-1"><i
class="bi bi-box-arrow-up-right"></i></a> <span
class="ms-2 text-muted small">{{ apiHref && apiHref[1] ? t(apiHref[1]) : '' }}</span></label>
<div class="input-group">
<input :type="showPass?'text':'password'" class="form-control" :class="{'is-invalid': invalidApiKey}"
:value="apiKey" @input="handleApiKeyChange($event.target.value)"
:placeholder="t('apiKeyPlaceholder')">
<button class="btn btn-outline-secondary" type="button" @click="showPass=!showPass"><i class="bi"
:class="showPass?'bi-eye':'bi-eye-slash'"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('modelIdLabel') }}</label>
<input type="text" class="form-control" :class="{'is-invalid': invalidModelId}"
:value="modelId" @input="handleModelChange($event.target.value)" required
:placeholder="t('modelIdPlaceholder')">
<label class="form-label">{{ t('modelPresetLabel') }}</label>
<select class="form-select" :class="{'is-invalid': invalidModelPreset}"
:value="modelPreset" :disabled="!presets.length"
@change="handlePresetChange($event.target.value)">
<option value="" disabled>{{ presets.length ? t('modelPresetPlaceholder') : t('modelPresetEmpty') }}</option>
<option v-for="preset in presets" :key="preset.id" :value="preset.id">{{ preset.label }}</option>
</select>
<div class="form-text mt-2" v-if="presets.length">{{ t('modelPresetRuntimeHint') }}</div>
<div class="form-text mt-2" v-else>{{ t('modelPresetEmpty') }}</div>
</div>
</div>`,
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,

35
run.bat Normal file
View File

@@ -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%