@ggboy123/dsh-paper-reader 0.2.1 → 0.6.1

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
@@ -7,6 +7,8 @@ import { existsSync } from 'node:fs';
7
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。 */
@@ -76,6 +80,8 @@ function babeldocFlags(bin) {
76
80
  /**
77
81
  * 启动 babeldoc 后台翻译。幂等:已生成或进行中直接返回 false 表示未新启动。
78
82
  * 完成/失败结果写进 busy map,由 zhStatus 暴露。
83
+ * babeldoc 缺失时不再报错:后台自动走 uv 安装链路(首次约几分钟),
84
+ * 期间 zhStatus.phase 展示安装进度,装完直接接着翻译。
79
85
  */
80
86
  export async function startTranslation(ref, dataDir, endpoint) {
81
87
  const st = zhStatus(ref);
@@ -83,16 +89,15 @@ export async function startTranslation(ref, dataDir, endpoint) {
83
89
  return { started: false, reason: 'already-exists' };
84
90
  if (st.busy)
85
91
  return { started: false, reason: 'busy' };
86
- const pre = await precheck(dataDir, endpoint);
92
+ const pre = precheck(endpoint);
87
93
  if (pre)
88
94
  return pre;
89
- return launch(ref, dataDir, endpoint);
95
+ const firstRun = !(await findBabeldoc(dataDir));
96
+ void runPipeline(ref, dataDir, endpoint);
97
+ return { started: true, firstRun };
90
98
  }
91
- /** 端点/babeldoc 预检:不齐全时返回与 startTranslation 同形的失败结果。 */
92
- async function precheck(dataDir, endpoint) {
93
- const bin = await findBabeldoc(dataDir);
94
- if (!bin)
95
- return { started: false, reason: '未找到 babeldoc(pip install babeldoc,或复用 pdfqa 的 .venv-pdf2zh)' };
99
+ /** 端点预检:不齐全时返回与 startTranslation 同形的失败结果。 */
100
+ function precheck(endpoint) {
96
101
  if (!endpoint.baseUrl || !endpoint.apiKey || !endpoint.model) {
97
102
  // code 供前端判定「该弹配置表单了」,别让它去匹配中文文案
98
103
  return {
@@ -103,27 +108,42 @@ async function precheck(dataDir, endpoint) {
103
108
  }
104
109
  return null;
105
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
+ }
106
125
  /**
107
126
  * 重新翻译:删除已有译文产物后再启动(换模型/翻得不好时用)。
108
- * 先预检再删——端点不齐或 babeldoc 缺失时不动旧译文,免得删完才发现跑不起来。
127
+ * 先预检再删——端点不齐时不动旧译文,免得删完才发现跑不起来。
109
128
  * babeldoc 的段落缓存 key 含模型名,换模型自然失效,无需 --ignore-cache。
110
129
  */
111
130
  export async function restartTranslation(ref, dataDir, endpoint) {
112
131
  if (zhStatus(ref).busy)
113
132
  return { started: false, reason: 'busy' };
114
- const pre = await precheck(dataDir, endpoint);
133
+ const pre = precheck(endpoint);
115
134
  if (pre)
116
135
  return pre;
117
136
  await rm(zhPdfPath(ref), { force: true });
118
137
  await rm(dualPdfPath(ref), { force: true });
119
- return launch(ref, dataDir, endpoint);
138
+ const firstRun = !(await findBabeldoc(dataDir));
139
+ void runPipeline(ref, dataDir, endpoint);
140
+ return { started: true, firstRun };
120
141
  }
121
- /** 真正拉起 babeldoc 子进程(precheck 已通过、产物不存在/已删)。 */
122
- async function launch(ref, dataDir, endpoint) {
123
- const bin = (await findBabeldoc(dataDir));
142
+ /** 真正拉起 babeldoc 子进程(bin 已就绪、产物不存在/已删)。 */
143
+ async function launch(ref, dataDir, endpoint, bin) {
124
144
  const tmpDir = join(dataDir, '.pdf2zh-tmp');
125
145
  await mkdir(tmpDir, { recursive: true });
126
- busy.set(ref.pdfPath, null);
146
+ busy.set(ref.pdfPath, null); // 清掉安装阶段说明,回到「翻译进行中」
127
147
  const stem = basename(ref.pdfPath, extname(ref.pdfPath));
128
148
  const dstStem = ref.pdfPath.slice(0, -extname(ref.pdfPath).length);
129
149
  // 推理型模型(glm-5.x / k3 / deepseek-flash)默认每段都先跑隐藏推理,翻译慢好几倍还偶尔返回空。
@@ -155,7 +175,7 @@ async function launch(ref, dataDir, endpoint) {
155
175
  if (err) {
156
176
  const tail = (stdout + '\n' + stderr).slice(-400);
157
177
  console.error('[dsh-paper-reader] babeldoc failed:', tail);
158
- busy.set(ref.pdfPath, `babeldoc 执行失败: ${err.message}`);
178
+ busy.set(ref.pdfPath, { error: `babeldoc 执行失败: ${err.message}` });
159
179
  return;
160
180
  }
161
181
  // 产出从 scratch 挪回文献目录:<名>.zh-CN.mono.pdf → -zh.pdf,.dual.pdf → -dual.pdf
@@ -167,19 +187,18 @@ async function launch(ref, dataDir, endpoint) {
167
187
  await rename(f, dstStem + '-dual.pdf');
168
188
  }
169
189
  if (!existsSync(zhPdfPath(ref)) && !existsSync(dualPdfPath(ref))) {
170
- busy.set(ref.pdfPath, 'babeldoc 未产出译文 PDF');
190
+ busy.set(ref.pdfPath, { error: 'babeldoc 未产出译文 PDF' });
171
191
  return;
172
192
  }
173
193
  busy.delete(ref.pdfPath);
174
194
  console.log(`[dsh-paper-reader] babeldoc done: ${ref.name}`);
175
195
  }
176
196
  catch (e) {
177
- busy.set(ref.pdfPath, `移动译文产物失败: ${e instanceof Error ? e.message : String(e)}`);
197
+ busy.set(ref.pdfPath, { error: `移动译文产物失败: ${e instanceof Error ? e.message : String(e)}` });
178
198
  }
179
199
  })();
180
200
  });
181
201
  child.unref?.();
182
- return { started: true };
183
202
  }
184
203
  /** 解析 PDF 变体路径(原文/zh/dual)。 */
185
204
  export function pdfVariantPath(ref, variant) {
package/lib/client.js CHANGED
@@ -54,6 +54,29 @@ window.__ModuleLoader__.load({
54
54
  window.dispatchEvent(new CustomEvent('dpr:paper', { detail: { topic, name, n } }))
55
55
  }
56
56
 
57
+ /* ---------- 会话消息里的阅读器跳转链接 ----------
58
+ agent 引用页码时给 [文字](http://<host>/paper-reader/?topic=..&name=..&page=N)
59
+ (dsh 渲染器对 http(s) 链接加 target=_blank,capture 拦截是应用内跳转的唯一窗口)。
60
+ 同论文 → 阅读器 iframe 原地跳页:不重载文档,用户正在看中文版就落在中文版该页;
61
+ 跨论文/iframe 未就绪 → setCurrentPaper 换论文,页码经 pendingJumpPage 挂上 src,
62
+ 阅读器 bootstrap 直达目标页。 */
63
+ let readerIframeEl = null
64
+ let pendingJump = null // { page, q }:跨论文/iframe 未就绪时挂上 src,由阅读器 bootstrap 消费
65
+ function jumpToPaperPage(topic, name, page, q) {
66
+ // 阅读器页签若关着尽量展开(seat 未绑定时静默失败,跳转本身仍生效)
67
+ try { clientApi?.sidebarRight?.openTab('dpr-reader', { revealIfOpened: true }) } catch {}
68
+ const cur = JSON.parse(localStorage.getItem('dpr.last') || 'null')
69
+ const tp = topic || cur?.topic, nm = name || cur?.name
70
+ if (!tp || !nm) return
71
+ const win = readerIframeEl?.contentWindow
72
+ if (cur && tp === cur.topic && nm === cur.name && win?.dpr?.jumpPage) {
73
+ win.dpr.jumpPage(page, q || undefined)
74
+ return
75
+ }
76
+ pendingJump = { page, q }
77
+ setCurrentPaper(tp, nm, cur && tp === cur.topic && nm === cur.name ? cur.n : 0)
78
+ }
79
+
57
80
  async function api(path, opts) {
58
81
  const r = await fetch('/paper-reader/api' + path, opts)
59
82
  if (!r.ok) {
@@ -116,7 +139,12 @@ window.__ModuleLoader__.load({
116
139
  const [geo, setGeo] = useState({ visible: false, left: 0, top: 0, width: 0, height: 0 })
117
140
  const lastShownRef = React.useRef(0)
118
141
  useEffect(() => {
119
- const on = (e) => setCur({ topic: e.detail.topic, name: e.detail.name })
142
+ const on = (e) => {
143
+ // 跳转链接挂的页码/引文(jumpToPaperPage 的跨论文路径):随 src 传给阅读器 bootstrap
144
+ const j = pendingJump
145
+ pendingJump = null
146
+ setCur({ topic: e.detail.topic, name: e.detail.name, page: j?.page, q: j?.q })
147
+ }
120
148
  window.addEventListener('dpr:paper', on)
121
149
  return () => window.removeEventListener('dpr:paper', on)
122
150
  }, [])
@@ -185,9 +213,10 @@ window.__ModuleLoader__.load({
185
213
  return () => { alive = false; clearInterval(timer) }
186
214
  }, [])
187
215
  if (!cur) return null
188
- const src = `/paper-reader/?topic=${encodeURIComponent(cur.topic)}&name=${encodeURIComponent(cur.name)}`
216
+ const src = `/paper-reader/?topic=${encodeURIComponent(cur.topic)}&name=${encodeURIComponent(cur.name)}${cur.page ? '&page=' + cur.page : ''}${cur.q ? '&q=' + encodeURIComponent(cur.q) : ''}`
189
217
  return createPortal(
190
218
  h('iframe', {
219
+ ref: (el) => { readerIframeEl = el },
191
220
  src, title: t('pdfTab'),
192
221
  'data-dsh-plugin': 'dsh-paper-reader', 'data-dsh-surface': 'reader-overlay',
193
222
  style: {
@@ -752,6 +781,23 @@ window.__ModuleLoader__.load({
752
781
 
753
782
  clientApi = { slots: ctx.slots, layout: ctx.layout, uiWorkspace: ctx.uiWorkspace, sidebarRight: ctx.sidebarRight }
754
783
 
784
+ // 阅读器跳转链接拦截(capture 阶段,一次绑定):渲染器给 http(s) 链接加了
785
+ // target=_blank,不拦会新开浏览器标签页 —— 拦下后交给应用内跳转
786
+ if (!window.__dprJumpLinkBound) {
787
+ window.__dprJumpLinkBound = true
788
+ document.addEventListener('click', (e) => {
789
+ const a = e.target?.closest?.('a[href*="/paper-reader/"]')
790
+ if (!a) return
791
+ let url
792
+ try { url = new URL(a.href) } catch { return }
793
+ const page = +(url.searchParams.get('page') || 0)
794
+ if (!page) return
795
+ e.preventDefault()
796
+ e.stopImmediatePropagation()
797
+ jumpToPaperPage(url.searchParams.get('topic'), url.searchParams.get('name'), page, url.searchParams.get('q') || '')
798
+ }, true)
799
+ }
800
+
755
801
  // 常驻浮层(与模式开关无关):阅读器 iframe + 对话头部历史/新建按钮组。
756
802
  // shell.overlay 是官方钦定的整页浮层自留地(list slot,additive,不遮蔽任何人)。
757
803
  // (对话头部按钮不走 slot —— session 作用域 slot 不渲染动态包条目,只能自渲染。)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggboy123/dsh-paper-reader",
3
- "version": "0.2.1",
3
+ "version": "0.6.1",
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.
@@ -21,18 +23,25 @@
21
23
  - study_progress: read the student's learning record for a paper (plan + quiz history), stored in <论文>.study.json next to the PDF.
22
24
  - study_update: update that record — set_plan (3~6 steps), complete_step (with your feedback note), record_quiz (score/total + weak-point summary).
23
25
 
26
+ Tool results carry a `阅读器链接基址` (readerUrl) line. EVERY page reference in your replies MUST be a clickable markdown link built from it: [链接文字](<readerUrl 原样照搬>&page=N) — e.g. [第 4 页](…&page=4). Clicking it jumps the student's built-in reader straight to that page, in whichever language version (原文/中英对照/纯中文) they are currently viewing — so never add any language/variant parameter yourself.
27
+
28
+ When pointing at a specific passage (a re-read assignment, the evidence for an answer), ALSO append a quote parameter so the reader highlights the exact passage: &q=<a verbatim 3~8 word phrase lifted from that passage, URL-encoded, e.g. &q=inexpensive%20commodity%20hardware>. Rules for q: copy it character-for-character from the search_paper/transcript text (never paraphrase), prefer a phrase at the START of the passage, no line breaks inside it, always percent-encode spaces (%20) and other non-ASCII characters. If you're unsure of the exact wording, omit q rather than guess — a wrong q silently degrades to a plain page jump. Bare "(第 N 页)" text citations are a formatting failure; always link. If the latest tool result has no readerUrl (null), fall back to plain text page numbers.
29
+
24
30
  Your session is bound to one paper: every paper tool auto-locates it when you omit topic/name, so NEVER ask the user which paper they mean in a paper session — just call the tool. Only pass topic/name explicitly when the user asks about a DIFFERENT paper.
25
31
 
26
32
  Two modes:
27
33
 
28
- 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.
34
+ 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 links like [第 4 页](…&page=4), match the user's language. No lesson, no plan, no quiz.
29
35
 
30
- 2) Guided learning. When the user wants to LEARN the paper ("带我读", "我想学这篇", "继续", "检验我", or is working through a plan), act as the tutor:
36
+ 2) Guided learning. When the user wants to LEARN the paper ("带我读", "我想学这篇", "继续", "检验我", or is working through a plan), act as the examiner-coach:
31
37
  - 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.
38
+ - 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.
39
+ - 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 links. 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.
40
+ - 精读 steps (question-driven): for each step, FIRST give the question(s) and the exact pages to read (as links), then let the student go read and answer. Questions must force engagement: "为什么这里用 X 而不是 Y?", "第 N 页的图/公式说明了什么?", "去掉这个设计会怎样?" — never "请总结第 N 节".
41
+ - 严格师傅原则(答案不如意时的固定动作): when an answer is vague, wrong, or smells pasted, your FIRST move is NOT to explain and NOT merely to follow up verbally — send the student back to the source: name the exact section and give a re-read link with q so the passage highlights on click ([重读 3.2 节(第 5 页)](…&page=5&q=…)), tell them what specific thing to look for, and have them re-answer the same question afterwards. Escalate up the hint ladder only across re-attempts: re-read assignment → a smaller sub-question → pointing at the exact paragraph. Explain directly ONLY after a genuine failed re-attempt — and then explain just that point, not the whole section.
42
+ - 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.
43
+ - 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 — each weak point gets a re-read link to the exact page.
44
+ - 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
45
 
37
46
  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
47
 
@@ -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;
@@ -37,9 +42,33 @@
37
42
  #pdf-pages { display: flex; flex-direction: column; align-items: safe center; gap: 14px; }
38
43
  .page { position: relative; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.18); }
39
44
  .page.flash { outline: 3px solid var(--hl); outline-offset: 2px; transition: outline .15s; }
40
- .textLayer { position: absolute; inset: 0; overflow: hidden; line-height: 1; }
41
- .textLayer span { position: absolute; white-space: pre; color: transparent; cursor: text; transform-origin: 0 0; }
45
+ /* textLayer CSS 必须是 pdf.js 6.x 官方规则的子集:TextLayer 只往 span 写 --font-height/--scale-x/--rotate
46
+ 变量,font-size 和 transform 全靠这里的 calc 表达式撑开;缺了的话 span 会按 body 字号渲染,
47
+ 盒子与 canvas 字形错位 —— 表现为选中高亮缺字符。--scale-factor 由 JS 在渲染时写入 */
48
+ .textLayer {
49
+ position: absolute; inset: 0; overflow: clip; line-height: 1; text-align: initial;
50
+ letter-spacing: normal; word-spacing: normal; text-size-adjust: none;
51
+ forced-color-adjust: none; transform-origin: 0 0; caret-color: CanvasText; z-index: 0;
52
+ --total-scale-factor: calc(var(--scale-factor, 1) * var(--user-unit, 1));
53
+ --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size, 1));
54
+ --min-font-size-inv: calc(1 / var(--min-font-size, 1));
55
+ --scale-round-x: 1px; --scale-round-y: 1px;
56
+ }
57
+ .textLayer :is(span, br) { color: transparent; position: absolute; white-space: pre; cursor: text; transform-origin: 0 0; user-select: text; }
58
+ .textLayer > :not(.markedContent),
59
+ .textLayer .markedContent span:not(.markedContent) {
60
+ z-index: 1;
61
+ --font-height: 0;
62
+ font-size: calc(var(--text-scale-factor) * var(--font-height));
63
+ --scale-x: 1; --rotate: 0deg;
64
+ transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
65
+ }
66
+ .textLayer .markedContent { display: contents; }
42
67
  .textLayer ::selection { background: rgba(22,93,255,.3); }
68
+ .textLayer br::selection { background: transparent; }
69
+ /* 跳转链接的片段高亮(&q= 引文定位):常驻不淡出,用户在阅读器里任意点一下即消
70
+ (同代码编辑器的选中高亮语义:消失由用户触发,不由计时器触发) */
71
+ .textLayer span.dpr-hl { background: rgba(255,196,0,.45); border-radius: 2px; }
43
72
  #empty { margin: 60px auto; color: var(--text-2); text-align: center; max-width: 420px; }
44
73
 
45
74
  /* ---------- 选中即问浮层 ---------- */
@@ -144,6 +173,9 @@ function esc(s) { return s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;
144
173
  let current = null; // { topic, name }
145
174
  let zhState = { zh: false, dual: false, busy: false };
146
175
  let zhVariant = null; // null=原文 'dual'=中英对照 'zh'=纯中文
176
+ // 每篇论文记住上次用的语言版本:伴读会话的跳转链接原地跳页不重载,
177
+ // 加上这里的恢复,「中文界面跳中文」在同篇跳转/重开/跨论文三条路径上都成立
178
+ const variantKey = (topic, name) => `dpr.variant.${topic}/${name}`;
147
179
 
148
180
  /* ================= 打开文献 ================= */
149
181
  function pdfUrl(variant) {
@@ -155,8 +187,6 @@ async function openPaper(topic, name, opts = {}) {
155
187
  if (!name) return;
156
188
  current = { topic, name };
157
189
  localStorage.setItem('dpr.last', JSON.stringify(current));
158
- zhVariant = null;
159
- $('zh-toggle').classList.remove('active');
160
190
  $('status').textContent = '加载 PDF…';
161
191
  const q = `topic=${encodeURIComponent(topic)}&name=${encodeURIComponent(name)}`;
162
192
  const meta = await api('/paper?' + q);
@@ -164,8 +194,13 @@ async function openPaper(topic, name, opts = {}) {
164
194
  ? `${name} · ${meta.pageCount ?? '?'} 页`
165
195
  : `${name} · 未转录(提问时会自动转录)`;
166
196
  await refreshZhState();
167
- await loadPdf(pdfUrl(null));
168
- if (opts.page) jumpToPage(opts.page);
197
+ // 恢复该论文上次的语言版本(译文已不存在时回落原文)
198
+ const saved = localStorage.getItem(variantKey(topic, name));
199
+ zhVariant = (saved === 'zh' && zhState.zh) || (saved === 'dual' && zhState.dual) ? saved : null;
200
+ $('zh-toggle').classList.toggle('active', zhVariant !== null);
201
+ if (zhVariant) { fitMode = true; fitBtn.classList.add('active'); } // 译文版页宽不同,强制适宽
202
+ await loadPdf(pdfUrl(zhVariant));
203
+ if (opts.page) jumpTo(opts.page, opts.q); // 不 await:跳转内部自带「等页面渲出来」轮询
169
204
  }
170
205
 
171
206
  /* ================= 中英切换 ================= */
@@ -175,9 +210,11 @@ async function refreshZhState() {
175
210
  btn.style.display = '';
176
211
  btn.disabled = zhState.busy;
177
212
  btn.textContent = zhState.busy ? '译' : '中';
178
- btn.title = zhState.busy ? `翻译进行中${zhState.model ? `(${zhState.model})` : ''}…`
213
+ btn.title = zhState.busy ? (zhState.phase || `翻译进行中${zhState.model ? `(${zhState.model})` : ''}…`)
179
214
  : (zhState.zh || zhState.dual) ? '切换中文译文'
180
215
  : '还没有中文版,点击生成(babeldoc,需要几分钟)';
216
+ // 安装/准备阶段在状态栏持续可见(轮询每 5s 刷新一次)
217
+ if (zhState.busy && zhState.phase) $('status').textContent = zhState.phase;
181
218
  // ↻ 只在「已有译文且空闲」时出现;状态变化时顺手拆掉两击确认的待确认态
182
219
  const regen = $('zh-regen');
183
220
  const canRegen = (zhState.zh || zhState.dual) && !zhState.busy;
@@ -190,6 +227,7 @@ $('zh-toggle').onclick = async () => {
190
227
  if (zhState.dual || zhState.zh) {
191
228
  // 两态切换:原文 ↔ 纯中文;只存在对照版时退到对照
192
229
  zhVariant = zhVariant === null ? (zhState.zh ? 'zh' : 'dual') : null;
230
+ localStorage.setItem(variantKey(current.topic, current.name), zhVariant ?? '');
193
231
  $('zh-toggle').classList.toggle('active', zhVariant !== null);
194
232
  // 切换变体后强制适宽:译文版页宽与原文不同(对照双栏≈2×),不重算会溢出裁切
195
233
  fitMode = true; fitBtn.classList.add('active');
@@ -202,12 +240,14 @@ $('zh-toggle').onclick = async () => {
202
240
  // 生成/重翻共用:force=true 时服务端先删旧译文再启动;成功后轮询到完成
203
241
  async function startGenerate(force) {
204
242
  try {
205
- await api('/zh/generate', {
243
+ const r = await api('/zh/generate', {
206
244
  method: 'POST', headers: { 'content-type': 'application/json' },
207
245
  body: JSON.stringify(force ? { ...current, force: true } : current),
208
246
  });
209
247
  const m = zhState.model ? `(${zhState.model})` : '';
210
- toast(force ? `已删除旧译文,开始重新翻译${m}…` : `开始生成中文版${m}(约几分钟)…`, 5000);
248
+ toast(r.firstRun
249
+ ? '首次使用:正在自动安装翻译引擎(uv + Python + babeldoc,约几分钟),装完自动开始翻译…'
250
+ : (force ? `已删除旧译文,开始重新翻译${m}…` : `开始生成中文版${m}(约几分钟)…`), 6000);
211
251
  } catch (e) {
212
252
  // 没配端点 → 直接弹配置表单;填好保存后自动回来重试(记住是不是重翻)
213
253
  if (e.code === 'no-endpoint') { cfgRetryForce = force; openCfgPop(true); return; }
@@ -349,7 +389,7 @@ $('cfg-save').onclick = async () => {
349
389
  };
350
390
 
351
391
  /* ================= PDF 渲染(pdf.js:缩放 + devicePixelRatio 高清,移植自 pdfqa) ================= */
352
- pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js';
392
+ /* pdfjsLib 与 workerSrc 由头部 module 脚本设置(本地 vendor,pdf.js 6.x) */
353
393
 
354
394
  let pdfDoc = null;
355
395
  let zoomScale = 1.5;
@@ -367,7 +407,7 @@ let renderToken = 0;
367
407
  })();
368
408
 
369
409
  async function loadPdf(url) {
370
- pdfDoc = await pdfjsLib.getDocument(url).promise;
410
+ pdfDoc = await pdfjsLib.getDocument({ url }).promise; // v6 起不接受字符串参数,必须传对象
371
411
  $('empty').style.display = 'none';
372
412
  if (fitMode) { fitToWidth(); return; } // fitToWidth 内部 setZoom → renderAllPages
373
413
  await renderAllPages();
@@ -417,7 +457,8 @@ async function renderAllPages() {
417
457
  if (token !== renderToken) return;
418
458
  const tc = await page.getTextContent();
419
459
  if (token !== renderToken) return;
420
- await pdfjsLib.renderTextLayer({ textContent: tc, container: tl, viewport, textDivs: [] }).promise;
460
+ // pdf.js 6.x: renderTextLayer() 移除,改 TextLayer 类
461
+ await new pdfjsLib.TextLayer({ textContentSource: tc, container: tl, viewport }).render();
421
462
  }
422
463
  const newRange = pane.scrollHeight - pane.clientHeight;
423
464
  pane.scrollTop = anchorRatio * Math.max(0, newRange);
@@ -448,6 +489,55 @@ function jumpToPage(n) {
448
489
  setTimeout(() => div.classList.remove('flash'), 1600);
449
490
  }
450
491
 
492
+ /* 片段定位高亮(跳转链接的 &q= 引文):在指定页 textLayer 里找 q、高亮并滚到它。
493
+ 归一化时忽略空白/连字符/软连字符、大小写 —— 论文跨行断词(dis-tributed)也能命中。
494
+ 找不到(如用户在中文界面而 q 是英文原文)返回 false,调用方退回仅跳页。 */
495
+ function clearHighlights() {
496
+ document.querySelectorAll('.textLayer span.dpr-hl').forEach((sp) => sp.classList.remove('dpr-hl'));
497
+ }
498
+ // 高亮的消失交给用户:阅读器里任意点一下即清(mousedown 比 click 早,划选文字的那一下也算)
499
+ document.addEventListener('mousedown', clearHighlights);
500
+
501
+ function highlightSnippet(page, q) {
502
+ const spans = [...(document.querySelector(`.page[data-page="${page}"]`)?.querySelectorAll('.textLayer span:not(.markedContent)') ?? [])];
503
+ if (!spans.length || !q) return false;
504
+ let hay = '';
505
+ const startOf = [];
506
+ for (const sp of spans) { startOf.push(hay.length); hay += sp.textContent; }
507
+ // 归一化串 + 回原串的索引映射(归一化改变长度,不能直接用原索引)
508
+ const map = [];
509
+ let nh = '';
510
+ for (let i = 0; i < hay.length; i++) {
511
+ const c = hay[i].toLowerCase();
512
+ if (/\s/.test(c) || c === '-' || c === '­') continue;
513
+ nh += c; map.push(i);
514
+ }
515
+ const needle = q.toLowerCase().replace(/[\s\-­]+/g, '');
516
+ const at = needle ? nh.indexOf(needle) : -1;
517
+ if (at < 0) return false;
518
+ const from = map[at], to = map[at + needle.length - 1] + 1;
519
+ const hit = spans.filter((sp, i) => startOf[i] < to && startOf[i] + sp.textContent.length > from);
520
+ if (!hit.length) return false;
521
+ clearHighlights();
522
+ for (const sp of hit) sp.classList.add('dpr-hl');
523
+ hit[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
524
+ return true;
525
+ }
526
+
527
+ /* 统一跳转入口:先等目标页渲染出来(fit 模式下 loadPdf 返回时页面可能还在渲),
528
+ 有 q 就尝试片段高亮,否则整页跳转 + 边框闪烁 */
529
+ async function jumpTo(page, q) {
530
+ const p0 = Math.max(1, (page | 0) || 1);
531
+ for (let i = 0; i < 100; i++) {
532
+ if (pdfDoc && document.querySelector(`.page[data-page="${Math.min(p0, pdfDoc.numPages)}"]`)) {
533
+ const p = Math.min(p0, pdfDoc.numPages);
534
+ if (!(q && highlightSnippet(p, q))) jumpToPage(p);
535
+ return;
536
+ }
537
+ await new Promise((r) => setTimeout(r, 100));
538
+ }
539
+ }
540
+
451
541
  /* ================= 选中即问 ================= */
452
542
  const askPop = $('ask-pop');
453
543
  let askSelection = null;
@@ -500,6 +590,9 @@ $('ask-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.
500
590
  async function bootstrap() {
501
591
  const params = new URLSearchParams(location.search);
502
592
  const pTopic = params.get('topic'), pName = params.get('name');
593
+ // 伴读会话里的跳转链接([重读第 N 页](…?page=N))经此直达目标页
594
+ const pPage = +(params.get('page') || 0) || undefined;
595
+ const pQ = params.get('q') || undefined;
503
596
  const lib = await api('/library');
504
597
  if (lib.topics.length === 0) {
505
598
  $('empty').style.display = 'block';
@@ -513,14 +606,20 @@ async function bootstrap() {
513
606
  if (last && lib.papers.some((p) => p.topic === last.topic && p.name === last.name)) target = last;
514
607
  else target = lib.papers[0];
515
608
  }
516
- if (target) await openPaper(target.topic, target.name);
609
+ if (target) await openPaper(target.topic, target.name, { page: pPage, q: pQ });
517
610
  }
518
611
  bootstrap().catch((e) => {
519
612
  $('empty').style.display = 'block';
520
613
  $('empty').textContent = '文献库加载失败:' + e.message;
521
614
  });
522
615
 
523
- window.dpr = { openPaper: (topic, name, page) => openPaper(topic, name, { page }), jumpToPage };
616
+ window.dpr = {
617
+ openPaper: (topic, name, page, q) => openPaper(topic, name, { page, q }),
618
+ jumpToPage,
619
+ // 伴读会话的跳转链接用:当前文档里原地跳转,不重载 —— 用户正在看中文版就落在中文版;
620
+ // 带 q(原文短引文)时定位并闪烁高亮对应片段,找不到则退回整页跳转
621
+ jumpPage: (n, q) => { jumpTo(n, q); },
622
+ };
524
623
  </script>
525
624
  </body>
526
625
  </html>