Files
docutranslate/docutranslate/static/index.html
T

1 line
162 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN" data-bs-theme="auto">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n-title="pageTitle">DocuTranslate - 交互式文档翻译</title>
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
<!-- Bootstrap CSS -->
<link href="/static/bootstrap.css" rel="stylesheet" crossorigin="anonymous">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="/static/bootstrap-icons.css">
<style>
body {
background-color: var(--bs-body-bg);
}
.main-container {
display: flex;
flex-direction: column;
height: 100vh;
padding-top: 1rem;
padding-bottom: 1rem;
}
.settings-panel {
height: calc(100vh - 2rem);
overflow-y: auto;
padding-right: 15px; /* for scrollbar */
}
.task-area {
height: calc(100vh - 2rem);
overflow-y: auto;
}
.task-card {
transition: all 0.3s ease-in-out;
}
.task-id-placeholder {
color: var(--bs-secondary-color);
font-style: italic;
}
.log-area {
height: 150px;
background-color: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: .375rem;
font-family: monospace;
font-size: 0.8rem;
padding: 10px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.file-drop-area {
border: 2px dashed var(--bs-secondary-bg);
border-radius: .375rem;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s ease-in-out;
background-color: var(--bs-body-bg);
}
.file-drop-area.drag-over {
border-color: var(--bs-primary);
background-color: var(--bs-secondary-bg);
}
.file-drop-area.file-selected {
border-style: solid;
border-color: var(--bs-success);
background-color: var(--bs-success-bg-subtle);
}
.file-drop-area.input-error {
border-color: var(--bs-danger);
}
.file-name-display.input-error-text {
color: var(--bs-danger);
font-weight: bold;
}
#printFrame, #translatedPreviewFrame {
border: none;
width: 100%;
}
#previewOffcanvas {
--bs-offcanvas-width: 95vw;
max-width: 1600px;
}
.preview-split-container {
display: flex;
flex-direction: row;
height: 90%;
}
.preview-pane-wrapper {
display: flex;
flex-direction: column;
overflow: hidden; /* Important for split.js */
}
.preview-pane-wrapper h6 {
flex-shrink: 0;
padding: 0.25rem;
}
.preview-pane-wrapper .preview-pane {
flex-grow: 1; /* Make the inner pane grow */
border: 1px solid var(--bs-border-color);
border-radius: .375rem;
overflow: auto;
}
.gutter {
background-color: var(--bs-tertiary-bg);
background-repeat: no-repeat;
background-position: 50%;
}
.gutter.gutter-horizontal {
cursor: col-resize;
border-left: 1px solid var(--bs-border-color);
border-right: 1px solid var(--bs-border-color);
}
.gutter.gutter-vertical {
cursor: row-resize;
border-top: 1px solid var(--bs-border-color);
border-bottom: 1px solid var(--bs-border-color);
}
.preview-pane iframe, .preview-pane pre {
width: 100%;
height: 95%;
border: none;
margin: 0;
padding: 0;
overflow: auto;
background-color: var(--bs-body-bg);
}
.slider-reset-btn {
visibility: hidden;
}
.bottom-left-controls {
position: fixed;
bottom: 1rem;
left: 1rem;
z-index: 1050;
display: flex;
gap: 0.5rem;
}
@media (max-width: 991.98px) {
.main-container {
height: auto;
padding-bottom: 6rem;
}
.settings-panel, .task-area {
height: auto;
overflow-y: visible;
}
.settings-panel {
padding-right: 0;
margin-bottom: 2rem;
}
}
@media (max-width: 767.98px) {
.task-card .col-md-7 {
margin-top: 1.5rem;
}
#previewOffcanvas {
--bs-offcanvas-width: 100vw;
}
}
</style>
</head>
<body>
<div class="container-fluid main-container">
<div class="row gx-4">
<!-- Left: Settings Panel -->
<div class="col-lg-4">
<div class="settings-panel">
<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"><i class="bi"></i>DocuTranslate</h4>
<span id="versionDisplay" class="badge bg-success me-4"></span>
<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 data-i18n="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
data-i18n="projectContributeBtn">项目协作</span>
</button>
</div>
</div>
</div>
<form id="translateForm">
<div class="accordion" id="settingsAccordion">
<!-- Workflow Selection -->
<div class="accordion-item" id="workflowSettingsContainer">
<h2 class="accordion-header" id="headingZero">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseZero" aria-expanded="true"
aria-controls="collapseZero">
<strong><i class="bi bi-diagram-3 me-2"></i><span
data-i18n="workflowTitle">1. 选择工作流</span></strong>
</button>
</h2>
<div id="collapseZero" class="accordion-collapse collapse show"
aria-labelledby="headingZero">
<div class="accordion-body">
<select class="form-select" id="workflowTypeSelect">
<option value="markdown_based" data-i18n="workflowOptionMarkdown">转Markdown再翻译
(.pdf/.md/.png等)
</option>
<option value="txt" data-i18n="workflowOptionTxt">纯文本翻译 (.txt)</option>
<option value="json" data-i18n="workflowOptionJson">JSON翻译 (.json)</option>
<option value="docx" data-i18n="workflowOptionDocx">DOCX翻译 (.docx)</option>
<option value="xlsx" data-i18n="workflowOptionXlsx">XLSX翻译 (.xlsx/.csv)</option>
<option value="srt" data-i18n="workflowOptionSrt">SRT字幕翻译 (.srt)</option>
<option value="epub" data-i18n="workflowOptionEpub">EPUB翻译 (.epub)</option>
<option value="html" data-i18n="workflowOptionHtml">HTML翻译 (.html)</option>
</select>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" role="switch"
id="autoWorkflowSwitch">
<label class="form-check-label" for="autoWorkflowSwitch"
data-i18n="autoWorkflowLabel">自动选择工作流</label>
</div>
</div>
</div>
</div>
<!-- [NEW] DOCX Settings Container -->
<div class="accordion-item" id="docxSettingsContainer" style="display: none;">
<h2 class="accordion-header" id="headingDocx">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseDocx" aria-expanded="false"
aria-controls="collapseDocx">
<strong id="docxSettingsTitle"><i class="bi bi-file-earmark-word-fill me-2"></i>2.
DOCX翻译选项</strong>
</button>
</h2>
<div id="collapseDocx" class="accordion-collapse collapse" aria-labelledby="headingDocx">
<div class="accordion-body">
<div class="mb-3">
<label for="docx_insert_mode" class="form-label" data-i18n="insertModeLabel">插入模式</label>
<select class="form-select" id="docx_insert_mode" name="insert_mode">
<option value="replace" data-i18n="insertModeReplace">替换原文 (Replace)
</option>
<option value="append" data-i18n="insertModeAppend">附加到原文后 (Append)
</option>
<option value="prepend" data-i18n="insertModePrepend">附加到原文前
(Prepend)
</option>
</select>
<div class="form-text" data-i18n="insertModeHelpDocx">
选择如何将翻译后的文本插入。
</div>
</div>
<div class="mb-3" id="docxSeparatorGroup" style="display: none;">
<label for="docx_separator" class="form-label"
data-i18n="separatorLabel">分隔符</label>
<input type="text" class="form-control" id="docx_separator" name="separator"
data-i18n-placeholder="separatorPlaceholder"
placeholder="例如: \n---翻译---\n">
<div class="form-text" data-i18n="separatorHelp">
当插入模式为附加或前置时,用于分隔原文和译文的字符。<code>\n</code> 代表换行。
</div>
</div>
</div>
</div>
</div>
<!-- XLSX Settings Container -->
<div class="accordion-item" id="xlsxSettingsContainer" style="display: none;">
<h2 class="accordion-header" id="headingXlsx">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseXlsx" aria-expanded="false"
aria-controls="collapseXlsx">
<strong id="xlsxSettingsTitle"><i
class="bi bi-file-earmark-spreadsheet-fill me-2"></i>2.
XLSX翻译选项</strong>
</button>
</h2>
<div id="collapseXlsx" class="accordion-collapse collapse" aria-labelledby="headingXlsx">
<div class="accordion-body">
<div class="mb-3">
<label for="xlsx_insert_mode" class="form-label" data-i18n="insertModeLabel">插入模式</label>
<select class="form-select" id="xlsx_insert_mode" name="insert_mode">
<option value="replace" data-i18n="insertModeReplace">替换原文 (Replace)
</option>
<option value="append" data-i18n="insertModeAppend">附加到原文后 (Append)
</option>
<option value="prepend" data-i18n="insertModePrepend">附加到原文前
(Prepend)
</option>
</select>
<div class="form-text" data-i18n="insertModeHelpXlsx">
选择如何将翻译后的文本插入到单元格中。
</div>
</div>
<div class="mb-3" id="xlsxSeparatorGroup" style="display: none;">
<label for="xlsx_separator" class="form-label"
data-i18n="separatorLabel">分隔符</label>
<input type="text" class="form-control" id="xlsx_separator" name="separator"
data-i18n-placeholder="separatorPlaceholderSimple"
placeholder="例如: \n---\n">
<div class="form-text" data-i18n="separatorHelp">
当插入模式为附加或前置时,用于分隔原文和译文的字符。<code>\n</code> 代表换行。
</div>
</div>
<div class="mb-3">
<label for="xlsx_translate_regions" class="form-label"
data-i18n="xlsxTranslateRegionsLabel">翻译区域 (可选)</label>
<textarea class="form-control" id="xlsx_translate_regions"
name="translate_regions" rows="3"
data-i18n-placeholder="xlsxTranslateRegionsPlaceholder"
placeholder="每行一个区域, 例如:Sheet1!A1:B10(不指定表名则对所有表生效)"></textarea>
</div>
</div>
</div>
</div>
<!-- [NEW] SRT Settings Container -->
<div class="accordion-item" id="srtSettingsContainer" style="display: none;">
<h2 class="accordion-header" id="headingSrt">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseSrt" aria-expanded="false"
aria-controls="collapseSrt">
<strong id="srtSettingsTitle"><i class="bi bi-file-text me-2"></i>2.
SRT翻译选项</strong>
</button>
</h2>
<div id="collapseSrt" class="accordion-collapse collapse" aria-labelledby="headingSrt">
<div class="accordion-body">
<div class="mb-3">
<label for="srt_insert_mode" class="form-label"
data-i18n="insertModeLabel">插入模式</label>
<select class="form-select" id="srt_insert_mode" name="insert_mode">
<option value="replace" data-i18n="insertModeReplace">替换原文 (Replace)
</option>
<option value="append" data-i18n="insertModeAppend">附加到原文后 (Append)
</option>
<option value="prepend" data-i18n="insertModePrepend">附加到原文前
(Prepend)
</option>
</select>
<div class="form-text" data-i18n="insertModeHelpSrt">
选择如何将翻译后的文本插入。
</div>
</div>
<div class="mb-3" id="srtSeparatorGroup" style="display: none;">
<label for="srt_separator" class="form-label"
data-i18n="separatorLabel">分隔符</label>
<input type="text" class="form-control" id="srt_separator" name="separator"
data-i18n-placeholder="separatorPlaceholderSimple"
placeholder="例如: \n---\n">
<div class="form-text" data-i18n="separatorHelp">
当插入模式为附加或前置时,用于分隔原文和译文的字符。<code>\n</code> 代表换行。
</div>
</div>
</div>
</div>
</div>
<!-- [NEW] EPUB Settings Container -->
<div class="accordion-item" id="epubSettingsContainer" style="display: none;">
<h2 class="accordion-header" id="headingEpub">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseEpub" aria-expanded="false"
aria-controls="collapseEpub">
<strong id="epubSettingsTitle"><i class="bi bi-book me-2"></i>2.
EPUB翻译选项</strong>
</button>
</h2>
<div id="collapseEpub" class="accordion-collapse collapse" aria-labelledby="headingEpub">
<div class="accordion-body">
<div class="mb-3">
<label for="epub_insert_mode" class="form-label" data-i18n="insertModeLabel">插入模式</label>
<select class="form-select" id="epub_insert_mode" name="insert_mode">
<option value="replace" data-i18n="insertModeReplace">替换原文 (Replace)
</option>
<option value="append" data-i18n="insertModeAppend">附加到原文后 (Append)
</option>
<option value="prepend" data-i18n="insertModePrepend">附加到原文前
(Prepend)
</option>
</select>
<div class="form-text" data-i18n="insertModeHelpEpub">
选择如何将翻译后的文本插入。
</div>
</div>
<div class="mb-3" id="epubSeparatorGroup" style="display: none;">
<label for="epub_separator" class="form-label"
data-i18n="separatorLabel">分隔符</label>
<input type="text" class="form-control" id="epub_separator" name="separator"
data-i18n-placeholder="separatorPlaceholderSimple"
placeholder="例如: \n---\n">
<div class="form-text" data-i18n="separatorHelp">
当插入模式为附加或前置时,用于分隔原文和译文的字符。<code>\n</code> 代表换行。
</div>
</div>
</div>
</div>
</div>
<!-- [NEW] HTML Settings Container -->
<div class="accordion-item" id="htmlSettingsContainer" style="display: none;">
<h2 class="accordion-header" id="headingHtml">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseHtml" aria-expanded="false"
aria-controls="collapseHtml">
<strong id="htmlSettingsTitle"><i class="bi bi-filetype-html me-2"></i>2.
HTML翻译选项</strong>
</button>
</h2>
<div id="collapseHtml" class="accordion-collapse collapse" aria-labelledby="headingHtml">
<div class="accordion-body">
<div class="mb-3">
<label for="html_insert_mode" class="form-label" data-i18n="insertModeLabel">插入模式</label>
<select class="form-select" id="html_insert_mode" name="insert_mode">
<option value="replace" data-i18n="insertModeReplace">替换原文 (Replace)
</option>
<option value="append" data-i18n="insertModeAppend">附加到原文后 (Append)
</option>
<option value="prepend" data-i18n="insertModePrepend">附加到原文前
(Prepend)
</option>
</select>
<div class="form-text" data-i18n="insertModeHelpHtml">
选择如何将翻译后的文本插入。
</div>
</div>
<div class="mb-3" id="htmlSeparatorGroup" style="display: none;">
<label for="html_separator" class="form-label"
data-i18n="separatorLabel">分隔符</label>
<input type="text" class="form-control" id="html_separator" name="separator"
data-i18n-placeholder="separatorPlaceholderSimple"
placeholder="例如: <!-- translated -->">
<div class="form-text" data-i18n="separatorHelp">
当插入模式为附加或前置时,用于分隔原文和译文的字符。<code>\n</code> 代表换行。
</div>
</div>
</div>
</div>
</div>
<!-- JSON Paths Settings Container -->
<div class="accordion-item" id="jsonSettingsContainer" style="display: none;">
<h2 class="accordion-header" id="headingJson">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseJson" aria-expanded="false"
aria-controls="collapseJson">
<strong id="jsonSettingsTitle"><i class="bi bi-signpost-split me-2"></i>2. JSON路径配置</strong>
</button>
</h2>
<div id="collapseJson" class="accordion-collapse collapse" aria-labelledby="headingJson">
<div class="accordion-body">
<div class="mb-3">
<label for="json_paths_textarea" class="form-label" data-i18n="jsonPathLabel">需要翻译的JSON路径</label>
<textarea class="form-control" id="json_paths_textarea" name="json_paths"
rows="4" required
data-i18n-placeholder="jsonPathPlaceholder" placeholder="每行一个路径, 例如:
$.name
$.*"></textarea>
<div class="form-text" data-i18n="jsonPathHelp">
采用<code>jsonpath-ng</code>的路径选择语法,每一行表示一个json路径
</div>
</div>
</div>
</div>
</div>
<!-- Parsing Engine Settings Container -->
<div class="accordion-item" id="parsingSettingsContainer">
<h2 class="accordion-header" id="headingOne">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseOne" aria-expanded="false" aria-controls="collapseOne">
<strong id="parsingSettingsTitle"><i class="bi bi-file-earmark-binary me-2"></i>2.
解析配置</strong>
</button>
</h2>
<div id="collapseOne" class="accordion-collapse collapse" aria-labelledby="headingOne">
<div class="accordion-body">
<div class="mb-3">
<label for="convert_engine" class="form-label" data-i18n="parsingEngineLabel">解析引擎</label>
<select class="form-select" id="convert_engine" name="convert_engine">
<!-- Options will be populated by JS -->
</select>
<div class="form-text" data-i18n="parsingEngineHelp">
如果上传的文件本身是.md格式,此项可不选。
</div>
</div>
<div class="mb-3" id="mineruTokenGroup">
<label for="mineru_token" class="form-label">
Mineru Token
<a href="https://mineru.net/apiManage/token" target="_blank" class="ms-1"
data-i18n-title="getMineruTokenTitle" title="获取Mineru Token"><i
class="bi bi-box-arrow-up-right"></i></a>
</label>
<div class="input-group">
<input type="password" class="form-control" id="mineru_token"
name="mineru_token" data-i18n-placeholder="mineruTokenPlaceholder"
placeholder="使用Mineru引擎时需要">
<button class="btn btn-outline-secondary toggle-password" type="button"
data-target="mineru_token">
<i class="bi bi-eye-slash"></i>
</button>
</div>
</div>
<!-- NEW: Mineru Model Version -->
<div class="mb-3" id="mineruModelVersionGroup">
<label for="model_version" class="form-label" data-i18n="modelVersionLabel">Mineru
模型版本</label>
<select class="form-select" id="model_version" name="model_version">
<option value="vlm" data-i18n="modelVersionVlm">VLM</option>
<option value="pipeline" data-i18n="modelVersionPipline">Pipeline</option>
</select>
<div class="form-text" data-i18n="modelVersionHelp">
mineru VLM是更新的内测模型。
</div>
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" role="switch"
id="formula_ocr"
name="formula_ocr" checked>
<label class="form-check-label" for="formula_ocr" data-i18n="formulaOcrLabel">公式识别</label>
</div>
<div class="form-check form-switch mb-2" id="codeOcrSwitch">
<input class="form-check-input" type="checkbox" role="switch" id="code_ocr"
name="code_ocr" checked>
<label class="form-check-label" for="code_ocr"
data-i18n="codeOcrLabel">代码识别</label>
</div>
</div>
</div>
</div>
<!-- AI Settings -->
<div class="accordion-item">
<h2 class="accordion-header" id="headingTwo">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo">
<strong id="aiSettingsTitle"><i class="bi bi-robot me-2"></i>3. 翻译模型</strong>
</button>
</h2>
<div id="collapseTwo" class="accordion-collapse collapse" aria-labelledby="headingTwo">
<div class="accordion-body">
<div class="mb-3">
<label for="platform_select" class="form-label"
data-i18n="platformLabel">选择平台</label>
<select class="form-select" id="platform_select">
<option value="custom" data-i18n="platformCustom">自定义接口</option>
<option value="https://api.openai.com/v1">OpenAI</option>
<option value="https://generativelanguage.googleapis.com/v1beta/openai/">
Gemini
</option>
<option value="https://open.bigmodel.cn/api/paas/v4">智谱AI</option>
<option value="https://api.deepseek.com/v1">DeepSeek</option>
<option value="https://dashscope.aliyuncs.com/compatible-mode/v1">
阿里云百炼
</option>
<option value="https://www.dmxapi.cn/v1">DMXAPI</option>
<option value="https://openrouter.ai/api/v1">OpenRouter</option>
<option value="https://ark.cn-beijing.volces.com/api/v3">火山引擎</option>
<option value="https://api.siliconflow.cn/v1">硅基流动</option>
</select>
</div>
<div class="mb-3" id="baseUrlGroup">
<label for="base_url" class="form-label" data-i18n="baseUrlLabel">API 地址 (Base
URL)</label>
<input type="url" class="form-control" id="base_url" name="base_url" required
data-i18n-placeholder="baseUrlPlaceholder" placeholder="OpenAi兼容地址">
</div>
<div class="mb-3">
<label for="api_key" class="form-label">
API Key
<a href="#" target="_blank" class="ms-1" id="api_href"
data-i18n-title="getApiKeyTitle" title="获取API Key"><i
class="bi bi-box-arrow-up-right"></i></a>
</label>
<div class="input-group">
<input type="password" class="form-control" id="api_key" name="api_key"
required
data-i18n-placeholder="apiKeyPlaceholder"
placeholder="请输入您的API Key">
<button class="btn btn-outline-secondary toggle-password" type="button"
data-target="api_key">
<i class="bi bi-eye-slash"></i>
</button>
</div>
</div>
<div class="mb-3">
<label for="model_id" class="form-label" data-i18n="modelIdLabel">模型
ID</label>
<input type="text" class="form-control" id="model_id" name="model_id" required
data-i18n-placeholder="modelIdPlaceholder"
placeholder="例如: gpt-4o, glm-4">
</div>
</div>
</div>
</div>
<!-- Translation Settings -->
<div class="accordion-item">
<h2 class="accordion-header" id="headingThree">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseThree" aria-expanded="false"
aria-controls="collapseThree">
<strong id="translationSettingsTitle"><i class="bi bi-translate me-2"></i>4. 翻译配置</strong>
</button>
</h2>
<div id="collapseThree" class="accordion-collapse collapse" aria-labelledby="headingThree">
<div class="accordion-body">
<div class="mb-3">
<label for="to_lang" class="form-label"
data-i18n="targetLanguageLabel">目标语言</label>
<select class="form-select" id="to_lang" name="to_lang">
<option value="中文">中文(简体中文)</option>
<option value="英文">英文(English)</option>
<option value="西班牙文">西班牙文(Español)</option>
<option value="法文">法文(Français)</option>
<option value="德文">德文(Deutsch)</option>
<option value="日文">日文(日本語)</option>
<option value="韩文">韩文(한국어)</option>
<option value="俄文">俄文(Русский)</option>
<option value="葡萄牙文">葡萄牙文(Português)</option>
<option value="阿拉伯文">阿拉伯文(العَرَبِيَّة)</option>
<option value="越南文">越南文(tiếng Việt)</option>
<option value="custom" data-i18n="targetLanguageCustom">其它 (自定义)
</option>
</select>
<div class="mt-2" id="customLangGroup" style="display: none;">
<input type="text" class="form-control" id="custom_to_lang"
name="custom_to_lang" data-i18n-placeholder="customLangPlaceholder"
placeholder="请输入目标语言, 例如: Italian">
</div>
</div>
<div class="mb-3">
<label class="form-label" data-i18n="thinkingModeLabel">思考模式</label><i
class="bi bi-question-circle ms-2 tooltip-icon"
data-bs-toggle="tooltip"
data-bs-placement="top"
data-i18n-title="thinkingModeTooltip"
data-bs-title="设置混合推理模型的思考模式,目前支持智谱平台的glm-4.5系列、阿里云的qwen3系列、火山引擎的Doubao-Seed-1.6系列等">
</i>
<div id="thinkingModeBtnGroup" class="btn-group w-100" role="group"
aria-label="Thinking Mode Toggle">
<input type="radio" class="btn-check" name="thinking" id="thinkingEnable"
value="enable" autocomplete="off">
<label class="btn btn-outline-primary" for="thinkingEnable"
data-i18n="thinkingModeEnable">启用</label>
<input type="radio" class="btn-check" name="thinking" id="thinkingDisable"
value="disable" autocomplete="off">
<label class="btn btn-outline-primary" for="thinkingDisable"
data-i18n="thinkingModeDisable">禁用</label>
<input type="radio" class="btn-check" name="thinking" id="thinkingDefault"
value="default" autocomplete="off">
<label class="btn btn-outline-primary" for="thinkingDefault"
data-i18n="thinkingModeDefault">默认</label>
</div>
</div>
<div class="mb-3">
<label for="custom_prompt" class="form-label" data-i18n="customPromptLabel">自定义Prompt</label>
<textarea class="form-control" id="custom_prompt"
name="custom_prompt" rows="3"
data-i18n-placeholder="customPromptPlaceholder"
placeholder="可选,如“人名保持原文不翻译”"></textarea>
</div>
<!-- MOVED FROM ADVANCED SETTINGS -->
<div class="mb-3 border-top pt-3">
<label for="chunk-size-slider"
class="form-label d-flex justify-content-between">
<span><span data-i18n="chunkSizeLabel">分块大小</span>: <span
id="chunk-size-display"></span></span>
<button type="button"
class="btn btn-sm btn-outline-secondary py-0 px-1 slider-reset-btn"
id="chunk-size-reset" data-i18n="resetBtn">重置
</button>
</label>
<input type="range" class="form-range" min="1000" max="8000" step="100"
id="chunk-size-slider" name="chunk_size">
</div>
<div class="mb-3">
<label for="concurrent-slider"
class="form-label d-flex justify-content-between">
<span><span data-i18n="concurrentLabel">并发数</span>: <span
id="concurrent-display"></span></span>
<button type="button"
class="btn btn-sm btn-outline-secondary py-0 px-1 slider-reset-btn"
id="concurrent-reset" data-i18n="resetBtn">重置
</button>
</label>
<input type="range" class="form-range" min="1" max="60" step="1"
id="concurrent-slider" name="concurrent">
</div>
<div class="mb-3">
<label for="temperature-slider"
class="form-label d-flex justify-content-between">
<span>Temperature: <span id="temperature-display"></span></span>
<button type="button"
class="btn btn-sm btn-outline-secondary py-0 px-1 slider-reset-btn"
id="temperature-reset" data-i18n="resetBtn">重置
</button>
</label>
<input type="range" class="form-range" min="0" max="2" step="0.1"
id="temperature-slider" name="temperature">
</div>
</div>
</div>
</div>
<!-- [MODIFIED] Glossary Generation Settings -->
<div class="accordion-item" id="glossaryGenerationContainer">
<h2 class="accordion-header" id="headingGlossaryGen">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseGlossaryGen" aria-expanded="false"
aria-controls="collapseGlossaryGen">
<strong id="glossaryGenSettingsTitle"><i class="bi bi-journal-bookmark-fill me-2"></i><span data-i18n="glossaryGenTitle">5. 术语表</span></strong>
</button>
</h2>
<div id="collapseGlossaryGen" class="accordion-collapse collapse" aria-labelledby="headingGlossaryGen">
<div class="accordion-body">
<!-- [MOVED] Glossary Section -->
<div class="mb-3">
<label for="glossary_files" class="form-label" data-i18n="glossaryLabel">术语表 (可选)</label>
<input class="form-control" type="file" id="glossary_files" multiple accept=".csv">
<div class="form-text" data-i18n="glossaryHelp">
选择一个或多个CSV文件。文件需包含'src'和'dst'两列标题,分别代表原文和译文。
</div>
<div class="btn-group mt-2" role="group">
<button type="button" class="btn btn-sm btn-outline-info" id="viewGlossaryBtn">
<i class="bi bi-card-list me-1"></i><span data-i18n="viewGlossaryBtn">查看术语表</span>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" id="clearGlossaryBtn">
<i class="bi bi-trash me-1"></i><span data-i18n="clearGlossaryBtn">清空</span>
</button>
</div>
</div>
<!-- Enable/Disable Switch -->
<div class="form-check form-switch mb-3 border-top pt-3">
<input class="form-check-input" type="checkbox" role="switch"
id="glossary_generate_enable" name="glossary_generate_enable">
<label class="form-check-label" for="glossary_generate_enable" data-i18n="glossaryGenEnableLabel">自动生成术语表</label>
</div>
<!-- Agent Config Options (conditionally displayed) -->
<div id="glossaryAgentOptionsContainer" style="display: none;">
<div class="mb-3">
<label class="form-label" data-i18n="glossaryGenConfigLabel">生成术语表配置</label>
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check" name="glossary_agent_config_choice" id="glossaryAgentSame" value="same" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="glossaryAgentSame" data-i18n="glossaryGenConfigSame">与翻译配置相同</label>
<input type="radio" class="btn-check" name="glossary_agent_config_choice" id="glossaryAgentCustom" value="custom" autocomplete="off">
<label class="btn btn-outline-primary" for="glossaryAgentCustom" data-i18n="glossaryGenConfigCustom">自定义</label>
</div>
</div>
<!-- Custom Agent Config Form (conditionally displayed) -->
<div id="glossaryAgentCustomConfigContainer" class="border p-3 rounded" style="display: none;">
<div class="mb-3">
<label for="glossary_agent_platform_select" class="form-label" data-i18n="platformLabel">选择平台</label>
<select class="form-select" id="glossary_agent_platform_select">
<!-- Options will be copied from main select -->
</select>
</div>
<div class="mb-3" id="glossaryAgentBaseUrlGroup" style="display: none;">
<label for="glossary_agent_baseurl" class="form-label" data-i18n="baseUrlLabel">API 地址 (Base URL)</label>
<input type="url" class="form-control" id="glossary_agent_baseurl" name="glossary_agent_baseurl" data-i18n-placeholder="baseUrlPlaceholder" placeholder="OpenAi兼容地址">
</div>
<div class="mb-3">
<label for="glossary_agent_key" class="form-label">API Key</label>
<div class="input-group">
<input type="password" class="form-control" id="glossary_agent_key" name="glossary_agent_key" data-i18n-placeholder="apiKeyPlaceholder" placeholder="请输入您的API Key">
<button class="btn btn-outline-secondary toggle-password" type="button" data-target="glossary_agent_key"><i class="bi bi-eye-slash"></i></button>
</div>
</div>
<div class="mb-3">
<label for="glossary_agent_model_id" class="form-label" data-i18n="modelIdLabel">模型 ID</label>
<input type="text" class="form-control" id="glossary_agent_model_id" name="glossary_agent_model_id" data-i18n-placeholder="modelIdPlaceholder" placeholder="例如: gpt-4-turbo, glm-4">
</div>
<div class="mb-3">
<label for="glossary-agent-chunk-size-slider" class="form-label d-flex justify-content-between">
<span><span data-i18n="chunkSizeLabel">分块大小</span>: <span id="glossary-agent-chunk-size-display"></span></span>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1 slider-reset-btn" id="glossary-agent-chunk-size-reset" data-i18n="resetBtn">重置</button>
</label>
<input type="range" class="form-range" min="1000" max="8000" step="100" id="glossary-agent-chunk-size-slider" name="glossary_agent_chunk_size">
</div>
<div class="mb-3">
<label for="glossary-agent-concurrent-slider" class="form-label d-flex justify-content-between">
<span><span data-i18n="concurrentLabel">并发数</span>: <span id="glossary-agent-concurrent-display"></span></span>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1 slider-reset-btn" id="glossary-agent-concurrent-reset" data-i18n="resetBtn">重置</button>
</label>
<input type="range" class="form-range" min="1" max="60" step="1" id="glossary-agent-concurrent-slider" name="glossary_agent_concurrent">
</div>
<div class="mb-3">
<label for="glossary-agent-temperature-slider" class="form-label d-flex justify-content-between">
<span>Temperature: <span id="glossary-agent-temperature-display">0.7</span></span>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1 slider-reset-btn" id="glossary-agent-temperature-reset" data-i18n="resetBtn">重置</button>
</label>
<input type="range" class="form-range" min="0" max="2" step="0.1" id="glossary-agent-temperature-slider" name="glossary_agent_temperature">
</div>
<div class="mb-3">
<label class="form-label" data-i18n="thinkingModeLabel">思考模式</label>
<div id="glossaryAgentThinkingModeBtnGroup" class="btn-group w-100" role="group">
<input type="radio" class="btn-check" name="glossary_agent_thinking" id="glossaryAgentThinkingEnable" value="enable" autocomplete="off">
<label class="btn btn-outline-primary" for="glossaryAgentThinkingEnable" data-i18n="thinkingModeEnable">启用</label>
<input type="radio" class="btn-check" name="glossary_agent_thinking" id="glossaryAgentThinkingDisable" value="disable" autocomplete="off">
<label class="btn btn-outline-primary" for="glossaryAgentThinkingDisable" data-i18n="thinkingModeDisable">禁用</label>
<input type="radio" class="btn-check" name="glossary_agent_thinking" id="glossaryAgentThinkingDefault" value="default" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="glossaryAgentThinkingDefault" data-i18n="thinkingModeDefault">默认</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
<!-- 项目信息 -->
<div class="mt-4 text-left text-muted small">
<p class="bi bi-github mb-1" data-i18n="githubInfo">
GitHub主页(欢迎star❤): <br/>
<a href="https://github.com/xunbu/docutranslate" target="_blank" rel="noopener noreferrer">https://github.com/xunbu/docutranslate</a>
</p>
<p class="bi bi-tencent-qq mb-0" data-i18n="qqGroupInfo">
交流QQ群: 1047781902
</p>
</div>
</div>
</div>
<!-- Right: Task Area -->
<div class="col-lg-8">
<div class="task-area" id="task-area-container">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0"><i class="bi bi-list-task me-2"></i><span data-i18n="taskListTitle">任务列表</span>
</h4>
<button class="btn btn-primary" id="addNewTaskBtn"><i class="bi bi-plus-circle-fill me-2"></i><span
data-i18n="newTaskBtn">新建任务</span>
</button>
</div>
<div id="task-container">
<!-- Task cards will be injected here -->
</div>
<div id="no-task-placeholder" class="text-center text-muted mt-5">
<i class="bi bi-journal-plus" style="font-size: 4rem;"></i>
<p class="mt-3" data-i18n="noTaskPlaceholder">当前没有任务,点击“新建任务”开始吧!</p>
</div>
</div>
</div>
</div>
</div>
<!-- Task Card Template -->
<template id="taskCardTemplate">
<div class="card mb-3 task-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-bold"><span data-i18n="taskCardIdLabel">任务 ID</span>: <code class="task-id-display"><span
class="task-id-placeholder" data-i18n="taskCardIdPlaceholder">等待提交...</span></code></span>
<button type="button" class="btn-close remove-task-btn" aria-label="Close"></button>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-5">
<input type="file" class="d-none file-input">
<div class="file-drop-area">
<div class="file-drop-default">
<i class="bi bi-cloud-arrow-up fs-1"></i>
<p class="file-drop-prompt mb-0" data-i18n="taskCardFileDrop">点击或拖拽文件到此处</p>
</div>
<div class="file-drop-selected" style="display: none;">
<i class="bi bi-check-circle-fill fs-1 text-success"></i>
<p class="mb-0 mt-2 fw-bold text-success" data-i18n="taskCardFileSelected">文件已选择</p>
</div>
</div>
<div class="file-name-display-wrapper mt-2" style="display: none;">
<span class="fw-bold" data-i18n="taskCardFilenameLabel">文件名: </span>
<span class="file-name-display text-success"></span>
</div>
</div>
<div class="col-md-7">
<h6><i class="bi bi-terminal me-2"></i><span data-i18n="taskCardLogLabel">日志</span></h6>
<div class="log-area"></div>
<div class="mt-2">
<div class="status-message-container">
<span class="status-message small text-muted"
data-i18n="taskCardStatusWaiting">等待上传文件...</span>
</div>
<div class="progress mt-1" role="progressbar" style="height: 5px; display: none;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
style="width: 100%"></div>
</div>
</div>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<div class="download-buttons" style="display: none;">
<button class="btn btn-sm btn-success preview-html-btn" style="display: none;"><i
class="bi bi-eye-fill me-1"></i><span data-i18n="taskCardPreviewBtn">预览</span>
</button>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-secondary dropdown-toggle" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="bi bi-download me-1"></i><span data-i18n="taskCardDownloadBtn">下载</span>
</button>
<ul class="dropdown-menu download-menu-container">
<!-- Populated by JavaScript -->
</ul>
</div>
<!-- [NEW] Attachment Button Group -->
<div class="btn-group attachment-btn-group" style="display: none;">
<button type="button" class="btn btn-sm btn-info dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-paperclip me-1"></i><span data-i18n="taskCardAttachmentBtn">附件</span>
</button>
<ul class="dropdown-menu attachment-menu-container">
<!-- Populated by JavaScript -->
</ul>
</div>
</div>
<button class="btn btn-primary start-translate-btn ms-auto"><i class="bi bi-play-fill me-1"></i><span
data-i18n="taskCardStartBtn">开始翻译</span>
</button>
</div>
</div>
</template>
<!-- Reusable Download Menu Template -->
<template id="downloadMenuTemplate">
<li class="download-item-md"><a class="dropdown-item" href="#"><i
class="bi bi-markdown-fill me-2"></i><span data-i18n="downloadMdEmbedded">Markdown(嵌图)</span></a></li>
<li class="download-item-md-zip"><a class="dropdown-item" href="#"><i
class="bi bi-file-zip-fill me-2"></i><span data-i18n="downloadMdZip">Markdown压缩包</span></a></li>
<li class="download-item-txt"><a class="dropdown-item" href="#"><i
class="bi bi-filetype-txt me-2"></i>TXT</a></li>
<li class="download-item-json"><a class="dropdown-item" href="#"><i
class="bi bi-filetype-json me-2"></i>JSON</a></li>
<li class="download-item-docx"><a class="dropdown-item" href="#"><i
class="bi bi-filetype-docx me-2"></i>DOCX</a></li>
<li class="download-item-xlsx"><a class="dropdown-item" href="#"><i
class="bi bi-filetype-xlsx me-2"></i>XLSX</a></li>
<li class="download-item-csv"><a class="dropdown-item" href="#"><i
class="bi bi-filetype-csv me-2"></i>CSV</a></li>
<li class="download-item-srt"><a class="dropdown-item" href="#"><i
class="bi bi-file-text me-2"></i>SRT</a></li>
<li class="download-item-epub"><a class="dropdown-item" href="#"><i
class="bi bi-book me-2"></i>EPUB</a></li>
<li class="download-item-html"><a class="dropdown-item" href="#"><i
class="bi bi-filetype-html me-2"></i>HTML</a></li>
<li class="download-item-pdf"><a class="dropdown-item" href="#"><i
class="bi bi-file-earmark-pdf-fill me-2"></i>PDF</a></li>
</template>
<!-- Preview Offcanvas with Resizable Panes -->
<div class="offcanvas offcanvas-end" data-bs-scroll="true" tabindex="-1" id="previewOffcanvas"
aria-labelledby="previewOffcanvasLabel">
<div class="offcanvas-header border-bottom">
<h5 class="offcanvas-title" id="previewOffcanvasLabel" data-i18n="previewTitle">预览</h5>
<div class="btn-group me-auto ms-4" role="group">
<button type="button" class="btn btn-sm btn-primary" id="setBilingualViewBtn"
data-i18n="previewBilingualBtn">双语
</button>
<button type="button" class="btn btn-sm btn-outline-primary" id="setTranslatedOnlyViewBtn"
data-i18n="previewTranslatedOnlyBtn">仅译文
</button>
</div>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body d-flex flex-column p-2">
<div class="preview-split-container flex-grow-1">
<div id="originalPreviewContainer" class="preview-pane-wrapper">
<h6 class="text-center text-muted small" data-i18n="previewOriginal">原文</h6>
<div class="preview-pane" id="originalPreviewPane"></div>
</div>
<div id="translatedPreviewContainer" class="preview-pane-wrapper">
<h6 class="text-center text-muted small" data-i18n="previewTranslated">译文</h6>
<div class="preview-pane">
<iframe id="translatedPreviewFrame" src="about:blank"></iframe>
</div>
</div>
</div>
<div class="offcanvas-footer mt-2 pt-3 border-top d-flex justify-content-end align-items-center">
<button type="button" class="btn btn-secondary" data-bs-dismiss="offcanvas" data-i18n="closeBtn">关闭
</button>
<div class="btn-group ms-2">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown"
aria-expanded="false" id="previewDownloadBtn">
<i class="bi bi-download me-1"></i><span data-i18n="downloadBtn">下载</span>
</button>
<ul class="dropdown-menu dropdown-menu-end" id="previewDownloadMenu">
<!-- Populated by JavaScript -->
</ul>
</div>
</div>
</div>
</div>
<!-- Hidden iframe for direct PDF printing -->
<iframe id="printFrame" style="display: none;"></iframe>
<!-- Bottom Left Controls -->
<div class="bottom-left-controls">
<!-- Language Switcher -->
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="language-switcher" data-bs-toggle="dropdown"
aria-expanded="false" title="切换语言 / Switch Language">
<i class="bi bi-translate"></i>
</button>
<ul class="dropdown-menu" aria-labelledby="language-switcher" id="languageMenu">
<li><a class="dropdown-item" href="#" data-lang="zh">中文</a></li>
<li><a class="dropdown-item" href="#" data-lang="en">English</a></li>
</ul>
</div>
<!-- Theme Switcher -->
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="theme-switcher" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="bi bi-circle-half"></i>
</button>
<ul class="dropdown-menu" aria-labelledby="theme-switcher">
<li>
<button class="dropdown-item" type="button" data-bs-theme-value="light"><i
class="bi bi-sun-fill me-2"></i>
Light
</button>
</li>
<li>
<button class="dropdown-item" type="button" data-bs-theme-value="dark"><i
class="bi bi-moon-stars-fill me-2"></i> Dark
</button>
</li>
<li>
<button class="dropdown-item active" type="button" data-bs-theme-value="auto"><i
class="bi bi-circle-half me-2"></i> Auto
</button>
</li>
</ul>
</div>
</div>
<!-- Tutorial Modal -->
<div class="modal fade" id="tutorialModal" tabindex="-1" aria-labelledby="tutorialModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="tutorialModalLabel"><i class="bi bi-book-half me-2"></i><span
data-i18n="tutorialModalTitle">使用教程</span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" data-i18n="tutorialModalBody">
<p><i class="bi bi-camera-video me-2"></i>视频教程可以在B站搜索 <a
href="https://search.bilibili.com/all?keyword=docutranslate" target="_blank">docutranslate</a>
获取。</p>
<p>欢迎使用 DocuTranslate!请按照以下步骤完成文档翻译:</p>
<ol>
<li>
<strong><i class="bi bi-diagram-3 me-2"></i>选择工作流</strong>
<p class="mt-2">
首先,在配置面板顶部选择您需要的翻译流程。不同的工作流适用于不同类型的文件:
<ul>
<li><b>转Markdown再翻译</b>: 适用于翻译PDF、markdown、图片等文件。</li>
<li><b>纯文本翻译</b>: 用于翻译 <code>.txt</code> 等纯文本文件。</li>
<li><b>JSON翻译</b>: 用于翻译 <code>.json</code> 文件中的特定字段。</li>
<li><b>DOCX翻译</b>: 用于翻译 <code>.docx</code> 文件。</li>
<li><b>XLSX翻译</b>: 用于翻译 <code>.xlsx</code> 电子表格、 <code>.csv</code> 文件。</li>
<li><b>SRT字幕翻译</b>: 用于翻译 <code>.srt</code> 字幕文件。</li>
<li><b>EPUB翻译</b>: 用于翻译 <code>.epub</code> 电子书文件。</li>
<li><b>HTML翻译</b>: 用于翻译 <code>.html</code> 文件。</li>
</ul>
<div class="alert alert-info mt-2" role="alert">
<i class="bi bi-lightbulb-fill me-2"></i>新增功能:
"自动选择工作流"开关已默认开启。您只需上传文件,系统会自动为您匹配合适的工作流,简化操作。
</div>
</p>
</li>
<li>
<strong><i class="bi bi-gear-fill me-2"></i>配置参数</strong>
<p class="mt-2">根据您选择的工作流,完成相应的配置。所有配置项都会自动保存在您的浏览器中。</p>
<ul>
<li class="mb-2"><strong>解析配置</strong> (仅在“转Markdown再翻译”工作流下显示):
<ul class="mt-1">
<li><strong>解析引擎</strong>:
选择一个引擎将您的文件(如PDF)转换为适合翻译的Markdown格式。如果您的文件已经是Markdown格式,则无需选择。
</li>
<li><strong>Mineru Token</strong>: 如果您选择 <code>minerU</code> 引擎,需要在此处填入您的Token。
</li>
</ul>
</li>
<li class="mb-2"><strong>DOCX/XLSX/SRT/EPUB/HTML翻译选项</strong> (在对应工作流下显示):
<ul class="mt-1">
<li><strong>插入模式</strong>:
定义翻译结果如何放入文档或字幕。您可以选择直接“替换”原文,或是在原文之后“附加”,或是在原文之前“前置”。
</li>
<li><strong>分隔符</strong>: 当选择“附加”或“前置”模式时,此项用于在原文和译文之间插入分隔符。
</li>
</ul>
</li>
<li class="mb-2"><strong>JSON路径配置</strong> (仅在“JSON翻译”工作流下显示):
<ul class="mt-1">
<li><strong>需要翻译的JSON路径</strong>: 每行输入一个 <a
href="https://goessner.net/articles/JsonPath/" target="_blank">JSONPath</a>
表达式,指定需要翻译的字段。
</li>
<li>例如:<code>$..description</code>翻译所有键为description的值。<code>$.items[0].name</code>翻译第一个item的name值。
</li>
</ul>
</li>
<li class="mb-2"><strong>翻译模型</strong>:
<ul class="mt-1">
<li><strong>选择平台/API 地址/API Key/模型 ID</strong>:
配置您希望使用的AI翻译服务。
</li>
<li>模型ID参考平台文档,建议使用非推理模型或混合推理模型(关闭思考)。</li>
</ul>
</li>
<li class="mb-2"><strong>翻译配置</strong>:
<ul class="mt-1">
<li><strong>目标语言/自定义Prompt/术语表</strong>: 指定翻译的目标语言、附加指令以及用于保证特定名词翻译准确性的术语表。</li>
<li><strong>思考模式</strong>:设置混合推理模型是否进行思考,目前支持智谱的glm4.5系列、阿里云的qwen3系列、火山引擎的seed1.6系列,建议选择禁用思考。
</li>
<li><strong>分块大小/并发数/Temperature</strong>: 发给AI的分块大小、并发请求数和温度,通常保持默认即可。
</li>
</ul>
</li>
</ul>
</li>
<li>
<strong><i class="bi bi-file-earmark-arrow-up-fill me-2"></i>上传文件</strong>
<p class="mt-2">在右侧的任务列表中,点击或拖拽您的文档到文件上传区域。</p>
</li>
<li>
<strong><i class="bi bi-play-circle-fill me-2"></i>开始翻译</strong>
<p class="mt-2">文件选择成功后,点击任务卡片右下角的 <span
class="badge bg-primary">开始翻译</span> 按钮。系统将开始处理任务,您可以在日志区域查看实时进度。
</p>
</li>
<li>
<strong><i class="bi bi-check-circle-fill me-2"></i>查看与下载</strong>
<p class="mt-2">翻译完成后,任务卡片下方会出现操作按钮:</p>
<ul>
<li><span class="badge bg-success"><i class="bi bi-eye-fill me-1"></i>预览</span>:
在右侧滑出的面板中进行原文和译文的对照预览(仅作参考)。
</li>
<li><span class="badge bg-secondary"><i class="bi bi-download me-1"></i>下载</span>:
下载包括 PDF, DOCX, XLSX, HTML, Markdown 等多种格式的译文。
</li>
<li><span class="badge bg-info"><i class="bi bi-paperclip me-1"></i>附件</span>:
如果翻译过程中生成了附加文件(如术语表),可在此处下载。
</li>
</ul>
</li>
</ol>
<div class="alert alert-info mt-3" role="alert">
<i class="bi bi-info-circle-fill me-2"></i><strong>提示</strong>: 所有配置都会自动保存在您的浏览器本地,方便下次使用。
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" data-i18n="tutorialUnderstandBtn">
我明白了
</button>
</div>
</div>
</div>
</div>
<!-- Contributors Modal -->
<div class="modal fade" id="contributorsModal" tabindex="-1" aria-labelledby="contributorsModalLabel"
aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="contributorsModalLabel"><i
class="bi bi-heart-fill me-2 text-danger"></i><span
data-i18n="contributorsModalTitle">感谢贡献</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p data-i18n="contributorsPara1">DocuTranslate是一个开源项目!大家的需求与使用是项目进步的动力。</p>
<p data-i18n="contributorsPara2">感谢所有资助项目、提交代码与宝贵建议及给项目star的朋友们!</p>
<div class="alert alert-success mt-4" role="alert">
<p data-i18n="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 data-i18n="contributorsGithub">github 主页</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 data-i18n="contributorsPR">提交 Pull Request</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 data-i18n="contributorsIssue">报告 Issue</span>
</a>
</p>
<hr>
<p data-i18n="contributorsQQ">或者通过QQ群联系作者:<span>1047781902</span></p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="closeBtn">关闭
</button>
</div>
</div>
</div>
</div>
<!-- [NEW] Glossary Modal -->
<div class="modal fade" id="glossaryModal" tabindex="-1" aria-labelledby="glossaryModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="glossaryModalLabel" data-i18n="glossaryModalTitle">当前术语表</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col" data-i18n="glossaryTableSource">原文 (src)</th>
<th scope="col" data-i18n="glossaryTableDestination">译文 (dst)</th>
</tr>
</thead>
<tbody id="glossaryTableBody">
<!-- Content will be populated by JS -->
</tbody>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="closeBtn">关闭</button>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="/static/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
<!-- Split.js for resizable panes -->
<script src="/static/split.min.js"></script>
<!-- [NEW] PapaParse for CSV parsing -->
<script src="/static/papaparse.min.js"></script>
<script type="module">
// --- I18N Data ---
// Initialize with a minimal fallback for critical error messages.
// The full dataset will be loaded asynchronously in the init() function.
let i18nData = {
zh: {
init_i18n_failed_alert: '加载界面翻译资源失败,请检查网络连接或联系管理员。',
init_failed_alert: '初始化失败,无法连接到后端服务。请检查服务是否运行或刷新页面。',
clearGlossaryBtn: '清空',
glossaryEmpty: '术语表为空。',
taskCardAttachmentBtn: '附件'
},
en: {
init_i18n_failed_alert: 'Failed to load interface translations. Please check your network connection or contact an administrator.',
init_failed_alert: 'Initialization failed, could not connect to the backend service. Please ensure the service is running and refresh the page.',
clearGlossaryBtn: 'Clear',
glossaryEmpty: 'Glossary is empty.',
taskCardAttachmentBtn: 'Attachments'
}
};
let currentLang = 'zh'; // Global language state
// --- I18N Helper Functions ---
const getText = (key, fallback = '') => {
const translations = i18nData[currentLang] || i18nData.zh;
return translations[key] || fallback || key;
};
function setLanguage(lang) {
if (!i18nData[lang]) return;
currentLang = lang;
const translations = i18nData[lang];
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
// 1. Update all static elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.dataset.i18n;
if (translations[key] !== undefined) {
el.innerHTML = translations[key];
}
});
// 2. Update all placeholder attributes
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.dataset.i18nPlaceholder;
if (translations[key] !== undefined) {
el.placeholder = translations[key];
}
});
// 3. Update all title attributes (for tooltips and page title)
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.dataset.i18nTitle;
if (translations[key] !== undefined) {
if (el.tagName === 'TITLE') {
el.textContent = translations[key];
} else {
el.title = translations[key];
const tooltipInstance = bootstrap.Tooltip.getInstance(el);
if (tooltipInstance) {
tooltipInstance.setContent({'.tooltip-inner': translations[key]});
}
}
}
});
// 4. Update dynamic UI parts that are built with JS
updateWorkflowUI();
updateConvertEnginUI(true);
// 5. Update language switcher active state
const langMenu = document.getElementById('languageMenu');
langMenu.querySelectorAll('a.active').forEach(a => a.classList.remove('active'));
const activeLink = langMenu.querySelector(`a[data-lang="${lang}"]`);
if (activeLink) activeLink.classList.add('active');
// 6. Save preference
saveToStorage('ui_language', lang);
}
function initI18n() {
const langMenu = document.getElementById('languageMenu');
langMenu.querySelectorAll('a[data-lang]').forEach(a => {
a.addEventListener('click', (e) => {
e.preventDefault();
setLanguage(a.dataset.lang);
});
});
const savedLang = getFromStorage('ui_language') || (navigator.language.toLowerCase().startsWith('en') ? 'en' : 'zh');
setLanguage(savedLang);
}
// --- End I18N ---
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
// --- DOM Elements ---
const settingsForm = document.getElementById('translateForm');
// Workflow elements
const workflowTypeSelect = document.getElementById('workflowTypeSelect');
const autoWorkflowSwitch = document.getElementById('autoWorkflowSwitch');
const parsingSettingsContainer = document.getElementById('parsingSettingsContainer');
const parsingSettingsTitle = document.getElementById('parsingSettingsTitle');
const jsonSettingsContainer = document.getElementById('jsonSettingsContainer');
const jsonSettingsTitle = document.getElementById('jsonSettingsTitle');
const jsonPathsTextarea = document.getElementById('json_paths_textarea');
const xlsxSettingsContainer = document.getElementById('xlsxSettingsContainer');
const xlsxSettingsTitle = document.getElementById('xlsxSettingsTitle');
const xlsxInsertModeSelect = document.getElementById('xlsx_insert_mode');
const xlsxSeparatorGroup = document.getElementById('xlsxSeparatorGroup');
const xlsxSeparatorInput = document.getElementById('xlsx_separator');
const xlsxTranslateRegionsTextarea = document.getElementById('xlsx_translate_regions');
const docxSettingsContainer = document.getElementById('docxSettingsContainer');
const docxSettingsTitle = document.getElementById('docxSettingsTitle');
const docxInsertModeSelect = document.getElementById('docx_insert_mode');
const docxSeparatorGroup = document.getElementById('docxSeparatorGroup');
const docxSeparatorInput = document.getElementById('docx_separator');
const srtSettingsContainer = document.getElementById('srtSettingsContainer');
const srtSettingsTitle = document.getElementById('srtSettingsTitle');
const srtInsertModeSelect = document.getElementById('srt_insert_mode');
const srtSeparatorGroup = document.getElementById('srtSeparatorGroup');
const srtSeparatorInput = document.getElementById('srt_separator');
const epubSettingsContainer = document.getElementById('epubSettingsContainer');
const epubSettingsTitle = document.getElementById('epubSettingsTitle');
const epubInsertModeSelect = document.getElementById('epub_insert_mode');
const epubSeparatorGroup = document.getElementById('epubSeparatorGroup');
const epubSeparatorInput = document.getElementById('epub_separator');
const htmlSettingsContainer = document.getElementById('htmlSettingsContainer');
const htmlSettingsTitle = document.getElementById('htmlSettingsTitle');
const htmlInsertModeSelect = document.getElementById('html_insert_mode');
const htmlSeparatorGroup = document.getElementById('htmlSeparatorGroup');
const htmlSeparatorInput = document.getElementById('html_separator');
const aiSettingsTitle = document.getElementById('aiSettingsTitle');
const translationSettingsTitle = document.getElementById('translationSettingsTitle');
const glossaryGenSettingsTitle = document.getElementById('glossaryGenSettingsTitle');
// Parsing elements
const convertEnginSelect = document.getElementById('convert_engine');
const mineruTokenGroup = document.getElementById('mineruTokenGroup');
const mineruTokenInput = document.getElementById('mineru_token');
const mineruModelVersionGroup = document.getElementById('mineruModelVersionGroup');
const modelVersionSelect = document.getElementById('model_version');
const formulaCheckbox = document.getElementById('formula_ocr');
const codeCheckbox = document.getElementById('code_ocr');
const codeOcrSwitch = document.getElementById('codeOcrSwitch');
// AI elements
const platformSelect = document.getElementById('platform_select');
const apiHref = document.getElementById('api_href');
const baseUrlGroup = document.getElementById('baseUrlGroup');
const baseUrlInput = document.getElementById('base_url');
const apikeyInput = document.getElementById('api_key');
const modelInput = document.getElementById('model_id');
// Translation elements
const toLangSelect = document.getElementById('to_lang');
const customLangGroup = document.getElementById('customLangGroup');
const customLangInput = document.getElementById('custom_to_lang');
const customPromptTranslateArea = document.getElementById("custom_prompt");
// [NEW] Glossary elements
const glossaryFilesInput = document.getElementById('glossary_files');
const viewGlossaryBtn = document.getElementById('viewGlossaryBtn');
const clearGlossaryBtn = document.getElementById('clearGlossaryBtn');
const glossaryModalEl = document.getElementById('glossaryModal');
const glossaryModal = new bootstrap.Modal(glossaryModalEl);
const glossaryTableBody = document.getElementById('glossaryTableBody');
// [NEW] Glossary Generation elements
const glossaryGenerationContainer = document.getElementById('glossaryGenerationContainer');
const glossaryGenerateEnableSwitch = document.getElementById('glossary_generate_enable');
const glossaryAgentOptionsContainer = document.getElementById('glossaryAgentOptionsContainer');
const glossaryAgentConfigChoiceRadios = document.querySelectorAll('input[name="glossary_agent_config_choice"]');
const glossaryAgentCustomConfigContainer = document.getElementById('glossaryAgentCustomConfigContainer');
const glossaryAgentPlatformSelect = document.getElementById('glossary_agent_platform_select');
const glossaryAgentBaseUrlGroup = document.getElementById('glossaryAgentBaseUrlGroup');
const glossaryAgentBaseUrlInput = document.getElementById('glossary_agent_baseurl');
const glossaryAgentKeyInput = document.getElementById('glossary_agent_key');
const glossaryAgentModelIdInput = document.getElementById('glossary_agent_model_id');
const glossaryAgentTemperatureSlider = document.getElementById('glossary-agent-temperature-slider');
const glossaryAgentTemperatureDisplay = document.getElementById('glossary-agent-temperature-display');
const glossaryAgentTemperatureReset = document.getElementById('glossary-agent-temperature-reset');
const glossaryAgentChunkSizeSlider = document.getElementById('glossary-agent-chunk-size-slider');
const glossaryAgentChunkSizeDisplay = document.getElementById('glossary-agent-chunk-size-display');
const glossaryAgentChunkSizeReset = document.getElementById('glossary-agent-chunk-size-reset');
const glossaryAgentConcurrentSlider = document.getElementById('glossary-agent-concurrent-slider');
const glossaryAgentConcurrentDisplay = document.getElementById('glossary-agent-concurrent-display');
const glossaryAgentConcurrentReset = document.getElementById('glossary-agent-concurrent-reset');
const glossaryAgentThinkingRadios = document.querySelectorAll('input[name="glossary_agent_thinking"]');
// Moved Advanced elements (now part of translation settings)
const chunkSizeSlider = document.getElementById('chunk-size-slider');
const chunkSizeDisplay = document.getElementById('chunk-size-display');
const chunkSizeReset = document.getElementById('chunk-size-reset');
const concurrentSlider = document.getElementById('concurrent-slider');
const concurrentDisplay = document.getElementById('concurrent-display');
const concurrentReset = document.getElementById("concurrent-reset");
const temperatureSlider = document.getElementById('temperature-slider');
const temperatureDisplay = document.getElementById('temperature-display');
const temperatureReset = document.getElementById("temperature-reset");
// General UI elements
const versionDisplay = document.getElementById("versionDisplay");
const addNewTaskBtn = document.getElementById('addNewTaskBtn');
const taskContainer = document.getElementById('task-container');
const noTaskPlaceholder = document.getElementById('no-task-placeholder');
const taskCardTemplate = document.getElementById('taskCardTemplate');
const downloadMenuTemplate = document.getElementById('downloadMenuTemplate');
const previewOffcanvasEl = document.getElementById('previewOffcanvas');
const previewOffcanvas = new bootstrap.Offcanvas(previewOffcanvasEl);
const previewOffcanvasLabel = document.getElementById('previewOffcanvasLabel');
const originalPreviewPane = document.getElementById('originalPreviewPane');
const translatedPreviewFrame = document.getElementById('translatedPreviewFrame');
const originalPreviewContainer = document.getElementById('originalPreviewContainer');
const translatedPreviewContainer = document.getElementById('translatedPreviewContainer');
const setBilingualViewBtn = document.getElementById('setBilingualViewBtn');
const setTranslatedOnlyViewBtn = document.getElementById('setTranslatedOnlyViewBtn');
const printFrameEl = document.getElementById('printFrame');
// --- Global State ---
let defaultParams = {};
let glossaryData = {}; // [NEW] For glossary_dict
const tasks = {}; // { cardId: { elements: {...}, state: {...}, intervals: {...} } }
let isAdminMode = false;
let previewSplitInstance = null;
const apiHrefMap = {
"https://openrouter.ai/api/v1": "https://openrouter.ai/settings/keys",
"https://api.openai.com/v1": "https://platform.openai.com/api-keys",
"https://api.deepseek.com/v1": "https://platform.deepseek.com/api_keys",
"https://open.bigmodel.cn/api/paas/v4": "https://open.bigmodel.cn/usercenter/apikeys",
"https://dashscope.aliyuncs.com/compatible-mode/v1": "https://bailian.console.aliyun.com/?tab=model#/api-key",
"https://ark.cn-beijing.volces.com/api/v3": "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D",
"https://api.siliconflow.cn/v1": "https://cloud.siliconflow.cn/account/ak",
"https://www.dmxapi.cn/v1": "https://www.dmxapi.cn/token",
"https://generativelanguage.googleapis.com/v1beta/openai/": "https://aistudio.google.com/u/0/apikey"
};
const workflowExtensionMap = {
'txt': 'txt',
'xlsx': 'xlsx',
'csv': 'xlsx', // Map csv input to xlsx workflow
'docx': 'docx',
'json': 'json',
'srt': 'srt',
'epub': 'epub',
'html': 'html',
'htm': 'html',
};
const defaultAutoWorkflow = 'markdown_based';
// --- Utility Functions ---
const generateCardId = () => `card_${Math.random().toString(36).substring(2, 10)}`;
const saveToStorage = (key, value) => {
try {
localStorage.setItem(key, value);
} catch (e) {
console.warn("Save to storage failed:", e);
}
};
const getFromStorage = (key, defaultValue = '') => {
try {
return localStorage.getItem(key) || defaultValue;
} catch (e) {
console.warn("Read from storage failed:", e);
return defaultValue;
}
};
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const base64String = reader.result.split(',')[1];
resolve(base64String);
};
reader.onerror = error => reject(error);
});
}
// --- [NEW] Glossary Functions ---
async function handleGlossaryFiles(event) {
const files = event.target.files;
if (!files.length) return;
let newGlossary = {};
let loadedCount = 0;
const parsePromises = Array.from(files).map(file => {
return new Promise((resolve) => {
Papa.parse(file, {
header: true,
skipEmptyLines: true,
complete: (results) => {
if (results.data) {
results.data.forEach(row => {
const src = row.src ? row.src.trim() : null;
const dst = row.dst ? row.dst.trim() : null;
// Add only if src is valid and not already in the dictionary
if (src && dst && !newGlossary.hasOwnProperty(src)) {
newGlossary[src] = dst;
}
});
}
resolve();
},
error: (err) => {
console.error(`Error parsing ${file.name}:`, err);
resolve(); // Resolve anyway to not block other files
}
});
});
});
await Promise.all(parsePromises);
glossaryData = newGlossary;
loadedCount = Object.keys(glossaryData).length;
// Simple feedback
const btnText = viewGlossaryBtn.querySelector('span');
if (btnText) {
btnText.textContent = `${getText('viewGlossaryBtn')} (${loadedCount})`;
}
console.log("Glossary loaded:", glossaryData);
}
function populateGlossaryModal() {
glossaryTableBody.innerHTML = ''; // Clear previous content
const terms = Object.entries(glossaryData);
if (terms.length === 0) {
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = 2;
td.textContent = getText('glossaryEmpty');
td.className = 'text-center text-muted';
tr.appendChild(td);
glossaryTableBody.appendChild(tr);
} else {
terms.forEach(([src, dst]) => {
const tr = document.createElement('tr');
const tdSrc = document.createElement('td');
const tdDst = document.createElement('td');
tdSrc.textContent = src;
tdDst.textContent = dst;
tr.appendChild(tdSrc);
tr.appendChild(tdDst);
glossaryTableBody.appendChild(tr);
});
}
}
function clearGlossary() {
// Reset the data
glossaryData = {};
// Clear the file input so the user can re-select the same file if they want
glossaryFilesInput.value = '';
// Update the view button text to remove the count
const btnText = viewGlossaryBtn.querySelector('span');
if (btnText) {
btnText.textContent = getText('viewGlossaryBtn');
}
console.log("Glossary cleared.");
// If the modal is open, re-populate it to show it's empty
if (glossaryModalEl.classList.contains('show')) {
populateGlossaryModal();
}
}
// --- UI Update Functions based on Workflow ---
function updateWorkflowUI() {
const selectedWorkflow = workflowTypeSelect.value;
const translations = i18nData[currentLang] || i18nData.zh;
// Hide all workflow-specific containers first
parsingSettingsContainer.style.display = 'none';
jsonSettingsContainer.style.display = 'none';
xlsxSettingsContainer.style.display = 'none';
docxSettingsContainer.style.display = 'none';
srtSettingsContainer.style.display = 'none';
epubSettingsContainer.style.display = 'none';
htmlSettingsContainer.style.display = 'none';
// Default numbering
let currentStep = 2;
const getStep = () => currentStep++;
// Update main workflow title (it's static)
document.querySelector('#headingZero button strong').innerHTML = `<i class="bi bi-diagram-3 me-2"></i>${translations.workflowTitle}`;
switch (selectedWorkflow) {
case 'markdown_based':
parsingSettingsContainer.style.display = 'block';
parsingSettingsTitle.innerHTML = `<i class="bi bi-file-earmark-binary me-2"></i>${getStep()}. ${translations.parsingSettingsTitleText}`;
break;
case 'txt':
// No specific panel, just re-number
break;
case 'json':
jsonSettingsContainer.style.display = 'block';
jsonSettingsTitle.innerHTML = `<i class="bi bi-signpost-split me-2"></i>${getStep()}. ${translations.jsonSettingsTitleText}`;
break;
case 'xlsx':
xlsxSettingsContainer.style.display = 'block';
xlsxSettingsTitle.innerHTML = `<i class="bi bi-file-earmark-spreadsheet me-2"></i>${getStep()}. ${translations.xlsxSettingsTitleText}`;
updateSeparatorVisibility(xlsxInsertModeSelect, xlsxSeparatorGroup);
break;
case 'docx':
docxSettingsContainer.style.display = 'block';
docxSettingsTitle.innerHTML = `<i class="bi bi-file-earmark-word me-2"></i>${getStep()}. ${translations.docxSettingsTitleText}`;
updateSeparatorVisibility(docxInsertModeSelect, docxSeparatorGroup);
break;
case 'srt':
srtSettingsContainer.style.display = 'block';
srtSettingsTitle.innerHTML = `<i class="bi bi-file-text me-2"></i>${getStep()}. ${translations.srtSettingsTitleText}`;
updateSeparatorVisibility(srtInsertModeSelect, srtSeparatorGroup);
break;
case 'epub':
epubSettingsContainer.style.display = 'block';
epubSettingsTitle.innerHTML = `<i class="bi bi-book me-2"></i>${getStep()}. ${translations.epubSettingsTitleText}`;
updateSeparatorVisibility(epubInsertModeSelect, epubSeparatorGroup);
break;
case 'html':
htmlSettingsContainer.style.display = 'block';
htmlSettingsTitle.innerHTML = `<i class="bi bi-filetype-html me-2"></i>${getStep()}. ${translations.htmlSettingsTitleText}`;
updateSeparatorVisibility(htmlInsertModeSelect, htmlSeparatorGroup);
break;
}
// Renumber common sections
aiSettingsTitle.innerHTML = `<i class="bi bi-robot me-2"></i>${getStep()}. ${translations.aiSettingsTitleText}`;
translationSettingsTitle.innerHTML = `<i class="bi bi-translate me-2"></i>${getStep()}. ${translations.translationSettingsTitleText}`;
glossaryGenSettingsTitle.innerHTML = `<i class="bi bi-journal-bookmark-fill me-2"></i>${getStep()}. ${translations.glossaryGenTitle}`;
saveToStorage('translator_last_workflow', selectedWorkflow);
}
function updateSeparatorVisibility(modeSelect, separatorGroup) {
const selectedMode = modeSelect.value;
if (selectedMode === 'append' || selectedMode === 'prepend') {
separatorGroup.style.display = 'block';
} else {
separatorGroup.style.display = 'none';
}
}
function updateCustomLangUI() {
const isCustom = toLangSelect.value === 'custom';
customLangGroup.style.display = isCustom ? 'block' : 'none';
customLangInput.required = isCustom;
}
// --- Other UI Update Functions ---
function updatePlatformUI() {
const selectedPlatformValue = platformSelect.value;
apikeyInput.value = getFromStorage(`translator_platform_${selectedPlatformValue}_apikey`);
modelInput.value = getFromStorage(`translator_platform_${selectedPlatformValue}_model_id`);
if (selectedPlatformValue === 'custom') {
baseUrlGroup.style.display = 'block';
baseUrlInput.required = true;
baseUrlInput.value = getFromStorage('translator_platform_custom_base_url');
apiHref.style.display = 'none';
} else {
baseUrlGroup.style.display = 'none';
baseUrlInput.required = false;
baseUrlInput.value = selectedPlatformValue;
if (apiHrefMap[baseUrlInput.value]) {
apiHref.href = apiHrefMap[baseUrlInput.value];
apiHref.style.display = 'inline-block';
} else {
apiHref.style.display = 'none';
}
}
saveToStorage('translator_last_platform', selectedPlatformValue);
}
function updateConvertEnginUI(isLanguageChange = false) {
if (!isLanguageChange) {
const selectedEngin = convertEnginSelect.value;
mineruTokenGroup.style.display = selectedEngin === 'mineru' ? 'block' : 'none';
mineruModelVersionGroup.style.display = selectedEngin === 'mineru' ? 'block' : 'none';
mineruTokenInput.required = selectedEngin === 'mineru';
codeOcrSwitch.style.display = selectedEngin === 'docling' ? 'block' : 'none';
if (selectedEngin === 'mineru') {
mineruTokenInput.value = getFromStorage('translator_mineru_token');
}
saveToStorage('translator_convert_engin', selectedEngin);
} else {
// Repopulate options with translated text
const savedValue = convertEnginSelect.value;
const enginOptions = convertEnginSelect.querySelectorAll('option');
enginOptions.forEach(option => {
let textKey;
switch (option.value) {
case 'identity':
textKey = 'engineOptionIdentity';
break;
case 'mineru':
textKey = 'engineOptionMineru';
break;
case 'docling':
textKey = 'engineOptionDocling';
break;
default:
textKey = option.value;
}
option.textContent = getText(textKey, option.value);
});
convertEnginSelect.value = savedValue;
}
}
// [NEW] Glossary Generation UI Functions
function updateGlossaryGenUI() {
const isEnabled = glossaryGenerateEnableSwitch.checked;
glossaryAgentOptionsContainer.style.display = isEnabled ? 'block' : 'none';
if (isEnabled) {
updateGlossaryCustomConfigUI();
} else {
glossaryAgentCustomConfigContainer.style.display = 'none';
}
}
function updateGlossaryCustomConfigUI() {
const choice = document.querySelector('input[name="glossary_agent_config_choice"]:checked').value;
glossaryAgentCustomConfigContainer.style.display = choice === 'custom' ? 'block' : 'none';
}
function setupGlossaryAgentPlatformUI() {
// Clone options from the main platform selector
glossaryAgentPlatformSelect.innerHTML = platformSelect.innerHTML;
const updateUI = () => {
const selectedPlatformValue = glossaryAgentPlatformSelect.value;
const platformKeyPrefix = `glossary_agent_platform_${selectedPlatformValue}`;
glossaryAgentKeyInput.value = getFromStorage(`${platformKeyPrefix}_apikey`);
glossaryAgentModelIdInput.value = getFromStorage(`${platformKeyPrefix}_model_id`);
if (selectedPlatformValue === 'custom') {
glossaryAgentBaseUrlGroup.style.display = 'block';
glossaryAgentBaseUrlInput.required = true;
glossaryAgentBaseUrlInput.value = getFromStorage('glossary_agent_platform_custom_baseurl');
} else {
glossaryAgentBaseUrlGroup.style.display = 'none';
glossaryAgentBaseUrlInput.required = false;
glossaryAgentBaseUrlInput.value = selectedPlatformValue;
}
};
glossaryAgentPlatformSelect.addEventListener('change', () => {
const selectedPlatform = glossaryAgentPlatformSelect.value;
saveToStorage('glossary_agent_last_platform', selectedPlatform);
updateUI();
});
// Initial call to set state from saved value
glossaryAgentPlatformSelect.value = getFromStorage('glossary_agent_last_platform', 'https://api.openai.com/v1');
updateUI(); // Trigger initial UI state
}
function createSliderUpdater(slider, display, resetBtn, key, params) {
return () => {
const value = slider.value;
display.textContent = value;
resetBtn.style.visibility = value !== String(params[key]) ? 'visible' : 'hidden';
saveToStorage(key, value);
};
}
function setupSlider(slider, display, resetBtn, key, params) {
slider.value = getFromStorage(key, params[key]);
const updater = createSliderUpdater(slider, display, resetBtn, key, params);
slider.addEventListener('input', updater);
resetBtn.addEventListener('click', () => {
slider.value = params[key];
updater();
});
updater(); // Initial call
}
function updateTaskPlaceholderVisibility() {
noTaskPlaceholder.style.display = Object.keys(tasks).length === 0 ? 'block' : 'none';
}
function saveTaskIds() {
if (isAdminMode) return;
const submittedTaskIds = Object.values(tasks)
.map(task => task.state.backendTaskId)
.filter(id => id);
saveToStorage('active_task_ids', JSON.stringify(submittedTaskIds));
}
// --- Task Card Management ---
function createTaskCard(backendTaskId = null, restoreState = false) {
const cardId = backendTaskId || generateCardId();
if (tasks[cardId]) return; // Avoid duplicate cards
const cardFragment = taskCardTemplate.content.cloneNode(true);
const cardElement = cardFragment.querySelector('.task-card');
cardElement.dataset.cardId = cardId;
// Translate the new card immediately
const translations = i18nData[currentLang] || i18nData.zh;
cardElement.querySelectorAll('[data-i18n]').forEach(el => el.innerHTML = translations[el.dataset.i18n] || el.innerHTML);
cardElement.querySelectorAll('[data-i18n-placeholder]').forEach(el => el.placeholder = translations[el.dataset.i18nPlaceholder] || el.placeholder);
const elements = {
card: cardElement,
taskIdDisplay: cardElement.querySelector('.task-id-display'),
removeBtn: cardElement.querySelector('.remove-task-btn'),
fileInput: cardElement.querySelector('.file-input'),
fileDropArea: cardElement.querySelector('.file-drop-area'),
fileDropDefault: cardElement.querySelector('.file-drop-default'),
fileDropSelected: cardElement.querySelector('.file-drop-selected'),
fileNameDisplayWrapper: cardElement.querySelector('.file-name-display-wrapper'),
fileNameDisplay: cardElement.querySelector('.file-name-display'),
logArea: cardElement.querySelector('.log-area'),
statusMessage: cardElement.querySelector('.status-message'),
progress: cardElement.querySelector('.progress'),
downloadButtons: cardElement.querySelector('.download-buttons'),
downloadMenuContainer: cardElement.querySelector('.download-menu-container'),
previewBtn: cardElement.querySelector('.preview-html-btn'),
startBtn: cardElement.querySelector('.start-translate-btn'),
// [NEW] Attachment elements
attachmentBtnGroup: cardElement.querySelector('.attachment-btn-group'),
attachmentMenuContainer: cardElement.querySelector('.attachment-menu-container'),
};
if (restoreState && backendTaskId) {
elements.taskIdDisplay.innerHTML = `<code>${backendTaskId}</code>`;
}
tasks[cardId] = {
elements,
state: {
backendTaskId: backendTaskId,
isTranslating: false,
file: null,
htmlUrl: null,
fileNameStem: null,
isSubmitted: restoreState,
downloads: {},
attachment: {},
workflow: null
},
intervals: {
log: null,
status: null
}
};
addEventListenersToCard(cardId);
taskContainer.prepend(cardElement);
updateTaskPlaceholderVisibility();
if (restoreState && backendTaskId) {
pollStatus(backendTaskId, true);
}
}
async function releaseTask(backendTaskId) {
try {
await fetch(`/service/release/${backendTaskId}`, {method: 'POST'});
console.log(`[${backendTaskId}] Release request sent to backend.`);
} catch (error) {
console.error(`[${backendTaskId}] Failed to send release request:`, error);
}
}
async function removeTask(cardId) {
const task = tasks[cardId];
if (!task) return;
const backendTaskId = task.state.backendTaskId;
if (backendTaskId) {
stopPolling(backendTaskId);
await releaseTask(backendTaskId);
}
task.elements.card.remove();
delete tasks[cardId];
saveTaskIds();
updateTaskPlaceholderVisibility();
}
function addEventListenersToCard(cardId) {
const {elements} = tasks[cardId];
elements.card.addEventListener('click', () => {
const task = tasks[cardId];
if (task && task.state.workflow) {
workflowTypeSelect.value = task.state.workflow;
updateWorkflowUI();
}
});
elements.removeBtn.addEventListener('click', () => removeTask(cardId));
elements.fileDropArea.addEventListener('click', () => elements.fileInput.click());
elements.fileInput.addEventListener('change', () => handleFileSelect(cardId));
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
elements.fileDropArea.addEventListener(eventName, e => {
e.preventDefault();
e.stopPropagation();
}, false);
});
['dragenter', 'dragover'].forEach(eventName => {
elements.fileDropArea.addEventListener(eventName, () => elements.fileDropArea.classList.add('drag-over'), false);
});
['dragleave', 'drop'].forEach(eventName => {
elements.fileDropArea.addEventListener(eventName, () => elements.fileDropArea.classList.remove('drag-over'), false);
});
elements.fileDropArea.addEventListener('drop', e => {
if (e.dataTransfer.files.length > 0) {
elements.fileInput.files = e.dataTransfer.files;
handleFileSelect(cardId);
}
}, false);
elements.startBtn.addEventListener('click', () => {
if (tasks[cardId].state.isTranslating) {
cancelTranslation(cardId);
} else {
startTranslation(cardId);
}
});
}
function handleFileSelect(cardId) {
const {elements, state} = tasks[cardId];
const file = elements.fileInput.files[0];
if (file) {
state.file = file;
elements.fileNameDisplay.textContent = file.name;
elements.fileNameDisplayWrapper.style.display = 'block';
elements.fileDropArea.classList.add('file-selected');
elements.fileDropDefault.style.display = 'none';
elements.fileDropSelected.style.display = 'block';
elements.fileDropArea.classList.remove('input-error');
elements.fileNameDisplay.classList.remove('input-error-text');
if (autoWorkflowSwitch.checked) {
const fileExtension = file.name.split('.').pop().toLowerCase();
const targetWorkflow = workflowExtensionMap[fileExtension] || defaultAutoWorkflow;
workflowTypeSelect.value = targetWorkflow;
updateWorkflowUI();
}
}
}
// --- Core Translation Logic ---
async function startTranslation(cardId) {
const {elements, state} = tasks[cardId];
// --- File and common settings validation ---
if (!state.file) {
elements.statusMessage.textContent = getText('status_selectFileFirst');
elements.statusMessage.className = 'status-message small text-danger';
elements.fileDropArea.classList.add('input-error');
return;
}
const requiredCommonInputs = [apikeyInput, modelInput];
if (platformSelect.value === 'custom') requiredCommonInputs.push(baseUrlInput);
let isValid = true;
requiredCommonInputs.forEach(input => {
input.classList.remove('is-invalid');
if (!input.value.trim()) {
input.classList.add('is-invalid');
isValid = false;
}
});
if (toLangSelect.value === 'custom') {
customLangInput.classList.remove('is-invalid');
if (!customLangInput.value.trim()) {
customLangInput.classList.add('is-invalid');
isValid = false;
}
}
// --- Workflow-specific validation and payload building ---
const workflowType = workflowTypeSelect.value;
state.workflow = workflowType;
let workflowPayload = {};
let targetLanguage = toLangSelect.value;
if (targetLanguage === 'custom') {
targetLanguage = customLangInput.value.trim();
}
const basePayload = {
base_url: baseUrlInput.value,
api_key: apikeyInput.value,
model_id: modelInput.value,
to_lang: targetLanguage,
thinking: document.querySelector('input[name="thinking"]:checked')?.value || 'disable',
chunk_size: parseInt(chunkSizeSlider.value, 10),
concurrent: parseInt(concurrentSlider.value, 10),
temperature: parseFloat(temperatureSlider.value),
custom_prompt: customPromptTranslateArea.value || null,
glossary_dict: Object.keys(glossaryData).length > 0 ? glossaryData : null,
};
// [NEW] Add glossary generation config
if (glossaryGenerateEnableSwitch.checked) {
basePayload.glossary_generate_enable = true;
const agentChoice = document.querySelector('input[name="glossary_agent_config_choice"]:checked').value;
if (agentChoice === 'same') {
// Copy main translator config to agent config to satisfy backend validation
basePayload.glossary_agent_config = {
baseurl: baseUrlInput.value,
key: apikeyInput.value,
model_id: modelInput.value,
temperature: parseFloat(temperatureSlider.value),
chunk_size: parseInt(chunkSizeSlider.value, 10),
max_concurrent: parseInt(concurrentSlider.value, 10),
timeout: 2000, // Default timeout
thinking: document.querySelector('input[name="thinking"]:checked')?.value || 'default',
};
} else { // 'custom'
// Validate custom fields
const requiredAgentInputs = [glossaryAgentKeyInput, glossaryAgentModelIdInput];
if (glossaryAgentPlatformSelect.value === 'custom') {
requiredAgentInputs.push(glossaryAgentBaseUrlInput);
}
requiredAgentInputs.forEach(input => {
input.classList.remove('is-invalid');
if (!input.value.trim()) {
input.classList.add('is-invalid');
isValid = false;
}
});
// Read from custom fields
basePayload.glossary_agent_config = {
baseurl: glossaryAgentBaseUrlInput.value, // This is updated by the platform select logic
key: glossaryAgentKeyInput.value,
model_id: glossaryAgentModelIdInput.value,
temperature: parseFloat(glossaryAgentTemperatureSlider.value),
chunk_size: parseInt(glossaryAgentChunkSizeSlider.value, 10),
max_concurrent: parseInt(glossaryAgentConcurrentSlider.value, 10),
timeout: 2000, // Default timeout
thinking: document.querySelector('input[name="glossary_agent_thinking"]:checked')?.value || 'default',
};
}
} else {
basePayload.glossary_generate_enable = false;
basePayload.glossary_agent_config = null;
}
switch (workflowType) {
case 'markdown_based':
if (convertEnginSelect.value === 'mineru' && !mineruTokenInput.value.trim()) {
mineruTokenInput.classList.add('is-invalid');
isValid = false;
} else {
mineruTokenInput.classList.remove('is-invalid');
}
workflowPayload = {
...basePayload,
workflow_type: 'markdown_based',
convert_engine: convertEnginSelect.value || null,
mineru_token: mineruTokenInput.value || null,
formula_ocr: formulaCheckbox.checked,
code_ocr: codeCheckbox.checked,
model_version: modelVersionSelect.value,
};
break;
case 'txt':
workflowPayload = {
...basePayload,
workflow_type: 'txt'
};
break;
case 'json':
const jsonPaths = jsonPathsTextarea.value.trim().split('\n').map(p => p.trim()).filter(p => p);
if (jsonPaths.length === 0) {
jsonPathsTextarea.classList.add('is-invalid');
isValid = false;
} else {
jsonPathsTextarea.classList.remove('is-invalid');
}
workflowPayload = {
...basePayload,
workflow_type: 'json',
json_paths: jsonPaths,
};
break;
case 'xlsx':
const translateRegions = xlsxTranslateRegionsTextarea.value
.trim()
.split('\n')
.map(p => p.trim())
.filter(p => p);
workflowPayload = {
...basePayload,
workflow_type: 'xlsx',
insert_mode: xlsxInsertModeSelect.value,
separator: xlsxSeparatorInput.value.replace(/\\n/g, '\n'),
translate_regions: translateRegions.length > 0 ? translateRegions : null
};
break;
case 'docx':
workflowPayload = {
...basePayload,
workflow_type: 'docx',
insert_mode: docxInsertModeSelect.value,
separator: docxSeparatorInput.value.replace(/\\n/g, '\n')
};
break;
case 'srt':
workflowPayload = {
...basePayload,
workflow_type: 'srt',
insert_mode: srtInsertModeSelect.value,
separator: srtSeparatorInput.value.replace(/\\n/g, '\n')
};
break;
case 'epub':
workflowPayload = {
...basePayload,
workflow_type: 'epub',
insert_mode: epubInsertModeSelect.value,
separator: epubSeparatorInput.value.replace(/\\n/g, '\n')
};
break;
case 'html':
workflowPayload = {
...basePayload,
workflow_type: 'html',
insert_mode: htmlInsertModeSelect.value,
separator: htmlSeparatorInput.value.replace(/\\n/g, '\n')
};
break;
default:
elements.statusMessage.textContent = getText('status_invalidWorkflow');
elements.statusMessage.className = 'status-message small text-danger';
return;
}
if (!isValid) {
elements.statusMessage.textContent = getText('status_fillRequired');
elements.statusMessage.className = 'status-message small text-danger';
return;
}
// --- Release old task and start new one ---
const oldBackendTaskId = state.backendTaskId;
if (oldBackendTaskId) {
console.log(`[${oldBackendTaskId}] ${getText('status_releasingOldTask')}`);
await releaseTask(oldBackendTaskId);
state.backendTaskId = null;
}
state.isTranslating = true;
elements.startBtn.disabled = true;
elements.startBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> <span data-i18n="btn_initializing">${getText('btn_initializing')}</span>`;
elements.logArea.innerHTML = '';
elements.statusMessage.textContent = getText('status_encodingAndSubmitting');
elements.statusMessage.className = 'status-message small text-muted';
elements.downloadButtons.style.display = 'none';
elements.progress.style.display = 'block';
try {
const fileContentBase64 = await fileToBase64(state.file);
const finalRequest = {
file_name: state.file.name,
file_content: fileContentBase64,
payload: workflowPayload
};
const response = await fetch('/service/translate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(finalRequest)
});
const result = await response.json();
if (response.ok && result.task_started) {
const backendTaskId = result.task_id;
state.backendTaskId = backendTaskId;
state.isSubmitted = true;
elements.taskIdDisplay.innerHTML = `<code>${backendTaskId}</code>`;
elements.taskIdDisplay.classList.remove('task-id-placeholder');
saveTaskIds();
elements.statusMessage.textContent = result.message || getText('status_requestOk');
elements.statusMessage.className = 'status-message small text-info';
elements.startBtn.innerHTML = `<i class="bi bi-stop-circle-fill me-1"></i><span data-i18n="btn_cancelTranslation">${getText('btn_cancelTranslation')}</span>`;
elements.startBtn.classList.replace('btn-primary', 'btn-danger');
elements.startBtn.disabled = false;
startPolling(backendTaskId);
} else {
let errorMessage = result.detail || result.message || `${getText('status_requestFail')} (${response.status})`;
if (typeof errorMessage === 'object') {
errorMessage = JSON.stringify(errorMessage);
}
throw new Error(errorMessage);
}
} catch (error) {
state.isSubmitted = false;
console.error('Request failed:', error);
elements.statusMessage.textContent = `${getText('status_initFail')}: ${error.message}`;
elements.statusMessage.className = 'status-message small text-danger';
elements.startBtn.innerHTML = `<i class="bi bi-play-fill me-1"></i><span data-i18n="taskCardStartBtn">${getText('taskCardStartBtn')}</span>`;
elements.startBtn.classList.replace('btn-danger', 'btn-primary');
elements.startBtn.disabled = false;
elements.progress.style.display = 'none';
state.isTranslating = false;
}
}
async function cancelTranslation(cardId) {
const task = tasks[cardId];
if (!task || !task.state.backendTaskId) return;
const {elements} = task;
const backendTaskId = task.state.backendTaskId;
elements.startBtn.disabled = true;
elements.startBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> <span data-i18n="status_cancelling">${getText('status_cancelling')}</span>`;
try {
const response = await fetch(`/service/cancel/${backendTaskId}`, {method: 'POST'});
const result = await response.json();
if (response.ok && result.cancelled) {
elements.statusMessage.textContent = result.message || getText('status_cancelSent');
elements.statusMessage.className = 'status-message small text-warning';
} else {
throw new Error(result.message || getText('status_cancelFail'));
}
} catch (error) {
elements.statusMessage.textContent = `${getText('status_cancelFail')}: ${error.message}`;
elements.statusMessage.className = 'status-message small text-danger';
elements.startBtn.disabled = false;
elements.startBtn.innerHTML = `<i class="bi bi-stop-circle-fill me-1"></i><span data-i18n="btn_cancelTranslation">${getText('btn_cancelTranslation')}</span>`;
}
}
// --- Polling ---
function startPolling(backendTaskId) {
stopPolling(backendTaskId);
const card = Object.values(tasks).find(t => t.state.backendTaskId === backendTaskId);
if (!card) return;
card.intervals.log = setInterval(() => pollLogs(backendTaskId), 2000);
card.intervals.status = setInterval(() => pollStatus(backendTaskId), 1500);
pollLogs(backendTaskId);
pollStatus(backendTaskId);
}
function stopPolling(backendTaskId) {
const card = Object.values(tasks).find(t => t.state.backendTaskId === backendTaskId);
if (!card) return;
const {intervals} = card;
if (intervals.log) clearInterval(intervals.log);
if (intervals.status) clearInterval(intervals.status);
intervals.log = null;
intervals.status = null;
}
async function pollLogs(backendTaskId) {
const card = Object.values(tasks).find(t => t.state.backendTaskId === backendTaskId);
if (!card) return;
const {elements} = card;
try {
const response = await fetch(`/service/logs/${backendTaskId}`);
if (!response.ok) return;
const data = await response.json();
if (data.logs && data.logs.length > 0) {
elements.logArea.innerHTML += data.logs.map(log => log.replace(/</g, "<").replace(/>/g, ">") + '<br>').join('');
elements.logArea.scrollTop = elements.logArea.scrollHeight;
}
} catch (error) {
console.warn(`[${backendTaskId}] Error polling logs:`, error);
}
}
async function pollStatus(backendTaskId, isRestore = false) {
const card = Object.values(tasks).find(t => t.state.backendTaskId === backendTaskId);
if (!card) {
if (isRestore) {
console.warn(`Restored task ${backendTaskId} not found in UI, removing from storage.`);
const savedTaskIds = JSON.parse(getFromStorage('active_task_ids', '[]'));
const newIds = savedTaskIds.filter(id => id !== backendTaskId);
saveToStorage('active_task_ids', JSON.stringify(newIds));
}
return;
}
const {elements, state} = card;
const cardId = elements.card.dataset.cardId;
try {
const response = await fetch(`/service/status/${backendTaskId}`);
if (!response.ok) {
if (response.status === 404 && isRestore) {
console.warn(`Task ${backendTaskId} not found on server (404). Removing from UI.`);
await removeTask(cardId);
}
return;
}
const status = await response.json();
if (status.original_filename && (!state.file || isRestore)) {
elements.fileNameDisplay.textContent = status.original_filename;
elements.fileNameDisplayWrapper.style.display = 'block';
}
elements.statusMessage.textContent = status.status_message || getText('status_gettingStatus');
elements.statusMessage.className = `status-message small ${status.error_flag ? 'text-danger' : 'text-info'}`;
if (!status.is_processing) {
pollLogs(backendTaskId); // Get final logs
stopPolling(backendTaskId);
state.isTranslating = false;
elements.startBtn.disabled = false;
elements.startBtn.innerHTML = `<i class="bi bi-arrow-clockwise me-1"></i><span data-i18n="btn_reTranslate">${getText('btn_reTranslate')}</span>`;
elements.startBtn.classList.replace('btn-danger', 'btn-primary');
elements.progress.style.display = 'none';
if (status.download_ready && !status.error_flag) {
elements.statusMessage.className = 'status-message small text-success';
updateResultButtons(cardId, status);
} else {
elements.downloadButtons.style.display = 'none';
}
} else {
state.isTranslating = true;
elements.startBtn.innerHTML = `<i class="bi bi-stop-circle-fill me-1"></i><span data-i18n="btn_cancelTranslation">${getText('btn_cancelTranslation')}</span>`;
elements.startBtn.classList.replace('btn-primary', 'btn-danger');
elements.startBtn.disabled = false;
elements.progress.style.display = 'block';
elements.downloadButtons.style.display = 'none';
if (isRestore && !card.intervals.status) {
startPolling(backendTaskId);
}
}
} catch (error) {
console.error(`[${backendTaskId}] Error polling status:`, error);
elements.statusMessage.textContent = getText('status_updateError');
elements.statusMessage.className = 'status-message small text-danger';
}
}
// --- Download and Preview ---
function populateDownloadMenu(menuElement, downloads, cardId) {
menuElement.innerHTML = ''; // Clear existing items
if (!downloadMenuTemplate) return false;
const content = downloadMenuTemplate.content.cloneNode(true);
// Translate the template before using
content.querySelectorAll('[data-i18n]').forEach(el => el.innerHTML = getText(el.dataset.i18n));
let anyLinkAdded = false;
const setupLink = (selector, key) => {
const li = content.querySelector(selector);
if (li && downloads[key]) {
li.querySelector('a').href = downloads[key];
menuElement.appendChild(li);
anyLinkAdded = true;
}
};
setupLink('.download-item-md', 'markdown');
setupLink('.download-item-md-zip', 'markdown_zip');
setupLink('.download-item-txt', 'txt');
setupLink('.download-item-json', 'json');
setupLink('.download-item-docx', 'docx');
setupLink('.download-item-xlsx', 'xlsx');
setupLink('.download-item-csv', 'csv');
setupLink('.download-item-srt', 'srt');
setupLink('.download-item-epub', 'epub');
setupLink('.download-item-html', 'html');
// Special handler for PDF, which is generated on the fly
const pdfLi = content.querySelector('.download-item-pdf');
if (pdfLi && downloads.html) {
const pdfLink = pdfLi.querySelector('a');
pdfLink.href = '#';
pdfLink.onclick = (e) => {
e.preventDefault();
downloadPdf(cardId);
};
menuElement.appendChild(pdfLi);
anyLinkAdded = true;
}
return anyLinkAdded;
}
function updateResultButtons(cardId, status) {
const { elements, state } = tasks[cardId];
const { downloads, attachment } = status;
state.downloads = downloads;
state.attachment = attachment;
const { previewBtn, downloadButtons, downloadMenuContainer, attachmentBtnGroup, attachmentMenuContainer } = elements;
// Reset visibility of individual components first
previewBtn.style.display = 'none';
attachmentBtnGroup.style.display = 'none';
let anyDownloadAvailable = false;
let anyAttachmentAvailable = false;
let anyPreviewAvailable = false;
// Handle preview
if (downloads && downloads.html) {
state.htmlUrl = downloads.html;
state.fileNameStem = status.original_filename_stem;
previewBtn.style.display = 'inline-block';
previewBtn.onclick = () => setupPreview(cardId);
anyPreviewAvailable = true;
}
// Handle standard downloads
if (downloads) {
anyDownloadAvailable = populateDownloadMenu(downloadMenuContainer, downloads, cardId);
}
// Handle attachments
if (attachment && Object.keys(attachment).length > 0) {
attachmentMenuContainer.innerHTML = ''; // Clear previous items
Object.entries(attachment).forEach(([identifier, url]) => {
const li = document.createElement('li');
const a = document.createElement('a');
a.className = 'dropdown-item';
a.href = url;
// A simple icon and the identifier text
a.innerHTML = `<i class="bi bi-file-earmark-arrow-down me-2"></i>${identifier}`;
li.appendChild(a);
attachmentMenuContainer.appendChild(li);
});
attachmentBtnGroup.style.display = 'inline-block';
anyAttachmentAvailable = true;
}
// Set visibility of the main container
if (anyPreviewAvailable || anyDownloadAvailable || anyAttachmentAvailable) {
downloadButtons.style.display = 'flex'; // Use flex for proper alignment
} else {
downloadButtons.style.display = 'none';
}
}
function setupPreview(cardId) {
const {state} = tasks[cardId];
if (!state.htmlUrl) return;
const existingOriginalContent = originalPreviewPane.querySelector('iframe, pre, p');
if (existingOriginalContent) existingOriginalContent.remove();
translatedPreviewFrame.src = 'about:blank';
translatedPreviewFrame.srcdoc = getText('preview_loading');
setPreviewDisplayMode('bilingual');
previewOffcanvas.show();
const menu = document.getElementById('previewDownloadMenu');
populateDownloadMenu(menu, state.downloads, cardId);
if (state.file) {
const fileType = state.file.type;
const fileExtension = state.file.name.split('.').pop().toLowerCase();
const textLikeExtensions = ['md', 'json', 'xml', 'log', 'py', 'js', 'css', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'rb', 'php', 'swift', 'kt', 'go', 'rs', 'ts', 'txt', 'srt'];
if (fileType.startsWith('text/') || textLikeExtensions.includes(fileExtension)) {
const pre = document.createElement('pre');
state.file.text()
.then(text => pre.textContent = text)
.catch(() => pre.textContent = getText('preview_cantReadOriginal'));
originalPreviewPane.appendChild(pre);
} else if (['application/pdf', 'text/html'].includes(fileType) || ['html', 'htm'].includes(fileExtension)) {
const iframe = document.createElement('iframe');
iframe.src = URL.createObjectURL(state.file);
originalPreviewPane.appendChild(iframe);
} else {
const p = document.createElement('p');
p.className = 'p-3 text-muted';
p.textContent = `${getText('preview_cantPreviewType')} (${fileType || 'unknown: ' + fileExtension}).`;
originalPreviewPane.appendChild(p);
}
} else {
const p = document.createElement('p');
p.className = 'p-3 text-muted';
p.textContent = getText('preview_noOriginalCache');
originalPreviewPane.appendChild(p);
}
fetch(state.htmlUrl)
.then(resp => resp.ok ? resp.text() : Promise.reject(`HTTP error ${resp.status}`))
.then(html => {
translatedPreviewFrame.srcdoc = html;
})
.catch(err => {
console.error('Preview fetch failed:', err);
translatedPreviewFrame.srcdoc = `${getText('preview_loadFailed')}<p>${err.message}</p>`;
});
}
function downloadPdf(cardId) {
const {elements, state} = tasks[cardId];
if (!state.htmlUrl) return;
const toast = new bootstrap.Toast(document.createElement('div'), {
autohide: true,
delay: 3000
});
toast._element.classList.add('toast', 'position-fixed', 'top-0', 'end-0', 'p-3', 'bg-info', 'text-white');
toast._element.innerHTML = `<div class="toast-body">${getText('pdf_preparing')}</div>`;
document.body.appendChild(toast._element);
toast.show();
fetch(state.htmlUrl)
.then(resp => resp.ok ? resp.text() : Promise.reject(`HTTP error ${resp.status}`))
.then(html => {
printFrameEl.onload = () => {
setTimeout(() => {
try {
printFrameEl.contentWindow.focus();
printFrameEl.contentWindow.print();
} catch (err) {
alert(getText('pdf_print_failed'));
} finally {
printFrameEl.onload = null;
printFrameEl.srcdoc = ''; // Clear content
}
}, 500);
};
printFrameEl.srcdoc = html;
})
.catch(err => {
alert(getText('pdf_fetch_failed'));
});
}
function setPreviewDisplayMode(mode) {
if (previewSplitInstance) {
previewSplitInstance.destroy();
previewSplitInstance = null;
}
const isMobileView = window.innerWidth < 992;
const splitContainer = document.querySelector('.preview-split-container');
originalPreviewContainer.style.display = 'flex';
translatedPreviewContainer.style.display = 'flex';
[originalPreviewContainer, translatedPreviewContainer].forEach(el => {
el.style.width = '';
el.style.height = '';
});
splitContainer.style.flexDirection = isMobileView ? 'column' : 'row';
if (mode === 'bilingual') {
previewOffcanvasLabel.textContent = getText('preview_bilingual');
if (isMobileView) {
previewSplitInstance = Split(['#originalPreviewContainer', '#translatedPreviewContainer'], {
direction: 'vertical', sizes: [50, 50], minSize: 150, gutterSize: 10, cursor: 'row-resize',
});
} else {
previewSplitInstance = Split(['#originalPreviewContainer', '#translatedPreviewContainer'], {
direction: 'horizontal', sizes: [50, 50], minSize: 200, gutterSize: 10, cursor: 'col-resize',
});
}
setBilingualViewBtn.classList.add('btn-primary');
setBilingualViewBtn.classList.remove('btn-outline-primary');
setTranslatedOnlyViewBtn.classList.remove('btn-primary');
setTranslatedOnlyViewBtn.classList.add('btn-outline-primary');
} else { // mode === 'translatedOnly'
previewOffcanvasLabel.textContent = getText('preview_translatedOnly');
originalPreviewContainer.style.display = 'none';
if (isMobileView) {
translatedPreviewContainer.style.height = '100%';
} else {
translatedPreviewContainer.style.width = '100%';
}
setTranslatedOnlyViewBtn.classList.add('btn-primary');
setTranslatedOnlyViewBtn.classList.remove('btn-outline-primary');
setBilingualViewBtn.classList.remove('btn-primary');
setBilingualViewBtn.classList.add('btn-outline-primary');
}
}
// --- Password Toggle Functionality ---
function setupPasswordToggle(button) {
const targetId = button.dataset.target;
const passwordInput = document.getElementById(targetId);
const icon = button.querySelector('i');
button.addEventListener('click', () => {
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
icon.classList.remove('bi-eye-slash');
icon.classList.add('bi-eye');
} else {
passwordInput.type = 'password';
icon.classList.remove('bi-eye');
icon.classList.add('bi-eye-slash');
}
});
}
// --- Initialization ---
async function init() {
// Step 1: Fetch i18n data. This is critical for all UI text, including error messages.
try {
const response = await fetch("/static/i18nData.json");
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
i18nData = await response.json(); // Overwrite the initial fallback object
} catch (error) {
console.error("Fatal: Failed to load i18n data. Application cannot start.", error);
const lang = getFromStorage('ui_language') || (navigator.language.toLowerCase().startsWith('en') ? 'en' : 'zh');
currentLang = lang;
alert(getText('init_i18n_failed_alert'));
return; // Stop execution
}
// Step 2: Fetch other backend data
isAdminMode = window.location.pathname.includes('/admin');
try {
const [metaRes, enginRes, paramsRes] = await Promise.all([
fetch("/service/meta"), fetch('/service/engin-list'), fetch("/service/default-params")
]);
if (!metaRes.ok || !enginRes.ok || !paramsRes.ok) {
throw new Error("Failed to fetch initial data from server.");
}
const meta = await metaRes.json();
versionDisplay.textContent = `v${meta.version}`;
const enginList = await enginRes.json();
// Populate convert engin select
convertEnginSelect.innerHTML = ''; // Clear
const defaultOption = document.createElement('option');
defaultOption.value = 'identity';
convertEnginSelect.appendChild(defaultOption);
enginList.forEach(engin => {
const option = document.createElement('option');
option.value = engin;
convertEnginSelect.appendChild(option);
});
defaultParams = await paramsRes.json();
} catch (error) {
console.error("Initialization failed:", error);
alert(getText('init_failed_alert'));
return;
}
// Step 3: Initialize i18n system (now that all data is loaded and dynamic elements are populated)
initI18n();
// Step 4: Restore user settings and update UI based on them
workflowTypeSelect.value = getFromStorage('translator_last_workflow', 'markdown_based');
autoWorkflowSwitch.checked = getFromStorage('translator_auto_workflow_enabled', 'true') === 'true';
xlsxInsertModeSelect.value = getFromStorage('translator_xlsx_insert_mode', 'replace');
xlsxSeparatorInput.value = getFromStorage('translator_xlsx_separator', '\\n');
xlsxTranslateRegionsTextarea.value = getFromStorage('translator_xlsx_translate_regions', '');
docxInsertModeSelect.value = getFromStorage('translator_docx_insert_mode', 'replace');
docxSeparatorInput.value = getFromStorage('translator_docx_separator', '\\n');
srtInsertModeSelect.value = getFromStorage('translator_srt_insert_mode', 'replace');
srtSeparatorInput.value = getFromStorage('translator_srt_separator', '\\n');
epubInsertModeSelect.value = getFromStorage('translator_epub_insert_mode', 'replace');
epubSeparatorInput.value = getFromStorage('translator_epub_separator', '\\n');
htmlInsertModeSelect.value = getFromStorage('translator_html_insert_mode', 'replace');
htmlSeparatorInput.value = getFromStorage('translator_html_separator', ' ');
jsonPathsTextarea.value = getFromStorage('translator_json_paths');
platformSelect.value = getFromStorage('translator_last_platform', 'https://api.openai.com/v1');
convertEnginSelect.value = getFromStorage('translator_convert_engin', 'mineru');
modelVersionSelect.value = getFromStorage('translator_model_version', 'vlm');
toLangSelect.value = getFromStorage('translator_to_lang', '中文');
customLangInput.value = getFromStorage('translator_custom_to_lang', '');
formulaCheckbox.checked = getFromStorage('translator_formula_ocr', 'true') === 'true';
codeCheckbox.checked = getFromStorage('translator_code_ocr', 'true') === 'true';
customPromptTranslateArea.value = getFromStorage("custom_prompt");
// Set thinking mode from storage or default
const savedThinkingMode = getFromStorage('translator_thinking_mode', 'default');
const thinkingRadioToSelect = document.querySelector(`#thinkingModeBtnGroup input[value="${savedThinkingMode}"]`);
if (thinkingRadioToSelect) thinkingRadioToSelect.checked = true;
// [NEW] Restore glossary generation settings
glossaryGenerateEnableSwitch.checked = getFromStorage('glossary_generate_enable', 'false') === 'true';
const savedAgentChoice = getFromStorage('glossary_agent_config_choice', 'same');
document.querySelector(`input[name="glossary_agent_config_choice"][value="${savedAgentChoice}"]`).checked = true;
const savedAgentThinkingMode = getFromStorage('glossary_agent_thinking_mode', 'default');
document.querySelector(`input[name="glossary_agent_thinking"][value="${savedAgentThinkingMode}"]`).checked = true;
// Initial UI updates based on restored settings
updateWorkflowUI();
updatePlatformUI();
updateConvertEnginUI();
updateCustomLangUI();
// [NEW] Setup glossary generation UI
setupGlossaryAgentPlatformUI();
updateGlossaryGenUI();
// Setup sliders
setupSlider(chunkSizeSlider, chunkSizeDisplay, chunkSizeReset, 'chunk_size', defaultParams);
setupSlider(concurrentSlider, concurrentDisplay, concurrentReset, 'concurrent', defaultParams);
setupSlider(temperatureSlider, temperatureDisplay, temperatureReset, 'temperature', defaultParams);
// [MODIFIED] Setup agent sliders. Their default values should be the same as the main translation configuration's defaults.
const agentDefaultParams = {
'glossary_agent_temperature': defaultParams.temperature,
'glossary_agent_chunk_size': defaultParams.chunk_size,
'glossary_agent_concurrent': defaultParams.concurrent
};
setupSlider(glossaryAgentTemperatureSlider, glossaryAgentTemperatureDisplay, glossaryAgentTemperatureReset, 'glossary_agent_temperature', agentDefaultParams);
setupSlider(glossaryAgentChunkSizeSlider, glossaryAgentChunkSizeDisplay, glossaryAgentChunkSizeReset, 'glossary_agent_chunk_size', agentDefaultParams);
setupSlider(glossaryAgentConcurrentSlider, glossaryAgentConcurrentDisplay, glossaryAgentConcurrentReset, 'glossary_agent_concurrent', agentDefaultParams);
// Setup other UI elements
document.querySelectorAll('.toggle-password').forEach(button => setupPasswordToggle(button));
// Restore tasks
if (isAdminMode) {
document.title = "DocuTranslate - Admin Panel";
try {
const response = await fetch('/service/task-list');
if (!response.ok) throw new Error(`Failed to fetch task list: ${response.statusText}`);
const allTaskIds = await response.json();
if (allTaskIds && Array.isArray(allTaskIds) && allTaskIds.length > 0) {
allTaskIds.reverse().forEach(taskId => createTaskCard(taskId, true));
}
} catch (error) {
console.error("Admin mode: Failed to load task list from server.", error);
alert(getText('admin_tasklist_failed'));
}
updateTaskPlaceholderVisibility();
} else {
const savedTaskIds = JSON.parse(getFromStorage('active_task_ids', '[]'));
if (savedTaskIds.length > 0) {
savedTaskIds.forEach(taskId => createTaskCard(taskId, true));
} else {
createTaskCard();
}
}
// Add event listeners for saving settings
workflowTypeSelect.addEventListener('change', updateWorkflowUI);
autoWorkflowSwitch.addEventListener('change', e => saveToStorage('translator_auto_workflow_enabled', e.target.checked));
xlsxInsertModeSelect.addEventListener('change', e => {
updateSeparatorVisibility(xlsxInsertModeSelect, xlsxSeparatorGroup);
saveToStorage('translator_xlsx_insert_mode', e.target.value);
});
xlsxSeparatorInput.addEventListener('input', e => saveToStorage('translator_xlsx_separator', e.target.value));
xlsxTranslateRegionsTextarea.addEventListener('input', e => saveToStorage('translator_xlsx_translate_regions', e.target.value));
docxInsertModeSelect.addEventListener('change', e => {
updateSeparatorVisibility(docxInsertModeSelect, docxSeparatorGroup);
saveToStorage('translator_docx_insert_mode', e.target.value);
});
docxSeparatorInput.addEventListener('input', e => saveToStorage('translator_docx_separator', e.target.value));
srtInsertModeSelect.addEventListener('change', e => {
updateSeparatorVisibility(srtInsertModeSelect, srtSeparatorGroup);
saveToStorage('translator_srt_insert_mode', e.target.value);
});
srtSeparatorInput.addEventListener('input', e => saveToStorage('translator_srt_separator', e.target.value));
epubInsertModeSelect.addEventListener('change', e => {
updateSeparatorVisibility(epubInsertModeSelect, epubSeparatorGroup);
saveToStorage('translator_epub_insert_mode', e.target.value);
});
epubSeparatorInput.addEventListener('input', e => saveToStorage('translator_epub_separator', e.target.value));
htmlInsertModeSelect.addEventListener('change', e => {
updateSeparatorVisibility(htmlInsertModeSelect, htmlSeparatorGroup);
saveToStorage('translator_html_insert_mode', e.target.value);
});
htmlSeparatorInput.addEventListener('input', e => saveToStorage('translator_html_separator', e.target.value));
jsonPathsTextarea.addEventListener('input', e => saveToStorage('translator_json_paths', e.target.value));
platformSelect.addEventListener('change', updatePlatformUI);
apikeyInput.addEventListener('input', e => saveToStorage(`translator_platform_${platformSelect.value}_apikey`, e.target.value));
modelInput.addEventListener('input', e => saveToStorage(`translator_platform_${platformSelect.value}_model_id`, e.target.value));
baseUrlInput.addEventListener('input', e => {
if (platformSelect.value === 'custom') saveToStorage('translator_platform_custom_base_url', e.target.value);
});
convertEnginSelect.addEventListener('change', () => updateConvertEnginUI(false));
mineruTokenInput.addEventListener('input', e => saveToStorage('translator_mineru_token', e.target.value));
modelVersionSelect.addEventListener('change', e => saveToStorage('translator_model_version', e.target.value));
toLangSelect.addEventListener('change', e => {
saveToStorage('translator_to_lang', e.target.value);
updateCustomLangUI();
});
customLangInput.addEventListener('input', e => saveToStorage('translator_custom_to_lang', e.target.value));
document.querySelectorAll('#thinkingModeBtnGroup input[name="thinking"]').forEach(radio => {
radio.addEventListener('change', (e) => {
saveToStorage('translator_thinking_mode', e.target.value);
});
});
formulaCheckbox.addEventListener('change', e => saveToStorage('translator_formula_ocr', e.target.checked));
codeCheckbox.addEventListener('change', e => saveToStorage('translator_code_ocr', e.target.checked));
customPromptTranslateArea.addEventListener('input', () => saveToStorage("custom_prompt", customPromptTranslateArea.value));
glossaryFilesInput.addEventListener('change', handleGlossaryFiles);
viewGlossaryBtn.addEventListener('click', () => {
populateGlossaryModal();
glossaryModal.show();
});
clearGlossaryBtn.addEventListener('click', clearGlossary);
// [NEW] Glossary generation event listeners
glossaryGenerateEnableSwitch.addEventListener('change', (e) => {
saveToStorage('glossary_generate_enable', e.target.checked);
updateGlossaryGenUI();
});
glossaryAgentConfigChoiceRadios.forEach(radio => {
radio.addEventListener('change', (e) => {
saveToStorage('glossary_agent_config_choice', e.target.value);
updateGlossaryCustomConfigUI();
});
});
glossaryAgentBaseUrlInput.addEventListener('input', e => {
if (glossaryAgentPlatformSelect.value === 'custom') saveToStorage('glossary_agent_platform_custom_baseurl', e.target.value);
});
glossaryAgentKeyInput.addEventListener('input', e => saveToStorage(`glossary_agent_platform_${glossaryAgentPlatformSelect.value}_apikey`, e.target.value));
glossaryAgentModelIdInput.addEventListener('input', e => saveToStorage(`glossary_agent_platform_${glossaryAgentPlatformSelect.value}_model_id`, e.target.value));
glossaryAgentThinkingRadios.forEach(radio => {
radio.addEventListener('change', e => saveToStorage('glossary_agent_thinking_mode', e.target.value));
});
// Add task button listener
addNewTaskBtn.addEventListener('click', () => createTaskCard());
// Add preview listeners
setBilingualViewBtn.addEventListener('click', () => setPreviewDisplayMode('bilingual'));
setTranslatedOnlyViewBtn.addEventListener('click', () => setPreviewDisplayMode('translatedOnly'));
window.addEventListener('resize', () => {
// Re-initialize split view on resize if canvas is open
if (previewOffcanvasEl.classList.contains('show')) {
setPreviewDisplayMode(originalPreviewContainer.style.display !== 'none' ? 'bilingual' : 'translatedOnly');
}
});
}
// --- Theme switcher logic ---
const getPreferredTheme = () => {
const storedTheme = localStorage.getItem('theme');
if (storedTheme) {
return storedTheme;
}
return 'auto';
};
const setTheme = theme => {
if (theme === 'auto') {
document.documentElement.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
} else {
document.documentElement.setAttribute('data-bs-theme', theme);
}
};
const showActiveTheme = (theme) => {
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
element.classList.remove('active');
});
const activeButton = document.querySelector(`[data-bs-theme-value="${theme}"]`);
if (activeButton) {
activeButton.classList.add('active');
}
};
const preferredTheme = getPreferredTheme();
setTheme(preferredTheme);
showActiveTheme(preferredTheme);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const storedTheme = localStorage.getItem('theme');
if (storedTheme === 'auto' || !storedTheme) {
setTheme('auto');
}
});
document.querySelectorAll('[data-bs-theme-value]').forEach(toggle => {
toggle.addEventListener('click', () => {
const theme = toggle.getAttribute('data-bs-theme-value');
localStorage.setItem('theme', theme);
setTheme(theme);
showActiveTheme(theme);
});
});
// --- Start the application ---
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>