From 34d00da37cdccfe880e85c7feb81cc773d42cc2a Mon Sep 17 00:00:00 2001 From: MaurUppi Date: Thu, 13 Aug 2026 16:14:40 +0800 Subject: [PATCH 01/12] feat(glossary): support per-task glossary selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 任务可显式选用参与匹配的词库,不再只能跟随全局 enabled 开关。 - core: 新增 resolveTaskGlossaryEntries(glossaries, ids)。筛选发生在 normalizeGlossaries 之后、enabled 过滤之前:给了 ids 就按 ids 取(忽略 enabled,关掉的库仍可被显式勾上),没给就按 enabled。两条路径都保持全局 order 排序与「同原文首个胜出」语义,冲突判定不随勾选顺序变化。 - core: 新增纯函数 describeGlossarySource() 生成来源标注, logGlossaryConflicts / logGlossaryMatches 的 context 追加「任务词库 N 个」 或「全局已启用」,便于排查未命中原因。 - glossaryManager: 新增 getTaskGlossaryResolution(ids); resolveEnabledGlossaryEntries / getActiveGlossaryResolution 保留为等价包装, 校对链路调用点不受影响。 - translateWithProvider: 已有 10 个位置参数,扩参走尾部 options 对象 ({ glossaryIds }),避免错位;未传 options 的调用点日志字节级不变。 - types: IFormData.glossaryIds?: string[]。undefined = 回落全部已启用(旧配方 与历史快照的语义),[] = 明确不用词库,两者全链路不得互相塌缩;非数组的畸形 输入按 undefined 走旧行为。 - renderer: 新增 GlossarySelectControl 配置条多选,未建库时显示「去词库」链接 且不挡开始;停用库标注「已停用」仍可勾选;提供「恢复默认」写回 undefined。 - test: scripts/test-glossary.ts 扩 testTaskGlossarySelection,覆盖 undefined / [] / 指定 id / 勾中 disabled 库 / order 不随勾选顺序变 / 未知与重复 id / 畸形输入 / 入参不被改写。63 → 87 passed。 --- main/glossary/core.ts | 68 ++++--- main/helpers/glossaryManager.ts | 9 +- main/translate/index.ts | 3 + main/translate/services/ai.ts | 4 +- .../translate/services/translationProvider.ts | 12 +- main/translate/types/index.ts | 2 + .../tasks/GlossarySelectControl.tsx | 166 ++++++++++++++++++ renderer/components/tasks/InlineConfigBar.tsx | 3 + renderer/public/locales/en/tasks.json | 11 +- renderer/public/locales/zh/tasks.json | 11 +- scripts/test-glossary.ts | 164 +++++++++++++++++ types/types.ts | 2 + 12 files changed, 424 insertions(+), 31 deletions(-) create mode 100644 renderer/components/tasks/GlossarySelectControl.tsx diff --git a/main/glossary/core.ts b/main/glossary/core.ts index f409c4bc..bba75bef 100644 --- a/main/glossary/core.ts +++ b/main/glossary/core.ts @@ -129,42 +129,64 @@ export function glossarySourceKey(source: string): string { return source.normalize('NFKC').toLowerCase(); } +/** 词库来源标注:undefined = 全局已启用回落;数组 = 本次任务显式选用。 */ +export function describeGlossarySource(glossaryIds?: string[]): string { + if (!Array.isArray(glossaryIds)) return '全局已启用'; + return `任务词库 ${glossaryIds.length} 个`; +} + /** - * 将所有启用词库解析为一条优先级有序的词条流。 - * 相同原文(忽略大小写与全/半角)只保留首个,并返回冲突供任务日志提示。 + * 按任务选用解析词条。`glossaryIds === undefined` 回落全部已启用(旧行为); + * `[]` 表示明确不用词库。筛选发生在 normalize 之后:给了 ids 就按 id 取 + * (忽略 enabled),没给就按 enabled。两条路径都按全局 order 与「同原文首个胜出」。 */ -export function resolveEnabledGlossaryEntries( +export function resolveTaskGlossaryEntries( glossaries: Glossary[], + glossaryIds?: string[], ): GlossaryResolution { const entries: ResolvedGlossaryEntry[] = []; const conflicts: GlossaryConflict[] = []; const firstBySource = new Map(); - normalizeGlossaries(glossaries) - .filter((glossary) => glossary.enabled) - .forEach((glossary) => { - glossary.entries.forEach((entry, entryOrder) => { - const resolved: ResolvedGlossaryEntry = { - ...entry, - glossaryId: glossary.id, - glossaryName: glossary.name, - glossaryOrder: glossary.order, - entryOrder, - }; - const key = glossarySourceKey(entry.source); - const kept = firstBySource.get(key); - if (kept) { - conflicts.push({ source: entry.source, kept, ignored: resolved }); - return; - } - firstBySource.set(key, resolved); - entries.push(resolved); - }); + const normalized = normalizeGlossaries(glossaries); + const selectedIds = Array.isArray(glossaryIds) + ? glossaryIds.filter((id): id is string => typeof id === 'string') + : undefined; + const selected = + selectedIds === undefined + ? normalized.filter((glossary) => glossary.enabled) + : normalized.filter((glossary) => selectedIds.includes(glossary.id)); + + selected.forEach((glossary) => { + glossary.entries.forEach((entry, entryOrder) => { + const resolved: ResolvedGlossaryEntry = { + ...entry, + glossaryId: glossary.id, + glossaryName: glossary.name, + glossaryOrder: glossary.order, + entryOrder, + }; + const key = glossarySourceKey(entry.source); + const kept = firstBySource.get(key); + if (kept) { + conflicts.push({ source: entry.source, kept, ignored: resolved }); + return; + } + firstBySource.set(key, resolved); + entries.push(resolved); }); + }); return { entries, conflicts }; } +/** + * 将所有启用词库解析为一条优先级有序的词条流。 + * 相同原文(忽略大小写与全/半角)只保留首个,并返回冲突供任务日志提示。 + */ +export const resolveEnabledGlossaryEntries = (glossaries: Glossary[]) => + resolveTaskGlossaryEntries(glossaries, undefined); + /** 单条优化用稳定指纹去重;冲突消失后返回空串,恢复时会重新记录。 */ export function glossaryConflictFingerprint( conflicts: GlossaryConflict[], diff --git a/main/helpers/glossaryManager.ts b/main/helpers/glossaryManager.ts index fb32ec60..026db15b 100644 --- a/main/helpers/glossaryManager.ts +++ b/main/helpers/glossaryManager.ts @@ -13,7 +13,7 @@ import { mergeGlossaryImportEntry, normalizeGlossaries, reorderGlossaries, - resolveEnabledGlossaryEntries, + resolveTaskGlossaryEntries, } from '../glossary/core'; import { logMessage, store } from './storeManager'; @@ -285,9 +285,14 @@ export function importGlossaryEntries( return { glossary, added, updated, skipped }; } +/** 按任务选用读取运行期快照;`ids === undefined` 回落全部已启用。 */ +export function getTaskGlossaryResolution(ids?: string[]): GlossaryResolution { + return resolveTaskGlossaryEntries(listGlossaries(), ids); +} + /** 读取运行期快照;日志由每次翻译/优化操作在自己的边界显式记录。 */ export function getActiveGlossaryResolution(): GlossaryResolution { - return resolveEnabledGlossaryEntries(listGlossaries()); + return getTaskGlossaryResolution(undefined); } export function logGlossaryConflicts( diff --git a/main/translate/index.ts b/main/translate/index.ts index 20f0d5c9..b11de577 100644 --- a/main/translate/index.ts +++ b/main/translate/index.ts @@ -192,6 +192,9 @@ export default async function translate( onProgress, handleTranslationResult, retryCount, + undefined, + undefined, + { glossaryIds: formData?.glossaryIds }, ); logMessage('Translation completed', 'info'); diff --git a/main/translate/services/ai.ts b/main/translate/services/ai.ts index 421d1981..03a195f9 100644 --- a/main/translate/services/ai.ts +++ b/main/translate/services/ai.ts @@ -221,7 +221,9 @@ export async function handleAIBatchTranslation( const glossaryBlock = buildGlossaryPromptBlock(glossarySelection.included); logGlossaryMatches( glossarySelection.included, - `AI 翻译批次 ${currentBatchIndex}/${totalBatches}`, + `AI 翻译批次 ${currentBatchIndex}/${totalBatches}${ + config.glossarySourceLabel ? `,${config.glossarySourceLabel}` : '' + }`, glossarySelection.omittedCount, ); diff --git a/main/translate/services/translationProvider.ts b/main/translate/services/translationProvider.ts index 6e96452a..7c7d1d74 100644 --- a/main/translate/services/translationProvider.ts +++ b/main/translate/services/translationProvider.ts @@ -29,8 +29,9 @@ import { } from '../../service'; import { DEFAULT_BATCH_SIZE } from '../constants'; import { getTaskSignal } from '../../helpers/taskContext'; +import { describeGlossarySource } from '../../glossary/core'; import { - getActiveGlossaryResolution, + getTaskGlossaryResolution, logGlossaryConflicts, } from '../../helpers/glossaryManager'; @@ -73,14 +74,18 @@ export async function translateWithProvider( maxRetries: number = 0, useGlossary: boolean = true, onResponseMeta?: TranslationConfig['onResponseMeta'], + options?: { glossaryIds?: string[] }, ): Promise { const supportsGlossary = provider.isAi || provider.type === 'qwenMt'; const glossaryResolution = - supportsGlossary && useGlossary ? getActiveGlossaryResolution() : undefined; + supportsGlossary && useGlossary + ? getTaskGlossaryResolution(options?.glossaryIds) + : undefined; + const glossarySourceLabel = describeGlossarySource(options?.glossaryIds); if (glossaryResolution) { logGlossaryConflicts( glossaryResolution.conflicts, - provider.type === 'qwenMt' ? 'Qwen-MT 翻译' : 'AI 翻译', + `${provider.type === 'qwenMt' ? 'Qwen-MT 翻译' : 'AI 翻译'},${glossarySourceLabel}`, ); } const glossaryEntries = glossaryResolution?.entries; @@ -90,6 +95,7 @@ export async function translateWithProvider( targetLanguage, translator, glossaryEntries, + ...(options ? { glossarySourceLabel } : {}), signal: getTaskSignal(), onResponseMeta, }; diff --git a/main/translate/types/index.ts b/main/translate/types/index.ts index e7508b0c..8eae008a 100644 --- a/main/translate/types/index.ts +++ b/main/translate/types/index.ts @@ -19,6 +19,8 @@ export interface TranslationConfig { provider: Provider; translator: TranslatorFunction; glossaryEntries?: ResolvedGlossaryEntry[]; + /** 词库来源标注;缺省时 match 日志保持旧文案。 */ + glossarySourceLabel?: string; signal?: AbortSignal; /** 测试面板注入的思考元数据收集器(openspec: ai-thinking-mode-control D7) */ onResponseMeta?: (meta: TranslationResponseMeta) => void; diff --git a/renderer/components/tasks/GlossarySelectControl.tsx b/renderer/components/tasks/GlossarySelectControl.tsx new file mode 100644 index 00000000..71a73ec9 --- /dev/null +++ b/renderer/components/tasks/GlossarySelectControl.tsx @@ -0,0 +1,166 @@ +/** + * 任务配置条的词库多选。未交互时 formData.glossaryIds 保持 undefined + * (回落全部已启用);勾选后写入显式 id 数组,取消最后一项得到 []。 + */ +import React, { useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { BookOpenText } from 'lucide-react'; +import { cn } from 'lib/utils'; +import { useTranslation } from 'next-i18next'; +import { useGlossaries } from 'hooks/useGlossaries'; + +interface GlossarySelectControlProps { + form: any; + formData: any; +} + +const GlossarySelectControl: React.FC = ({ + form, + formData, +}) => { + const { t } = useTranslation('tasks'); + const router = useRouter(); + const { locale } = router.query; + const [open, setOpen] = useState(false); + const { glossaries, loading } = useGlossaries(); + + const setValue = (name: string, value: unknown) => + form.setValue(name, value, { shouldDirty: true }); + + const rawIds = formData?.glossaryIds; + const isExplicit = Array.isArray(rawIds); + const selectedIds: string[] = isExplicit ? rawIds : []; + + const enabledIds = glossaries + .filter((glossary) => glossary.enabled) + .map((glossary) => glossary.id); + + const isChecked = (id: string) => + isExplicit ? selectedIds.includes(id) : enabledIds.includes(id); + + const toggle = (id: string, checked: boolean) => { + const current = isExplicit ? selectedIds : enabledIds; + const next = checked + ? current.includes(id) + ? current + : [...current, id] + : current.filter((item) => item !== id); + setValue('glossaryIds', next); + }; + + const restoreDefault = () => { + setValue('glossaryIds', undefined); + }; + + const selectedGlossaries = isExplicit + ? glossaries.filter((glossary) => selectedIds.includes(glossary.id)) + : []; + + const stateLabel = !isExplicit + ? t('configBar.glossaryAllEnabled') + : selectedGlossaries.length === 0 + ? t('configBar.glossaryNone') + : selectedGlossaries.length === 1 + ? selectedGlossaries[0].name + : t('configBar.glossaryCount', { count: selectedGlossaries.length }); + + if (!loading && glossaries.length === 0) { + return ( +
+ + {t('configBar.glossary')} + + + + {t('configBar.glossaryEmpty')} + +
+ ); + } + + return ( +
+ + {t('configBar.glossary')} + + + + + + +
+

{t('configBar.glossary')}

+

+ {t('configBar.glossaryIntro')} +

+
+ + {isExplicit && ( + + )} + +
+ {glossaries.map((glossary) => ( +
+ + toggle(glossary.id, checked === true) + } + /> + {glossary.name} + {glossary.enabled === false && ( + + {t('configBar.glossaryDisabled')} + + )} +
+ ))} +
+
+
+
+ ); +}; + +export default GlossarySelectControl; diff --git a/renderer/components/tasks/InlineConfigBar.tsx b/renderer/components/tasks/InlineConfigBar.tsx index 0912f41b..db896c5a 100644 --- a/renderer/components/tasks/InlineConfigBar.tsx +++ b/renderer/components/tasks/InlineConfigBar.tsx @@ -14,6 +14,7 @@ import { AlertCircle, CheckCircle2, Download, Languages } from 'lucide-react'; import { Button } from '@/components/ui/button'; import Models from '@/components/Models'; import AiRefineControl from '@/components/tasks/AiRefineControl'; +import GlossarySelectControl from '@/components/tasks/GlossarySelectControl'; import ManuscriptControl from '@/components/tasks/ManuscriptControl'; import { supportedLanguage } from 'lib/utils'; import { isProviderConfigured } from 'lib/providerUtils'; @@ -299,6 +300,8 @@ const InlineConfigBar: React.FC = ({ + + )} diff --git a/renderer/public/locales/en/tasks.json b/renderer/public/locales/en/tasks.json index 14c733ae..92271c40 100644 --- a/renderer/public/locales/en/tasks.json +++ b/renderer/public/locales/en/tasks.json @@ -112,7 +112,16 @@ "targetLanguage": "Translate to", "provider": "Translation service", "style": "Output content", - "format": "Subtitle format" + "format": "Subtitle format", + "glossary": "Glossary", + "glossaryAllEnabled": "All enabled", + "glossaryNone": "None", + "glossaryCount": "{{count}} selected", + "glossaryDisabled": "disabled", + "glossaryRestoreDefault": "Restore default (all enabled)", + "glossaryEmpty": "No glossaries yet", + "glossaryGoManage": "Go to glossaries", + "glossaryIntro": "Choose which glossaries this task will match. Leave untouched to use every enabled glossary." }, "manuscript": { "label": "Reference script", diff --git a/renderer/public/locales/zh/tasks.json b/renderer/public/locales/zh/tasks.json index 04bf1ab8..2dd8b1af 100644 --- a/renderer/public/locales/zh/tasks.json +++ b/renderer/public/locales/zh/tasks.json @@ -112,7 +112,16 @@ "targetLanguage": "翻译成", "provider": "翻译服务", "style": "输出内容", - "format": "字幕格式" + "format": "字幕格式", + "glossary": "词库", + "glossaryAllEnabled": "全局已启用", + "glossaryNone": "不使用词库", + "glossaryCount": "{{count}} 个词库", + "glossaryDisabled": "已停用", + "glossaryRestoreDefault": "恢复默认(全部已启用)", + "glossaryEmpty": "还没有词库", + "glossaryGoManage": "去词库", + "glossaryIntro": "选择本次任务参与匹配的词库。未选择时使用全部已启用词库。" }, "manuscript": { "label": "参考文稿", diff --git a/scripts/test-glossary.ts b/scripts/test-glossary.ts index 982d5b6f..b9eb4acf 100644 --- a/scripts/test-glossary.ts +++ b/scripts/test-glossary.ts @@ -8,7 +8,9 @@ import { parseGlossaryContent, renderGlossarySystemPrompt, reorderGlossaries, + describeGlossarySource, resolveEnabledGlossaryEntries, + resolveTaskGlossaryEntries, selectGlossaryPromptEntries, serializeGlossaryEntries, textContainsGlossarySource, @@ -573,6 +575,166 @@ function testTxtImportExport(): void { ); } +function testTaskGlossarySelection(): void { + const glossaries = [ + glossary('later', 'Later', 8, [entry('2', 'Alice', '后者')]), + glossary('first', 'First', 1, [entry('1', 'Alice', '艾丽丝')]), + glossary('off', 'Disabled', 0, [entry('3', 'Bob', '鲍勃')], false), + ]; + const snapshot = JSON.stringify(glossaries); + + const enabled = resolveEnabledGlossaryEntries(glossaries); + const taskDefault = resolveTaskGlossaryEntries(glossaries, undefined); + equal( + taskDefault, + enabled, + 'undefined ids reproduce resolveEnabledGlossaryEntries exactly', + ); + equal( + taskDefault.entries.map((item) => [ + item.source, + item.target, + item.glossaryName, + ]), + [['Alice', '艾丽丝', 'First']], + 'undefined ids use only enabled glossaries sorted by global order', + ); + ok( + taskDefault.conflicts.length === 1 && + taskDefault.conflicts[0].kept.glossaryName === 'First' && + taskDefault.conflicts[0].ignored.glossaryName === 'Later', + 'undefined ids report first-source-wins conflicts in the same shape', + ); + + const nfkcCase = [ + glossary('upper', 'Upper', 0, [entry('1', 'Alice', '艾丽丝')]), + glossary('wide', 'Wide', 1, [entry('2', 'Alice', '爱丽丝')]), + ]; + const nfkcResolution = resolveTaskGlossaryEntries(nfkcCase, undefined); + equal( + nfkcResolution.entries.map((item) => item.target), + ['艾丽丝'], + 'undefined ids dedup with NFKC and case-insensitive keys', + ); + ok( + nfkcResolution.conflicts.length === 1, + 'NFKC duplicate reports a conflict', + ); + + equal( + resolveTaskGlossaryEntries(glossaries, []), + { entries: [], conflicts: [] }, + 'an empty id array means explicitly no glossary, not all enabled', + ); + + const disabledSelected = resolveTaskGlossaryEntries(glossaries, ['off']); + equal( + disabledSelected.entries.map((item) => [ + item.source, + item.target, + item.glossaryId, + ]), + [['Bob', '鲍勃', 'off']], + 'explicit ids include a disabled glossary and ignore the enabled flag', + ); + equal( + resolveTaskGlossaryEntries(glossaries, ['first']).entries.map( + (item) => item.glossaryId, + ), + ['first'], + 'explicit ids use only the selected glossaries', + ); + ok( + resolveTaskGlossaryEntries(glossaries, ['first']).conflicts.length === 0, + 'unselected later glossary does not contribute a conflict', + ); + + const reversedIds = resolveTaskGlossaryEntries(glossaries, [ + 'later', + 'first', + ]); + equal( + reversedIds.entries.map((item) => item.target), + ['艾丽丝'], + 'explicit ids still follow global order, not the id argument order', + ); + ok( + reversedIds.conflicts.length === 1 && + reversedIds.conflicts[0].kept.glossaryName === 'First' && + reversedIds.conflicts[0].ignored.glossaryName === 'Later', + 'conflict winner follows global order when ids are reversed', + ); + + const unknownAndDup = resolveTaskGlossaryEntries(glossaries, [ + 'missing', + 'first', + 'first', + 'nope', + ]); + equal( + unknownAndDup.entries.map((item) => [ + item.source, + item.target, + item.glossaryId, + ]), + [['Alice', '艾丽丝', 'first']], + 'unknown ids are ignored and duplicate ids do not double-inject entries', + ); + ok( + unknownAndDup.conflicts.length === 0, + 'duplicate ids do not fabricate self-conflicts', + ); + + const enabledFallback = resolveTaskGlossaryEntries(glossaries, undefined); + for (const malformed of [null, 'abc', 42, {}]) { + equal( + resolveTaskGlossaryEntries(glossaries, malformed as never), + enabledFallback, + `${JSON.stringify(malformed)} is treated as undefined, not as empty ids`, + ); + } + + equal( + resolveTaskGlossaryEntries( + glossaries, + ['first', 1, null, 'later', {}] as never, + ), + resolveTaskGlossaryEntries(glossaries, ['first', 'later']), + 'non-string members in an id array are ignored, valid string ids are kept', + ); + + ok( + typeof resolveEnabledGlossaryEntries === 'function' && + resolveEnabledGlossaryEntries.length === 1, + 'resolveEnabledGlossaryEntries remains exported with one argument', + ); + equal( + resolveEnabledGlossaryEntries(glossaries), + resolveTaskGlossaryEntries(glossaries, undefined), + 'resolveEnabledGlossaryEntries is a thin wrapper over undefined ids', + ); + + equal(JSON.stringify(glossaries), snapshot, 'resolver does not mutate input'); +} + +function testGlossarySourceLabel(): void { + equal( + describeGlossarySource(undefined), + '全局已启用', + 'undefined ids label as globally enabled fallback', + ); + equal( + describeGlossarySource([]), + '任务词库 0 个', + 'empty id array labels as zero task glossaries', + ); + equal( + describeGlossarySource(['a', 'b']), + '任务词库 2 个', + 'explicit ids label counts the ids that were passed', + ); +} + function main(): void { testNormalizationAndPriority(); testGlossaryReordering(); @@ -582,6 +744,8 @@ function main(): void { testConflictFingerprint(); testCsvImportExport(); testTxtImportExport(); + testTaskGlossarySelection(); + testGlossarySourceLabel(); console.log(`\nglossary tests: ${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); diff --git a/types/types.ts b/types/types.ts index 5473e2d6..f47a4df9 100644 --- a/types/types.ts +++ b/types/types.ts @@ -266,4 +266,6 @@ export interface IFormData { manuscriptPath?: string; /** 创建快照时的显示名;运行时仍以 manuscriptPath 为唯一数据源。 */ manuscriptName?: string; + /** 本次任务参与匹配的词库 id;undefined = 回落「全部已启用」(旧行为) */ + glossaryIds?: string[]; } From 4e2ee30f8e6849338d7ebc95aa7aa7e891f1817c Mon Sep 17 00:00:00 2001 From: MaurUppi Date: Thu, 13 Aug 2026 16:36:43 +0800 Subject: [PATCH 02/12] feat(glossary): carry task glossary selection through to proofread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 任务选用的词库随 sidecar 落盘,校对台的单条优化 / 批量优化 / 重翻失败 三条链路都按任务词库工作,而不再固定读全局已启用。 - types/proofreadData: ProofreadDataMeta 增加 glossaryIds?: string[];meta 归一化 抽出 normalizeProofreadMeta + 纯函数 normalizeMetaGlossaryIds 做边界校验。 非数组 → undefined(旧 sidecar 回落全局已启用),数组只保留字符串成员, [] 保持 [](明确不用词库)。旧 sidecar 归一化后不写出显式 undefined 键。 PROOFREAD_DATA_VERSION 保持 2:meta 是展开合并、未知字段原样保留,无需 bump。 - proofreadData: writeProofreadDataFromFiles 增加 glossaryIds 参数,条件展开写入 meta;fileProcessor 调用点传 formData?.glossaryIds。 - subtitleCorrectionService: CorrectionParams 增加 glossaryIds?, getActiveGlossaryResolution() 改为 getTaskGlossaryResolution(params.glossaryIds); 不传时语义与此前完全一致。correctionRunner 从 formData 透传。 - ipcProofreadHandlers: 三个 handler 的 payload 增加 proofreadDataFile?, 经 readSidecarGlossaryIds 读取 meta.glossaryIds。无路径或读取失败 → 记 warning 并回落 undefined,独立校对模式(直接拖字幕进校对台)行为不变。 - renderer: ProofreadEditor 把 file.proofreadDataFile 透传给 SubtitleEditToolbar、 BatchAiOptimizeDialog 与 useRetranslateFailed,三处 invoke 带上该路径。 - test: 新增 scripts/test-proofread-data.ts 与 yarn test:proofread-data,覆盖 保留显式 id / 保留 [] / 旧 v1 与 v2 sidecar 无该键 / 五种畸形输入 / 非字符串 成员剔除 / 未知 meta 字段前后兼容。32 passed。 --- main/helpers/fileProcessor.ts | 1 + main/helpers/ipcProofreadHandlers.ts | 35 +++- main/helpers/proofreadData.ts | 3 + main/helpers/subtitleCorrectionService.ts | 6 +- .../subtitleRefine/correctionRunner.ts | 1 + package.json | 1 + .../components/proofread/ProofreadEditor.tsx | 2 + .../subtitle/BatchAiOptimizeDialog.tsx | 3 + .../subtitle/SubtitleEditToolbar.tsx | 5 + renderer/hooks/useRetranslateFailed.ts | 6 + scripts/test-proofread-data.ts | 175 ++++++++++++++++++ types/proofreadData.ts | 40 ++-- 12 files changed, 263 insertions(+), 15 deletions(-) create mode 100644 scripts/test-proofread-data.ts diff --git a/main/helpers/fileProcessor.ts b/main/helpers/fileProcessor.ts index 59cc51bc..41d4f0e8 100644 --- a/main/helpers/fileProcessor.ts +++ b/main/helpers/fileProcessor.ts @@ -761,6 +761,7 @@ export async function processFile( translateContent: formData?.translateContent, outputFormat: formData?.subtitleOutputFormat, speakerSegments, + glossaryIds: formData?.glossaryIds, }); if ('filePath' in proofreadDataResult) { file.proofreadDataFile = proofreadDataResult.filePath; diff --git a/main/helpers/ipcProofreadHandlers.ts b/main/helpers/ipcProofreadHandlers.ts index bafb105c..0b663030 100644 --- a/main/helpers/ipcProofreadHandlers.ts +++ b/main/helpers/ipcProofreadHandlers.ts @@ -51,15 +51,33 @@ import { selectGlossaryPromptEntries, } from '../glossary/core'; import { - getActiveGlossaryResolution, + getTaskGlossaryResolution, logGlossaryConflicts, logGlossaryMatches, } from './glossaryManager'; +import { readProofreadDataFile } from './proofreadData'; // 校对批量操作(批量 AI 优化 / 重翻失败)取消注册表 const batchAbortControllers = new Map(); const singleOptimizeConflictFingerprints = new WeakMap(); +/** 从 sidecar 读任务词库;缺路径 / 读失败 → undefined(回落全部已启用)。 */ +async function readSidecarGlossaryIds( + proofreadDataFile?: string, +): Promise { + if (!proofreadDataFile) return undefined; + try { + const data = await readProofreadDataFile(proofreadDataFile); + return data.meta.glossaryIds; + } catch (error) { + logMessage( + `Failed to read sidecar glossaryIds from ${proofreadDataFile}: ${error}`, + 'warning', + ); + return undefined; + } +} + /** * 设置字幕校对相关的 IPC 处理器 */ @@ -488,12 +506,14 @@ export function setupProofreadHandlers(): void { providerId, customPrompt, mode = 'translation', + proofreadDataFile, }: { sourceText: string; targetText: string; providerId?: string; customPrompt?: string; mode?: 'translation' | 'transcript'; + proofreadDataFile?: string; }, ) => { try { @@ -546,8 +566,9 @@ export function setupProofreadHandlers(): void { // 获取源语言和目标语言 const sourceLanguage = userConfig.sourceLanguage || 'en'; const targetLanguage = userConfig.targetLanguage || 'zh'; + const ids = await readSidecarGlossaryIds(proofreadDataFile); const glossaryResolution = - mode === 'translation' ? getActiveGlossaryResolution() : undefined; + mode === 'translation' ? getTaskGlossaryResolution(ids) : undefined; if (glossaryResolution) { const fingerprint = glossaryConflictFingerprint( glossaryResolution.conflicts, @@ -688,6 +709,7 @@ Only respond with the translation, nothing else.`; maxRetries = 2, batchId, mode = 'translation', + proofreadDataFile, }: { subtitles: Array<{ id: string; @@ -701,6 +723,7 @@ Only respond with the translation, nothing else.`; maxRetries?: number; batchId?: string; mode?: 'translation' | 'transcript'; + proofreadDataFile?: string; }, ) => { const abortController = new AbortController(); @@ -753,6 +776,7 @@ Only respond with the translation, nothing else.`; const sourceLanguage = userConfig.sourceLanguage || 'en'; const targetLanguage = userConfig.targetLanguage || 'zh'; + const ids = await readSidecarGlossaryIds(proofreadDataFile); // 批处理循环已抽取到共享校正服务(openspec: add-ai-subtitle-refine D7): // legacyMap 协议保持既有请求/响应格式、默认提示词与逐项提取规则, @@ -777,6 +801,7 @@ Only respond with the translation, nothing else.`; maxRetries, signal: abortController.signal, useGlossary: mode === 'translation', + glossaryIds: ids, glossaryLabel: '校对页批量 AI 优化', onBatchProgress: (info) => { event.sender.send('batchOptimizeProgress', { @@ -855,6 +880,7 @@ Only respond with the translation, nothing else.`; sourceLanguage, targetLanguage, batchId, + proofreadDataFile, }: { subtitles: Array<{ id: string; @@ -865,6 +891,7 @@ Only respond with the translation, nothing else.`; sourceLanguage?: string; targetLanguage?: string; batchId?: string; + proofreadDataFile?: string; }, ) => { const abortController = new AbortController(); @@ -897,6 +924,7 @@ Only respond with the translation, nothing else.`; const from = sourceLanguage || userConfig.sourceLanguage || 'en'; const to = targetLanguage || userConfig.targetLanguage || 'zh'; + const ids = await readSidecarGlossaryIds(proofreadDataFile); logMessage( `Retranslating ${subtitles.length} subtitles with ${provider.name} (${from} -> ${to})`, @@ -923,6 +951,9 @@ Only respond with the translation, nothing else.`; }); }, 1, + undefined, + undefined, + { glossaryIds: ids }, ); }, ); diff --git a/main/helpers/proofreadData.ts b/main/helpers/proofreadData.ts index 7c7dd03b..2f26464e 100644 --- a/main/helpers/proofreadData.ts +++ b/main/helpers/proofreadData.ts @@ -148,6 +148,7 @@ export async function writeProofreadDataFromFiles({ translateContent, outputFormat, speakerSegments, + glossaryIds, }: { file: IFiles; sourceFile?: string; @@ -158,6 +159,7 @@ export async function writeProofreadDataFromFiles({ translateContent?: string; outputFormat?: string; speakerSegments?: SpeakerDiarizationSegment[]; + glossaryIds?: string[]; }): Promise { try { const sourceEntries = await readSubtitleEntries(sourceFile); @@ -184,6 +186,7 @@ export async function writeProofreadDataFromFiles({ sourceFile, targetFile, finalTargetFile, + ...(glossaryIds !== undefined ? { glossaryIds } : {}), }, speakers: normalizeSpeakerRoster([], cues), cues, diff --git a/main/helpers/subtitleCorrectionService.ts b/main/helpers/subtitleCorrectionService.ts index b0e86f0f..4e409ede 100644 --- a/main/helpers/subtitleCorrectionService.ts +++ b/main/helpers/subtitleCorrectionService.ts @@ -34,7 +34,7 @@ import { selectGlossaryPromptEntries, } from '../glossary/core'; import { - getActiveGlossaryResolution, + getTaskGlossaryResolution, logGlossaryConflicts, logGlossaryMatches, } from './glossaryManager'; @@ -76,6 +76,8 @@ export interface CorrectionParams { maxRetries?: number; signal?: AbortSignal; useGlossary?: boolean; + /** 本次任务选用词库;undefined = 回落全部已启用。 */ + glossaryIds?: string[]; /** 术语冲突/命中日志的场景标签(如「校对页批量 AI 优化」/「AI 字幕校正」)。 */ glossaryLabel?: string; /** anchored:低置信词标注(whisper token p 低于阈值的词,辅助定点修正)。 */ @@ -216,7 +218,7 @@ export async function runSubtitleCorrection( // 术语表:调用方决定是否启用(校对台仅 translation 模式;管线校正恒开)。 let glossaryEntries: Parameters[0] = []; if (params.useGlossary) { - const resolution = getActiveGlossaryResolution(); + const resolution = getTaskGlossaryResolution(params.glossaryIds); if (resolution) { logGlossaryConflicts( resolution.conflicts, diff --git a/main/helpers/subtitleRefine/correctionRunner.ts b/main/helpers/subtitleRefine/correctionRunner.ts index 83e5a1c8..ed6b86ea 100644 --- a/main/helpers/subtitleRefine/correctionRunner.ts +++ b/main/helpers/subtitleRefine/correctionRunner.ts @@ -73,6 +73,7 @@ export async function runAiCorrection( maxRetries: 2, signal, useGlossary: true, + glossaryIds: formData?.glossaryIds as string[] | undefined, glossaryLabel: 'AI 字幕校正', suspectWords, onBatchProgress: (info) => diff --git a/package.json b/package.json index 34938a60..52985d92 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "test:refine": "tsc scripts/test-refine-units.ts --outDir node_modules/.cache/refine-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/refine-tests/scripts/test-refine-units.js", "test:speaker-diarization": "node scripts/test-speaker-diarization-config.cjs && tsc scripts/test-speaker-diarization.ts --outDir node_modules/.cache/speaker-diarization-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck && node node_modules/.cache/speaker-diarization-tests/scripts/test-speaker-diarization.js", "test:proofread-speakers": "tsc scripts/test-proofread-speakers.ts --outDir node_modules/.cache/proofread-speaker-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck && node node_modules/.cache/proofread-speaker-tests/scripts/test-proofread-speakers.js", + "test:proofread-data": "tsc scripts/test-proofread-data.ts --outDir node_modules/.cache/proofread-data-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/proofread-data-tests/scripts/test-proofread-data.js", "test:manuscript": "tsc scripts/test-manuscript-matching.ts --outDir node_modules/.cache/manuscript-tests --module commonjs --moduleResolution node --target es2022 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/manuscript-tests/scripts/test-manuscript-matching.js", "longgap:gen": "tsc scripts/longgap/gen-audio.ts --outDir node_modules/.cache/longgap --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/longgap/scripts/longgap/gen-audio.js", "test:longgap": "tsc scripts/longgap/run.ts --outDir node_modules/.cache/longgap --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/longgap/scripts/longgap/run.js", diff --git a/renderer/components/proofread/ProofreadEditor.tsx b/renderer/components/proofread/ProofreadEditor.tsx index b97658b4..7ad50ea7 100644 --- a/renderer/components/proofread/ProofreadEditor.tsx +++ b/renderer/components/proofread/ProofreadEditor.tsx @@ -124,6 +124,7 @@ export default function ProofreadEditor({ updateSubtitles, sourceLanguage: file.sourceLanguage, targetLanguage: file.targetLanguage, + proofreadDataFile: file.proofreadDataFile, }); // 使用视频播放器 hook @@ -444,6 +445,7 @@ export default function ProofreadEditor({ onToggleExpandAll={toggleExpandAll} fontScale={fontScale} onFontScale={handleFontScale} + proofreadDataFile={file.proofreadDataFile} /> diff --git a/renderer/components/subtitle/BatchAiOptimizeDialog.tsx b/renderer/components/subtitle/BatchAiOptimizeDialog.tsx index 571cd11c..66580743 100644 --- a/renderer/components/subtitle/BatchAiOptimizeDialog.tsx +++ b/renderer/components/subtitle/BatchAiOptimizeDialog.tsx @@ -62,6 +62,7 @@ interface BatchAiOptimizeDialogProps { optimizations: Array<{ index: number; targetContent: string }>, ) => void; shouldShowTranslation: boolean; + proofreadDataFile?: string; } export default function BatchAiOptimizeDialog({ @@ -70,6 +71,7 @@ export default function BatchAiOptimizeDialog({ subtitles, onApplyOptimizations, shouldShowTranslation, + proofreadDataFile, }: BatchAiOptimizeDialogProps) { const { t } = useTranslation('home'); @@ -265,6 +267,7 @@ IMPORTANT: Return ONLY a valid JSON object with subtitle IDs as keys and optimiz maxRetries: 2, batchId, mode: isTranscriptMode ? 'transcript' : 'translation', + proofreadDataFile, }); if (result.success && result.data) { diff --git a/renderer/components/subtitle/SubtitleEditToolbar.tsx b/renderer/components/subtitle/SubtitleEditToolbar.tsx index b2437acf..ea7c77b0 100644 --- a/renderer/components/subtitle/SubtitleEditToolbar.tsx +++ b/renderer/components/subtitle/SubtitleEditToolbar.tsx @@ -135,6 +135,7 @@ interface SubtitleEditToolbarProps { onToggleExpandAll?: () => void; fontScale?: 's' | 'm' | 'l'; onFontScale?: (scale: 's' | 'm' | 'l') => void; + proofreadDataFile?: string; } export default function SubtitleEditToolbar({ @@ -161,6 +162,7 @@ export default function SubtitleEditToolbar({ onToggleExpandAll, fontScale, onFontScale, + proofreadDataFile, }: SubtitleEditToolbarProps) { const { t } = useTranslation('home'); @@ -642,6 +644,7 @@ Only respond with the corrected text, nothing else.`; customPrompt: customPrompt.trim() || (isTranscriptMode ? defaultProofreadPrompt : undefined), + proofreadDataFile, }); if (result.success && result.data) { @@ -664,6 +667,7 @@ Only respond with the corrected text, nothing else.`; customPrompt, isTranscriptMode, defaultProofreadPrompt, + proofreadDataFile, ]); // 采纳 AI 优化结果(纯转写模式写回原文) @@ -1281,6 +1285,7 @@ Only respond with the corrected text, nothing else.`; subtitles={subtitles} onApplyOptimizations={handleApplyBatchOptimizations} shouldShowTranslation={shouldShowTranslation} + proofreadDataFile={proofreadDataFile} /> {/* 视图控制(右对齐):折叠左侧面板 / 展开全部 / 字号 */} diff --git a/renderer/hooks/useRetranslateFailed.ts b/renderer/hooks/useRetranslateFailed.ts index 8fdf0790..057c7338 100644 --- a/renderer/hooks/useRetranslateFailed.ts +++ b/renderer/hooks/useRetranslateFailed.ts @@ -25,6 +25,7 @@ interface UseRetranslateFailedOptions { updateSubtitles: (subtitles: Subtitle[]) => void; sourceLanguage?: string; targetLanguage?: string; + proofreadDataFile?: string; } export function useRetranslateFailed({ @@ -33,6 +34,7 @@ export function useRetranslateFailed({ updateSubtitles, sourceLanguage, targetLanguage, + proofreadDataFile, }: UseRetranslateFailedOptions): RetranslateControl { const { t } = useTranslation('home'); const [running, setRunning] = useState(false); @@ -48,6 +50,7 @@ export function useRetranslateFailed({ updateSubtitles, sourceLanguage, targetLanguage, + proofreadDataFile, }); latestRef.current = { getSubtitles, @@ -55,6 +58,7 @@ export function useRetranslateFailed({ updateSubtitles, sourceLanguage, targetLanguage, + proofreadDataFile, }; // 进度事件(按 batchId 过滤) @@ -77,6 +81,7 @@ export function useRetranslateFailed({ getFailedTranslationIndices: getFailed, sourceLanguage: from, targetLanguage: to, + proofreadDataFile: sidecarFile, } = latestRef.current; const current = getSubs(); @@ -105,6 +110,7 @@ export function useRetranslateFailed({ sourceLanguage: from, targetLanguage: to, batchId, + proofreadDataFile: sidecarFile, }); if (!result?.success && result?.error === 'NO_DEFAULT_PROVIDER') { diff --git a/scripts/test-proofread-data.ts b/scripts/test-proofread-data.ts new file mode 100644 index 00000000..0065cc1b --- /dev/null +++ b/scripts/test-proofread-data.ts @@ -0,0 +1,175 @@ +import { + PROOFREAD_DATA_VERSION, + normalizeMetaGlossaryIds, + normalizeProofreadData, +} from '../types/proofreadData'; + +let passed = 0; +let failed = 0; + +function ok(value: unknown, name: string): void { + if (value) { + passed++; + } else { + failed++; + console.error(`x ${name}`); + } +} + +function equal(actual: T, expected: T, name: string): void { + const success = JSON.stringify(actual) === JSON.stringify(expected); + ok(success, name); + if (!success) { + console.error(` expected: ${JSON.stringify(expected)}`); + console.error(` actual: ${JSON.stringify(actual)}`); + } +} + +function sidecar( + version: 1 | 2, + metaExtra: Record = {}, +): unknown { + return { + version, + meta: { + createdAt: '2026-08-05T00:00:00.000Z', + updatedAt: '2026-08-05T00:00:00.000Z', + ...metaExtra, + }, + cues: [], + ...(version === 2 ? { speakers: [] } : {}), + }; +} + +function testKeepsExplicitGlossaryIds(): void { + equal( + normalizeMetaGlossaryIds(['a', 'b']), + ['a', 'b'], + 'normalizeMetaGlossaryIds keeps [a,b] in order', + ); + const result = normalizeProofreadData( + sidecar(2, { glossaryIds: ['a', 'b'] }), + ); + equal( + result.meta.glossaryIds, + ['a', 'b'], + 'normalizeProofreadData keeps glossaryIds [a,b] in order', + ); +} + +function testKeepsEmptyGlossaryIds(): void { + equal( + normalizeMetaGlossaryIds([]), + [], + 'normalizeMetaGlossaryIds keeps empty array (explicit no glossary)', + ); + const result = normalizeProofreadData(sidecar(2, { glossaryIds: [] })); + equal( + result.meta.glossaryIds, + [], + 'normalizeProofreadData keeps empty glossaryIds (must not drop to undefined)', + ); + ok( + Object.prototype.hasOwnProperty.call(result.meta, 'glossaryIds'), + 'empty glossaryIds remains an own key after normalize', + ); +} + +function assertLegacySidecarHasNoGlossaryIds( + version: 1 | 2, + name: string, +): void { + const result = normalizeProofreadData(sidecar(version)); + equal(result.version, PROOFREAD_DATA_VERSION, `${name} upgrades to v2`); + equal( + result.meta.glossaryIds, + undefined, + `${name} glossaryIds stays undefined`, + ); + ok( + !Object.prototype.hasOwnProperty.call(result.meta, 'glossaryIds'), + `${name} does not write an explicit undefined glossaryIds key`, + ); +} + +function testLegacySidecarOmitsGlossaryIds(): void { + assertLegacySidecarHasNoGlossaryIds(1, 'v1 sidecar'); + assertLegacySidecarHasNoGlossaryIds(2, 'v2 sidecar'); +} + +function testMalformedGlossaryIdsBecomeUndefined(): void { + const malformed: unknown[] = [null, 'abc', 42, {}, true]; + for (const value of malformed) { + const label = JSON.stringify(value); + equal( + normalizeMetaGlossaryIds(value), + undefined, + `normalizeMetaGlossaryIds(${label}) is undefined (not [])`, + ); + const result = normalizeProofreadData(sidecar(2, { glossaryIds: value })); + equal( + result.meta.glossaryIds, + undefined, + `normalizeProofreadData(${label}) glossaryIds is undefined (not [])`, + ); + ok( + !Object.prototype.hasOwnProperty.call(result.meta, 'glossaryIds'), + `normalizeProofreadData(${label}) omits glossaryIds key`, + ); + } +} + +function testUnknownMetaFieldsArePreserved(): void { + const result = normalizeProofreadData( + sidecar(2, { episodeSummary: 'x', futureField: 42 }), + ); + equal( + (result.meta as { episodeSummary?: string }).episodeSummary, + 'x', + 'unknown episodeSummary is preserved through normalize', + ); + equal( + (result.meta as { futureField?: number }).futureField, + 42, + 'unknown futureField is preserved through normalize', + ); +} + +function testDropsNonStringGlossaryIdMembers(): void { + equal( + normalizeMetaGlossaryIds(['a', 1, null, 'b', {}]), + ['a', 'b'], + 'normalizeMetaGlossaryIds drops non-string members and keeps order', + ); + equal( + normalizeProofreadData(sidecar(2, { glossaryIds: ['a', 1, null, 'b', {}] })) + .meta.glossaryIds, + ['a', 'b'], + 'normalizeProofreadData drops non-string glossaryIds members', + ); + equal( + normalizeMetaGlossaryIds([1, null]), + [], + 'all-invalid members stay [] (explicit empty, not undefined)', + ); + equal( + normalizeProofreadData(sidecar(2, { glossaryIds: [1, null] })).meta + .glossaryIds, + [], + 'normalizeProofreadData keeps [] when every member is invalid', + ); +} + +function main(): void { + testKeepsExplicitGlossaryIds(); + testKeepsEmptyGlossaryIds(); + testLegacySidecarOmitsGlossaryIds(); + testMalformedGlossaryIdsBecomeUndefined(); + testDropsNonStringGlossaryIdMembers(); + testUnknownMetaFieldsArePreserved(); + + console.log(`\nproofread-data tests: ${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); +} + +main(); diff --git a/types/proofreadData.ts b/types/proofreadData.ts index 33db700f..6d0469d5 100644 --- a/types/proofreadData.ts +++ b/types/proofreadData.ts @@ -37,6 +37,8 @@ export interface ProofreadDataMeta { sourceFile?: string; targetFile?: string; finalTargetFile?: string; + /** 任务选用词库 id;undefined = 回落全部已启用;[] = 明确不用词库 */ + glossaryIds?: string[]; } export interface ProofreadDataCue { @@ -230,6 +232,32 @@ export function normalizeSpeakerRoster( return roster; } +/** + * 归一化 sidecar meta.glossaryIds。 + * 非数组 → undefined(旧 sidecar / 回落全部已启用); + * 数组(含 [])只保留字符串成员,顺序不变;[] = 明确不用词库。 + */ +export function normalizeMetaGlossaryIds(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + return value.filter((id): id is string => typeof id === 'string'); +} + +function normalizeProofreadMeta(rawMeta: unknown): ProofreadDataMeta { + const metaInput = + rawMeta && typeof rawMeta === 'object' + ? (rawMeta as Partial) + : {}; + const now = new Date(0).toISOString(); + const { glossaryIds: rawGlossaryIds, ...restMeta } = metaInput; + const glossaryIds = normalizeMetaGlossaryIds(rawGlossaryIds); + return { + ...restMeta, + createdAt: String(metaInput.createdAt || now), + updatedAt: String(metaInput.updatedAt || metaInput.createdAt || now), + ...(glossaryIds !== undefined ? { glossaryIds } : {}), + }; +} + /** Accept v1/v2 sidecars and return the single canonical v2 shape. */ export function normalizeProofreadData(input: unknown): ProofreadDataFileV2 { if (!input || typeof input !== 'object') { @@ -261,19 +289,9 @@ export function normalizeProofreadData(input: unknown): ProofreadDataFileV2 { }); return normalized as ProofreadDataCue; }); - const metaInput = - raw.meta && typeof raw.meta === 'object' - ? (raw.meta as Partial) - : {}; - const now = new Date(0).toISOString(); - const meta: ProofreadDataMeta = { - ...metaInput, - createdAt: String(metaInput.createdAt || now), - updatedAt: String(metaInput.updatedAt || metaInput.createdAt || now), - }; return { version: PROOFREAD_DATA_VERSION, - meta, + meta: normalizeProofreadMeta(raw.meta), speakers: normalizeSpeakerRoster( raw.version === 2 && Array.isArray(raw.speakers) ? raw.speakers : [], cues, From 03a348bcb0a3507fc029137840fb0a81a9351e6a Mon Sep 17 00:00:00 2001 From: MaurUppi Date: Thu, 13 Aug 2026 16:46:03 +0800 Subject: [PATCH 03/12] feat(summary): add factory summary prompt and translation-page editor Product-level defaultSummaryPrompt plus settings.summaryPrompt with a restore-to-factory control. Translation page is now providers + summary panel. Empty stored drafts fall back to the factory template. --- main/helpers/store/types.ts | 5 + .../resources/SummaryPromptPanel.tsx | 94 +++++++++++++++++++ renderer/pages/[locale]/translation.tsx | 11 ++- .../public/locales/en/translateControl.json | 6 ++ .../public/locales/zh/translateControl.json | 6 ++ types/index.ts | 1 + types/summaryPrompt.ts | 73 ++++++++++++++ types/types.ts | 17 ++++ 8 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 renderer/components/resources/SummaryPromptPanel.tsx create mode 100644 types/summaryPrompt.ts diff --git a/main/helpers/store/types.ts b/main/helpers/store/types.ts index 997a73a3..e7a882fc 100644 --- a/main/helpers/store/types.ts +++ b/main/helpers/store/types.ts @@ -101,6 +101,11 @@ export type StoreType = { model?: string; asrProviderId?: string; }; + /** + * 通读摘要提示词用户稿。空 / 缺省回落 types/summaryPrompt.ts 的出厂模板, + * 不做首启动写死,避免锁死后续出厂更新。 + */ + summaryPrompt?: string; fasterWhisperDevice?: 'auto' | 'cpu' | 'cuda'; fasterWhisperComputeType?: string; fasterWhisperModelsPath?: string; diff --git a/renderer/components/resources/SummaryPromptPanel.tsx b/renderer/components/resources/SummaryPromptPanel.tsx new file mode 100644 index 00000000..5301389d --- /dev/null +++ b/renderer/components/resources/SummaryPromptPanel.tsx @@ -0,0 +1,94 @@ +/** + * 「翻译」页下段:产品级通读摘要提示词。 + * 不属于任何服务商折叠项;空值回落出厂稿,「恢复出厂」清空 settings 键。 + */ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'next-i18next'; +import { RotateCcw } from 'lucide-react'; +import { Panel, PanelHeader } from '@/components/ui/panel'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { defaultSummaryPrompt, resolveSummaryPrompt } from '../../../types'; + +const SAVE_DEBOUNCE_MS = 400; + +const SummaryPromptPanel: React.FC = () => { + const { t } = useTranslation('translateControl'); + const [draft, setDraft] = useState(defaultSummaryPrompt); + const [loaded, setLoaded] = useState(false); + const saveTimer = useRef | null>(null); + + useEffect(() => { + let cancelled = false; + (async () => { + const settings = await window?.ipc?.invoke('getSettings'); + if (cancelled) return; + setDraft(resolveSummaryPrompt(settings?.summaryPrompt)); + setLoaded(true); + })(); + return () => { + cancelled = true; + if (saveTimer.current) clearTimeout(saveTimer.current); + }; + }, []); + + const persist = useCallback(async (next: string) => { + const trimmed = next.trim(); + await window?.ipc?.invoke('setSettings', { + summaryPrompt: trimmed === defaultSummaryPrompt.trim() ? '' : next, + }); + }, []); + + const handleChange = (value: string) => { + setDraft(value); + if (saveTimer.current) clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(() => { + void persist(value); + }, SAVE_DEBOUNCE_MS); + }; + + const handleRestore = async () => { + if (saveTimer.current) clearTimeout(saveTimer.current); + setDraft(defaultSummaryPrompt); + await persist(defaultSummaryPrompt); + }; + + const isFactory = draft.trim() === defaultSummaryPrompt.trim(); + + return ( + + + + {t('summaryPrompt.restore')} + + } + /> +
+