Compare commits

..

10 Commits

Author SHA1 Message Date
r-earth-or
9d8eacf0b4 feat:前端不显示模型api-key
隐藏GitHub链接
2026-04-15 13:57:05 +08:00
r-earth-or
47a3e9126a feat:自动根据环境变量填写前端输入项,支持术语表与领域知识 2026-04-07 13:20:43 +08:00
toy
9e82daa2a1 feat:兼容qwen-mt模型 2026-02-10 15:50:42 +08:00
xunbu
86a9958f58 添加依赖 2026-01-19 10:08:54 +08:00
xunbu
cd218a5ad0 增加regex 2026-01-19 09:52:22 +08:00
xunbu
6a2563bed6 修改备注 2026-01-18 23:56:08 +08:00
xunbu
2c7e879cd5 还原gemini的baseurl 2026-01-18 23:55:48 +08:00
xunbu
a6fe62420a 更新版本 2026-01-18 23:54:17 +08:00
xunbu
93009d70a9 优化pptx翻译效果 2026-01-18 23:50:58 +08:00
xunbu
5871f5dd85 修复gemini供应商标识 2026-01-18 21:13:57 +08:00
21 changed files with 1010 additions and 691 deletions

29
.env.example Normal file
View File

@@ -0,0 +1,29 @@
# DocuTranslate 环境变量配置示例
# 复制此文件为 .env 并按需修改(需配合 python-dotenv 或容器环境变量使用)
# --- 代理配置 ---
# 是否启用系统代理,设置为 true 开启
# DOCUTRANSLATE_PROXY_ENABLED=true
# --- 缓存配置 ---
# 任务缓存数量(默认 10
# DOCUTRANSLATE_CACHE_NUM=10
# --- 翻译 API 默认配置 ---
# 前端"自定义接口-default"平台的默认值,留空则不预填
# API 地址Base URL例如 https://api.openai.com/v1
DOCUTRANSLATE_BASE_URL=
# API 密钥
DOCUTRANSLATE_API_KEY=
# 模型 ID例如 qwen-mt-turbo
DOCUTRANSLATE_MODEL_ID=
# --- 限流配置 ---
# RPM 限制(每分钟请求数),留空则不限制
DOCUTRANSLATE_RPM=
# TPM 限制(每分钟 Token 数),留空则不限制
DOCUTRANSLATE_TPM=

3
.gitignore vendored
View File

@@ -14,3 +14,6 @@ docutranslate/output/
.idea/
#claude
.claude/
/.omc/
# Environment variables
.env

39
AGENTS.md Normal file
View File

@@ -0,0 +1,39 @@
# Repository Guidelines
## Project Structure & Module Organization
Core Python code is in `docutranslate/`.
- `docutranslate/workflow/`: file-type workflows (`*_workflow.py`) that orchestrate conversion, translation, and export.
- `docutranslate/converter/`, `docutranslate/translator/`, `docutranslate/exporter/`: pipeline stages.
- `docutranslate/app.py`: FastAPI/Web UI backend entrypoint.
- `docutranslate/cli.py`: CLI entry (`docutranslate` command).
- `docutranslate/static/` and `docutranslate/template/`: bundled frontend/static assets.
- Packaging/build files live at root: `pyproject.toml`, `Dockerfile`, `*.spec`, `.github/workflows/`.
## Build, Test, and Development Commands
- `uv sync`: install project dependencies from `pyproject.toml`/`uv.lock`.
- `uv run docutranslate -i`: start local Web UI + API (default `127.0.0.1:8010`).
- `uv run docutranslate -i -p 8011 --cors`: run on a custom port with CORS enabled.
- `docker run -d -p 8010:8010 xunbu/docutranslate:latest`: run the published container locally.
- `uv pip install pyinstaller && uv run pyinstaller lite.spec --noconfirm --clean -y`: build a lightweight desktop package (see also `full.spec`, `lite_mac.spec`).
## Coding Style & Naming Conventions
- Follow Python 3.11+ conventions and PEP 8: 4-space indentation, clear type-oriented config classes, small focused functions.
- Use `snake_case` for modules/functions/variables and `PascalCase` for classes.
- Keep workflow naming consistent: `xxx_workflow.py`, matching config and workflow class names.
- Prefer explicit, composable configs over hard-coded provider values.
## Testing Guidelines
There is currently no first-party `tests/` suite or enforced coverage gate in this repository.
- For behavior changes, run a manual smoke test: start `docutranslate -i`, open `/docs`, and execute at least one translation path you touched.
- If you add automated tests, place them under `tests/` with `test_*.py` names and keep fixtures small and file-type specific.
## Commit & Pull Request Guidelines
Recent history favors short, imperative commit subjects (Chinese or English), for example: `Fix Gemini provider tag`, `Add regex dependency`, `Add Vietnamese`.
- Keep subject lines concise and action-focused.
- In PRs, include: what changed, why, how you validated it, and UI screenshots when `docutranslate/static/` or interface behavior changes.
- Link related issues and note any new env vars/API provider requirements.
## Security & Configuration Tips
- Never commit real API keys or tokens.
- Keep provider credentials in environment variables or local untracked config.
- For LAN exposure, use `--host 0.0.0.0` intentionally and restrict network access as needed.

View File

@@ -1,3 +1,3 @@
# SPDX-FileCopyrightText: 2025 QinHan
# SPDX-License-Identifier: MPL-2.0
__version__="1.6.2"
__version__="1.6.3"

View File

@@ -59,6 +59,7 @@ class AgentConfig:
rpm: int | None = None # 每分钟请求数限制
tpm: int | None = None # 每分钟Token数限制
provider: ProviderType | None = None
source_lang: str | None = None # qwen-mt: 源语言
class TotalErrorCounter:
@@ -290,6 +291,223 @@ _COMPLEX_SCRIPT_PATTERN = re.compile(
r'[\u2e80-\u9fff\u0400-\u04ff\u0600-\u06ff\u0e00-\u0e7f\u0900-\u097f]'
)
def _normalize_mt_lang_key(lang: str) -> str:
key = str(lang).strip().lower()
key = key.replace("_", "-")
key = key.replace("'", "'").replace("'", "'")
key = key.replace("", "-").replace("", "-")
key = re.sub(r"\s+", " ", key)
return key
_MT_LANG_BY_CODE = {
"en": "English",
"zh": "Chinese",
"zh-tw": "Traditional Chinese",
"ru": "Russian",
"ja": "Japanese",
"ko": "Korean",
"es": "Spanish",
"fr": "French",
"pt": "Portuguese",
"de": "German",
"it": "Italian",
"th": "Thai",
"vi": "Vietnamese",
"id": "Indonesian",
"ms": "Malay",
"ar": "Arabic",
"hi": "Hindi",
"he": "Hebrew",
"my": "Burmese",
"ta": "Tamil",
"ur": "Urdu",
"bn": "Bengali",
"pl": "Polish",
"nl": "Dutch",
"ro": "Romanian",
"tr": "Turkish",
"km": "Khmer",
"lo": "Lao",
"yue": "Cantonese",
"cs": "Czech",
"el": "Greek",
"sv": "Swedish",
"hu": "Hungarian",
"da": "Danish",
"fi": "Finnish",
"uk": "Ukrainian",
"bg": "Bulgarian",
"sr": "Serbian",
"te": "Telugu",
"af": "Afrikaans",
"hy": "Armenian",
"as": "Assamese",
"ast": "Asturian",
"eu": "Basque",
"be": "Belarusian",
"bs": "Bosnian",
"ca": "Catalan",
"ceb": "Cebuano",
"hr": "Croatian",
"arz": "Egyptian Arabic",
"et": "Estonian",
"gl": "Galician",
"ka": "Georgian",
"gu": "Gujarati",
"is": "Icelandic",
"jv": "Javanese",
"kn": "Kannada",
"kk": "Kazakh",
"lv": "Latvian",
"lt": "Lithuanian",
"lb": "Luxembourgish",
"mk": "Macedonian",
"mai": "Maithili",
"mt": "Maltese",
"mr": "Marathi",
"acm": "Mesopotamian Arabic",
"ary": "Moroccan Arabic",
"ars": "Najdi Arabic",
"ne": "Nepali",
"az": "North Azerbaijani",
"apc": "North Levantine Arabic",
"uz": "Northern Uzbek",
"nb": "Norwegian Bokmål",
"nn": "Norwegian Nynorsk",
"oc": "Occitan",
"or": "Odia",
"pag": "Pangasinan",
"scn": "Sicilian",
"sd": "Sindhi",
"si": "Sinhala",
"sk": "Slovak",
"sl": "Slovenian",
"ajp": "South Levantine Arabic",
"sw": "Swahili",
"tl": "Tagalog",
"acq": "Ta'izzi-Adeni Arabic",
"sq": "Tosk Albanian",
"aeb": "Tunisian Arabic",
"vec": "Venetian",
"war": "Waray",
"cy": "Welsh",
"fa": "Western Persian",
}
_MT_LANG_BY_NAME = {
_normalize_mt_lang_key(name): name for name in set(_MT_LANG_BY_CODE.values())
}
_MT_LANG_ALIASES = {
# Existing UI/common aliases
"english": "English",
"英语": "English",
"英文": "English",
"简体中文": "Chinese",
"中文": "Chinese",
"simplified chinese": "Chinese",
"chinese": "Chinese",
"traditional chinese": "Traditional Chinese",
"繁体中文": "Traditional Chinese",
"zh-hans": "Chinese",
"zh-cn": "Chinese",
"zh-hant": "Traditional Chinese",
# Full Chinese aliases from qwen-mt language list
"俄语": "Russian",
"日语": "Japanese",
"韩语": "Korean",
"西班牙语": "Spanish",
"法语": "French",
"葡萄牙语": "Portuguese",
"德语": "German",
"意大利语": "Italian",
"泰语": "Thai",
"越南语": "Vietnamese",
"印度尼西亚语": "Indonesian",
"马来语": "Malay",
"阿拉伯语": "Arabic",
"印地语": "Hindi",
"希伯来语": "Hebrew",
"缅甸语": "Burmese",
"泰米尔语": "Tamil",
"乌尔都语": "Urdu",
"孟加拉语": "Bengali",
"波兰语": "Polish",
"荷兰语": "Dutch",
"罗马尼亚语": "Romanian",
"土耳其语": "Turkish",
"高棉语": "Khmer",
"老挝语": "Lao",
"粤语": "Cantonese",
"捷克语": "Czech",
"希腊语": "Greek",
"瑞典语": "Swedish",
"匈牙利语": "Hungarian",
"丹麦语": "Danish",
"芬兰语": "Finnish",
"乌克兰语": "Ukrainian",
"保加利亚语": "Bulgarian",
"塞尔维亚语": "Serbian",
"泰卢固语": "Telugu",
"南非荷兰语": "Afrikaans",
"亚美尼亚语": "Armenian",
"阿萨姆语": "Assamese",
"阿斯图里亚斯语": "Asturian",
"巴斯克语": "Basque",
"白俄罗斯语": "Belarusian",
"波斯尼亚语": "Bosnian",
"加泰罗尼亚语": "Catalan",
"宿务语": "Cebuano",
"克罗地亚语": "Croatian",
"埃及阿拉伯语": "Egyptian Arabic",
"爱沙尼亚语": "Estonian",
"加利西亚语": "Galician",
"格鲁吉亚语": "Georgian",
"古吉拉特语": "Gujarati",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"卡纳达语": "Kannada",
"哈萨克语": "Kazakh",
"拉脱维亚语": "Latvian",
"立陶宛语": "Lithuanian",
"卢森堡语": "Luxembourgish",
"马其顿语": "Macedonian",
"马加希语": "Maithili",
"马耳他语": "Maltese",
"马拉地语": "Marathi",
"美索不达米亚阿拉伯语": "Mesopotamian Arabic",
"摩洛哥阿拉伯语": "Moroccan Arabic",
"内志阿拉伯语": "Najdi Arabic",
"尼泊尔语": "Nepali",
"北阿塞拜疆语": "North Azerbaijani",
"北黎凡特阿拉伯语": "North Levantine Arabic",
"北乌兹别克语": "Northern Uzbek",
"书面语挪威语": "Norwegian Bokmål",
"新挪威语": "Norwegian Nynorsk",
"奥克语": "Occitan",
"奥里亚语": "Odia",
"邦阿西楠语": "Pangasinan",
"西西里语": "Sicilian",
"信德语": "Sindhi",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"南黎凡特阿拉伯语": "South Levantine Arabic",
"斯瓦希里语": "Swahili",
"他加禄语": "Tagalog",
"塔伊兹-亚丁阿拉伯语": "Ta'izzi-Adeni Arabic",
"托斯克阿尔巴尼亚语": "Tosk Albanian",
"突尼斯阿拉伯语": "Tunisian Arabic",
"威尼斯语": "Venetian",
"瓦莱语": "Waray",
"威尔士语": "Welsh",
"西波斯语": "Western Persian",
# English punctuation/variant aliases
"norwegian bokmal": "Norwegian Bokmål",
"ta'izzi-adeni arabic": "Ta'izzi-Adeni Arabic",
}
class Agent:
def __init__(self, config: AgentConfig):
@@ -316,6 +534,11 @@ class Agent:
self.rate_limiter = RateLimiter(rpm=config.rpm, tpm=config.tpm)
self.provider = config.provider if config.provider is not None else get_provider_by_domain(self.domain)
self.is_mt_mode = "mt" in self.model_id.lower()
self.mt_source_lang = config.source_lang if config.source_lang else "auto"
self.mt_target_lang = getattr(config, "to_lang", None)
self.mt_domains = getattr(config, "custom_prompt", None)
self.mt_glossary_dict = getattr(config, "glossary_dict", None)
def _estimate_tokens(self, text: str) -> int:
"""
@@ -352,6 +575,52 @@ class Agent:
elif self.thinking == "disable":
data[field_thinking] = val_disable
def _normalize_mt_lang(self, lang: str | None) -> str | None:
if lang is None:
return None
lang_text = str(lang).strip()
if not lang_text:
return None
key = _normalize_mt_lang_key(lang_text)
if key in _MT_LANG_BY_CODE:
return _MT_LANG_BY_CODE[key]
if key in _MT_LANG_BY_NAME:
return _MT_LANG_BY_NAME[key]
if key in _MT_LANG_ALIASES:
return _MT_LANG_ALIASES[key]
return lang_text
def _build_mt_translation_options(self, prompt: str = "") -> dict:
translation_options = {}
source_lang = self._normalize_mt_lang(self.mt_source_lang)
if source_lang:
translation_options["source_lang"] = source_lang
target_lang = self._normalize_mt_lang(self.mt_target_lang)
if target_lang:
translation_options["target_lang"] = target_lang
domains = str(self.mt_domains).strip() if self.mt_domains is not None else ""
if domains:
translation_options["domains"] = domains
if self.mt_glossary_dict:
terminology_list = [
{"source": src, "target": tgt}
for src, tgt in self.mt_glossary_dict.items()
if src and tgt and src.lower() in prompt.lower()
]
if terminology_list:
translation_options["terms"] = terminology_list
return translation_options
def _build_mt_user_prompt(self, prompt: str, system_prompt: str) -> str:
# MT模式下直接返回原始prompt不添加任何system prompt
# MT模型会把整个user prompt当作待翻译内容
return prompt
def _prepare_request_data(
self, prompt: str, system_prompt: str, temperature=None, top_p=0.9, json_format=False
):
@@ -361,6 +630,19 @@ class Agent:
"Content-Type": "application/json",
"Authorization": f"Bearer {self.key}",
}
if self.is_mt_mode:
data = {
"model": self.model_id,
"messages": [
{"role": "user", "content": self._build_mt_user_prompt(prompt, system_prompt)},
],
}
translation_options = self._build_mt_translation_options(prompt=prompt)
if translation_options:
data["translation_options"] = translation_options
return headers, data
data = {
"model": self.model_id,
"messages": [

View File

@@ -32,15 +32,15 @@ For each Key-Value Pair in the JSON, translate the contents of the value into {t
> The segment IDs in the output must exactly match those in the input. And all segment IDs in input must appear in the output.
> If necessary, two segments can only be translated together, the translation should be proportionally allocated to the corresponding key's value based on the word count ratio of the segments.
Here is an example of the expected format:
Here is an example of the expected format (Note: This is ONLY a format example, do NOT translate the example content):
<example>
Input:
```json
{{
"3":source,
"4":source,
"EXAMPLE_KEY_1": "source text",
"EXAMPLE_KEY_2": "source text"
}}
```
@@ -48,8 +48,8 @@ Output(target language: {to_lang}):
```json
{{
"3":translation,
"4":translation,
"EXAMPLE_KEY_1": "translated text",
"EXAMPLE_KEY_2": "translated text"
}}
```
For statements that must be combined during translation, employ merging at the minimal structural level. The total number of keys must remain unchanged after merging, and any empty values should be retained.
@@ -58,18 +58,20 @@ Below is an example of how merging should be done when necessary:
input:
```json
{{
"3":"汤姆说:“杰克你",
"4":"好”。"
"EXAMPLE_KEY_1":"汤姆说:“杰克你",
"EXAMPLE_KEY_2":"好”。"
}}
```
output:
```json
{{
"3":"Tom says:\"Hello Jack.\"",
"4":""
"EXAMPLE_KEY_1":"Tom says:\"Hello Jack.\"",
"EXAMPLE_KEY_2":""
}}
```
</example>
IMPORTANT: Only translate the content in the <input> section above. Do NOT include or translate the example content from this <example> section in your output.
Please return the translated JSON directly without including any additional information and preserve special tags or untranslatable elements (such as code, brand names, technical terms) as they are.
"""
@@ -124,7 +126,12 @@ class SegmentsTranslateAgent(Agent):
- 如果键完全匹配,返回翻译结果。
- 如果键不匹配,构造一个部分成功的结果,并通过 PartialTranslationError 异常抛出,以触发重试。
- 其他错误如JSON解析失败、模型偷懒则抛出普通 ValueError 触发重试。
- MT模式下如果返回的是纯文本而非JSON将其按行分割并映射到原始键。
"""
# MT模式下直接解析origin_prompt为JSON纯净JSON没有<input>包装)
if self.is_mt_mode:
original_segments = origin_prompt
else:
original_segments = get_original_segments(origin_prompt)
result = get_target_segments(result)
if result == "":
@@ -137,6 +144,37 @@ class SegmentsTranslateAgent(Agent):
original_chunk = json_repair.loads(original_segments)
repaired_result = json_repair.loads(result)
# MT模式兼容处理各种非标准返回格式
if self.is_mt_mode:
# 如果是列表,尝试合并所有字典
if isinstance(repaired_result, list):
logger.debug(f"[MT模式] 返回结果是列表,包含 {len(repaired_result)} 个元素")
merged_result = {}
for item in repaired_result:
if isinstance(item, dict):
merged_result.update(item)
repaired_result = merged_result
# 如果返回的是纯文本(字符串),尝试将其映射到原始键
if isinstance(repaired_result, str):
original_keys = list(original_chunk.keys())
# 按行分割结果,去除空行
result_lines = [line.strip() for line in repaired_result.split('\n') if line.strip()]
# 如果只有一行结果但多个键,将整个结果分配给第一个键,其余为空
if len(result_lines) == 1 and len(original_keys) > 1:
repaired_result = {original_keys[0]: result_lines[0]}
for key in original_keys[1:]:
repaired_result[key] = ""
# 如果结果行数与键数匹配,逐行对应
elif len(result_lines) == len(original_keys):
repaired_result = {original_keys[i]: result_lines[i] for i in range(len(original_keys))}
# 如果结果行数不匹配,将所有结果合并给第一个键
else:
repaired_result = {original_keys[0]: repaired_result}
for key in original_keys[1:]:
repaired_result[key] = ""
if not isinstance(repaired_result, dict):
raise AgentResultError(f"Agent返回结果不是dict的json形式, result: {result}")
@@ -174,6 +212,32 @@ class SegmentsTranslateAgent(Agent):
return repaired_result
except (RuntimeError, JSONDecodeError) as e:
# MT模式兼容如果JSON解析失败尝试将结果作为纯文本处理
if self.is_mt_mode:
try:
original_chunk = json_repair.loads(original_segments)
original_keys = list(original_chunk.keys())
result_lines = [line.strip() for line in result.split('\n') if line.strip()]
if len(result_lines) == 1 and len(original_keys) > 1:
repaired_result = {original_keys[0]: result_lines[0]}
for key in original_keys[1:]:
repaired_result[key] = ""
elif len(result_lines) == len(original_keys):
repaired_result = {original_keys[i]: result_lines[i] for i in range(len(original_keys))}
else:
repaired_result = {original_keys[0]: result}
for key in original_keys[1:]:
repaired_result[key] = ""
# 验证结果
if set(repaired_result.keys()) != set(original_chunk.keys()):
raise AgentResultError(f"MT模式解析后键不匹配")
return repaired_result
except Exception as mt_e:
raise AgentResultError(f"MT模式纯文本处理失败: {mt_e.__repr__()}")
# 对于JSON解析等硬性错误继续抛出普通ValueError
raise AgentResultError(f"结果处理失败: {e.__repr__()}")
@@ -182,6 +246,10 @@ class SegmentsTranslateAgent(Agent):
处理在所有重试后仍然失败的请求。
作为备用方案,返回原文内容,并将所有值转换为字符串。
"""
# MT模式下直接解析origin_prompt为JSON纯净JSON没有<input>包装)
if self.is_mt_mode:
original_segments = origin_prompt
else:
original_segments = get_original_segments(origin_prompt)
if original_segments == "":
return {}
@@ -198,6 +266,10 @@ class SegmentsTranslateAgent(Agent):
def send_segments(self, segments: list[str], chunk_size: int) -> list[str]:
indexed_originals, chunks, merged_indices_list = segments2json_chunks(segments, chunk_size)
# MT模式下直接发送纯净JSON不添加额外提示词
if self.is_mt_mode:
prompts = [json.dumps(chunk, ensure_ascii=False, indent=0) for chunk in chunks]
else:
prompts = [generate_prompt(json.dumps(chunk, ensure_ascii=False, indent=0), self.to_lang) for chunk in chunks]
translated_chunks = super().send_prompts(prompts=prompts, json_format=self.force_json,
pre_send_handler=self._pre_send_handler,
@@ -236,6 +308,10 @@ class SegmentsTranslateAgent(Agent):
async def send_segments_async(self, segments: list[str], chunk_size: int) -> list[str]:
indexed_originals, chunks, merged_indices_list = await asyncio.to_thread(segments2json_chunks, segments,
chunk_size)
# MT模式下直接发送纯净JSON不添加额外提示词
if self.is_mt_mode:
prompts = [json.dumps(chunk, ensure_ascii=False, indent=0) for chunk in chunks]
else:
prompts = [generate_prompt(json.dumps(chunk, ensure_ascii=False, indent=0), self.to_lang) for chunk in chunks]
translated_chunks = await super().send_prompts_async(prompts=prompts, force_json=self.force_json,

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)}")
@@ -2472,6 +2487,17 @@ async def service_flat_translate(
})
@app.get("/api/config", tags=["Config"], summary="获取服务端环境变量默认配置")
async def get_config():
"""返回前端可用的模型预设列表与全局默认配置,不包含敏感信息。"""
return JSONResponse({
"model_presets": get_public_model_presets(),
"default_model_preset": get_default_model_preset(),
"rpm": DOCUTRANSLATE_RPM,
"tpm": DOCUTRANSLATE_TPM,
})
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
async def main_page():
index_path = Path(STATIC_DIR) / "index.html"

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")
@@ -60,7 +63,7 @@ def create_workflow_from_payload(payload: TranslatePayload, logger: logging.Logg
# 1. Markdown Based Workflow
if isinstance(payload, MarkdownWorkflowParams):
translator_args = payload.model_dump(
include={"skip_translate", "base_url", "api_key", "model_id", "to_lang", "custom_prompt",
include={"skip_translate", "base_url", "api_key", "model_id", "to_lang", "source_lang", "custom_prompt",
"temperature", "thinking", "chunk_size", "concurrent", "glossary_dict", "timeout",
"retry", "system_proxy_enable", "force_json", "rpm", "tpm", "provider"},
exclude_none=True,
@@ -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="",
@@ -157,6 +162,9 @@ class BaseWorkflowParams(BaseModel):
custom_prompt: Optional[str] = Field(
default="", description="用户自定义的翻译Prompt。", alias="custom_prompt"
)
source_lang: Optional[str] = Field(
default=None, description="源语言qwen-mt系列模型专用'Chinese''English')。", examples=[None]
)
glossary_dict: Optional[Dict[str, str]] = Field(
None, description="术语表字典key为原文value为译文。", examples=[None]
)
@@ -193,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

@@ -0,0 +1,185 @@
# SPDX-FileCopyrightText: 2025 QinHan
# SPDX-License-Identifier: MPL-2.0
"""
集中管理所有环境变量。
所有 os.getenv() 调用应在此处统一声明,其他模块从这里导入。
"""
import json
import os
from functools import lru_cache
from typing import Any
from dotenv import load_dotenv
load_dotenv() # 自动从项目根目录的 .env 文件加载环境变量(不覆盖已有的 shell 变量)
# --- 代理配置 ---
# 是否启用系统代理,设置为 "true" 开启
DOCUTRANSLATE_PROXY_ENABLED: bool = (
os.getenv("DOCUTRANSLATE_PROXY_ENABLED", "").lower() == "true"
)
# --- 缓存配置 ---
# 任务缓存数量
DOCUTRANSLATE_CACHE_NUM: int = int(os.getenv("DOCUTRANSLATE_CACHE_NUM", "10"))
# --- 翻译 API 默认配置 ---
# 默认 API 地址 (自定义接口的 Base URL)
DOCUTRANSLATE_BASE_URL: str = os.getenv("DOCUTRANSLATE_BASE_URL", "")
# 默认 API 密钥
DOCUTRANSLATE_API_KEY: str = os.getenv("DOCUTRANSLATE_API_KEY", "")
# 默认模型 ID
DOCUTRANSLATE_MODEL_ID: str = os.getenv("DOCUTRANSLATE_MODEL_ID", "")
# --- 限流默认配置 ---
# 默认 RPM 限制 (Requests Per Minute),不设置则不限制
_rpm_str = os.getenv("DOCUTRANSLATE_RPM", "")
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

@@ -1,16 +1,9 @@
# SPDX-FileCopyrightText: 2025 QinHan
# SPDX-License-Identifier: MPL-2.0
import os
from docutranslate.environment import DOCUTRANSLATE_PROXY_ENABLED
from .conditional_import import available_packages, conditional_import
USE_PROXY = (
True
if (
os.getenv("DOCUTRANSLATE_PROXY_ENABLED")
and os.getenv("DOCUTRANSLATE_PROXY_ENABLED").lower() == "true"
)
else False
)
USE_PROXY = DOCUTRANSLATE_PROXY_ENABLED
if USE_PROXY:
print(f"USE_PROXY:{USE_PROXY}")

View File

@@ -198,6 +198,7 @@ class Client:
retry: Optional[int] = None,
thinking: Optional[ThinkingMode] = None,
custom_prompt: Optional[str] = None,
source_lang: Optional[str] = None,
system_proxy_enable: Optional[bool] = None,
force_json: Optional[bool] = None,
rpm: Optional[int] = None,
@@ -264,6 +265,7 @@ class Client:
retry: Optional[int] = None,
thinking: Optional[ThinkingMode] = None,
custom_prompt: Optional[str] = None,
source_lang: Optional[str] = None,
system_proxy_enable: Optional[bool] = None,
force_json: Optional[bool] = None,
rpm: Optional[int] = None,

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"
@@ -596,6 +581,7 @@
<option value="Portuguese">葡萄牙文(Português)</option>
<option value="Arabic">阿拉伯文(العَرَبِيَّة)</option>
<option value="Vietnamese">越南文(tiếng Việt)</option>
<option value="Indonesian">印尼文(Bahasa Indonesia)</option>
<option value="custom">{{ t('targetLanguageCustom') }}</option>
</select>
<div class="mt-2" v-if="form.to_lang === 'custom'">
@@ -673,7 +659,7 @@
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseGlossary">
<strong><span class="step-number">{{ stepMap.glossary }} </span><i
<strong><i
class="bi bi-journal-bookmark me-2"></i><span>{{ t('glossaryGenTitle')
}}</span></strong>
</button>
@@ -698,148 +684,6 @@
</button>
</div>
</div>
<div class="form-check form-switch mb-3 border-top pt-3">
<input class="form-check-input" type="checkbox" role="switch"
v-model="form.glossary_generate_enable"
@change="saveSetting('glossary_generate_enable', form.glossary_generate_enable)">
<label class="form-check-label">{{ t('glossaryGenEnableLabel') }}</label>
</div>
<div v-if="form.glossary_generate_enable">
<div class="mb-3">
<label class="form-label">{{ t('glossaryCustomPromptLabel') }}</label>
<textarea class="form-control"
v-model="form.glossary_agent_custom_prompt"
@input="saveSetting('glossary_agent_custom_prompt', form.glossary_agent_custom_prompt)"
rows="3"
:placeholder="t('glossaryCustomPromptPlaceholder')"></textarea>
</div>
<div class="mb-3">
<label class="form-label">{{ t('glossaryGenConfigLabel') }}</label>
<div class="btn-group w-100">
<input type="radio" class="btn-check" value="same" id="gSame"
v-model="form.glossary_agent_config_choice"
@change="saveSetting('glossary_agent_config_choice', 'same')">
<label class="btn btn-outline-primary"
for="gSame">{{ t('glossaryGenConfigSame') }}</label>
<input type="radio" class="btn-check" value="custom" id="gCustom"
v-model="form.glossary_agent_config_choice"
@change="saveSetting('glossary_agent_config_choice', 'custom')">
<label class="btn btn-outline-primary"
for="gCustom">{{ t('glossaryGenConfigCustom') }}</label>
</div>
</div>
<div v-if="form.glossary_agent_config_choice === 'custom'"
class="border p-3 rounded">
<platform-selector
v-model:platform="form.glossary_agent_platform"
v-model:base-url="form.glossary_agent_baseurl"
v-model:api-key="form.glossary_agent_key"
v-model:model-id="form.glossary_agent_model_id"
v-model:provider="form.glossary_agent_provider"
:t="t" prefix="glossary_agent_platform"></platform-selector>
<div class="mb-3">
<label class="form-label">{{ t('targetLanguageLabel') }}</label>
<select class="form-select" v-model="form.glossary_agent_to_lang"
@change="saveSetting('glossary_agent_to_lang', form.glossary_agent_to_lang)">
<option value="Simplified Chinese">中文(简体中文)</option>
<option value="English">英文(English)</option>
<option value="Spanish">西班牙文(Español)</option>
<option value="French">法文(Français)</option>
<option value="German">德文(Deutsch)</option>
<option value="Japanese">日文(日本語)</option>
<option value="Korean">韩文(한국어)</option>
<option value="Russian">俄文(Русский)</option>
<option value="Portuguese">葡萄牙文(Português)</option>
<option value="Arabic">阿拉伯文(العَرَبِيَّة)</option>
<option value="Vietnamese">越南文(tiếng Việt)</option>
<option value="custom">{{ t('targetLanguageCustom') }}</option>
</select>
<div class="mt-2" v-if="form.glossary_agent_to_lang === 'custom'">
<input type="text" class="form-control"
v-model="form.glossary_agent_custom_to_lang"
@input="saveSetting('glossary_agent_custom_to_lang', form.glossary_agent_custom_to_lang)"
:placeholder="t('customLangPlaceholder')">
</div>
</div>
<slider-control :label="t('chunkSizeLabel')"
v-model="form.glossary_agent_chunk_size"
save-key="glossary_agent_chunk_size"
:default-val="defaultParams.chunk_size" :min="1000"
:max="8000" :step="100" :t="t"></slider-control>
<slider-control :label="t('concurrentLabel')"
v-model="form.glossary_agent_concurrent"
save-key="glossary_agent_concurrent"
:default-val="defaultParams.concurrent" :min="1"
:max="120" :step="1" :t="t"></slider-control>
<slider-control label="Temperature"
v-model="form.glossary_agent_temperature"
save-key="glossary_agent_temperature" :default-val="0.7"
:min="0" :max="2" :step="0.1" :t="t"></slider-control>
<slider-control :label="t('retryLabel')"
v-model="form.glossary_agent_retry"
save-key="glossary_agent_retry"
:default-val="defaultParams.retry" :min="1" :max="6"
:step="1" :t="t"></slider-control>
<!-- Glossary Agent RPM/TPM [Vertical Layout] -->
<div class="mb-3">
<label class="form-label">RPM <small
class="text-muted">({{ t('rpmLabel')
}})</small></label>
<input type="number" class="form-control"
v-model="form.glossary_agent_rpm"
@input="saveSetting('glossary_agent_rpm', form.glossary_agent_rpm)"
min="1" :placeholder="t('unlimitedPlaceholder')">
</div>
<div class="mb-3">
<label class="form-label">TPM <small
class="text-muted">({{ t('tpmLabel')
}})</small></label>
<input type="number" class="form-control"
v-model="form.glossary_agent_tpm"
@input="saveSetting('glossary_agent_tpm', form.glossary_agent_tpm)"
min="1" :placeholder="t('unlimitedPlaceholder')">
</div>
<div class="mb-3">
<label class="form-label">{{ t('thinkingModeLabel') }}</label>
<div class="btn-group w-100">
<input type="radio" class="btn-check" value="enable"
id="gtEnable" v-model="form.glossary_agent_thinking"
@change="saveSetting('glossary_agent_thinking_mode', 'enable')">
<label class="btn btn-outline-primary"
for="gtEnable">{{ t('thinkingModeEnable') }}</label>
<input type="radio" class="btn-check" value="disable"
id="gtDisable" v-model="form.glossary_agent_thinking"
@change="saveSetting('glossary_agent_thinking_mode', 'disable')">
<label class="btn btn-outline-primary"
for="gtDisable">{{ t('thinkingModeDisable') }}</label>
<input type="radio" class="btn-check" value="default"
id="gtDefault" v-model="form.glossary_agent_thinking"
@change="saveSetting('glossary_agent_thinking_mode', 'default')">
<label class="btn btn-outline-primary"
for="gtDefault">{{ t('thinkingModeDefault') }}</label>
</div>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch"
v-model="form.glossary_agent_system_proxy_enable"
@change="saveSetting('glossary_agent_system_proxy_enable', form.glossary_agent_system_proxy_enable)">
<label class="form-check-label">{{ t('systemProxyLabel') }}</label>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch"
v-model="form.glossary_agent_force_json"
@change="saveSetting('glossary_agent_force_json', form.glossary_agent_force_json)">
<label class="form-check-label">{{ t('forceJson') }}</label>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -858,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>
@@ -1023,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">
@@ -1207,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
};
}
};
@@ -1375,7 +1045,7 @@
];
createApp({
components: {SliderControl, PlatformSelector},
components: {SliderControl, ModelPresetSelector},
setup() {
const version = ref("");
const currentLang = ref(localStorage.getItem('ui_language') || 'zh');
@@ -1384,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);
@@ -1398,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,
@@ -1430,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',
@@ -1447,25 +1113,6 @@
retry: 3,
rpm: null, // New RPM
tpm: null, // New TPM
glossary_generate_enable: false,
glossary_agent_custom_prompt: '',
glossary_agent_config_choice: 'same',
glossary_agent_platform: 'https://api.302.ai/v1',
glossary_agent_baseurl: '',
glossary_agent_key: '',
glossary_agent_model_id: '',
glossary_agent_provider: 'api.openai.com', // Default glossary provider
glossary_agent_to_lang: 'Simplified Chinese',
glossary_agent_custom_to_lang: '',
glossary_agent_chunk_size: 1000,
glossary_agent_concurrent: 5,
glossary_agent_temperature: 0.7,
glossary_agent_retry: 3,
glossary_agent_thinking: 'default',
glossary_agent_system_proxy_enable: false,
glossary_agent_force_json: false,
glossary_agent_rpm: null, // New Glossary Agent RPM
glossary_agent_tpm: null // New Glossary Agent TPM
});
// Nested Params for specific workflows
@@ -1496,8 +1143,12 @@
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', 'markdown_based');
form.workflow_type = get('translator_last_workflow', 'docx');
form.auto_workflow_enabled = getBool('translator_auto_workflow_enabled', true);
form.convert_engine = get('translator_convert_engin', 'mineru');
form.mineru_token = get('translator_mineru_token', '');
@@ -1516,12 +1167,12 @@
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', 'https://api.302.ai/v1');
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');
form.custom_to_lang = get('translator_custom_to_lang', '');
form.thinking = get('translator_thinking_mode', 'disable');
form.thinking = get('translator_thinking_mode', 'default');
form.custom_prompt = get('custom_prompt', '');
form.chunk_size = getNum('chunk_size', 1000);
form.concurrent = getNum('concurrent', 5);
@@ -1530,37 +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 : '';
}
form.glossary_generate_enable = getBool('glossary_generate_enable', false);
form.glossary_agent_custom_prompt = get('glossary_agent_custom_prompt', '');
form.glossary_agent_config_choice = get('glossary_agent_config_choice', 'same');
form.glossary_agent_platform = get('glossary_agent_platform_last_platform', 'https://api.302.ai/v1');
form.glossary_agent_to_lang = get('glossary_agent_to_lang', 'Simplified Chinese');
form.glossary_agent_custom_to_lang = get('glossary_agent_custom_to_lang', '');
form.glossary_agent_chunk_size = getNum('glossary_agent_chunk_size', 1000);
form.glossary_agent_concurrent = getNum('glossary_agent_concurrent', 5);
form.glossary_agent_temperature = getNum('glossary_agent_temperature', 0.7);
form.glossary_agent_retry = getNum('glossary_agent_retry', 3);
form.glossary_agent_thinking = get('glossary_agent_thinking_mode', 'default');
form.glossary_agent_system_proxy_enable = getBool('glossary_agent_system_proxy_enable', false);
form.glossary_agent_force_json = getBool('glossary_agent_force_json', false);
form.glossary_agent_rpm = getNumOrNull('glossary_agent_rpm'); // Load Glossary RPM
form.glossary_agent_tpm = getNumOrNull('glossary_agent_tpm'); // Load Glossary TPM
// Determine Glossary Provider
const gPlatObj = KNOWN_PLATFORMS.find(p => p.val === form.glossary_agent_platform);
if (form.glossary_agent_platform === 'custom') {
form.glossary_agent_provider = get('glossary_agent_platform_custom_provider', 'default');
} else {
form.glossary_agent_provider = gPlatObj ? gPlatObj.provider : '';
if (!validPresetIds.includes(form.model_preset)) {
form.model_preset = fallbackPreset;
}
// Restore workflow specific params
@@ -1571,10 +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);
updatePlatformParams(form.glossary_agent_platform, 'glossary_agent_platform', form, true);
};
// --- 新增:专门用于将当前 form 数据全部写入 localStorage 的函数 ---
@@ -1604,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);
@@ -1620,36 +1238,7 @@
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. 术语表相关
s('glossary_generate_enable', f.glossary_generate_enable);
s('glossary_agent_custom_prompt', f.glossary_agent_custom_prompt);
s('glossary_agent_config_choice', f.glossary_agent_config_choice);
s('glossary_agent_platform_last_platform', f.glossary_agent_platform);
s('glossary_agent_to_lang', f.glossary_agent_to_lang);
s('glossary_agent_custom_to_lang', f.glossary_agent_custom_to_lang);
s('glossary_agent_chunk_size', f.glossary_agent_chunk_size);
s('glossary_agent_concurrent', f.glossary_agent_concurrent);
s('glossary_agent_temperature', f.glossary_agent_temperature);
s('glossary_agent_retry', f.glossary_agent_retry);
s('glossary_agent_thinking_mode', f.glossary_agent_thinking);
s('glossary_agent_system_proxy_enable', f.glossary_agent_system_proxy_enable);
s('glossary_agent_force_json', f.glossary_agent_force_json);
s('glossary_agent_rpm', f.glossary_agent_rpm || '');
s('glossary_agent_tpm', f.glossary_agent_tpm || '');
// 术语表平台 Key
s(`glossary_agent_platform_${f.glossary_agent_platform}_apikey`, f.glossary_agent_key);
s(`glossary_agent_platform_${f.glossary_agent_platform}_model_id`, f.glossary_agent_model_id);
s('glossary_agent_provider', f.glossary_agent_provider);
if (f.glossary_agent_platform === 'custom') s('glossary_agent_platform_custom_base_url', f.glossary_agent_baseurl);
// 3. 自动循环保存所有具体工作流参数 (txt, docx, xlsx...)
// 2. 自动循环保存所有具体工作流参数 (txt, docx, xlsx...)
for (const [wfType, params] of Object.entries(workflowParams)) {
for (const [key, val] of Object.entries(params)) {
s(`translator_${wfType}_${key}`, val);
@@ -1657,27 +1246,6 @@
}
};
const updatePlatformParams = (plat, prefix, target, isGlossary = false) => {
const get = (k) => localStorage.getItem(k) || '';
if (isGlossary) {
target.glossary_agent_key = get(`${prefix}_${plat}_apikey`);
target.glossary_agent_model_id = get(`${prefix}_${plat}_model_id`);
target.glossary_agent_baseurl = plat === 'custom' ? get(`${prefix}_custom_base_url`) : plat;
} else {
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);
});
watch(() => form.glossary_agent_platform, (n) => {
updatePlatformParams(n, 'glossary_agent_platform', form, true);
});
const t = (k) => {
const dict = i18nData.value[currentLang.value] || i18nData.value['zh'] || {};
return dict[k] || k;
@@ -1685,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;
@@ -1751,12 +1328,11 @@
// Dynamic Step Numbering
const stepMap = computed(() => {
let step = 2;
const map = {specific: 0, parsing: 0, ai: 0, trans: 0, glossary: 0};
const map = {specific: 0, parsing: 0, ai: 0, trans: 0};
if (currentWorkflowConfig.value) map.specific = step++;
if (form.workflow_type === 'markdown_based') map.parsing = step++;
map.ai = step++;
if (!form.skip_translate) map.trans = step++;
map.glossary = step++;
return map;
});
@@ -1861,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),
@@ -1875,33 +1448,11 @@
glossary_dict: Object.keys(glossaryData.value).length ? glossaryData.value : null,
system_proxy_enable: form.system_proxy_enable,
force_json: form.force_json,
glossary_generate_enable: form.glossary_generate_enable,
workflow_type: form.workflow_type,
rpm: emptyToNull(form.rpm),
tpm: emptyToNull(form.tpm)
};
// Agent Config
if (basePayload.glossary_generate_enable) {
const isCustom = form.glossary_agent_config_choice === 'custom';
basePayload.glossary_agent_config = {
base_url: isCustom ? emptyToNull(form.glossary_agent_baseurl) : basePayload.base_url,
api_key: isCustom ? (form.glossary_agent_key || "") : basePayload.api_key,
model_id: isCustom ? emptyToNull(form.glossary_agent_model_id) : basePayload.model_id,
provider: isCustom ? emptyToNull(form.glossary_agent_provider) : basePayload.provider, // Add provider
to_lang: isCustom ? (form.glossary_agent_to_lang === 'custom' ? form.glossary_agent_custom_to_lang : form.glossary_agent_to_lang) : basePayload.to_lang,
custom_prompt: emptyToNull(form.glossary_agent_custom_prompt),
temperature: isCustom ? Number(form.glossary_agent_temperature) : basePayload.temperature,
concurrent: isCustom ? Number(form.glossary_agent_concurrent) : basePayload.concurrent,
retry: isCustom ? Number(form.glossary_agent_retry) : basePayload.retry,
thinking: isCustom ? form.glossary_agent_thinking : basePayload.thinking,
system_proxy_enable: isCustom ? form.glossary_agent_system_proxy_enable : basePayload.system_proxy_enable,
chunk_size: isCustom ? Number(form.glossary_agent_chunk_size) : basePayload.chunk_size,
force_json: isCustom ? form.glossary_agent_force_json : basePayload.force_json,
rpm: isCustom ? emptyToNull(form.glossary_agent_rpm) : basePayload.rpm,
tpm: isCustom ? emptyToNull(form.glossary_agent_tpm) : basePayload.tpm
};
}
// Specific Workflow Params
if (form.workflow_type === 'markdown_based') {
@@ -1959,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) {
@@ -2303,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) {
@@ -2351,6 +1902,10 @@
projectContributeBtn: "项目协作",
workflowTitle: "选择工作流",
autoWorkflowLabel: "自动选择工作流",
modelPresetLabel: "模型预设",
modelPresetPlaceholder: "请选择模型预设",
modelPresetEmpty: "请先在服务端环境变量中配置模型预设",
modelPresetRuntimeHint: "运行时将从服务端环境变量读取供应商、模型端点与 API Key。",
workflowOptionPptx: "PPTX 演示文稿",
pptxSettingsTitleText: "PPTX 设置",
mineruDeployServerUrlLabel: "Server URL",
@@ -2364,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",
@@ -2377,13 +1936,26 @@
// Backend Metadata
try {
const [metaRes, enginRes, paramsRes] = await Promise.all([
fetch("/service/meta"), fetch('/service/engin-list'), fetch("/service/default-params")
const [metaRes, enginRes, paramsRes, configRes] = await Promise.all([
fetch("/service/meta"), fetch('/service/engin-list'), fetch("/service/default-params"),
fetch("/api/config")
]);
const meta = await metaRes.json();
version.value = meta.version;
enginList.value = await enginRes.json();
Object.assign(defaultParams, await paramsRes.json());
const envConfig = await configRes.json().catch(() => ({}));
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));
}
if (envConfig.tpm != null && !localStorage.getItem('tpm')) {
localStorage.setItem('tpm', String(envConfig.tpm));
}
} catch (e) {
console.error("Backend init failed", e);
}
@@ -2418,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,

View File

@@ -31,6 +31,7 @@ class MDTranslator(AiTranslator):
if not self.skip_translate:
agent_config = MDTranslateAgentConfig(custom_prompt=config.custom_prompt,
to_lang=config.to_lang,
source_lang=config.source_lang,
base_url=config.base_url,
api_key=config.api_key,
model_id=config.model_id,

View File

@@ -1,12 +1,12 @@
# SPDX-FileCopyrightText: 2025 QinHan
# SPDX-License-Identifier: MPL-2.0
import asyncio
import regex # [使用您依赖列表中的 regex 库]
from dataclasses import dataclass
from io import BytesIO
from typing import Self, Literal, List, Dict, Any, Tuple
from pptx import Presentation
from pptx.enum.dml import MSO_COLOR_TYPE
from pptx.enum.shapes import MSO_SHAPE_TYPE
from pptx.enum.text import MSO_AUTO_SIZE
from pptx.oxml.ns import qn
@@ -17,6 +17,59 @@ from docutranslate.ir.document import Document
from docutranslate.translator.ai_translator.base import AiTranslatorConfig, AiTranslator
# ---------------- 辅助工具类:语言与字体智能适配 ----------------
class LanguageHelper:
"""
专门处理 PPTX 的语言标签与字体渲染适配。
利用 regex 库的 Unicode 属性检测脚本类型。
"""
# 常用语言映射 (覆盖常见写法)
_COMMON_MAP = {
"chinese": "zh-CN", "simplified chinese": "zh-CN", "zh": "zh-CN",
"english": "en-US", "en": "en-US",
"japanese": "ja-JP", "ja": "ja-JP",
"korean": "ko-KR", "ko": "ko-KR",
"french": "fr-FR", "fr": "fr-FR",
"german": "de-DE", "de": "de-DE",
"spanish": "es-ES", "es": "es-ES",
"russian": "ru-RU", "ru": "ru-RU",
# ... 其他语言
}
# [关键改进] 使用 regex 库的 Unicode 属性进行精确匹配
# \p{Han}: 汉字
# \p{Hiragana} / \p{Katakana}: 日文假名
# \p{Hangul}: 韩文
# 如果包含这些字符,说明需要启用东亚字体渲染
_CJK_PATTERN = regex.compile(r'[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]')
@classmethod
def guess_lang_tag(cls, config_lang: str, text_content: str) -> str:
"""
根据用户配置和实际文本内容,推断最合适的 PPT XML lang 属性。
"""
# 1. 优先尝试解析用户配置
if config_lang:
clean_lang = config_lang.lower().strip()
if clean_lang in cls._COMMON_MAP:
return cls._COMMON_MAP[clean_lang]
# 如果看起来像 ISO 代码 (如 'fr-FR'), 直接信赖
if regex.match(r'^[a-z]{2,3}(-[a-z0-9]+)?$', clean_lang):
return config_lang
# 2. [兜底策略] 基于内容的脚本检测
# 使用 regex 检查是否包含中日韩字符
if cls._CJK_PATTERN.search(text_content):
# 包含 CJK 字符 -> 声明为中文,激活东亚字体槽 (a:ea)
# 即使是日文/韩文,设为 zh-CN 在字体回退机制上通常也能正确激活 CJK 渲染逻辑
return "zh-CN"
else:
# 不含 CJK -> 默认为英文,激活西文字体槽 (a:latin)
# 这涵盖了英文、法文、德文、俄文、越南语等绝大多数非 CJK 语言
return "en-US"
# ---------------- 配置类 ----------------
@dataclass
class PPTXTranslatorConfig(AiTranslatorConfig):
@@ -27,13 +80,8 @@ class PPTXTranslatorConfig(AiTranslatorConfig):
# ---------------- 主类 ----------------
class PPTXTranslator(AiTranslator):
"""
基于 python-pptx 的 .pptx 文件翻译器 (增强版)。
改进特性:
1. 深度遍历:支持母版、版式、备注页、以及隐藏在 AlternateContent (兼容性块) 中的文本。
2. 公式保护:智能检测文本间的公式,防止翻译后文字错位。
3. 样式保留:翻译后完全保留原有的中英文字体设置,不做强制覆盖。
4. 布局自适应:防止翻译后文本溢出。
基于 python-pptx 的 .pptx 文件翻译器 (最终增强版)。
使用 regex 库进行高性能的脚本检测。
"""
def __init__(self, config: PPTXTranslatorConfig):
@@ -56,80 +104,92 @@ class PPTXTranslator(AiTranslator):
self.insert_mode = config.insert_mode
self.separator = config.separator
# ---------------- 辅助函数:样式与字体 ----------------
# ---------------- 辅助函数:视觉样式 ----------------
def _get_font_signature(self, run) -> Tuple:
"""获取 Run 的字体样式签名,用于合并判断。"""
font = run.font
color_key = None
def _get_visual_style_signature(self, run) -> Tuple:
"""获取 Run 的视觉样式签名"""
r_element = run._r
rPr = r_element.rPr
# 稳健的颜色获取逻辑
if hasattr(font, 'color') and font.color:
try:
if font.color.type == MSO_COLOR_TYPE.RGB:
color_key = str(font.color.rgb)
elif font.color.type == MSO_COLOR_TYPE.THEME:
color_key = f"THEME_{font.color.theme_color}_{font.color.brightness}"
except AttributeError:
pass
if rPr is None:
return ("DEFAULT",)
return (
font.name,
font.size,
font.bold,
font.italic,
font.underline,
color_key
)
def get_bool_attr(tag_name):
node = rPr.find(qn(f'a:{tag_name}'))
if node is None: return None
val = node.get('val')
return val if val is not None else '1'
bold = get_bool_attr('b')
italic = get_bool_attr('i')
u_node = rPr.find(qn('a:u'))
underline = u_node.get('val') if u_node is not None else None
strike_node = rPr.find(qn('a:strike'))
strike = strike_node.get('val') if strike_node is not None else None
sz = rPr.get('sz')
latin = rPr.find(qn('a:latin'))
latin_face = latin.get('typeface') if latin is not None else None
ea = rPr.find(qn('a:ea'))
ea_face = ea.get('typeface') if ea is not None else None
color_sig = "INHERITED"
for tag in ['solidFill', 'gradFill', 'noFill', 'blipFill', 'pattFill']:
fill_node = rPr.find(qn(f'a:{tag}'))
if fill_node is not None:
parts = [tag]
for child in fill_node:
val = child.get('val') or ""
parts.append(f"{child.tag.split('}')[-1]}:{val}")
color_sig = "-".join(parts)
break
baseline = rPr.get('baseline')
effect_sig = []
for tag in ['highlight', 'effectLst', 'sp3d']:
if rPr.find(qn(f'a:{tag}')) is not None:
effect_sig.append(tag)
return (bold, italic, underline, strike, sz, latin_face, ea_face, baseline, color_sig,
tuple(sorted(effect_sig)))
def _have_same_significant_styles(self, run1, run2) -> bool:
"""检查两个 Run 是否样式相同且在 XML 结构上紧邻(中间无公式)。"""
if run1 is None or run2 is None:
return False
# 1. 检查视觉样式是否一致
if self._get_font_signature(run1) != self._get_font_signature(run2):
return False
# 2. 检查 XML 邻接性
# 如果 run1 和 run2 之间夹杂了 <m:oMath> (公式) 或其他标签,
# 它们的 XML 索引将不连续。此时必须切分,否则回填时文字会跑到公式前面。
"""检查两个 Run 是否样式一致且紧邻"""
if run1 is None or run2 is None: return False
if self._get_visual_style_signature(run1) != self._get_visual_style_signature(run2): return False
try:
r1_element = run1._r
r2_element = run2._r
parent = r1_element.getparent()
# 只有当它们属于同一个父节点且索引差为1时才视为紧邻
if parent == r2_element.getparent():
index1 = parent.index(r1_element)
index2 = parent.index(r2_element)
if index2 != index1 + 1:
return False # 中间有东西(如公式),禁止合并
if parent != r2_element.getparent(): return False
if parent.index(r2_element) != parent.index(r1_element) + 1: return False
except Exception:
# 如果底层操作失败,保守起见不合并
return False
return True
def _apply_lang_correction(self, run, text_content: str):
"""[智能修正] 根据配置和文本内容,设置正确的 lang 属性"""
if not text_content: return
best_lang = LanguageHelper.guess_lang_tag(self.config.to_lang, text_content)
if best_lang:
rPr = run._r.get_or_add_rPr()
rPr.set('lang', best_lang)
rPr.set('altLang', best_lang)
# ---------------- 核心遍历逻辑 ----------------
def _process_text_frame(self, text_frame: TextFrame, elements: List[Dict[str, Any]], texts: List[str]):
"""处理 TextFrame 中的所有段落"""
for paragraph in text_frame.paragraphs:
self._process_paragraph(paragraph, elements, texts)
def _process_paragraph(self, paragraph: _Paragraph, elements: List[Dict[str, Any]], texts: List[str]):
"""处理单个段落,智能切分文本"""
if not paragraph.runs:
return
if not paragraph.runs: return
current_runs = []
state = {'current_runs': []}
def flush_segment():
if not current_runs:
return
current_runs = state['current_runs']
if not current_runs: return
full_text = "".join(r.text for r in current_runs)
# 只有非空文本才翻译
if full_text.strip():
elements.append({
"type": "text_runs",
@@ -141,29 +201,20 @@ class PPTXTranslator(AiTranslator):
current_runs.clear()
for run in paragraph.runs:
# 这里的 run.text 只有纯文本,不包含公式内容
if not run.text:
continue
last_run = current_runs[-1] if current_runs else None
# 样式不同 或 物理位置不连续(中间有公式)则切分
if not run.text: continue
last_run = state['current_runs'][-1] if state['current_runs'] else None
if last_run and not self._have_same_significant_styles(last_run, run):
flush_segment()
current_runs.append(run)
state['current_runs'].append(run)
flush_segment()
def _process_shape(self, shape, elements: List[Dict[str, Any]], texts: List[str]):
"""递归处理常规形状"""
# 1. 组合图形
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
for child_shape in shape.shapes:
self._process_shape(child_shape, elements, texts)
return
# 2. 表格
if shape.has_table:
for row in shape.table.rows:
for cell in row.cells:
@@ -171,7 +222,6 @@ class PPTXTranslator(AiTranslator):
self._process_text_frame(cell.text_frame, elements, texts)
return
# 3. 常规文本框
if shape.has_text_frame:
try:
self._process_text_frame(shape.text_frame, elements, texts)
@@ -179,120 +229,79 @@ class PPTXTranslator(AiTranslator):
pass
def _scan_deep_xml_for_text(self, slide_element, elements: List[Dict[str, Any]], texts: List[str]):
"""
[深度扫描] 直接遍历 XML 树,寻找标准 API 无法触及的文本。
修复了 KeyError: 'mc' 问题。
"""
# 定义 XML 命名空间 URI
MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
# 手动构建带命名空间的标签名,不依赖 qn()
MC_ALT = f"{{{MC_NS}}}AlternateContent"
MC_CHOICE = f"{{{MC_NS}}}Choice"
# 对于 'p' (PresentationML) 命名空间python-pptx 支持 qn可以继续使用
P_SP = qn('p:sp')
P_TXBODY = qn('p:txBody')
# 查找所有 AlternateContent 块
for alt_content in slide_element.iter(MC_ALT):
# 找到 Choice 分支
choice = alt_content.find(MC_CHOICE)
if choice is None:
continue
# 在 Choice 内部寻找形状 (p:sp)
if choice is None: continue
for sp in choice.iter(P_SP):
# 寻找 p:txBody (文本主体)
txBody = sp.find(P_TXBODY)
if txBody is not None:
try:
# 手动构建 TextFrame 对象
# 这里的 parent 设为 None 在读取/写入 text 属性时通常是安全的
tf = TextFrame(txBody, None)
self._process_text_frame(tf, elements, texts)
except Exception as e:
self.logger.warning(f"处理深度 XML 文本框时出错: {e}")
self.logger.warning(f"Deep XML Scan Error: {e}")
def _scan_presentation_content(self, prs: Presentation, elements: List[Dict[str, Any]], texts: List[str]):
"""全量扫描 PPT 内容"""
# 辅助内部函数:扫描单个“幻灯片类”对象
def scan_slide_object(slide_obj):
# 1. 常规 API 遍历 (处理普通文本、表格、组合)
for shape in slide_obj.shapes:
self._process_shape(shape, elements, texts)
# 2. 深度 XML 遍历 (处理 AlternateContent/公式文本)
self._scan_deep_xml_for_text(slide_obj.element, elements, texts)
# 1. 遍历普通幻灯片 (Slides)
for slide in prs.slides:
scan_slide_object(slide)
# 备注页
if slide.has_notes_slide:
notes = slide.notes_slide
if notes.notes_text_frame:
self._process_text_frame(notes.notes_text_frame, elements, texts)
if slide.has_notes_slide and slide.notes_slide.notes_text_frame:
self._process_text_frame(slide.notes_slide.notes_text_frame, elements, texts)
# 2. 遍历母版 (Slide Masters)
for master in prs.slide_masters:
scan_slide_object(master)
# 3. 遍历版式 (Layouts)
for layout in master.slide_layouts:
scan_slide_object(layout)
# ---------------- 翻译前后处理 ----------------
# ---------------- 翻译逻辑 ----------------
def _pre_translate(self, document: Document) -> Tuple[Presentation, List[Dict[str, Any]], List[str]]:
"""解析 PPT 文件"""
prs = Presentation(BytesIO(document.content))
elements, texts = [], []
self._scan_presentation_content(prs, elements, texts)
self.logger.info(f"共提取了 {len(texts)} 个文本片段 (包含隐藏的公式文本)。")
self.logger.info(f"Extracted {len(texts)} text segments.")
return prs, elements, texts
def _apply_translation(self, element_info: Dict[str, Any], final_text: str):
"""回填翻译,精细控制样式"""
runs = element_info["runs"]
if not runs:
return
if not runs: return
original_text = "".join(r.text for r in runs)
text_to_set = final_text
if self.insert_mode == "append":
text_to_set = original_text + self.separator + final_text
elif self.insert_mode == "prepend":
text_to_set = final_text + self.separator + original_text
# --- 回填策略 ---
primary_run = runs[0]
try:
# 1. 写入文本 (python-pptx 会自动保留原有的 rPr 属性,即保留默认字体)
primary_run.text = text_to_set
# 调用利用 regex 的智能修正
self._apply_lang_correction(primary_run, text_to_set)
# 2. (已移除字体强制设置逻辑,以保留 PPT 原样)
# 3. 处理溢出
text_frame = element_info.get("text_frame")
if text_frame and hasattr(text_frame, 'auto_size'):
if text_frame.auto_size == MSO_AUTO_SIZE.NONE:
text_frame.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
except Exception as e:
self.logger.warning(f"应用翻译到 Run 时出错: {e}")
self.logger.warning(f"Error applying translation: {e}")
return
# 清空后续 run (模拟合并效果)
for i in range(1, len(runs)):
runs[i].text = ""
def _after_translate(self, prs: Presentation, elements: List[Dict[str, Any]], translated: List[str],
originals: List[str]) -> bytes:
"""保存结果"""
if len(elements) != len(translated):
min_len = min(len(elements), len(translated))
elements = elements[:min_len]
@@ -305,26 +314,20 @@ class PPTXTranslator(AiTranslator):
prs.save(output_stream)
return output_stream.getvalue()
# ---------------- 接口实现 ----------------
# ---------------- 接口 ----------------
def translate(self, document: Document) -> Self:
prs, elements, originals = self._pre_translate(document)
if not originals:
self.logger.info("未找到可翻译文本。")
self.logger.info("No text found.")
document.content = self._after_translate(prs, elements, [], [])
return self
if self.glossary_agent:
# 1. 获取增量
glossary_dict_gen = self.glossary_agent.send_segments(originals, self.chunk_size)
# 2. 在 Translator 层统一合并 (SSOT)
if self.glossary:
self.glossary.update(glossary_dict_gen)
# 3. 将合并后的【完整字典】传给 Agent
if self.translate_agent and self.glossary:
self.translate_agent.update_glossary_dict(self.glossary.glossary_dict)
if self.glossary: self.glossary.update(glossary_dict_gen)
if self.translate_agent and self.glossary: self.translate_agent.update_glossary_dict(
self.glossary.glossary_dict)
translated = self.translate_agent.send_segments(originals,
self.chunk_size) if self.translate_agent else originals
@@ -334,21 +337,15 @@ class PPTXTranslator(AiTranslator):
async def translate_async(self, document: Document) -> Self:
prs, elements, originals = await asyncio.to_thread(self._pre_translate, document)
if not originals:
self.logger.info("未找到可翻译文本。")
self.logger.info("No text found.")
document.content = await asyncio.to_thread(self._after_translate, prs, elements, [], [])
return self
if self.glossary_agent:
# 1. 获取增量
glossary_dict_gen = await self.glossary_agent.send_segments_async(originals, self.chunk_size)
# 2. 在 Translator 层统一合并 (SSOT)
if self.glossary:
self.glossary.update(glossary_dict_gen)
# 3. 将合并后的【完整字典】传给 Agent
if self.translate_agent and self.glossary:
self.translate_agent.update_glossary_dict(self.glossary.glossary_dict)
if self.glossary: self.glossary.update(glossary_dict_gen)
if self.translate_agent and self.glossary: self.translate_agent.update_glossary_dict(
self.glossary.glossary_dict)
translated = await self.translate_agent.send_segments_async(originals,
self.chunk_size) if self.translate_agent else originals

View File

@@ -20,6 +20,9 @@ dependencies = [
"httpx>=0.28.1",
"python-pptx>=1.0.2",
"pypdf>=6.4.2",
"regex>=2025.11.3",
"charset-normalizer>=3.4.4",
"python-dotenv>=1.0.0",
]
dynamic = ["version"]

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%

14
uv.lock generated
View File

@@ -370,6 +370,7 @@ name = "docutranslate"
source = { editable = "." }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "charset-normalizer" },
{ name = "fastapi", extra = ["standard"] },
{ name = "httpx" },
{ name = "json-repair" },
@@ -382,7 +383,9 @@ dependencies = [
{ name = "pypdf" },
{ name = "pysubs2" },
{ name = "python-docx" },
{ name = "python-dotenv" },
{ name = "python-pptx" },
{ name = "regex" },
{ name = "srt" },
{ name = "xlsx2html" },
]
@@ -404,6 +407,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "beautifulsoup4", specifier = ">=4.13.4" },
{ name = "charset-normalizer", specifier = ">=3.4.4" },
{ name = "docling", marker = "extra == 'docling'", specifier = ">=2.40.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" },
{ name = "hf-xet", marker = "extra == 'docling'", specifier = ">=1.1.10" },
@@ -419,7 +423,9 @@ requires-dist = [
{ name = "pypdf", specifier = ">=6.4.2" },
{ name = "pysubs2", specifier = ">=1.8.0" },
{ name = "python-docx", specifier = ">=1.2.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-pptx", specifier = ">=1.0.2" },
{ name = "regex", specifier = ">=2025.11.3" },
{ name = "srt", specifier = ">=3.5.3" },
{ name = "xlsx2html", specifier = ">=0.6.2" },
]
@@ -1734,14 +1740,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 },
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441 },
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291 },
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632 },
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905 },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495 },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388 },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879 },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 },
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980 },
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865 },
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256 },

View File

@@ -1,5 +1,14 @@
更新日志
----------------
v1.6.3版 2025.1.18
特性
-UI/README增加越南语
修复
-修复前端界面gemini平台不可用的问题
优化
-优化pptx的翻译效果
-使用charset_normalizer替代chardet
----------------
v1.6.2版 2025.1.11
特性
- 支持mineruv2.7.1版本