@ggboy123/dsh-paper-reader 1.3.4 → 1.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/host.js CHANGED
@@ -1,14 +1,19 @@
1
1
  // host.ts — webServer 路由:阅读器页面 + PDF 文件 + 转录数据接口 + 选中即问。
2
2
  // 壳层代码:只做 HTTP ↔ 纯函数核心(library/transcribe)的转接。
3
- import { createReadStream, readFileSync } from 'node:fs';
3
+ import { createReadStream, existsSync, readFileSync, realpathSync } from 'node:fs';
4
4
  import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
5
5
  import { randomUUID } from 'node:crypto';
6
- import { basename, join } from 'node:path';
7
- import { deletePaper, deleteTopic, listPapers, listTopics, paperRefFor, renamePaper, renameTopic, resolveDataDir, resolvePaper, } from "./library.js";
8
- import { readTranscript, transcribePaper } from "./transcribe.js";
6
+ import { basename, join, resolve } from 'node:path';
7
+ import { listPapers, listTopics, resolveDataDir, resolvePaper, resolveUploadTopic, validateEntryBoundary, validateEntryName, } from "./library.js";
8
+ import { ManageError, assertInside, clearTopic, planTopicClear, deletePaper, deleteTopic, isManageError, planPaperDelete, planTopicDelete, realDataDir, renamePaper, renameTopic, resolveTopicDir, validateName, } from "./library-manage.js";
9
+ import { readFormulaIndex } from "./formulas.js";
10
+ import { MineruError, sanitizeDetail } from "./mineru.js";
11
+ import { readTranscript, transcribePaper, ScannedPdfError, ShortTextError } from "./transcribe.js";
9
12
  import { pdfVariantPath, restartTranslation, startTranslation, zhStatus } from "./translate.js";
10
13
  import { clearTranslateConfig, maskApiKey, readTranslateConfig, resolveTranslateEndpoint, testTranslateEndpoint, writeTranslateConfig, } from "./translate-config.js";
11
14
  import { clearTypesafeConfig, readTypesafeConfig, resolveTypesafeConfig, testTypesafeEndpoint, writeTypesafeConfig, } from "./typesafe-config.js";
15
+ import { clearEmbedConfig, maskApiKey as maskEmbedApiKey, readEmbedConfig, resolveEmbedConfig, testEmbedEndpoint, writeEmbedConfig, } from "./embed-config.js";
16
+ import { clearMineruConfig, mergeMineruBody, precheckMineruConfig, resolveMineruConfig, testMineruCloud, testMineruLocal, writeMineruConfig, } from "./mineru-config.js";
12
17
  import { installPaperPreset, PAPER_PRESET_ID } from "./preset.js";
13
18
  import { noteOrigin } from "./origin.js";
14
19
  function json(res, status, body) {
@@ -16,12 +21,34 @@ function json(res, status, body) {
16
21
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': buf.length });
17
22
  res.end(buf);
18
23
  }
24
+ /**
25
+ * 错误文案判别:带 `.code` 的 Node errno 系统错误 → 固定中文文案(不回显原始错误,
26
+ * 避免把 dataDir/目标绝对路径带进响应体);不带 `.code` 的是我们自己抛的中文业务 Error
27
+ * (专题不存在/已存在同名/名字不能包含… 等)→ 保留 `e.message`,用户仍能定位问题。
28
+ */
29
+ function readableError(err) {
30
+ const code = err?.code;
31
+ if (typeof code === 'string' && code)
32
+ return '系统错误,请稍后重试';
33
+ return err instanceof Error ? err.message : String(err);
34
+ }
19
35
  async function readBody(req) {
20
36
  const chunks = [];
21
37
  for await (const c of req)
22
38
  chunks.push(c);
23
39
  const raw = Buffer.concat(chunks).toString('utf8');
24
- return raw ? JSON.parse(raw) : {};
40
+ if (!raw)
41
+ return {};
42
+ // 畸形 JSON / 整包 null / 非对象 → {}:路由据此给**可读 400**(原来是 JSON.parse 抛错 →
43
+ // 外层兜底 500 internal,错误分类错误)。PR-1 的错误分类收口,rebase 时一并带入。
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(raw);
47
+ }
48
+ catch {
49
+ return {};
50
+ }
51
+ return parsed !== null && typeof parsed === 'object' ? parsed : {};
25
52
  }
26
53
  /** header 元数据解码:客户端 encodeURIComponent 过(HTTP header 值不允许非 Latin-1,如中文文件名);非字符串/未编码原样返回。 */
27
54
  function decodeHdr(v) {
@@ -60,7 +87,14 @@ export function registerRoutes(ctx, config) {
60
87
  args.topic = topic;
61
88
  if (name)
62
89
  args.name = name;
63
- return resolvePaper(dataDir, args);
90
+ try {
91
+ return resolvePaper(dataDir, args);
92
+ }
93
+ catch {
94
+ // 原先普通 Error 会冒泡成 500 且把「PDF 不存在: /abs/path」这类绝对路径写进响应体;
95
+ // 这里统一成 404 + 只含名称的文案(读路径也一样,语义更准且不泄露服务端路径)。
96
+ throw notFound(args.path ? '文献不存在(path 指向的文件不可用)' : `文献不存在:${args.topic ? `${args.topic}/` : ''}${args.name ?? ''}`);
97
+ }
64
98
  };
65
99
  /** 每篇文献的伴读会话 id(确定性,重启/刷新后可续);n 支持同文献多会话(会话1/2/…)。 */
66
100
  const sessionPrefixFor = (ref) => `dpr-${Buffer.from(`${ref.topic}/${ref.name}`).toString('base64url')}`;
@@ -122,7 +156,7 @@ export function registerRoutes(ctx, config) {
122
156
  return workspace.workspaceId;
123
157
  }
124
158
  catch (err) {
125
- console.warn('[dsh-paper-reader] workspace 注册失败(回退 cwd 模式):', err);
159
+ console.warn('[dsh-paper-reader] workspace 注册失败(回退 cwd 模式):', readableError(err));
126
160
  return undefined;
127
161
  }
128
162
  };
@@ -144,6 +178,153 @@ export function registerRoutes(ctx, config) {
144
178
  }
145
179
  return sessionController.create({ sessionId, cwd: join(dataDir, topic) });
146
180
  };
181
+ /* ── 破坏性操作(删除/重命名)的共用出口 ──────────────────────────────────
182
+ 契约:docs/reader-ux-requirements.md §2。错误文案一律不含服务端绝对路径;
183
+ 结构性字段(trashDir/trashRel)只在必须给用户恢复线索时出现(见 O4 说明)。 */
184
+ const manageFail = (res, err) => {
185
+ if (!isManageError(err))
186
+ return false;
187
+ json(res, err.status, { error: err.message, code: err.code, ...err.extra });
188
+ return true;
189
+ };
190
+ /**
191
+ * errno 错误通道 —— **复用上游 #4 的 `fsError` 语义**:EBUSY/EPERM(文件被占用)→ 409 占用文案,
192
+ * 其余 errno → 400 + 固定中文文案(`readableError` 对带 `.code` 的错误不回显原始 message,
193
+ * 因此绝不泄露 dataDir/绝对路径);不带 `.code` 的业务 Error 保留可读 message。
194
+ * 说明(t4 评审 O6 的旧裁定在此被上游必修 3 推翻):上游要求的正是这条通道,现予恢复。
195
+ */
196
+ const fsError = (res2, err) => {
197
+ const e = err;
198
+ if (e?.code === 'EBUSY' || e?.code === 'EPERM') {
199
+ json(res2, 409, { error: '文件被占用(可能正在阅读器或其他程序中打开),关闭后重试', code: 'file-busy' });
200
+ return true;
201
+ }
202
+ json(res2, 400, { error: readableError(err) });
203
+ return true;
204
+ };
205
+ /** 破坏性路由的统一失败出口:我们的 ManageError 优先(status/code/extra),否则走 errno 通道。 */
206
+ const libFail = (res2, err) => {
207
+ if (isManageError(err))
208
+ return manageFail(res2, err);
209
+ return fsError(res2, err);
210
+ };
211
+ /**
212
+ * 某文献的伴读会话统计。**查不到就标 unknown**(不再吞成「0 个会话」):
213
+ * R4-F3 指出 fail-open 会削弱 S7「有运行中会话就拒绝」——会话服务不可用时无法证明「没有运行中会话」,
214
+ * 破坏性操作据 unknown 走 fail-closed(见 requireSessionClear)。读路径不受影响(只有破坏性路由用它)。
215
+ */
216
+ /** 对外形状固定为 `{total, running}`(既有断言 deepEqual 它);`unknown` 只在服务端内部用。 */
217
+ const toWire = (i) => ({ total: i.total, running: i.running });
218
+ const sessionInfo = async (ref) => {
219
+ if (typeof sessionController?.list !== 'function')
220
+ return { total: 0, running: 0, unknown: true };
221
+ try {
222
+ const s = await scanPaperSessions(ref);
223
+ return { total: s.length, running: s.filter((x) => x.running).length, unknown: false };
224
+ }
225
+ catch {
226
+ return { total: 0, running: 0, unknown: true };
227
+ }
228
+ };
229
+ /**
230
+ * 破坏性操作的会话准入(**fail-closed**):查不到会话状态就**拒绝**,不冒险动手。
231
+ * 取向理由:删除会把产物搬进回收站、重命名会改会话工作区下的文件名;若此时真有会话在跑,
232
+ * 破坏的是用户当前正在用的工作目录。代价是「会话服务抖动时可能白拒一次」,用户重试即可——
233
+ * 可恢复性远好于误删。unknown 用 503 + code=session-unknown,running 用 409 + code=session-running。
234
+ */
235
+ const requireSessionClear = async (ref, res) => {
236
+ const info = await sessionInfo(ref);
237
+ if (info.unknown) {
238
+ json(res, 503, { error: '暂时无法确认该文献的伴读会话状态,为避免误删已取消本次操作;请稍后重试', code: 'session-unknown' });
239
+ return false;
240
+ }
241
+ if (info.running > 0) {
242
+ json(res, 409, { error: `该文献有 ${info.running} 个正在运行的伴读会话,请先结束后再操作`, code: 'session-running', sessions: toWire(info) });
243
+ return false;
244
+ }
245
+ return true;
246
+ };
247
+ /** 专题级会话准入:该专题内任一篇文献会话状态不明/在跑 → 拒绝。 */
248
+ const requireTopicSessionsClear = async (topic, res) => {
249
+ for (const p of listPapers(dataDir, topic)) {
250
+ if (!(await requireSessionClear(p, res)))
251
+ return false;
252
+ }
253
+ return true;
254
+ };
255
+ /**
256
+ * 契约 R5「并发互斥」的最小在途守卫(R4-F2):
257
+ * 同一篇文献的删除/重命名在途时,第二个请求直接 409 `busy`(而不是让两个请求交错改同一批文件:
258
+ * 实测后果是第二个请求拿到 404/500 的中间态)。进程内 Set 足够——本插件是单进程 ws 路由。
259
+ * 机器码取 `busy` 而非契约 §5 R5 字面的 `in-progress`:两者语义完全一致(「有操作在途」),
260
+ * `busy` 更短且与既有 `translation-busy`/`session-running` 的短词风格一致;前端 errText 已按 `busy` 对齐。
261
+ */
262
+ const inflight = new Set();
263
+ const withInflight = async (key, res, fn) => {
264
+ if (inflight.has(key)) {
265
+ json(res, 409, { error: '同一篇文献的另一个删除/重命名操作正在进行,请稍后重试', code: 'busy' });
266
+ return;
267
+ }
268
+ inflight.add(key);
269
+ try {
270
+ await fn();
271
+ }
272
+ finally {
273
+ inflight.delete(key);
274
+ }
275
+ };
276
+ /** 「文献不存在」的统一出口:404 + 不含服务端绝对路径的文案(原先普通 Error 会冒泡成 500 并带路径)。 */
277
+ const notFound = (what) => new ManageError('not-found', 404, what);
278
+ /**
279
+ * **写路径**的文献定位(t3-O2:/api/transcribe 曾接受任意 `path` 并据此写盘)。
280
+ * 规则与删除同级:只认 topic+name;给了 `path` 时其 **realpath 必须落在 dataDir 内**
281
+ * (符号链接指向库外同样拒绝)。校验逻辑全部复用 library-manage 的既有实现,不另写一套。
282
+ */
283
+ const locateForWrite = (body, isUrlSearch = false) => {
284
+ void isUrlSearch;
285
+ if (body.path) {
286
+ const abs = resolve(String(body.path));
287
+ let real;
288
+ try {
289
+ real = realpathSync(abs);
290
+ }
291
+ catch {
292
+ throw notFound('文献不存在(path 指向的文件不可读)');
293
+ }
294
+ assertInside(realDataDir(dataDir), real); // 越界/符号链接逃逸 → 400 escape-rejected
295
+ return resolvePaper(dataDir, { path: abs });
296
+ }
297
+ // exactOptionalPropertyTypes: true —— 不能把 undefined 直接挂到可选属性上
298
+ const args = {};
299
+ if (body.topic !== undefined)
300
+ args.topic = body.topic;
301
+ if (body.name !== undefined)
302
+ args.name = body.name;
303
+ return resolvePaper(dataDir, args);
304
+ };
305
+ /** 破坏性路由用的文献定位:把 resolvePaper 的普通 Error 统一成 404(含符号链接 .pdf 的情况)。 */
306
+ const resolveLibraryPaper = (topic, name) => {
307
+ try {
308
+ return resolvePaper(dataDir, { topic, name });
309
+ }
310
+ catch {
311
+ throw notFound(`文献不存在:${name}`);
312
+ }
313
+ };
314
+ /**
315
+ * 外层兜底文案脱敏:把任何**绝对路径**替换成 `<path>`。
316
+ * 说明:这里没有采用「一律不回显 err.message」的极端做法——那会把 MinerU/转录等**可读的领域错误**
317
+ * 一起变成不可读(既有测试明确断言这些文案要含 `MinerU` 等关键词,不许放宽)。改为:领域错误保留可读文案、
318
+ * 但**先剥掉绝对路径**;未知内部错误则只给通用文案。两种情况都带机器可读 `code:'internal'`。
319
+ */
320
+ const stripAbsPaths = (msg) => msg.replace(/(?:[A-Za-z]:)?(?:[\\/][^\s'"“”()()]+)+/g, '<path>');
321
+ /** 破坏性路由的统一前置检查:拒绝 path 参数(写路径只认 topic+name)。 */
322
+ const rejectWritePath = (body, res) => {
323
+ if (body.path === undefined || body.path === null || body.path === '')
324
+ return false;
325
+ json(res, 400, { error: '删除/重命名不接受 path 参数,请用 topic + name', code: 'escape-rejected' });
326
+ return true;
327
+ };
147
328
  const dispatch = async (req, res, url) => {
148
329
  const sub = url.pathname.slice('/paper-reader'.length); // '' | '/' | '/api/...'
149
330
  if ((sub === '' || sub === '/') && req.method === 'GET') {
@@ -182,16 +363,27 @@ export function registerRoutes(ctx, config) {
182
363
  json(res, 200, { dataDir, topics: listTopics(dataDir), papers });
183
364
  return;
184
365
  }
185
- // 新建专题(目录)
366
+ // 新建专题(目录)。校验与 upload / rename 的 to 侧同一套(validateName:边界 + 命名规范),
367
+ // 保证「能创建的必然能删除/改名」(PR-1 的创建/删除对称原则;创建走 inline 正则时,
368
+ // `.hidden`/超长/含控制字符的名字都能被创建,却是删不掉的孤儿)。
186
369
  if (sub === '/api/library/topic' && req.method === 'POST') {
187
370
  const body = (await readBody(req));
188
- const name = body.name?.trim();
189
- if (!name || /[/\\]|\.\./.test(name)) {
190
- json(res, 400, { error: '专题名不能为空,且不能包含 / \\ ..' });
191
- return;
371
+ try {
372
+ const name = validateName(body.name, 'topic');
373
+ const root = realDataDir(dataDir);
374
+ const dir = join(root, name);
375
+ // 已存在 → 必须是真目录且不是符号链接(与 upload 同一条链路);不存在 → 词法层已保证不越界
376
+ if (existsSync(dir))
377
+ resolveTopicDir(dataDir, name);
378
+ else
379
+ assertInside(root, dir);
380
+ await mkdir(dir, { recursive: true });
381
+ json(res, 200, { ok: true, topic: name });
382
+ }
383
+ catch (err) {
384
+ if (!manageFail(res, err))
385
+ throw err;
192
386
  }
193
- await mkdir(join(dataDir, name), { recursive: true });
194
- json(res, 200, { ok: true, topic: name });
195
387
  return;
196
388
  }
197
389
  // 上传 PDF 到专题(raw body;文件名/专题走 header,避免 multipart 解析。
@@ -200,144 +392,389 @@ export function registerRoutes(ctx, config) {
200
392
  const topic = decodeHdr(req.headers['x-dpr-topic']);
201
393
  const filename = decodeHdr(req.headers['x-dpr-name']);
202
394
  if (typeof topic !== 'string' || typeof filename !== 'string' || !topic.trim() || !filename.trim()) {
203
- json(res, 400, { error: '缺少 x-dpr-topic / x-dpr-name 头' });
395
+ json(res, 400, { error: '缺少 x-dpr-topic / x-dpr-name 头', code: 'bad-request' });
396
+ return;
397
+ }
398
+ // 词法层:与其余 CRUD 同强度(../ / \ 隐藏名 控制字符 超长 空串)
399
+ const errName = validateEntryName(topic);
400
+ if (errName) {
401
+ json(res, 400, { error: errName, code: 'bad-name' });
204
402
  return;
205
403
  }
206
404
  if (!filename.toLowerCase().endsWith('.pdf')) {
207
- json(res, 400, { error: '只支持 PDF 文件' });
405
+ json(res, 400, { error: '只支持 PDF 文件', code: 'bad-request' });
208
406
  return;
209
407
  }
210
408
  const safeName = filename.replaceAll('/', '_').replaceAll('\\', '_');
409
+ // 文件名走**边界类**(上游 #4 口径):前导点 / 超长 / 非 NUL 控制字符都能上传,
410
+ // 与删除侧对称——upload 能落盘的名字必须能被删(library-symmetry R7/R14)。
411
+ const stem = safeName.slice(0, -4);
412
+ const errStem = validateEntryBoundary(stem);
413
+ if (errStem) {
414
+ json(res, 400, { error: errStem, code: 'bad-name' });
415
+ return;
416
+ }
417
+ // realpath 子路径层(上游 #4 的 resolveUploadTopic):`data/<topic> → 库外` 与**悬空**符号
418
+ // 链接都在这里被挡(悬空链接 lstat 算存在、realpath 失败 → 400 可读文案,而不是 mkdir 抛 ENOENT 的 500)。
419
+ // 顺序:目标目录的全部校验都在**读 body 之前**——越界目标不消费请求体、不碰盘。
420
+ let dir;
421
+ try {
422
+ dir = resolveUploadTopic(dataDir, topic);
423
+ }
424
+ catch (err) {
425
+ json(res, 400, { error: readableError(err), code: 'escape-rejected' });
426
+ return;
427
+ }
428
+ // 我们的加严:上游放行「real 落在库内」的符号链接专题,我们一律拒(评审 §B 点名表扬)。
429
+ try {
430
+ if (existsSync(dir))
431
+ resolveTopicDir(dataDir, topic.trim());
432
+ else
433
+ assertInside(realDataDir(dataDir), dir);
434
+ }
435
+ catch (err) {
436
+ if (libFail(res, err))
437
+ return;
438
+ throw err;
439
+ }
211
440
  const chunks = [];
212
441
  for await (const c of req)
213
442
  chunks.push(c);
214
443
  const buf = Buffer.concat(chunks);
215
444
  if (buf.length < 100) {
216
- json(res, 400, { error: '文件内容为空' });
445
+ json(res, 400, { error: '文件内容为空', code: 'bad-request' });
217
446
  return;
218
447
  }
219
- const dir = join(dataDir, topic.trim());
220
- await mkdir(dir, { recursive: true });
221
- const target = join(dir, safeName);
222
- await writeFile(target, buf);
223
- json(res, 200, { ok: true, topic: topic.trim(), name: safeName.slice(0, -4), bytes: buf.length });
224
- return;
225
- }
226
- /* ---- 库管理:专题/文献 改名与删除。----
227
- * 翻译进行中(busy)一律 409 拒绝——babeldoc 异步跑着会往旧路径写 -zh/-dual 产物,
228
- * 改名/删除会让它写丢;busy 键控在旧路径上,动了就失联。
229
- * Windows 下删除被阅读器 iframe 占用的文件可能 EBUSY/EPERM,转成可读文案。 */
230
- const busyPapersInTopic = (topic) => listPapers(dataDir, topic)
231
- .filter((p) => zhStatus(paperRefFor(dataDir, p.topic, p.name)).busy)
232
- .map((p) => p.name);
233
- const fsError = (res2, err) => {
234
- const e = err;
235
- if (e?.code === 'EBUSY' || e?.code === 'EPERM') {
236
- json(res2, 409, { error: '文件被占用(可能正在阅读器或其他程序中打开),关闭后重试' });
237
- }
238
- else {
239
- json(res2, 400, { error: e instanceof Error ? e.message : String(err) });
448
+ try {
449
+ await mkdir(dir, { recursive: true });
450
+ await writeFile(join(dir, safeName), buf);
240
451
  }
241
- };
242
- if (sub === '/api/library/rename-topic' && req.method === 'POST') {
243
- const body = (await readBody(req));
244
- if (!body.topic?.trim()) {
245
- json(res, 400, { error: '缺少 topic' });
452
+ catch (err) {
453
+ fsError(res, err);
246
454
  return;
247
455
  }
248
- const busy = busyPapersInTopic(body.topic.trim());
249
- if (busy.length) {
250
- json(res, 409, { error: `以下文献正在翻译,稍后再试:${busy.join('、')}` });
456
+ json(res, 200, { ok: true, topic: topic.trim(), name: stem, bytes: buf.length });
457
+ return;
458
+ }
459
+ /* ── 路由名兼容层 ─────────────────────────────────────────────────────
460
+ 上游 v1.3.0(614b935)为文献库自建了 4 条 CRUD 路由
461
+ `/api/library/{delete,rename}-{topic,paper}` —— **对外规范路径**(上游 UI / 文档 / 上游用例
462
+ 都走它们)。我们分支的 `/api/library/{topic,paper}/{delete,rename}` 保留为别名。
463
+ 两族共用同一批 handler,底层永远是 library-manage 的加固实现(回收站可恢复 / 精确 stem /
464
+ realpath 三层穿越防护 / 会话·翻译·在途 409 / 删除计划);上游那份带穿越洞的第二实现
465
+ (rmSync(recursive) 且 topic 未校验)已整段删除 —— 任何时刻只有一份实现。
466
+ body 键也做兼容:topic ?? name、newName ?? to。 */
467
+ /** 非法/越界名字 → 400 可读文案(词法层复用上游 #4 的 validateEntryName;realpath 层由 primitive 兜底)。
468
+ * 放在任何文件系统操作之前:越界输入不会碰到盘。返回 true 表示已响应。 */
469
+ const badEntry = (res, raw) => {
470
+ const err = typeof raw === 'string' ? validateEntryName(raw) : '名字不能为空';
471
+ if (!err)
472
+ return false;
473
+ json(res, 400, { error: err, code: 'bad-name' });
474
+ return true;
475
+ };
476
+ /** 删除 / 改名-from 用(上游 #4 的分层):只做边界类校验(空、分隔符、`..`、NUL),不卡命名规范。 */
477
+ const badEntryBoundary = (res, raw) => {
478
+ const err = typeof raw === 'string' ? validateEntryBoundary(raw) : '名字不能为空';
479
+ if (!err)
480
+ return false;
481
+ json(res, 400, { error: err, code: 'bad-name' });
482
+ return true;
483
+ };
484
+ // 删除预览(dry-run):确认弹窗的文件清单必须来自这里,不许前端自己拼
485
+ if (sub === '/api/library/paper/delete-plan' && req.method === 'GET') {
486
+ const topic = url.searchParams.get('topic') ?? '';
487
+ const name = url.searchParams.get('name') ?? '';
488
+ if (url.searchParams.get('path')) {
489
+ json(res, 400, { error: '不接受 path 参数,请用 topic + name', code: 'escape-rejected' });
251
490
  return;
252
491
  }
253
492
  try {
254
- renameTopic(dataDir, body.topic.trim(), body.to ?? '');
493
+ const plan = planPaperDelete(dataDir, topic, name);
494
+ const ref = resolveLibraryPaper(plan.topic, plan.name);
495
+ const sessions = await sessionInfo(ref);
496
+ const zh = zhStatus(ref);
497
+ json(res, 200, {
498
+ topic: plan.topic,
499
+ name: plan.name,
500
+ files: plan.files,
501
+ totalBytes: plan.totalBytes,
502
+ trashRoot: plan.trashRoot,
503
+ sessions: toWire(sessions),
504
+ translation: { busy: zh.busy, hasZh: zh.zh, hasDual: zh.dual },
505
+ notDeleted: plan.notDeleted,
506
+ });
255
507
  }
256
508
  catch (err) {
257
- fsError(res, err);
258
- return;
509
+ if (!libFail(res, err))
510
+ throw err;
259
511
  }
260
- workspaceCache.delete(join(dataDir, body.topic.trim())); // 旧目录的工作区记录失效(残留无害)
261
- json(res, 200, { ok: true, topic: body.to?.trim() });
262
512
  return;
263
513
  }
264
- if (sub === '/api/library/delete-topic' && req.method === 'POST') {
514
+ // 删除文献:产物移入 <dataDir>/.trash/(可恢复)。规范名 = 上游 /api/library/delete-paper,
515
+ // 别名 = /api/library/paper/delete。词法门走 badEntry(topic) + badEntryBoundary(name)。
516
+ if ((sub === '/api/library/delete-paper' || sub === '/api/library/paper/delete') && req.method === 'POST') {
265
517
  const body = (await readBody(req));
266
- if (!body.topic?.trim()) {
267
- json(res, 400, { error: '缺少 topic' });
518
+ if (badEntry(res, body.topic) || badEntryBoundary(res, body.name))
268
519
  return;
269
- }
270
- const busy = busyPapersInTopic(body.topic.trim());
271
- if (busy.length) {
272
- json(res, 409, { error: `以下文献正在翻译,稍后再试:${busy.join('、')}` });
520
+ if (rejectWritePath(body, res))
273
521
  return;
274
- }
275
- try {
276
- deleteTopic(dataDir, body.topic.trim());
277
- }
278
- catch (err) {
279
- fsError(res, err);
522
+ await withInflight(`${String(body.topic ?? '')}\u0000${String(body.name ?? '')}`, res, async () => {
523
+ try {
524
+ const plan = planPaperDelete(dataDir, body.topic, body.name);
525
+ const ref = resolveLibraryPaper(plan.topic, plan.name);
526
+ const sessions = await sessionInfo(ref); // 全部前提先校验,再动手
527
+ if (!(await requireSessionClear(ref, res)))
528
+ return;
529
+ if (zhStatus(ref).busy) {
530
+ throw new ManageError('translation-busy', 409, '该文献正在生成译文,请等待完成后再删除', {});
531
+ }
532
+ const r = deletePaper(dataDir, plan.topic, plan.name);
533
+ // `deleted` = 上游字段名(上游用例断言),`moved` = 我们的;同源同义
534
+ json(res, 200, { ...r, deleted: r.moved, sessions: toWire(sessions) });
535
+ }
536
+ catch (err) {
537
+ if (!libFail(res, err))
538
+ throw err;
539
+ }
540
+ });
541
+ return;
542
+ }
543
+ // 重命名文献:全部同名产物一起改名(含译文变体),`.pdf` 最后移。
544
+ // 规范名 = /api/library/rename-paper(上游 UI 与用例),别名 = /api/library/paper/rename。
545
+ if ((sub === '/api/library/rename-paper' || sub === '/api/library/paper/rename') && req.method === 'POST') {
546
+ const body = (await readBody(req));
547
+ const rawTo = body.newName ?? body.to;
548
+ if (badEntry(res, body.topic) || badEntryBoundary(res, body.name) || badEntry(res, rawTo))
280
549
  return;
281
- }
282
- json(res, 200, { ok: true });
550
+ if (rejectWritePath(body, res))
551
+ return;
552
+ await withInflight(`${String(body.topic ?? '')}\u0000${String(body.name ?? '')}`, res, async () => {
553
+ try {
554
+ // 先校验名称/越界/存在(planPaperDelete 内部做全套检查)——不能先 resolvePaper:
555
+ // 非法名会在那里抛出非 ManageError 的普通错误,被外层记成 500。
556
+ const plan = planPaperDelete(dataDir, body.topic, body.name);
557
+ const ref = resolveLibraryPaper(plan.topic, plan.name);
558
+ const sessions = await sessionInfo(ref);
559
+ if (!(await requireSessionClear(ref, res)))
560
+ return;
561
+ if (zhStatus(ref).busy) {
562
+ throw new ManageError('translation-busy', 409, '该文献正在生成译文,请等待完成后再重命名', {});
563
+ }
564
+ const r = renamePaper(dataDir, ref.topic, ref.name, rawTo);
565
+ // 会话 id 内嵌 topic/name → 改名后旧会话不再出现在该文献的列表里(不删除,如实回显)
566
+ json(res, 200, { ...r, name: r.to, detachedSessions: sessions.total });
567
+ }
568
+ catch (err) {
569
+ if (!libFail(res, err))
570
+ throw err;
571
+ }
572
+ });
283
573
  return;
284
574
  }
285
- if (sub === '/api/library/rename-paper' && req.method === 'POST') {
575
+ // 删除专题:仅空专题(非隐藏条目为 0),非递归语义;隐藏条目进回收站而不是销毁。
576
+ // 规范名 = /api/library/delete-topic;上游 UI 只发 {topic}(没有确认字段),我们的 UI 发
577
+ // {name, confirmName} 两段式确认。confirmName 语义(本文件):
578
+ // · 给了就必须与专题名完全一致(confirmName === topic|name,否则 400 confirm-mismatch);
579
+ // · 没给也照走全部前置——**空专题免确认**(上游契约,没有数据可毁),
580
+ // 非空专题一律 409 topic-not-empty(我们的加固,比上游「一点递归删光」保守)。
581
+ if ((sub === '/api/library/delete-topic' || sub === '/api/library/topic/delete') && req.method === 'POST') {
286
582
  const body = (await readBody(req));
287
- if (!body.topic?.trim() || !body.name?.trim()) {
288
- json(res, 400, { error: '缺少 topic / name' });
583
+ const rawName = typeof body.topic === 'string' && body.topic ? body.topic : body.name;
584
+ if (typeof rawName !== 'string' || rawName.length === 0) {
585
+ badEntry(res, rawName);
289
586
  return;
290
587
  }
291
- let ref;
292
- try {
293
- ref = resolvePaper(dataDir, { topic: body.topic.trim(), name: body.name.trim() });
294
- }
295
- catch (err) {
296
- fsError(res, err);
588
+ if (badEntryBoundary(res, rawName))
589
+ return;
590
+ // 词法门两段式(4 条路由都必须在文件系统操作之前过 badEntry,见 upstream V12 的结构性断言):
591
+ // ① badEntry(上游 #4 的 validateEntryName)挡**类型 / 空值**——非字符串、空串在任何模式下都非法;
592
+ // ② 命名规范类(长度/前导点/非 NUL 控制字符)只对「创建 / 改名-to」强制;删除/改名-from
593
+ // 走 badEntryBoundary(分层理由见其定义:历史遗留的不规范名必须能清掉,否则是删不掉的孤儿)。
594
+ if (rejectWritePath(body, res))
595
+ return;
596
+ await withInflight(`topic\u0000${String(rawName ?? '')}`, res, async () => {
597
+ try {
598
+ const name = rawName;
599
+ // confirmName 是**可选**栅栏:给了就必须一致(400 confirm-mismatch);没给也照走全部前置——
600
+ // 空专题免确认(上游 delete-topic {topic} 契约:没有数据可毁),非空专题一律
601
+ // 409 topic-not-empty(我们的加固,比上游「一点递归删光」保守,见 docs/reader-ux-requirements.md)。
602
+ if (body.confirmName !== undefined && body.confirmName !== name) {
603
+ throw new ManageError('confirm-mismatch', 400, '需要 confirmName 与 name 完全一致才允许删除专题', {});
604
+ }
605
+ const plan = planTopicDelete(dataDir, name); // 全部前提先校验,再动手
606
+ if (plan.entries.length > 0) {
607
+ // clearEndpoint:非空专题的「下一步」——UI 据此进入**批量清空**中间态(上游评论第 10 条)
608
+ throw new ManageError('topic-not-empty', 409, `专题内还有 ${plan.entries.length} 个条目(${plan.papers.length} 篇文献),请用批量清空或先逐篇删除`, {
609
+ entries: plan.entries,
610
+ papers: plan.papers,
611
+ clearEndpoint: '/api/library/topic/clear',
612
+ clearPlanEndpoint: '/api/library/topic/clear-plan',
613
+ });
614
+ }
615
+ if (!(await requireTopicSessionsClear(plan.name, res)))
616
+ return;
617
+ let busy = 0;
618
+ for (const p of listPapers(dataDir, plan.name)) {
619
+ if (zhStatus(resolveLibraryPaper(p.topic, p.name)).busy)
620
+ busy++;
621
+ }
622
+ if (busy > 0)
623
+ throw new ManageError('translation-busy', 409, '该专题有正在生成译文的文献,请等待完成后再删除', {});
624
+ const r = deleteTopic(dataDir, plan.name);
625
+ workspaceCache.delete(join(dataDir, plan.name)); // 清掉指向已删目录的工作区缓存
626
+ json(res, 200, { ...r, archivedSessionsNote: true });
627
+ }
628
+ catch (err) {
629
+ if (!libFail(res, err))
630
+ throw err;
631
+ }
632
+ });
633
+ return;
634
+ }
635
+ // 重命名专题:目录整体改名(历史会话不删除,但不再出现在该专题下)
636
+ if ((sub === '/api/library/rename-topic' || sub === '/api/library/topic/rename') && req.method === 'POST') {
637
+ const body = (await readBody(req));
638
+ const rawName = typeof body.topic === 'string' && body.topic ? body.topic : body.name;
639
+ const rawTo = body.newName ?? body.to;
640
+ if (typeof rawName !== 'string' || rawName.length === 0) {
641
+ badEntry(res, rawName);
297
642
  return;
298
643
  }
299
- if (zhStatus(ref).busy) {
300
- json(res, 409, { error: '该文献正在翻译,稍后再试' });
644
+ // from 侧同 delete-topic:badEntry 挡类型/空值,命名规范只在改名**目标**(badEntry(rawTo))上强制
645
+ // ——这样「历史遗留的怪名专题 → 改成规范名」这条路不被自己的校验堵死。
646
+ if (badEntryBoundary(res, rawName) || badEntry(res, rawTo))
647
+ return;
648
+ if (rejectWritePath(body, res))
649
+ return;
650
+ await withInflight(`topic\u0000${String(rawName ?? '')}`, res, async () => {
651
+ try {
652
+ const name = String(rawName ?? '');
653
+ const plan = planTopicDelete(dataDir, name); // 复用同一个「存在且合法」校验
654
+ if (!(await requireTopicSessionsClear(plan.name, res)))
655
+ return;
656
+ let busy = 0;
657
+ let detached = 0;
658
+ for (const p of listPapers(dataDir, plan.name)) {
659
+ const ref = resolveLibraryPaper(p.topic, p.name);
660
+ const s = await sessionInfo(ref);
661
+ if (s.unknown) {
662
+ json(res, 503, { error: '暂时无法确认该专题的伴读会话状态,为避免误删已取消本次操作;请稍后重试', code: 'session-unknown' });
663
+ return;
664
+ }
665
+ detached += s.total;
666
+ if (zhStatus(ref).busy)
667
+ busy++;
668
+ }
669
+ if (busy > 0)
670
+ throw new ManageError('translation-busy', 409, '该专题有正在生成译文的文献,请等待完成后再重命名', {});
671
+ const r = renameTopic(dataDir, plan.name, rawTo);
672
+ workspaceCache.delete(join(dataDir, plan.name));
673
+ // 响应补 `topic`(上游 UI/文档读法)——我们的 RenameResult 用 from/to
674
+ json(res, 200, { ...r, topic: r.to, detachedSessions: detached });
675
+ }
676
+ catch (err) {
677
+ if (!libFail(res, err))
678
+ throw err;
679
+ }
680
+ });
681
+ return;
682
+ }
683
+ // 清空预览(dry-run,只读):非空专题删除的**中间态**清单(上游评论第 10 条)。
684
+ // 形状与 /api/library/paper/delete-plan 对齐(files/totalBytes/notDeleted),逐篇额外给
685
+ // sessions/translation,让 UI 在用户点确认**之前**就能提示「第 7 篇正在翻译」。
686
+ if (sub === '/api/library/topic/clear-plan' && req.method === 'GET') {
687
+ const topic = url.searchParams.get('topic') ?? '';
688
+ if (url.searchParams.get('path')) {
689
+ json(res, 400, { error: '不接受 path 参数,请用 topic', code: 'escape-rejected' });
301
690
  return;
302
691
  }
303
- let renamed;
304
692
  try {
305
- renamed = renamePaper(dataDir, ref.topic, ref.name, body.to ?? '');
693
+ const plan = planTopicClear(dataDir, topic);
694
+ const papers = [];
695
+ for (const p of plan.papers) {
696
+ const ref = resolveLibraryPaper(plan.topic, p.name);
697
+ const sessions = await sessionInfo(ref);
698
+ const zh = zhStatus(ref);
699
+ papers.push({
700
+ name: p.name,
701
+ files: p.files,
702
+ totalBytes: p.totalBytes,
703
+ sessions: toWire(sessions),
704
+ translation: { busy: zh.busy, hasZh: zh.zh, hasDual: zh.dual },
705
+ });
706
+ }
707
+ json(res, 200, {
708
+ topic: plan.topic,
709
+ papers,
710
+ papersCount: plan.papersCount,
711
+ filesCount: plan.filesCount,
712
+ totalBytes: plan.totalBytes,
713
+ hidden: plan.hidden,
714
+ trashRoot: plan.trashRoot,
715
+ notDeleted: plan.notDeleted,
716
+ clearEndpoint: '/api/library/topic/clear',
717
+ });
306
718
  }
307
719
  catch (err) {
308
- fsError(res, err);
309
- return;
720
+ if (!libFail(res, err))
721
+ throw err;
310
722
  }
311
- json(res, 200, { ok: true, topic: ref.topic, name: body.to?.trim(), renamed });
312
723
  return;
313
724
  }
314
- if (sub === '/api/library/delete-paper' && req.method === 'POST') {
725
+ // 批量清空专题(一次确认、逐篇进回收站、失败即整体回滚):`confirmName === topic` 是**必需**的
726
+ // (手输专题名确认保留在清空这一步,不放松)。
727
+ // 前置:全部校验完(存在性 / 非空 / 逐篇会话 / 逐篇翻译)才动一个字节;
728
+ // 执行:withInflight(同一专题并发只放一个)+ library-manage.clearTopic(非递归 rmdir)。
729
+ if (sub === '/api/library/topic/clear' && req.method === 'POST') {
315
730
  const body = (await readBody(req));
316
- if (!body.topic?.trim() || !body.name?.trim()) {
317
- json(res, 400, { error: '缺少 topic / name' });
731
+ const rawTopic = typeof body.topic === 'string' && body.topic ? body.topic : body.name;
732
+ if (typeof rawTopic !== 'string' || rawTopic.length === 0) {
733
+ badEntry(res, rawTopic);
318
734
  return;
319
735
  }
320
- let ref;
321
- try {
322
- ref = resolvePaper(dataDir, { topic: body.topic.trim(), name: body.name.trim() });
323
- }
324
- catch (err) {
325
- fsError(res, err);
736
+ if (badEntryBoundary(res, rawTopic))
326
737
  return;
327
- }
328
- if (zhStatus(ref).busy) {
329
- json(res, 409, { error: '该文献正在翻译,稍后再试' });
738
+ if (rejectWritePath(body, res))
330
739
  return;
331
- }
332
- let deleted;
333
- try {
334
- deleted = deletePaper(dataDir, ref.topic, ref.name);
335
- }
336
- catch (err) {
337
- fsError(res, err);
740
+ if (body.confirmName !== rawTopic) {
741
+ json(res, 400, { error: '需要 confirmName 与专题名完全一致才允许清空', code: 'confirm-mismatch' });
338
742
  return;
339
743
  }
340
- json(res, 200, { ok: true, deleted });
744
+ await withInflight(`topic\u0000${rawTopic}`, res, async () => {
745
+ try {
746
+ const plan = planTopicClear(dataDir, rawTopic); // 存在性 404 / 越界 400 / 符号链接专题 400
747
+ if (plan.papersCount === 0) {
748
+ throw new ManageError('bad-request', 400, '专题已为空,请用删除专题', { clearEndpoint: '/api/library/delete-topic' });
749
+ }
750
+ let detached = 0;
751
+ const busyTrans = [];
752
+ for (const p of plan.papers) {
753
+ const ref = resolveLibraryPaper(plan.topic, p.name);
754
+ if (!(await requireSessionClear(ref, res)))
755
+ return; // unknown→503 / running→409(同一套 code)
756
+ detached += (await sessionInfo(ref)).total;
757
+ if (zhStatus(ref).busy)
758
+ busyTrans.push(p.name);
759
+ }
760
+ if (busyTrans.length > 0) {
761
+ throw new ManageError('translation-busy', 409, `以下文献正在生成译文,请等待完成后再清空:${busyTrans.join('、')}`, { papers: busyTrans });
762
+ }
763
+ const r = clearTopic(dataDir, plan.topic);
764
+ workspaceCache.delete(join(dataDir, plan.topic)); // 清掉指向已清空目录的工作区缓存
765
+ json(res, 200, { ...r, detachedSessions: detached, archivedSessionsNote: true });
766
+ }
767
+ catch (err) {
768
+ if (!libFail(res, err))
769
+ throw err;
770
+ }
771
+ });
772
+ return;
773
+ }
774
+ // 公式索引:MinerU content_list 的 equation 块(LaTeX + bbox),供阅读器画热区/列表面板
775
+ if (sub === '/api/formulas' && req.method === 'GET') {
776
+ const ref = locate(url); // 只读路径,沿用 path/topic/name 三种定位
777
+ json(res, 200, await readFormulaIndex(ref));
341
778
  return;
342
779
  }
343
780
  // 新建伴读会话(会话N+1)。最新可见会话还是空白草稿时直接复用它——
@@ -432,7 +869,16 @@ export function registerRoutes(ctx, config) {
432
869
  // 启动后台翻译(幂等;force=true 为重翻:先删已有译文再启动)。端点:UI 里填的 > profile 的 translate
433
870
  if (sub === '/api/zh/generate' && req.method === 'POST') {
434
871
  const body = (await readBody(req));
435
- const ref = resolvePaper(dataDir, body);
872
+ let ref;
873
+ try {
874
+ // 同样写盘(<文献>-zh.pdf 落在 PDF 旁边)→ 与 transcribe 同一套 path 边界约束
875
+ ref = locateForWrite(body);
876
+ }
877
+ catch (err) {
878
+ if (manageFail(res, err))
879
+ return;
880
+ throw err;
881
+ }
436
882
  const { endpoint } = await resolveTranslateEndpoint(config.translate);
437
883
  const r = body.force
438
884
  ? await restartTranslation(ref, dataDir, endpoint)
@@ -533,11 +979,168 @@ export function registerRoutes(ctx, config) {
533
979
  json(res, 200, { ok: true });
534
980
  return;
535
981
  }
982
+ // ── 嵌入模型端点配置(设置面板第三张端点卡片)─────────────────────────
983
+ // 与 translate/typesafe 完全同一套约定:GET 只回掩码 key;POST 预检通过才落盘;DELETE 回落。
984
+ if (sub === '/api/embed/config' && req.method === 'GET') {
985
+ const { cfg, source, enabled } = await resolveEmbedConfig(config.embed);
986
+ json(res, 200, {
987
+ baseUrl: cfg.baseUrl ?? '',
988
+ model: cfg.model ?? '',
989
+ hasApiKey: Boolean(cfg.apiKey),
990
+ apiKeyHint: cfg.apiKey ? maskEmbedApiKey(cfg.apiKey) : '',
991
+ enabled,
992
+ source,
993
+ });
994
+ return;
995
+ }
996
+ // POST:baseUrl/model/key 三项齐全是启用条件;key 留空 = 沿用已存的那把。
997
+ // 校验 → 预检(真打一发 /embeddings)→ 通了才落盘:嵌入失败是静默降级,填错很难察觉。
998
+ if (sub === '/api/embed/config' && req.method === 'POST') {
999
+ const body = (await readBody(req));
1000
+ const baseUrl = body.baseUrl?.trim() ?? '';
1001
+ const model = body.model?.trim() ?? '';
1002
+ const prev = await readEmbedConfig();
1003
+ const apiKey = body.apiKey?.trim() || prev?.apiKey || '';
1004
+ if (baseUrl && !/^https?:\/\//i.test(baseUrl)) {
1005
+ json(res, 400, { error: '端点 URL 需要以 http:// 或 https:// 开头' });
1006
+ return;
1007
+ }
1008
+ if (!model) {
1009
+ json(res, 400, { error: '嵌入模型名不能为空' });
1010
+ return;
1011
+ }
1012
+ if (!apiKey) {
1013
+ json(res, 400, { error: 'API Key 不能为空' });
1014
+ return;
1015
+ }
1016
+ const test = await testEmbedEndpoint({ baseUrl, apiKey, model });
1017
+ if (!test.ok) {
1018
+ json(res, 400, { error: `连接测试失败:${test.detail ?? '未知原因'}` });
1019
+ return;
1020
+ }
1021
+ await writeEmbedConfig({ baseUrl, apiKey, model });
1022
+ json(res, 200, { ok: true });
1023
+ return;
1024
+ }
1025
+ // DELETE:清掉文件,回落到 profile 的 embed / 环境变量
1026
+ if (sub === '/api/embed/config' && req.method === 'DELETE') {
1027
+ await clearEmbedConfig();
1028
+ json(res, 200, { ok: true });
1029
+ return;
1030
+ }
1031
+ // ── MinerU 解析后端配置(设置面板第三张卡片的读写口)────────────────────
1032
+ // 与 translate/typesafe 同一套约定:GET 只回显掩码 key;POST 预检通过才落盘;DELETE 回落。
1033
+ const MINERU_SOURCES = new Set(['auto', 'pdfjs', 'mineru-local', 'mineru-cloud']);
1034
+ if (sub === '/api/mineru/config' && req.method === 'GET') {
1035
+ const { config: cfg, source } = await resolveMineruConfig(config.mineru);
1036
+ json(res, 200, {
1037
+ mode: cfg.mode,
1038
+ source,
1039
+ local: {
1040
+ baseUrl: cfg.local.baseUrl,
1041
+ backend: cfg.local.backend,
1042
+ effort: cfg.local.effort,
1043
+ parseMethod: cfg.local.parseMethod,
1044
+ langList: cfg.local.langList,
1045
+ hasApiKey: Boolean(cfg.local.apiKey),
1046
+ apiKeyHint: cfg.local.apiKey ? maskApiKey(cfg.local.apiKey) : '',
1047
+ },
1048
+ cloud: {
1049
+ baseUrl: cfg.cloud.baseUrl,
1050
+ modelVersion: cfg.cloud.modelVersion,
1051
+ hasApiKey: Boolean(cfg.cloud.apiKey),
1052
+ apiKeyHint: cfg.cloud.apiKey ? maskApiKey(cfg.cloud.apiKey) : '',
1053
+ },
1054
+ });
1055
+ return;
1056
+ }
1057
+ // POST:校验 → 预检 → 通了才落盘。key 留空沿用已存值(明文不回传,前端无法预填)。
1058
+ if (sub === '/api/mineru/config' && req.method === 'POST') {
1059
+ const body = (await readBody(req));
1060
+ if (body.mode !== undefined && body.mode !== 'off' && body.mode !== 'local' && body.mode !== 'cloud') {
1061
+ json(res, 400, { error: 'mode 只能是 off/local/cloud' });
1062
+ return;
1063
+ }
1064
+ const prev = await resolveMineruConfig(config.mineru);
1065
+ const next = mergeMineruBody(prev.config, body);
1066
+ const test = await precheckMineruConfig(next);
1067
+ if (!test.ok) {
1068
+ json(res, 400, { error: test.detail ?? '连接测试失败' });
1069
+ return;
1070
+ }
1071
+ await writeMineruConfig(next);
1072
+ json(res, 200, { ok: true });
1073
+ return;
1074
+ }
1075
+ // DELETE:清掉文件,回落到 profile 的 mineru / 环境变量
1076
+ if (sub === '/api/mineru/config' && req.method === 'DELETE') {
1077
+ await clearMineruConfig();
1078
+ json(res, 200, { ok: true });
1079
+ return;
1080
+ }
1081
+ // 连通性预检(不落盘):设置面板「测试连接」按钮用;HTTP 恒 200,便于 UI 展示。
1082
+ if (sub === '/api/mineru/test' && req.method === 'POST') {
1083
+ const body = (await readBody(req));
1084
+ const prev = await resolveMineruConfig(config.mineru);
1085
+ const next = mergeMineruBody(prev.config, body);
1086
+ const test = await precheckMineruConfig(next);
1087
+ if (test.ok)
1088
+ json(res, 200, { reachable: true, mode: next.mode });
1089
+ else
1090
+ json(res, 200, { reachable: false, mode: next.mode, error: test.detail ?? '连接测试失败' });
1091
+ return;
1092
+ }
1093
+ // 健康探针:GET /api/mineru/health(可选 ?mode=local|cloud,默认按生效 mode)
1094
+ if (sub === '/api/mineru/health' && req.method === 'GET') {
1095
+ const modeParam = url.searchParams.get('mode');
1096
+ const mode = modeParam === 'local' || modeParam === 'cloud' ? modeParam : (await resolveMineruConfig(config.mineru)).config.mode;
1097
+ if (mode === 'off') {
1098
+ json(res, 200, { reachable: false, mode: 'off', error: 'MinerU 未启用(mode=off)' });
1099
+ return;
1100
+ }
1101
+ if (mode === 'local') {
1102
+ const { config: cfg } = await resolveMineruConfig(config.mineru);
1103
+ const t = await testMineruLocal(cfg.local.baseUrl);
1104
+ if (!t.ok) {
1105
+ json(res, 200, { reachable: false, mode: 'local', error: t.detail ?? '不可达' });
1106
+ return;
1107
+ }
1108
+ const h = t.health ?? {};
1109
+ json(res, 200, {
1110
+ reachable: true,
1111
+ mode: 'local',
1112
+ status: h['status'] ?? 'healthy',
1113
+ version: t.version ?? '',
1114
+ protocolVersion: h['protocol_version'] ?? null,
1115
+ queuedTasks: h['queued_tasks'] ?? 0,
1116
+ processingTasks: h['processing_tasks'] ?? 0,
1117
+ });
1118
+ return;
1119
+ }
1120
+ const { config: cfg } = await resolveMineruConfig(config.mineru);
1121
+ const t = await testMineruCloud(cfg.cloud.baseUrl, cfg.cloud.apiKey);
1122
+ if (t.ok)
1123
+ json(res, 200, { reachable: true, mode: 'cloud' });
1124
+ else
1125
+ json(res, 200, { reachable: false, mode: 'cloud', error: t.detail ?? '不可达' });
1126
+ return;
1127
+ }
536
1128
  if (sub === '/api/transcribe' && req.method === 'POST') {
537
1129
  const body = (await readBody(req));
538
- const ref = resolvePaper(dataDir, body);
539
- const t = await transcribePaper(ref);
540
- json(res, 200, { chars: t.chars, pageCount: t.pageCount, hasPageIndex: t.pages.length > 0, source: t.source });
1130
+ let ref;
1131
+ try {
1132
+ // t3-O2:这条路由会**写盘**(<文献>.txt 等落在 PDF 旁边),所以 path 必须受与删除同级的边界约束
1133
+ ref = locateForWrite(body);
1134
+ }
1135
+ catch (err) {
1136
+ if (manageFail(res, err))
1137
+ return;
1138
+ throw err;
1139
+ }
1140
+ const mineru = await resolveMineruConfig(config.mineru);
1141
+ const source = MINERU_SOURCES.has(body.source ?? 'auto') ? body.source : 'auto';
1142
+ const t = await transcribePaper(ref, { force: body.force === true, source, mineru });
1143
+ json(res, 200, { chars: t.chars, pageCount: t.pageCount, hasPageIndex: t.pages.length > 0, source: t.source, producer: t.producer, backend: t.backend });
541
1144
  return;
542
1145
  }
543
1146
  // 文献的伴读会话列表(多会话:会话1/会话2/…)
@@ -614,12 +1217,17 @@ export function registerRoutes(ctx, config) {
614
1217
  }
615
1218
  catch { /* 已存在则复用 */ }
616
1219
  const hasQuote = typeof body.selectedText === 'string' && body.selectedText.trim() !== '';
1220
+ // 公式引用(kind='equation'):引用内容来自 MinerU 的 LaTeX,不是文本层乱码(契约 §1.3);
1221
+ // 非公式路径的文案必须与改造前逐字一致(回归项 A11)。
1222
+ const isEquation = body.kind === 'equation';
617
1223
  const where = hasQuote && body.page ? `(选中于第 ${body.page} 页)` : '';
618
1224
  const lines = [
619
1225
  `[论文伴读] 文献:${ref.topic}/${ref.name}(同目录下,search_paper 可直接检索;回答注明页码)`,
620
1226
  ];
621
1227
  if (hasQuote) {
622
- lines.push(`用户在阅读器里选中了一段文字${where}:`, `"""${body.selectedText.trim()}"""`);
1228
+ lines.push(isEquation
1229
+ ? `用户在阅读器里引用了${body.page ? `第 ${body.page} 页的` : ''}一个公式(LaTeX 源,来自 MinerU 版面解析):`
1230
+ : `用户在阅读器里选中了一段文字${where}:`, `"""${body.selectedText.trim()}"""`);
623
1231
  }
624
1232
  lines.push(`用户的问题:${body.question}`);
625
1233
  const prompt = lines.join('\n');
@@ -649,10 +1257,25 @@ export function registerRoutes(ctx, config) {
649
1257
  noteOrigin(req.headers.host);
650
1258
  const url = new URL(req.url ?? '/', 'http://x');
651
1259
  dispatch(req, res, url).catch((err) => {
652
- if (!res.headersSent)
653
- json(res, 500, { error: String(err instanceof Error ? err.message : err) });
654
- else
1260
+ if (res.headersSent) {
655
1261
  res.end();
1262
+ return;
1263
+ }
1264
+ // ① 域内可识别错误(ManageError):用它的状态码/机器码/结构化字段
1265
+ if (isManageError(err)) {
1266
+ json(res, err.status, { error: err.message, code: err.code, ...err.extra });
1267
+ return;
1268
+ }
1269
+ // ② MinerU / 转录的**领域错误**:保留可读文案(既有测试明确断言这些文案含 `MinerU` 等关键词,
1270
+ // 不许为了让文案变得「绝对安全」而把它们一起打哑),但先剥掉任何绝对路径。
1271
+ // ③ 其余未知内部错误:不回显 err.message(原生 fs 错误常带绝对路径),只给通用文案。
1272
+ // 两种情况都带 machine-readable code:'internal'。
1273
+ const known = err instanceof MineruError || err instanceof ShortTextError || err instanceof ScannedPdfError;
1274
+ const raw = err instanceof Error ? err.message : String(err);
1275
+ // R7-F2:兜底文案承诺「详见服务端日志」,这里必须真的落一条日志。双重脱敏:
1276
+ // stripAbsPaths 剥绝对路径、sanitizeDetail 剥凭据(Bearer / sk-* / ?token=),绝不把路径或密钥写进日志。
1277
+ console.error('[dsh-paper-reader] route error:', sanitizeDetail(stripAbsPaths(raw)));
1278
+ json(res, 500, { error: known ? stripAbsPaths(raw) : '内部错误(详见服务端日志)', code: 'internal' });
656
1279
  });
657
1280
  },
658
1281
  });