feat: 全公司共享三语术语表(中/英/印尼),保存即默认引用

- glossary_store.py: 进程级单例JSON存储,原子写入+asyncio锁,DOCUTRANSLATE_GLOSSARY_PATH可配置
- lang_mapping.py: 三列(zh/en/id)与{src:dst}互转,按目标语言自动取列;兼容旧2列CSV
- app.py: 新增GET/POST /service/glossary路由
- environment.py: 新增DOCUTRANSLATE_GLOSSARY_PATH环境变量
- index.html: 弹窗改为可编辑3列表+增删行+保存/导入/导出CSV;页面打开自动加载共享术语表;
  翻译时前端按to_lang编译为{src:dst},payload.glossary_dict契约不变
- i18nData.json: 三语新增术语表相关UI文案
- 翻译管线零改动:MT走translation_options.terms,非MT走system prompt注入

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 19:18:51 +08:00
parent 2d39c009cf
commit f2e6d847e2
7 changed files with 449 additions and 24 deletions

2
.gitignore vendored
View File

@@ -8,6 +8,8 @@ wheels/
tests/resource/
tests/
docutranslate/output/
# Shared glossary runtime data
data/
# Virtual environments
.venv
#idea

View File

@@ -2206,6 +2206,28 @@ async def service_get_app_version():
return JSONResponse(content={"version": __version__})
@service_router.get(
"/glossary", tags=["Application"], description="获取全公司共享术语表(三列:中文/English/Bahasa"
)
async def service_get_glossary():
from docutranslate.glossary.glossary_store import get_glossary_store
store = get_glossary_store()
return JSONResponse(content={"rows": store.get_rows()})
@service_router.post(
"/glossary", tags=["Application"], description="覆盖保存全公司共享术语表(保存即对所有用户生效)"
)
async def service_save_glossary(body: dict = Body(...)):
from docutranslate.glossary.glossary_store import get_glossary_store
raw_rows = body.get("rows") if isinstance(body, dict) else None
if not isinstance(raw_rows, list):
raise HTTPException(status_code=400, detail="请求体需为 {\"rows\": [...]}")
store = get_glossary_store()
saved = await store.save(raw_rows)
return JSONResponse(content={"rows": saved})
@service_router.post(
"/flat-translate",
summary="translate(sync)",

View File

@@ -24,6 +24,10 @@ DOCUTRANSLATE_PROXY_ENABLED: bool = (
# 任务缓存数量
DOCUTRANSLATE_CACHE_NUM: int = int(os.getenv("DOCUTRANSLATE_CACHE_NUM", "10"))
# --- 共享术语表存储路径 ---
# 留空则使用工作目录下 ./data/glossary.json
DOCUTRANSLATE_GLOSSARY_PATH: str = os.getenv("DOCUTRANSLATE_GLOSSARY_PATH", "")
# --- 翻译 API 默认配置 ---
# 默认 API 地址 (自定义接口的 Base URL)
DOCUTRANSLATE_BASE_URL: str = os.getenv("DOCUTRANSLATE_BASE_URL", "")

View File

@@ -0,0 +1,115 @@
# SPDX-FileCopyrightText: 2025 QinHan
# SPDX-License-Identifier: MPL-2.0
"""
共享术语表持久化存储。
设计要点:
- 进程级单例,所有用户读写同一份 JSON 文件。
- 原子写入(临时文件 + os.replaceasyncio.Lock 保护并发写。
- 三列结构 {zh, en, id},按目标语言编译为单向 {src: dst}(见 lang_mapping.py
"""
import asyncio
import json
import os
import tempfile
from pathlib import Path
from typing import Any
from docutranslate.logger import global_logger
_COLS = ("zh", "en", "id")
_VERSION = 1
def _default_path() -> Path:
"""解析术语表存储路径:优先环境变量,否则工作目录下 ./data/glossary.json"""
from docutranslate.environment import DOCUTRANSLATE_GLOSSARY_PATH
if DOCUTRANSLATE_GLOSSARY_PATH:
p = Path(DOCUTRANSLATE_GLOSSARY_PATH).expanduser()
else:
p = Path.cwd() / "data" / "glossary.json"
p.parent.mkdir(parents=True, exist_ok=True)
return p
def _normalize_row(raw: Any) -> dict:
"""规范化一行:确保 zh/en/id 三键均为 str空串允许"""
if not isinstance(raw, dict):
return {c: "" for c in _COLS}
return {c: str(raw.get(c, "") or "") for c in _COLS}
class GlossaryStore:
"""进程级共享术语表存储(单例)。"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._initialized = True
self.logger = global_logger
self._path = _default_path()
self._lock = asyncio.Lock()
self._cache: list[dict] | None = None
self.logger.info(f"术语表存储路径: {self._path}")
def _read_from_disk(self) -> list[dict]:
if not self._path.exists():
return []
try:
with self._path.open("r", encoding="utf-8") as f:
data = json.load(f)
rows = data.get("rows", []) if isinstance(data, dict) else []
return [_normalize_row(r) for r in rows]
except (OSError, json.JSONDecodeError) as e:
self.logger.error(f"读取术语表失败,回退为空: {e}")
return []
def _write_to_disk(self, rows: list[dict]) -> None:
payload = {"version": _VERSION, "rows": rows}
# 原子写:先写临时文件再替换
fd, tmp_path = tempfile.mkstemp(
prefix="glossary_", suffix=".json", dir=str(self._path.parent)
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
os.replace(tmp_path, self._path)
except Exception:
if os.path.exists(tmp_path):
os.remove(tmp_path)
raise
def get_rows(self) -> list[dict]:
"""同步读取所有行(带进程内缓存)。"""
if self._cache is None:
self._cache = self._read_from_disk()
return [dict(r) for r in self._cache]
async def save(self, rows: list[dict]) -> list[dict]:
"""校验并原子保存全部行,返回保存后的规范化行。"""
normalized = [_normalize_row(r) for r in rows]
# 过滤掉三列全空的行
normalized = [r for r in normalized if any(r[c] for c in _COLS)]
async with self._lock:
self._write_to_disk(normalized)
self._cache = [dict(r) for r in normalized]
return [dict(r) for r in normalized]
async def reload(self) -> list[dict]:
"""强制从磁盘重新加载(调试/外部编辑后)。"""
async with self._lock:
self._cache = self._read_from_disk()
return [dict(r) for r in self._cache]
def get_glossary_store() -> GlossaryStore:
"""获取全局单例。"""
return GlossaryStore()

View File

@@ -0,0 +1,123 @@
# SPDX-FileCopyrightText: 2025 QinHan
# SPDX-License-Identifier: MPL-2.0
"""
三语术语表(中文/English/Bahasa与单向 {src: dst} 字典的互转,
以及与 CSV 的导入/导出。
列约定zh / en / id
"""
import csv
import io
from typing import Any
# 目标语言 -> 列名 的映射(小写匹配)
_LANG_ALIASES = {
# 中文(简/繁)
"zh": "zh",
"zh-cn": "zh",
"zh-hans": "zh",
"zh-hant": "zh",
"chinese": "zh",
"simplified chinese": "zh",
"traditional chinese": "zh",
"中文": "zh",
"简体中文": "zh",
"繁体中文": "zh",
"汉语": "zh",
# 英文
"en": "en",
"en-us": "en",
"en-gb": "en",
"english": "en",
"英文": "en",
"英语": "en",
# 印尼语
"id": "id",
"id-id": "id",
"indonesian": "id",
"bahasa indonesia": "id",
"印尼文": "id",
"印尼语": "id",
"印度尼西亚语": "id",
}
_COLS = ("zh", "en", "id")
def lang_to_col(to_lang: str | None) -> str | None:
"""目标语言字符串 -> 列名 'zh'|'en'|'id',无法识别返回 None。"""
if not to_lang:
return None
key = str(to_lang).strip().lower()
return _LANG_ALIASES.get(key)
def build_glossary_dict(rows: list[dict], to_lang: str | None) -> dict[str, str] | None:
"""
将三列 rows 编译为单向 {src: dst} 字典。
规则:目标列作为 dst其它两列作为 src覆盖任意源语言命中文本。
返回 None 表示目标语言不受支持(不注入术语表,安全降级)。
"""
target_col = lang_to_col(to_lang)
if target_col is None or not rows:
return None
result: dict[str, str] = {}
for raw in rows:
if not isinstance(raw, dict):
continue
target = str(raw.get(target_col, "") or "").strip()
if not target:
continue
for col in _COLS:
if col == target_col:
continue
src = str(raw.get(col, "") or "").strip()
if src and src not in result:
result[src] = target
return result or None
def rows_to_csv(rows: list[dict]) -> str:
"""三列 rows -> CSV 字符串(含 BOM表头 zh,en,id"""
buf = io.StringIO()
buf.write("") # UTF-8 BOM便于 Excel 正确识别
writer = csv.writer(buf)
writer.writerow(["zh", "en", "id"])
for raw in rows:
if not isinstance(raw, dict):
continue
writer.writerow([str(raw.get(c, "") or "") for c in _COLS])
return buf.getvalue()
def csv_to_rows(parsed: list[dict] | list[list]) -> list[dict]:
"""
解析后的 CSVPapaParse 结果)-> rows。
支持三种格式:
- 3 列表头 zh/en/id
- 旧 2 列表头 src/dst
- 无表头的 2/3 列数组(兜底按 [zh,en] 或 [zh,en,id] 处理)
"""
rows: list[dict] = []
for item in parsed:
if isinstance(item, dict):
# 有表头
if any(c in item for c in _COLS):
rows.append({c: str(item.get(c, "") or "").strip() for c in _COLS})
elif "src" in item and "dst" in item:
# 旧 2 列格式:无法判断语言,放入 en 列作 src、zh 列作 dst最常见的英译中场景
rows.append({"zh": str(item.get("dst", "") or "").strip(),
"en": str(item.get("src", "") or "").strip(),
"id": ""})
else:
continue
elif isinstance(item, list):
cells = [str(x or "").strip() for x in item]
if len(cells) >= 3:
rows.append({"zh": cells[0], "en": cells[1], "id": cells[2]})
elif len(cells) == 2:
rows.append({"zh": cells[0], "en": cells[1], "id": ""})
# 过滤全空行
return [r for r in rows if any(r[c] for c in _COLS)]

View File

@@ -107,7 +107,7 @@
"tpmLabel": "每分钟Token数(估算)",
"unlimitedPlaceholder": "留空为无限制",
"glossaryGenTitle": "术语表",
"glossaryLabel": "术语表 (可选)",
"glossaryLabel": "术语表(全公司共享)",
"glossaryHelp": "选择一个或多个CSV文件。文件需包含'src'和'dst'两列标题,分别代表原文和译文。",
"viewGlossaryBtn": "查看术语表",
"clearGlossaryBtn": "清空",
@@ -174,7 +174,18 @@
"preview_translatedOnly": "仅译文预览",
"configImportSuccess": "配置导入成功!",
"configImportError": "配置文件解析失败,请检查文件格式。",
"providerLabel": "接口供应商"
"providerLabel": "接口供应商",
"glossarySharedHint": "此术语表全公司共享,保存后对所有用户生效。翻译时按目标语言自动取对应列。",
"glossaryColZh": "中文",
"glossaryColEn": "English",
"glossaryColId": "Bahasa Indonesia",
"glossaryDeleteRow": "删除此行",
"glossaryAddRow": "添加术语",
"glossarySave": "保存",
"glossaryExportCsv": "导出CSV",
"glossaryUnsaved": "未保存",
"glossarySaved": "术语表已保存,对所有用户生效。",
"glossarySaveFailed": "保存失败"
},
"en": {
"pageTitle": "DocuTranslate - Interactive Document Translation",
@@ -284,7 +295,7 @@
"tpmLabel": "Tokens Per Minute(Estimated)",
"unlimitedPlaceholder": "Leave empty for unlimited",
"glossaryGenTitle": "Glossary",
"glossaryLabel": "Glossary (Optional)",
"glossaryLabel": "Glossary (shared company-wide)",
"glossaryHelp": "Select one or more CSV files. Files must contain 'src' and 'dst' headers, representing source and destination terms.",
"viewGlossaryBtn": "View Glossary",
"clearGlossaryBtn": "Clear",
@@ -351,7 +362,18 @@
"preview_translatedOnly": "Translation Only Preview",
"configImportSuccess": "Configuration imported successfully!",
"configImportError": "Failed to parse config file, please check the file format.",
"providerLabel": "Provider"
"providerLabel": "Provider",
"glossarySharedHint": "This glossary is shared company-wide. Saved changes apply to all users. The target language column is used automatically during translation.",
"glossaryColZh": "中文",
"glossaryColEn": "English",
"glossaryColId": "Bahasa Indonesia",
"glossaryDeleteRow": "Delete row",
"glossaryAddRow": "Add term",
"glossarySave": "Save",
"glossaryExportCsv": "Export CSV",
"glossaryUnsaved": "Unsaved",
"glossarySaved": "Glossary saved and applied to all users.",
"glossarySaveFailed": "Save failed"
},
"id": {
"pageTitle": "DocuTranslate - Terjemahan Dokumen Interaktif",
@@ -461,7 +483,7 @@
"tpmLabel": "Token per Menit (TPM - Estimasi)",
"unlimitedPlaceholder": "Kosongkan untuk tanpa batas",
"glossaryGenTitle": "Glosarium",
"glossaryLabel": "Glosarium (Opsional)",
"glossaryLabel": "Glosarium (dibagikan ke seluruh perusahaan)",
"glossaryHelp": "Pilih file CSV dengan kolom src dan dst.",
"viewGlossaryBtn": "Lihat Glosarium",
"clearGlossaryBtn": "Hapus",
@@ -528,6 +550,17 @@
"preview_translatedOnly": "Pratinjau Hanya Terjemahan",
"configImportSuccess": "Konfigurasi berhasil diimpor!",
"configImportError": "Gagal memproses file konfigurasi.",
"providerLabel": "Provider"
"providerLabel": "Provider",
"glossarySharedHint": "Glosarium ini dibagikan ke seluruh perusahaan. Perubahan tersimpan berlaku untuk semua pengguna. Kolom bahasa target digunakan otomatis saat menerjemahkan.",
"glossaryColZh": "中文",
"glossaryColEn": "English",
"glossaryColId": "Bahasa Indonesia",
"glossaryDeleteRow": "Hapus baris",
"glossaryAddRow": "Tambah istilah",
"glossarySave": "Simpan",
"glossaryExportCsv": "Ekspor CSV",
"glossaryUnsaved": "Belum disimpan",
"glossarySaved": "Glosarium disimpan, berlaku untuk semua pengguna.",
"glossarySaveFailed": "Gagal menyimpan"
}
}

View File

@@ -848,31 +848,50 @@
<!-- Modals -->
<div class="modal fade" id="glossaryModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">{{ t('glossaryModalTitle') }}</h5>
<div class="modal-header"><h5 class="modal-title">{{ t('glossaryModalTitle') }}
<span v-if="glossaryDirty" class="badge bg-warning ms-2">{{ t('glossaryUnsaved') }}</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<table class="table table-striped table-hover">
<div class="alert alert-info py-2 small mb-2">{{ t('glossarySharedHint') }}</div>
<table class="table table-sm table-hover align-middle">
<thead>
<tr>
<th>{{ t('glossaryTableSource') }}</th>
<th>{{ t('glossaryTableDestination') }}</th>
<th style="width:32%">{{ t('glossaryColZh') }}</th>
<th style="width:32%">{{ t('glossaryColEn') }}</th>
<th style="width:32%">{{ t('glossaryColId') }}</th>
<th style="width:4%"></th>
</tr>
</thead>
<tbody>
<tr v-for="(dst, src) in glossaryData" :key="src">
<td>{{ src }}</td>
<td>{{ dst }}</td>
<tr v-for="(row, i) in glossaryRows" :key="i">
<td><input class="form-control form-control-sm" v-model="row.zh" @input="glossaryDirty=true"></td>
<td><input class="form-control form-control-sm" v-model="row.en" @input="glossaryDirty=true"></td>
<td><input class="form-control form-control-sm" v-model="row.id" @input="glossaryDirty=true"></td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-danger py-0 px-1"
@click="deleteGlossaryRow(i)" :title="t('glossaryDeleteRow')"><i class="bi bi-x-lg"></i></button>
</td>
</tr>
<tr v-if="Object.keys(glossaryData).length === 0">
<td colspan="2" class="text-center text-muted">{{ t('glossaryEmpty') }}</td>
<tr v-if="glossaryRows.length === 0">
<td colspan="4" class="text-center text-muted">{{ t('glossaryEmpty') }}</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="exportGlossaryCsv">
<i class="bi bi-download me-1"></i>{{ t('glossaryExportCsv') }}
</button>
<button type="button" class="btn btn-outline-primary" @click="addGlossaryRow">
<i class="bi bi-plus-lg me-1"></i>{{ t('glossaryAddRow') }}
</button>
<button type="button" class="btn btn-primary" @click="saveGlossary" :disabled="!glossaryDirty">
<i class="bi bi-save me-1"></i>{{ t('glossarySave') }}
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('closeBtn') }}</button>
</div>
</div>
@@ -1041,6 +1060,9 @@
const currentLang = ref(localStorage.getItem('ui_language') || detectBrowserLang());
const i18nData = ref({});
const glossaryData = ref({});
const glossaryRows = ref([]); // 共享术语表三列行 [{zh,en,id}]
const glossaryDirty = ref(false); // 是否有未保存的编辑
const glossarySaving = ref(false);
const tasks = ref([]);
const enginList = ref([]);
const defaultParams = reactive({});
@@ -1330,7 +1352,95 @@
showFormula: ['mineru', 'docling'].includes(form.convert_engine),
showCode: form.convert_engine === 'docling'
}));
const glossaryCount = computed(() => Object.keys(glossaryData.value).length);
const glossaryCount = computed(() => glossaryRows.value.length);
// --- 三列术语表(中文/English/Bahasa本地工具函数 ---
const LANG_COL_ALIASES = {
'zh':'zh','zh-cn':'zh','zh-hans':'zh','zh-hant':'zh','chinese':'zh',
'simplified chinese':'zh','traditional chinese':'zh','中文':'zh','简体中文':'zh','繁体中文':'zh','汉语':'zh',
'en':'en','en-us':'en','en-gb':'en','english':'en','英文':'en','英语':'en',
'id':'id','id-id':'id','indonesian':'id','bahasa indonesia':'id','印尼文':'id','印尼语':'id','印度尼西亚语':'id'
};
const langToCol = (lang) => {
if (!lang) return null;
return LANG_COL_ALIASES[String(lang).trim().toLowerCase()] || null;
};
// 根据当前目标语言,把三列行编译成 {src: dst}(供翻译引擎消费,契约与旧 glossaryData 一致)
const buildGlossaryDict = () => {
const targetCol = langToCol(form.to_lang === 'custom' ? form.custom_to_lang : form.to_lang);
if (!targetCol || !glossaryRows.value.length) return null;
const result = {};
for (const row of glossaryRows.value) {
const target = (row[targetCol] || '').trim();
if (!target) continue;
for (const col of ['zh','en','id']) {
if (col === targetCol) continue;
const src = (row[col] || '').trim();
if (src && !(src in result)) result[src] = target;
}
}
return Object.keys(result).length ? result : null;
};
const addGlossaryRow = () => {
glossaryRows.value.push({zh:'', en:'', id:''});
glossaryDirty.value = true;
};
const deleteGlossaryRow = (i) => {
glossaryRows.value.splice(i, 1);
glossaryDirty.value = true;
};
const loadGlossaryFromServer = async () => {
try {
const res = await fetch('/service/glossary');
const data = await res.json();
glossaryRows.value = (data && Array.isArray(data.rows)) ? data.rows.map(r => ({
zh: String(r.zh||''), en: String(r.en||''), id: String(r.id||'')
})) : [];
glossaryDirty.value = false;
} catch (e) {
console.error('Failed to load shared glossary', e);
}
};
const saveGlossary = async () => {
if (glossarySaving.value) return;
glossarySaving.value = true;
try {
const rows = glossaryRows.value
.map(r => ({zh:(r.zh||'').trim(), en:(r.en||'').trim(), id:(r.id||'').trim()}))
.filter(r => r.zh || r.en || r.id);
const res = await fetch('/service/glossary', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({rows})
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
glossaryRows.value = (data.rows || []).map(r => ({
zh: String(r.zh||''), en: String(r.en||''), id: String(r.id||'')
}));
glossaryDirty.value = false;
alert(t('glossarySaved'));
} catch (e) {
alert(t('glossarySaveFailed') + ': ' + e.message);
} finally {
glossarySaving.value = false;
}
};
const exportGlossaryCsv = () => {
const rows = glossaryRows.value;
const esc = (s) => {
s = String(s||'');
return /[",\n]/.test(s) ? '"' + s.replace(/"/g,'""') + '"' : s;
};
let csv = 'zh,en,id\n';
rows.forEach(r => { csv += [esc(r.zh), esc(r.en), esc(r.id)].join(',') + '\n'; });
const blob = new Blob([csv], {type: 'text/csv;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'glossary.csv'; a.click();
URL.revokeObjectURL(url);
};
const handleGlossaryFiles = (e) => {
const files = e.target.files;
@@ -1339,17 +1449,29 @@
Papa.parse(f, {
header: true, skipEmptyLines: true,
complete: (res) => {
if (res.data) res.data.forEach(r => {
if (r.src && r.dst) glossaryData.value[r.src.trim()] = r.dst.trim();
if (!res.data) return;
res.data.forEach(r => {
const row = {zh:'', en:'', id:''};
if ('zh' in r || 'en' in r || 'id' in r) {
row.zh = (r.zh||'').trim(); row.en = (r.en||'').trim(); row.id = (r.id||'').trim();
} else if ('src' in r && 'dst' in r) {
// 旧 2 列src 视为英文、dst 视为中文(最常见英译中场景)
row.zh = (r.dst||'').trim(); row.en = (r.src||'').trim();
} else { return; }
if (row.zh || row.en || row.id) {
glossaryRows.value.push(row);
glossaryDirty.value = true;
}
});
}
});
});
};
const clearGlossary = () => {
glossaryData.value = {};
if (glossaryInput.value) glossaryInput.value.value = '';
};
const clearGlossary = async () => {
// 清空当前编辑并从服务端重新拉取(避免误删共享数据)
await loadGlossaryFromServer();
};
const openGlossaryModal = () => new bootstrap.Modal(document.getElementById('glossaryModal')).show();
// Task Management
@@ -1435,7 +1557,7 @@
temperature: Number(form.temperature),
retry: Number(form.retry),
custom_prompt: emptyToNull(form.custom_prompt),
glossary_dict: Object.keys(glossaryData.value).length ? glossaryData.value : null,
glossary_dict: buildGlossaryDict(),
system_proxy_enable: form.system_proxy_enable,
force_json: form.force_json,
workflow_type: form.workflow_type,
@@ -1985,6 +2107,9 @@
loadConfig();
setTheme(localStorage.getItem('theme') || 'auto');
// Load shared trilingual glossary (全公司通用,默认引用)
await loadGlossaryFromServer();
// Restore tasks
if (window.location.pathname.includes('/admin')) {
document.title = "DocuTranslate - Admin Panel";
@@ -2011,12 +2136,13 @@
});
return {
version, currentLang, i18nData, glossaryData, glossaryCount, tasks, enginList, defaultParams,
version, currentLang, i18nData, glossaryData, glossaryCount, glossaryRows, glossaryDirty, glossarySaving, tasks, enginList, defaultParams,
modelPresets,
form, workflowParams, showMineruToken, previewMode, syncScrollEnabled, showIdentityOption,
errors, clearError,
t, createNewTask, removeTask, handleTaskFileSelect, handleTaskFileDrop, toggleTaskState,
handleGlossaryFiles, clearGlossary, openGlossaryModal,
addGlossaryRow, deleteGlossaryRow, saveGlossary, exportGlossaryCsv,
openPreview, setPreviewMode, toggleSyncScroll, printPdf, copyLog, getFileIcon,
exportConfig, importConfig, setLang, setTheme, capitalize,
currentWorkflowConfig, ocrOptions, stepMap,