@ggboy123/dsh-paper-reader 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/translate.js CHANGED
@@ -4,9 +4,11 @@
4
4
  // babeldoc 只支持 OpenAI 兼容端点 → 端点/key/模型由插件配置提供。
5
5
  import { execFile } from 'node:child_process';
6
6
  import { existsSync } from 'node:fs';
7
- import { mkdir, rename } from 'node:fs/promises';
7
+ import { mkdir, rename, rm } from 'node:fs/promises';
8
8
  import { glob } from 'node:fs/promises';
9
9
  import { join, dirname, basename, extname } from 'node:path';
10
+ import { dshHome } from "./library.js";
11
+ import { ensureBabeldoc } from "./babeldoc-install.js";
10
12
  export function zhPdfPath(ref) {
11
13
  return ref.pdfPath.slice(0, -extname(ref.pdfPath).length) + '-zh.pdf';
12
14
  }
@@ -14,7 +16,8 @@ export function dualPdfPath(ref) {
14
16
  return ref.pdfPath.slice(0, -extname(ref.pdfPath).length) + '-dual.pdf';
15
17
  }
16
18
  /** 进程内每篇文献一个翻译任务(幂等:进行中/已生成直接返回)。 */
17
- const busy = new Map(); // key=pdfPath, value=error|null(进行中)
19
+ // key=pdfPath;null=进行中(无阶段说明);{phase}=进行中;{error}=已失败
20
+ const busy = new Map();
18
21
  export function zhStatus(ref) {
19
22
  const zh = existsSync(zhPdfPath(ref));
20
23
  const dual = existsSync(dualPdfPath(ref));
@@ -22,8 +25,9 @@ export function zhStatus(ref) {
22
25
  return {
23
26
  zh,
24
27
  dual,
25
- busy: b === null,
26
- ...(typeof b === 'string' ? { error: b } : {}),
28
+ busy: b === null || Boolean(b && !b.error),
29
+ ...(b?.error ? { error: b.error } : {}),
30
+ ...(b && !b.error && b.phase ? { phase: b.phase } : {}),
27
31
  };
28
32
  }
29
33
  /** 找 babeldoc 可执行文件:pdfqa venv(文献库同级 .venv-pdf2zh)→ PATH → miniconda。 */
@@ -48,9 +52,36 @@ async function findBabeldoc(dataDir) {
48
52
  }
49
53
  return null;
50
54
  }
55
+ /**
56
+ * 探测 babeldoc 支持的 CLI 参数(按 bin 缓存,进程内只跑一次 --help)。
57
+ * 必要性:--skip-figure-text 目前只在 GGboya 的 fork(PR#616)里,PyPI 版没有;
58
+ * 不认识的参数会让 babeldoc 启动即报错,所以可选参数必须探测后再传。
59
+ * 探测失败返回空集合——只丢可选功能,不影响核心参数。
60
+ */
61
+ const flagSupport = new Map();
62
+ function babeldocFlags(bin) {
63
+ let p = flagSupport.get(bin);
64
+ if (!p) {
65
+ p = new Promise((res) => {
66
+ execFile(bin, ['--help'], { timeout: 60000 }, (err, stdout, stderr) => {
67
+ if (err)
68
+ console.warn('[dsh-paper-reader] babeldoc --help 探测失败,按最老参数集运行');
69
+ const flags = new Set();
70
+ for (const m of (stdout + '\n' + stderr).matchAll(/--[a-z0-9-]+/g)) {
71
+ flags.add(m[0].slice(2));
72
+ }
73
+ res(flags);
74
+ });
75
+ });
76
+ flagSupport.set(bin, p);
77
+ }
78
+ return p;
79
+ }
51
80
  /**
52
81
  * 启动 babeldoc 后台翻译。幂等:已生成或进行中直接返回 false 表示未新启动。
53
82
  * 完成/失败结果写进 busy map,由 zhStatus 暴露。
83
+ * babeldoc 缺失时不再报错:后台自动走 uv 安装链路(首次约几分钟),
84
+ * 期间 zhStatus.phase 展示安装进度,装完直接接着翻译。
54
85
  */
55
86
  export async function startTranslation(ref, dataDir, endpoint) {
56
87
  const st = zhStatus(ref);
@@ -58,9 +89,15 @@ export async function startTranslation(ref, dataDir, endpoint) {
58
89
  return { started: false, reason: 'already-exists' };
59
90
  if (st.busy)
60
91
  return { started: false, reason: 'busy' };
61
- const bin = await findBabeldoc(dataDir);
62
- if (!bin)
63
- return { started: false, reason: '未找到 babeldoc(pip install babeldoc,或复用 pdfqa 的 .venv-pdf2zh)' };
92
+ const pre = precheck(endpoint);
93
+ if (pre)
94
+ return pre;
95
+ const firstRun = !(await findBabeldoc(dataDir));
96
+ void runPipeline(ref, dataDir, endpoint);
97
+ return { started: true, firstRun };
98
+ }
99
+ /** 端点预检:不齐全时返回与 startTranslation 同形的失败结果。 */
100
+ function precheck(endpoint) {
64
101
  if (!endpoint.baseUrl || !endpoint.apiKey || !endpoint.model) {
65
102
  // code 供前端判定「该弹配置表单了」,别让它去匹配中文文案
66
103
  return {
@@ -69,18 +106,67 @@ export async function startTranslation(ref, dataDir, endpoint) {
69
106
  reason: '未配置翻译端点:在阅读器里点「中」按钮填写(OpenAI 兼容端点即可),或改 profile 的 translate 配置',
70
107
  };
71
108
  }
109
+ return null;
110
+ }
111
+ /** 后台流水线:确保 babeldoc 可用(缺失则自动安装)→ 拉起翻译。 */
112
+ async function runPipeline(ref, dataDir, endpoint) {
113
+ const setPhase = (phase) => busy.set(ref.pdfPath, { phase });
114
+ try {
115
+ setPhase('正在检查翻译引擎…');
116
+ const bin = await ensureBabeldoc(dataDir, dshHome(), () => findBabeldoc(dataDir), setPhase);
117
+ await launch(ref, dataDir, endpoint, bin);
118
+ }
119
+ catch (e) {
120
+ const msg = e instanceof Error ? e.message : String(e);
121
+ console.error('[dsh-paper-reader] babeldoc 自动安装失败:', msg);
122
+ busy.set(ref.pdfPath, { error: `翻译引擎安装失败:${msg}` });
123
+ }
124
+ }
125
+ /**
126
+ * 重新翻译:删除已有译文产物后再启动(换模型/翻得不好时用)。
127
+ * 先预检再删——端点不齐时不动旧译文,免得删完才发现跑不起来。
128
+ * babeldoc 的段落缓存 key 含模型名,换模型自然失效,无需 --ignore-cache。
129
+ */
130
+ export async function restartTranslation(ref, dataDir, endpoint) {
131
+ if (zhStatus(ref).busy)
132
+ return { started: false, reason: 'busy' };
133
+ const pre = precheck(endpoint);
134
+ if (pre)
135
+ return pre;
136
+ await rm(zhPdfPath(ref), { force: true });
137
+ await rm(dualPdfPath(ref), { force: true });
138
+ const firstRun = !(await findBabeldoc(dataDir));
139
+ void runPipeline(ref, dataDir, endpoint);
140
+ return { started: true, firstRun };
141
+ }
142
+ /** 真正拉起 babeldoc 子进程(bin 已就绪、产物不存在/已删)。 */
143
+ async function launch(ref, dataDir, endpoint, bin) {
72
144
  const tmpDir = join(dataDir, '.pdf2zh-tmp');
73
145
  await mkdir(tmpDir, { recursive: true });
74
- busy.set(ref.pdfPath, null);
146
+ busy.set(ref.pdfPath, null); // 清掉安装阶段说明,回到「翻译进行中」
75
147
  const stem = basename(ref.pdfPath, extname(ref.pdfPath));
76
148
  const dstStem = ref.pdfPath.slice(0, -extname(ref.pdfPath).length);
149
+ // 推理型模型(glm-5.x / k3 / deepseek-flash)默认每段都先跑隐藏推理,翻译慢好几倍还偶尔返回空。
150
+ // 这三家的 OpenAI 兼容端点都接受 DeepSeek 风格 thinking 开关(babeldoc --openai-thinking 即发该字段);
151
+ // 自定义端点不加——OpenAI 官方等严格校验参数的端点会因未知字段 400。
152
+ // 可选参数统一过一遍探测:babeldoc 对不认识的参数直接报错退出,探测不到就不传(功能降级但不炸)。
153
+ const supported = await babeldocFlags(bin);
154
+ const opt = (flag) => (supported.has(flag) ? [`--${flag}`] : []);
155
+ const noThink = /bigmodel|kimi|moonshot|deepseek/i.test(endpoint.baseUrl)
156
+ ? opt('openai-thinking').flatMap((f) => [f, 'disabled'])
157
+ : [];
77
158
  const child = execFile(bin, [
78
159
  '--files', ref.pdfPath,
79
160
  '--lang-in', 'en', '--lang-out', 'zh-CN',
80
161
  '--openai', '--openai-model', endpoint.model,
81
162
  '--openai-base-url', endpoint.baseUrl, '--openai-api-key', endpoint.apiKey,
82
- '--qps', '8', '--no-watermark', '--output', tmpDir,
83
- '--skip-figure-text', // 图/图片区域内的文字保持原文(架构图术语不翻)
163
+ ...noThink,
164
+ '--qps', '16', '--no-watermark', '--output', tmpDir,
165
+ // 图/图片区域内的文字保持原文(架构图术语不翻)——目前仅 fork(PR#616)支持
166
+ ...opt('skip-figure-text'),
167
+ // 跳过术语表自动抽取:该阶段要额外跑十几轮 LLM(推理型模型上能拖十几分钟,比正文还慢),
168
+ // 换来的术语一致性提升有限
169
+ ...opt('no-auto-extract-glossary'),
84
170
  ], {
85
171
  env: { ...process.env, HF_ENDPOINT: 'https://hf-mirror.com' }, // 版面模型走国内镜像
86
172
  timeout: 45 * 60 * 1000,
@@ -89,7 +175,7 @@ export async function startTranslation(ref, dataDir, endpoint) {
89
175
  if (err) {
90
176
  const tail = (stdout + '\n' + stderr).slice(-400);
91
177
  console.error('[dsh-paper-reader] babeldoc failed:', tail);
92
- busy.set(ref.pdfPath, `babeldoc 执行失败: ${err.message}`);
178
+ busy.set(ref.pdfPath, { error: `babeldoc 执行失败: ${err.message}` });
93
179
  return;
94
180
  }
95
181
  // 产出从 scratch 挪回文献目录:<名>.zh-CN.mono.pdf → -zh.pdf,.dual.pdf → -dual.pdf
@@ -101,19 +187,18 @@ export async function startTranslation(ref, dataDir, endpoint) {
101
187
  await rename(f, dstStem + '-dual.pdf');
102
188
  }
103
189
  if (!existsSync(zhPdfPath(ref)) && !existsSync(dualPdfPath(ref))) {
104
- busy.set(ref.pdfPath, 'babeldoc 未产出译文 PDF');
190
+ busy.set(ref.pdfPath, { error: 'babeldoc 未产出译文 PDF' });
105
191
  return;
106
192
  }
107
193
  busy.delete(ref.pdfPath);
108
194
  console.log(`[dsh-paper-reader] babeldoc done: ${ref.name}`);
109
195
  }
110
196
  catch (e) {
111
- busy.set(ref.pdfPath, `移动译文产物失败: ${e instanceof Error ? e.message : String(e)}`);
197
+ busy.set(ref.pdfPath, { error: `移动译文产物失败: ${e instanceof Error ? e.message : String(e)}` });
112
198
  }
113
199
  })();
114
200
  });
115
201
  child.unref?.();
116
- return { started: true };
117
202
  }
118
203
  /** 解析 PDF 变体路径(原文/zh/dual)。 */
119
204
  export function pdfVariantPath(ref, variant) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggboy123/dsh-paper-reader",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "DeepSeek Harness 插件:论文伴读工作台 — PDF 转录 / 检索 / 原生对话伴读 + 内置阅读器页面。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -55,7 +55,8 @@
55
55
  "reader"
56
56
  ],
57
57
  "dependencies": {
58
- "@deepseek-ai/dsh-tools": "0.1.5-rc.2"
58
+ "@deepseek-ai/dsh-tools": "0.1.5-rc.2",
59
+ "pdfjs-dist": "^6.3.289"
59
60
  },
60
61
  "peerDependencies": {
61
62
  "@deepseek-ai/cordis": "^4.0.2"
@@ -12,7 +12,9 @@
12
12
  complete: true
13
13
  includeRuntimeContext: false
14
14
  prefix: |
15
- You are a paper-reading tutor (论文伴读老师) powered by the {{model}} model. You don't just answer questions about a paper — you TEACH it, the way a good advisor walks a student through a paper: with a plan, step-by-step guidance, and exams.
15
+ You are a paper-reading examiner and coach (论文伴读教练) powered by the {{model}} model. Your job is NOT to deliver the paper's content — the paper itself does that, and the student learns by reading it with their own eyes. Your job is to design good questions, send the student off to read independently, and then rigorously verify what they actually understood. You are more examiner than lecturer.
16
+
17
+ The cardinal rule: NEVER summarize, walk through, or pre-explain content the student hasn't read and answered questions on yet. Feeding conclusions before the student reads robs them of the learning — that is the failure mode of this role. Your default loop is: assign reading + questions → wait for the student's own answer → probe and grade → and ONLY if a real gap remains after they've genuinely tried, explain that specific missing point with a page reference.
16
18
 
17
19
  Tools at your disposal:
18
20
  - list_papers: list topics and papers in the library.
@@ -27,12 +29,14 @@
27
29
 
28
30
  1) Quick Q&A. When the user asks a factual question (often a passage selected in the reader, with its page number), just answer it: search_paper first, answer with explicit page citations like (第 4 页), match the user's language. No lesson, no plan, no quiz.
29
31
 
30
- 2) Guided learning. When the user wants to LEARN the paper ("带我读", "我想学这篇", "继续", "检验我", or is working through a plan), act as the tutor:
32
+ 2) Guided learning. When the user wants to LEARN the paper ("带我读", "我想学这篇", "继续", "检验我", or is working through a plan), act as the examiner-coach:
31
33
  - Always start by calling study_progress to see where the student is.
32
- - No plan yet: skim the paper (transcribe/search the abstract, introduction, section headings), then design a 3~6 step reading plan. Each step names WHAT to read (with pages) and WHAT the student should be able to explain afterwards. Save it with set_plan, present it, and immediately start guiding step 1.
33
- - Guiding a step: point to the exact pages/sections, explain the background the student needs, and end with one focused question the student answers in their own words. When they respond, give specific feedback (what was right, what was missing, with page references) and IN THE SAME TURN call complete_step with a short note — feedback without the complete_step call is a bookkeeping failure; the next step only begins after the record shows it. If you skipped earlier bookkeeping, read study_progress and bring it up to date before continuing.
34
- - After the plan is done, or when the student asks to be tested: run a quiz — 3~5 questions grounded in the paper (mix of recall and "why is it designed this way"), one at a time or as a set; grade each answer specifically, and IN THE SAME TURN as the final grading call record_quiz with the score and a weak-point summary, then finish with targeted review advice (which pages to revisit).
35
- - Be encouraging but honest. Praise what was genuinely understood; never wave through a wrong answer. Chinese students get Chinese, English students get English.
34
+ - No plan yet: skim the paper yourself (transcribe/search the abstract, introduction, section headings), then design a 3~6 step plan with set_plan. The FIRST step is always an independent full read (独立通读) verified by 2~4 simple check questions; the remaining steps are theme-by-theme 精读, each verified by questions. After saving the plan, present ONLY the step-1 assignment: which pages to read and the check questions. Do NOT preview the paper — no abstract recap, no "this paper is about...". The questions themselves are the orientation.
35
+ - Step 1 (独立通读): pose 2~4 simple questions (e.g. 这篇论文要解决什么问题?方法用一句话怎么概括?最关键的实验结果/结论是什么?), point at the pages that matter if the paper is long, then STOP and wait. The student reads on their own and comes back with answers in their own words. Grade each answer specifically — what's right, what's vague, what's wrong, with page refs (第 N 页). If an answer is hollow, ask ONE sharper follow-up on the same point before deciding. complete_step (with a short feedback note) only when the answers demonstrate real grasp.
36
+ - 精读 steps (question-driven): for each step, FIRST give the question(s) and the exact pages to read, then let the student go read and answer. Questions must force engagement: "为什么这里用 X 而不是 Y?", "第 N 页的图/公式说明了什么?", "去掉这个设计会怎样?" — never "请总结第 N 节". When an answer reveals a gap, use a hint ladder: ask a smaller sub-question or point to the exact page/paragraph and let them retry. Explain directly ONLY after a genuine failed attempt — and then explain just that point, not the whole section.
37
+ - complete_step is earned by demonstrated understanding (a correct answer in the student's own words), never by "我看完了". Feedback without the complete_step call in the same turn is a bookkeeping failure; if you skipped earlier bookkeeping, read study_progress and bring it up to date first.
38
+ - After the plan is done, or when the student asks to be tested: run a quiz — 3~5 questions grounded in the paper (mix recall, "why is it designed this way", and "apply it to a new case"); grade each answer specifically, and IN THE SAME TURN as the final grading call record_quiz with the score and a weak-point summary, then finish with targeted review advice (which pages to revisit).
39
+ - Be warm but demanding. Praise genuine understanding; never wave through a wrong answer. If an answer smells pasted from the abstract or another AI, ask them to close the paper and re-answer from memory in their own words. Chinese students get Chinese, English students get English.
36
40
 
37
41
  The conversation itself is durable (the harness persists it) — never offer to save, archive, or export the conversation, and never mention file paths or tool names in your reply.
38
42
 
@@ -1,3 +1,3 @@
1
1
  name: 论文伴读
2
- description: 像老师一样带读论文的 Agent:制定分步阅读计划、逐节指导、出题检验并记录成绩(每篇论文一份 .study.json 学习档案)。
2
+ description: 考官式论文伴读:你先独立阅读,AI 只负责出题、校验和追问薄弱点,不灌输内容,只在你真正卡住时才讲解(每篇论文一份 .study.json 学习档案)。
3
3
  order: 10
package/reader/index.html CHANGED
@@ -4,7 +4,12 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>论文伴读 · dsh-paper-reader</title>
7
- <script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.js"></script>
7
+ <!-- pdf.js 6.x 本地 vendor(不再走 CDN):3.11 的字体解析对 babeldoc 嵌入的 CID 字体会回退成黑体 -->
8
+ <script type="module">
9
+ import * as pdfjsLib from '/paper-reader/vendor/pdf.min.mjs';
10
+ window.pdfjsLib = pdfjsLib;
11
+ pdfjsLib.GlobalWorkerOptions.workerSrc = '/paper-reader/vendor/pdf.worker.min.mjs';
12
+ </script>
8
13
  <style>
9
14
  :root {
10
15
  --bg: #f6f7f9; --panel: #ffffff; --border: #e2e5ea; --text: #24292f;
@@ -57,7 +62,9 @@
57
62
  #cfg-pop h3 { margin: 0; font-size: 13px; }
58
63
  #cfg-hint { font-size: 12px; color: var(--text-2); margin: 4px 0 2px; }
59
64
  #cfg-pop label { display: block; font-size: 12px; color: var(--text-2); margin: 8px 0 2px; }
60
- #cfg-pop input { width: 100%; border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; font: inherit; }
65
+ #cfg-pop input, #cfg-pop select { width: 100%; border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; font: inherit; background: var(--panel); color: var(--text); }
66
+ #cfg-model-hint, #cfg-source { font-size: 11.5px; color: var(--text-2); margin-top: 4px; line-height: 1.5; }
67
+ #cfg-source:empty { display: none; }
61
68
  #cfg-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
62
69
  #cfg-save { background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: 5px 14px; cursor: pointer; }
63
70
  #cfg-save:disabled { opacity: .5; cursor: default; }
@@ -71,6 +78,7 @@
71
78
  <div id="bar">
72
79
  <span id="status">论文伴读</span>
73
80
  <button id="zh-toggle" class="tbtn" title="切换中文译文(没有译文时可生成)" style="display:none">中</button>
81
+ <button id="zh-regen" class="tbtn" title="重新翻译:删除现有译文,用当前端点重跑(换模型后用)" style="display:none">↻</button>
74
82
  <span class="spacer"></span>
75
83
  <button id="cfg-open" class="tbtn" title="翻译端点设置">⚙</button>
76
84
  <button id="zoom-out" class="tbtn">−</button>
@@ -97,13 +105,18 @@
97
105
 
98
106
  <div id="cfg-pop">
99
107
  <h3>翻译端点</h3>
100
- <div id="cfg-hint">填 OpenAI 兼容端点即可(DeepSeek / 通义 / 本地 vLLM 等)。保存前会先测试连接。</div>
108
+ <div id="cfg-hint">选预设供应商,或选「自定义」填任意 OpenAI 兼容端点(通义 / 本地 vLLM 等)。保存前会先测试连接。</div>
109
+ <div id="cfg-source"></div>
110
+ <label for="cfg-provider">供应商</label>
111
+ <select id="cfg-provider"></select>
101
112
  <label for="cfg-url">端点 URL</label>
102
113
  <input id="cfg-url" type="text" placeholder="https://api.deepseek.com/v1" autocomplete="off">
103
114
  <label for="cfg-key">API Key</label>
104
115
  <input id="cfg-key" type="password" placeholder="sk-..." autocomplete="new-password">
105
116
  <label for="cfg-model">模型</label>
106
- <input id="cfg-model" type="text" placeholder="deepseek-chat" autocomplete="off">
117
+ <input id="cfg-model" type="text" placeholder="deepseek-chat" autocomplete="off" list="cfg-model-list">
118
+ <datalist id="cfg-model-list"></datalist>
119
+ <div id="cfg-model-hint">glm-5.x / k3 / deepseek-flash 是推理型模型,服务端已自动关闭思考;选自定义端点时建议用非推理模型,否则会慢好几倍。</div>
107
120
  <div id="cfg-actions">
108
121
  <button id="cfg-cancel">取消</button>
109
122
  <button id="cfg-save">保存</button>
@@ -167,9 +180,16 @@ async function refreshZhState() {
167
180
  btn.style.display = '';
168
181
  btn.disabled = zhState.busy;
169
182
  btn.textContent = zhState.busy ? '译' : '中';
170
- btn.title = zhState.busy ? '翻译进行中…'
183
+ btn.title = zhState.busy ? (zhState.phase || `翻译进行中${zhState.model ? `(${zhState.model})` : ''}…`)
171
184
  : (zhState.zh || zhState.dual) ? '切换中文译文'
172
185
  : '还没有中文版,点击生成(babeldoc,需要几分钟)';
186
+ // 安装/准备阶段在状态栏持续可见(轮询每 5s 刷新一次)
187
+ if (zhState.busy && zhState.phase) $('status').textContent = zhState.phase;
188
+ // ↻ 只在「已有译文且空闲」时出现;状态变化时顺手拆掉两击确认的待确认态
189
+ const regen = $('zh-regen');
190
+ const canRegen = (zhState.zh || zhState.dual) && !zhState.busy;
191
+ regen.style.display = canRegen ? '' : 'none';
192
+ if (!canRegen) disarmRegen();
173
193
  }
174
194
 
175
195
  $('zh-toggle').onclick = async () => {
@@ -183,28 +203,97 @@ $('zh-toggle').onclick = async () => {
183
203
  await loadPdf(pdfUrl(zhVariant));
184
204
  return;
185
205
  }
206
+ await startGenerate(false);
207
+ };
208
+
209
+ // 生成/重翻共用:force=true 时服务端先删旧译文再启动;成功后轮询到完成
210
+ async function startGenerate(force) {
186
211
  try {
187
- await api('/zh/generate', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(current) });
188
- toast('开始生成中文版(babeldoc,约几分钟)…', 5000);
212
+ const r = await api('/zh/generate', {
213
+ method: 'POST', headers: { 'content-type': 'application/json' },
214
+ body: JSON.stringify(force ? { ...current, force: true } : current),
215
+ });
216
+ const m = zhState.model ? `(${zhState.model})` : '';
217
+ toast(r.firstRun
218
+ ? '首次使用:正在自动安装翻译引擎(uv + Python + babeldoc,约几分钟),装完自动开始翻译…'
219
+ : (force ? `已删除旧译文,开始重新翻译${m}…` : `开始生成中文版${m}(约几分钟)…`), 6000);
189
220
  } catch (e) {
190
- // 没配端点 → 直接弹配置表单;填好保存后自动回来重试生成
191
- if (e.code === 'no-endpoint') { openCfgPop(true); return; }
221
+ // 没配端点 → 直接弹配置表单;填好保存后自动回来重试(记住是不是重翻)
222
+ if (e.code === 'no-endpoint') { cfgRetryForce = force; openCfgPop(true); return; }
192
223
  toast(e.message, 8000); return;
193
224
  }
194
225
  $('zh-toggle').disabled = true; $('zh-toggle').textContent = '译';
226
+ $('zh-regen').style.display = 'none';
195
227
  const timer = setInterval(async () => {
196
228
  await refreshZhState();
197
229
  if (!zhState.busy) {
198
230
  clearInterval(timer);
199
- if (zhState.dual || zhState.zh) { toast('中文版已生成'); $('zh-toggle').click(); }
231
+ if (zhState.dual || zhState.zh) {
232
+ toast('中文版已生成');
233
+ // 重翻完成时用户可能正停在译文视图:原地刷新同一变体,而不是来回切换
234
+ if (zhVariant) await loadPdf(pdfUrl(zhVariant));
235
+ else $('zh-toggle').click();
236
+ }
200
237
  else toast('生成失败:' + (zhState.error || '未知原因'), 8000);
201
238
  }
202
239
  }, 5000);
240
+ }
241
+
242
+ /* ================= 重新翻译(↻,两击确认) ================= */
243
+ // 不用原生 confirm:阅读器跑在 dsh 桌面壳的 iframe 里,原生对话框行为不可靠。
244
+ // 第一击把按钮变成「重翻?」待确认态(3 秒有效),第二击才真删真跑。
245
+ let regenArmTimer = null;
246
+ function disarmRegen() {
247
+ clearTimeout(regenArmTimer); regenArmTimer = null;
248
+ const btn = $('zh-regen');
249
+ btn.textContent = '↻'; btn.classList.remove('active');
250
+ }
251
+ $('zh-regen').onclick = async () => {
252
+ if (!current || zhState.busy) return;
253
+ const btn = $('zh-regen');
254
+ if (regenArmTimer) { // 第二击:确认执行
255
+ disarmRegen();
256
+ await startGenerate(true);
257
+ return;
258
+ }
259
+ btn.textContent = '重翻?'; // 第一击:进入待确认态
260
+ btn.classList.add('active');
261
+ regenArmTimer = setTimeout(disarmRegen, 3000);
203
262
  };
204
263
 
205
264
  /* ================= 翻译端点设置 ================= */
206
- // 保存成功后是否自动接着生成:点「中」发现没配 → 弹本表单 → 填完直接开跑
265
+ // 供应商预设(OpenAI 兼容端点,对齐 lflow 的供应商清单):选中即回填 URL + 推荐模型。
266
+ // 一律走 Coding 套餐端点——买套餐是常态;套餐 key 走按量口会报余额不足。按量用户选「自定义」手填。
267
+ // models 仅作 datalist 建议,输入框仍可自由填写(新型号上线不用发版)。
268
+ // glm-5.x / k3 / deepseek-flash 是推理型,服务端会对这三家域名自动关 thinking(translate.ts)。
269
+ const PROVIDERS = [
270
+ { id: 'custom', name: '自定义(OpenAI 兼容)', baseUrl: '', models: [] },
271
+ { id: 'deepseek', name: 'DeepSeek', baseUrl: 'https://api.deepseek.com', models: ['deepseek-v4-pro', 'deepseek-flash'] },
272
+ { id: 'glm', name: '智谱 GLM', baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', models: ['glm-5.3-flash', 'glm-5.3', 'glm-4.6'] },
273
+ { id: 'kimi', name: 'Kimi', baseUrl: 'https://api.kimi.com/coding/v1', models: ['k3'] },
274
+ ];
275
+ const normUrl = (u) => (u || '').trim().replace(/\/+$/, '');
276
+ // 按 baseUrl 反查预设(打开表单时回显选中项);查不到就是 custom
277
+ const providerOf = (baseUrl) => PROVIDERS.find((p) => p.id !== 'custom' && normUrl(p.baseUrl) === normUrl(baseUrl)) || PROVIDERS[0];
278
+
279
+ (function initProviderSelect() {
280
+ const sel = $('cfg-provider');
281
+ for (const p of PROVIDERS) {
282
+ const opt = document.createElement('option');
283
+ opt.value = p.id; opt.textContent = p.name;
284
+ sel.appendChild(opt);
285
+ }
286
+ sel.onchange = () => {
287
+ const p = PROVIDERS.find((x) => x.id === sel.value) || PROVIDERS[0];
288
+ if (p.baseUrl) $('cfg-url').value = p.baseUrl; // custom 不动用户已填的 URL
289
+ $('cfg-model-list').innerHTML = p.models.map((m) => `<option value="${esc(m)}">`).join('');
290
+ if (p.models.length && !p.models.includes($('cfg-model').value.trim())) $('cfg-model').value = p.models[0];
291
+ };
292
+ })();
293
+
294
+ // 保存成功后是否自动接着生成:点「中」/「↻」发现没配 → 弹本表单 → 填完直接开跑
207
295
  let cfgRetryGenerate = false;
296
+ let cfgRetryForce = false; // 待重试的是不是「重翻」(↻ 进来时旧译文还在,zh-toggle 点击语义不对,必须带 force)
208
297
 
209
298
  async function openCfgPop(retryGenerate) {
210
299
  cfgRetryGenerate = Boolean(retryGenerate);
@@ -214,23 +303,36 @@ async function openCfgPop(retryGenerate) {
214
303
  } catch (e) { toast('读取配置失败:' + e.message, 8000); return; }
215
304
  $('cfg-url').value = cfg.baseUrl || '';
216
305
  $('cfg-model').value = cfg.model || '';
306
+ // 按已存 URL 回显供应商,并触发一次 onchange 补齐模型候选(但不覆盖已存模型/URL)
307
+ const sel = $('cfg-provider');
308
+ sel.value = providerOf(cfg.baseUrl).id;
309
+ const keepUrl = $('cfg-url').value, keepModel = $('cfg-model').value;
310
+ sel.onchange();
311
+ $('cfg-url').value = keepUrl || $('cfg-url').value;
312
+ $('cfg-model').value = keepModel || $('cfg-model').value;
217
313
  // 明文 key 不回传:留空 = 沿用已存的那把
218
314
  $('cfg-key').value = '';
219
315
  $('cfg-key').placeholder = cfg.hasApiKey ? `已保存(${cfg.apiKeyHint}),留空则不修改` : 'sk-...';
316
+ // 生效配置的来源:界面保存的文件 优先于 profile 的 YAML 默认值——两条来源都有时不说明会分不清生效的是谁
317
+ $('cfg-source').textContent =
318
+ cfg.source === 'file' ? `当前生效:本界面保存的配置(key ${cfg.apiKeyHint})`
319
+ : cfg.source === 'profile' ? '当前生效:profile 默认配置(在本界面保存后会被覆盖)'
320
+ : '';
220
321
  const pop = $('cfg-pop');
221
322
  pop.style.display = 'block';
222
323
  // 先显示再量尺寸(不像 #ask-pop 那样写死尺寸常量,免得跟 CSS 漂移)
223
324
  pop.style.left = Math.max(8, (window.innerWidth - pop.offsetWidth) / 2) + 'px';
224
325
  pop.style.top = Math.max(8, (window.innerHeight - pop.offsetHeight) / 3) + 'px';
225
- $('cfg-url').focus();
326
+ $('cfg-key').focus();
226
327
  }
227
- function closeCfgPop() { $('cfg-pop').style.display = 'none'; cfgRetryGenerate = false; }
328
+ function closeCfgPop() { $('cfg-pop').style.display = 'none'; cfgRetryGenerate = false; cfgRetryForce = false; }
228
329
 
229
330
  $('cfg-open').onclick = () => openCfgPop(false);
230
331
  $('cfg-cancel').onclick = closeCfgPop;
231
332
 
232
333
  $('cfg-save').onclick = async () => {
233
- const btn = $('cfg-save'), label = btn.textContent, retry = cfgRetryGenerate;
334
+ // retry/force 先捞出来:closeCfgPop 会把这两个待确认态清零
335
+ const btn = $('cfg-save'), label = btn.textContent, retry = cfgRetryGenerate, force = cfgRetryForce;
234
336
  btn.disabled = true; btn.textContent = '测试中…';
235
337
  try {
236
338
  // 服务端先打一发极小的 chat/completions 验证 url/key/model,通过才落盘
@@ -251,12 +353,12 @@ $('cfg-save').onclick = async () => {
251
353
  }
252
354
  closeCfgPop();
253
355
  await refreshZhState();
254
- if (retry) $('zh-toggle').click(); // 复用「中」的生成逻辑(含它自己的 toast)
356
+ if (retry) startGenerate(force); // 沿用触发来源的语义:「中」=生成,「↻」=重翻
255
357
  else toast('翻译端点已保存');
256
358
  };
257
359
 
258
360
  /* ================= PDF 渲染(pdf.js:缩放 + devicePixelRatio 高清,移植自 pdfqa) ================= */
259
- pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js';
361
+ /* pdfjsLib 与 workerSrc 由头部 module 脚本设置(本地 vendor,pdf.js 6.x) */
260
362
 
261
363
  let pdfDoc = null;
262
364
  let zoomScale = 1.5;
@@ -274,7 +376,7 @@ let renderToken = 0;
274
376
  })();
275
377
 
276
378
  async function loadPdf(url) {
277
- pdfDoc = await pdfjsLib.getDocument(url).promise;
379
+ pdfDoc = await pdfjsLib.getDocument({ url }).promise; // v6 起不接受字符串参数,必须传对象
278
380
  $('empty').style.display = 'none';
279
381
  if (fitMode) { fitToWidth(); return; } // fitToWidth 内部 setZoom → renderAllPages
280
382
  await renderAllPages();
@@ -324,7 +426,8 @@ async function renderAllPages() {
324
426
  if (token !== renderToken) return;
325
427
  const tc = await page.getTextContent();
326
428
  if (token !== renderToken) return;
327
- await pdfjsLib.renderTextLayer({ textContent: tc, container: tl, viewport, textDivs: [] }).promise;
429
+ // pdf.js 6.x: renderTextLayer() 移除,改 TextLayer 类
430
+ await new pdfjsLib.TextLayer({ textContentSource: tc, container: tl, viewport }).render();
328
431
  }
329
432
  const newRange = pane.scrollHeight - pane.clientHeight;
330
433
  pane.scrollTop = anchorRatio * Math.max(0, newRange);