@ggboy123/dsh-paper-reader 0.4.0 → 0.6.2

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/host.js CHANGED
@@ -9,6 +9,7 @@ import { readTranscript, transcribePaper } from "./transcribe.js";
9
9
  import { pdfVariantPath, restartTranslation, startTranslation, zhStatus } from "./translate.js";
10
10
  import { clearTranslateConfig, maskApiKey, readTranslateConfig, resolveTranslateEndpoint, testTranslateEndpoint, writeTranslateConfig, } from "./translate-config.js";
11
11
  import { installPaperPreset, PAPER_PRESET_ID } from "./preset.js";
12
+ import { noteOrigin } from "./origin.js";
12
13
  function json(res, status, body) {
13
14
  const buf = Buffer.from(JSON.stringify(body), 'utf8');
14
15
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': buf.length });
@@ -464,6 +465,8 @@ export function registerRoutes(ctx, config) {
464
465
  json(res, rejection, { error: rejection === 401 ? 'unauthorized' : 'forbidden' });
465
466
  return;
466
467
  }
468
+ // 借浏览器请求的 Host 头记录服务 origin(工具结果里的 readerUrl 链接基址靠它拼)
469
+ noteOrigin(req.headers.host);
467
470
  const url = new URL(req.url ?? '/', 'http://x');
468
471
  dispatch(req, res, url).catch((err) => {
469
472
  if (!res.headersSent)
package/dist/origin.js ADDED
@@ -0,0 +1,13 @@
1
+ // origin.ts — 记录 dsh web 服务的外部地址(从插件路由收到的请求 Host 头捕获)。
2
+ // 工具进程不是 HTTP 请求,自己不知道服务端口;但浏览器客户端会持续打 /paper-reader/api
3
+ // (文献库 30s 轮询等),借它的 Host 头还原出可点击链接的基址。
4
+ let origin = null;
5
+ /** 每次插件路由被请求时调用;Host 已过宿主围栏校验,可信。 */
6
+ export function noteOrigin(host) {
7
+ if (typeof host === 'string' && host.length > 0)
8
+ origin = `http://${host}`;
9
+ }
10
+ /** 当前服务 origin;尚无任何请求时为 null(此时工具输出不带链接基址)。 */
11
+ export function readerOrigin() {
12
+ return origin;
13
+ }
package/dist/tools.js CHANGED
@@ -4,6 +4,7 @@ import { listPapers, listTopics, resolveDataDir, resolvePaper } from "./library.
4
4
  import { transcribePaper, readTranscript } from "./transcribe.js";
5
5
  import { chunkText, searchChunks, formatHits } from "./search.js";
6
6
  import { completeStep, formatStudy, readStudy, recordQuiz, setPlan } from "./study.js";
7
+ import { readerOrigin } from "./origin.js";
7
8
  const locateParams = {
8
9
  path: { type: 'string', description: 'PDF 绝对路径。与 topic+name 二选一。' },
9
10
  topic: { type: 'string', description: '专题名(文献库一级目录)。' },
@@ -42,6 +43,21 @@ export function paperFromSessionId(sessionId) {
42
43
  return null;
43
44
  }
44
45
  }
46
+ /** 某篇论文的阅读器链接基址(拼 &page=N 即跳转链接);origin 未捕获到时为 null。 */
47
+ function readerUrl(ref) {
48
+ const o = readerOrigin();
49
+ if (!o)
50
+ return null;
51
+ return `${o}/paper-reader/?topic=${encodeURIComponent(ref.topic)}&name=${encodeURIComponent(ref.name)}`;
52
+ }
53
+ /** 输出 schema 里的 readerUrl 字段声明(nullable:没有任何浏览器请求过时尚未捕获 origin)。 */
54
+ const readerUrlSchema = {
55
+ oneOf: [{ type: 'string' }, { type: 'null' }],
56
+ required: true,
57
+ description: '阅读器链接基址。引用页码时在末尾拼 &page=N 生成跳转链接;要定位到具体段落再拼 &q=<该段落原文短引文,3~8个词,URL编码>,点击会高亮对应片段。为 null 时本次不带链接。',
58
+ };
59
+ /** render 文本尾部统一带出的链接基址行(preset 规定了用法,这里只暴露值)。 */
60
+ const readerUrlLine = (url) => (url ? `\n阅读器链接基址:${url}` : '');
45
61
  export function registerTools(ctx, config) {
46
62
  const dataDir = resolveDataDir(config.dataDir);
47
63
  ctx.tools.register(defineTool({
@@ -102,6 +118,7 @@ export function registerTools(ctx, config) {
102
118
  pageCount: { type: 'integer', required: true },
103
119
  hasPageIndex: { type: 'boolean', required: true, description: '是否有页码索引(检索结果能否带页码)。' },
104
120
  source: { type: 'string', required: true, enum: ['cache', 'local'] },
121
+ readerUrl: readerUrlSchema,
105
122
  },
106
123
  additionalProperties: false,
107
124
  },
@@ -110,7 +127,8 @@ export function registerTools(ctx, config) {
110
127
  type: 'text',
111
128
  text: `已转录《${value.paper}》:${value.chars} 字符,${value.pageCount} 页` +
112
129
  `(${value.source === 'cache' ? '缓存复用' : '本地提取'}${value.hasPageIndex ? ',含页码索引' : ',无页码索引'})。` +
113
- `接下来可用 search_paper 检索具体内容。`,
130
+ `接下来可用 search_paper 检索具体内容。` +
131
+ readerUrlLine(value.readerUrl),
114
132
  },
115
133
  ],
116
134
  },
@@ -124,6 +142,7 @@ export function registerTools(ctx, config) {
124
142
  pageCount: t.pageCount,
125
143
  hasPageIndex: t.pages.length > 0,
126
144
  source: t.source,
145
+ readerUrl: readerUrl(ref),
127
146
  };
128
147
  },
129
148
  }));
@@ -162,6 +181,7 @@ export function registerTools(ctx, config) {
162
181
  additionalProperties: false,
163
182
  },
164
183
  },
184
+ readerUrl: readerUrlSchema,
165
185
  },
166
186
  additionalProperties: false,
167
187
  },
@@ -172,7 +192,7 @@ export function registerTools(ctx, config) {
172
192
  chunk: { index: h.chunk, start: 0, end: 0, text: h.text },
173
193
  score: 0,
174
194
  page: h.page,
175
- })), value.totalChunks, value.query),
195
+ })), value.totalChunks, value.query) + readerUrlLine(value.readerUrl),
176
196
  },
177
197
  ],
178
198
  },
@@ -193,6 +213,7 @@ export function registerTools(ctx, config) {
193
213
  query: args.query,
194
214
  totalChunks: chunks.length,
195
215
  hits: hits.map((h) => ({ chunk: h.chunk.index, page: h.page, text: h.chunk.text })),
216
+ readerUrl: readerUrl(ref),
196
217
  };
197
218
  },
198
219
  }));
@@ -235,13 +256,14 @@ export function registerTools(ctx, config) {
235
256
  additionalProperties: false,
236
257
  },
237
258
  },
259
+ readerUrl: readerUrlSchema,
238
260
  },
239
261
  additionalProperties: false,
240
262
  },
241
263
  render: (_args, value) => [
242
264
  {
243
265
  type: 'text',
244
- text: `《${value.paper}》学习档案:\n` + formatStudy(value.hasRecord ? { paper: value.paper, plan: value.plan, quizzes: value.quizzes } : null),
266
+ text: `《${value.paper}》学习档案:\n` + formatStudy(value.hasRecord ? { paper: value.paper, plan: value.plan, quizzes: value.quizzes } : null) + readerUrlLine(value.readerUrl),
245
267
  },
246
268
  ],
247
269
  },
@@ -253,6 +275,7 @@ export function registerTools(ctx, config) {
253
275
  hasRecord: record !== null,
254
276
  plan: record?.plan ?? [],
255
277
  quizzes: record?.quizzes ?? [],
278
+ readerUrl: readerUrl(ref),
256
279
  };
257
280
  },
258
281
  }));
@@ -93,7 +93,9 @@ export async function testTranslateEndpoint(ep) {
93
93
  if (r.ok) {
94
94
  // 智谱系网关切错路径时(如 /api/v1)会回 HTTP 200 + {"code":1001,"success":false},
95
95
  // 只看状态码会误判成功,babeldoc 跑起来才炸。真成功必须有 choices。
96
- const text = (await r.text().catch(() => '')).slice(0, 300);
96
+ // 上限放宽到 4000:GLM 等响应字段多,max_tokens:1 也有几百字节;
97
+ // 历史上 slice(0,300) 把合法 JSON 截断导致 parse 失败、误报「不是补全结果」(0.6.2 修复)
98
+ const text = (await r.text().catch(() => '')).slice(0, 4000);
97
99
  try {
98
100
  const data = JSON.parse(text);
99
101
  if (Array.isArray(data.choices))
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.4.0",
3
+ "version": "0.6.2",
4
4
  "description": "DeepSeek Harness 插件:论文伴读工作台 — PDF 转录 / 检索 / 原生对话伴读 + 内置阅读器页面。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,19 +23,24 @@
23
23
  - study_progress: read the student's learning record for a paper (plan + quiz history), stored in <论文>.study.json next to the PDF.
24
24
  - study_update: update that record — set_plan (3~6 steps), complete_step (with your feedback note), record_quiz (score/total + weak-point summary).
25
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
+
26
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.
27
31
 
28
32
  Two modes:
29
33
 
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.
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.
31
35
 
32
36
  2) Guided learning. When the user wants to LEARN the paper ("带我读", "我想学这篇", "继续", "检验我", or is working through a plan), act as the examiner-coach:
33
37
  - Always start by calling study_progress to see where the student is.
34
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.
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.
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.
37
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.
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).
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.
39
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.
40
45
 
41
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.
package/reader/index.html CHANGED
@@ -42,9 +42,33 @@
42
42
  #pdf-pages { display: flex; flex-direction: column; align-items: safe center; gap: 14px; }
43
43
  .page { position: relative; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.18); }
44
44
  .page.flash { outline: 3px solid var(--hl); outline-offset: 2px; transition: outline .15s; }
45
- .textLayer { position: absolute; inset: 0; overflow: hidden; line-height: 1; }
46
- .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; }
47
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; }
48
72
  #empty { margin: 60px auto; color: var(--text-2); text-align: center; max-width: 420px; }
49
73
 
50
74
  /* ---------- 选中即问浮层 ---------- */
@@ -149,6 +173,9 @@ function esc(s) { return s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;
149
173
  let current = null; // { topic, name }
150
174
  let zhState = { zh: false, dual: false, busy: false };
151
175
  let zhVariant = null; // null=原文 'dual'=中英对照 'zh'=纯中文
176
+ // 每篇论文记住上次用的语言版本:伴读会话的跳转链接原地跳页不重载,
177
+ // 加上这里的恢复,「中文界面跳中文」在同篇跳转/重开/跨论文三条路径上都成立
178
+ const variantKey = (topic, name) => `dpr.variant.${topic}/${name}`;
152
179
 
153
180
  /* ================= 打开文献 ================= */
154
181
  function pdfUrl(variant) {
@@ -160,8 +187,6 @@ async function openPaper(topic, name, opts = {}) {
160
187
  if (!name) return;
161
188
  current = { topic, name };
162
189
  localStorage.setItem('dpr.last', JSON.stringify(current));
163
- zhVariant = null;
164
- $('zh-toggle').classList.remove('active');
165
190
  $('status').textContent = '加载 PDF…';
166
191
  const q = `topic=${encodeURIComponent(topic)}&name=${encodeURIComponent(name)}`;
167
192
  const meta = await api('/paper?' + q);
@@ -169,8 +194,13 @@ async function openPaper(topic, name, opts = {}) {
169
194
  ? `${name} · ${meta.pageCount ?? '?'} 页`
170
195
  : `${name} · 未转录(提问时会自动转录)`;
171
196
  await refreshZhState();
172
- await loadPdf(pdfUrl(null));
173
- 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:跳转内部自带「等页面渲出来」轮询
174
204
  }
175
205
 
176
206
  /* ================= 中英切换 ================= */
@@ -197,6 +227,7 @@ $('zh-toggle').onclick = async () => {
197
227
  if (zhState.dual || zhState.zh) {
198
228
  // 两态切换:原文 ↔ 纯中文;只存在对照版时退到对照
199
229
  zhVariant = zhVariant === null ? (zhState.zh ? 'zh' : 'dual') : null;
230
+ localStorage.setItem(variantKey(current.topic, current.name), zhVariant ?? '');
200
231
  $('zh-toggle').classList.toggle('active', zhVariant !== null);
201
232
  // 切换变体后强制适宽:译文版页宽与原文不同(对照双栏≈2×),不重算会溢出裁切
202
233
  fitMode = true; fitBtn.classList.add('active');
@@ -458,6 +489,55 @@ function jumpToPage(n) {
458
489
  setTimeout(() => div.classList.remove('flash'), 1600);
459
490
  }
460
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
+
461
541
  /* ================= 选中即问 ================= */
462
542
  const askPop = $('ask-pop');
463
543
  let askSelection = null;
@@ -510,6 +590,9 @@ $('ask-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.
510
590
  async function bootstrap() {
511
591
  const params = new URLSearchParams(location.search);
512
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;
513
596
  const lib = await api('/library');
514
597
  if (lib.topics.length === 0) {
515
598
  $('empty').style.display = 'block';
@@ -523,14 +606,20 @@ async function bootstrap() {
523
606
  if (last && lib.papers.some((p) => p.topic === last.topic && p.name === last.name)) target = last;
524
607
  else target = lib.papers[0];
525
608
  }
526
- if (target) await openPaper(target.topic, target.name);
609
+ if (target) await openPaper(target.topic, target.name, { page: pPage, q: pQ });
527
610
  }
528
611
  bootstrap().catch((e) => {
529
612
  $('empty').style.display = 'block';
530
613
  $('empty').textContent = '文献库加载失败:' + e.message;
531
614
  });
532
615
 
533
- 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
+ };
534
623
  </script>
535
624
  </body>
536
625
  </html>