@ggboy123/dsh-paper-reader 1.3.5 → 1.4.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/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, resolveUploadTopic, validateEntryBoundary, validateEntryName, } 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) {
@@ -34,12 +39,14 @@ async function readBody(req) {
34
39
  const raw = Buffer.concat(chunks).toString('utf8');
35
40
  if (!raw)
36
41
  return {};
42
+ // 畸形 JSON / 整包 null / 非对象 → {}:路由据此给**可读 400**(原来是 JSON.parse 抛错 →
43
+ // 外层兜底 500 internal,错误分类错误)。PR-1 的错误分类收口,rebase 时一并带入。
37
44
  let parsed;
38
45
  try {
39
46
  parsed = JSON.parse(raw);
40
47
  }
41
48
  catch {
42
- return {}; // 畸形 JSON 与整包 null/非对象同待遇:路由据此给可读 400(不再 500)
49
+ return {};
43
50
  }
44
51
  return parsed !== null && typeof parsed === 'object' ? parsed : {};
45
52
  }
@@ -80,7 +87,14 @@ export function registerRoutes(ctx, config) {
80
87
  args.topic = topic;
81
88
  if (name)
82
89
  args.name = name;
83
- 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
+ }
84
98
  };
85
99
  /** 每篇文献的伴读会话 id(确定性,重启/刷新后可续);n 支持同文献多会话(会话1/2/…)。 */
86
100
  const sessionPrefixFor = (ref) => `dpr-${Buffer.from(`${ref.topic}/${ref.name}`).toString('base64url')}`;
@@ -164,6 +178,153 @@ export function registerRoutes(ctx, config) {
164
178
  }
165
179
  return sessionController.create({ sessionId, cwd: join(dataDir, topic) });
166
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
+ };
167
328
  const dispatch = async (req, res, url) => {
168
329
  const sub = url.pathname.slice('/paper-reader'.length); // '' | '/' | '/api/...'
169
330
  if ((sub === '' || sub === '/') && req.method === 'GET') {
@@ -202,22 +363,27 @@ export function registerRoutes(ctx, config) {
202
363
  json(res, 200, { dataDir, topics: listTopics(dataDir), papers });
203
364
  return;
204
365
  }
205
- // 新建专题(目录)。校验与 rename 的 to 侧一致(validateEntryName:边界 + 命名规范),
206
- // 保证「能创建的必然能删除/改名」(R12:创建/删除对称)。
366
+ // 新建专题(目录)。校验与 upload / rename 的 to 侧同一套(validateName:边界 + 命名规范),
367
+ // 保证「能创建的必然能删除/改名」(PR-1 的创建/删除对称原则;创建走 inline 正则时,
368
+ // `.hidden`/超长/含控制字符的名字都能被创建,却是删不掉的孤儿)。
207
369
  if (sub === '/api/library/topic' && req.method === 'POST') {
208
370
  const body = (await readBody(req));
209
- if (typeof body.name !== 'string' || !body.name.trim()) {
210
- json(res, 400, { error: '专题名不能为空' });
211
- 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 });
212
382
  }
213
- const err = validateEntryName(body.name);
214
- if (err) {
215
- json(res, 400, { error: err });
216
- return;
383
+ catch (err) {
384
+ if (!manageFail(res, err))
385
+ throw err;
217
386
  }
218
- const name = body.name.trim();
219
- await mkdir(join(dataDir, name), { recursive: true });
220
- json(res, 200, { ok: true, topic: name });
221
387
  return;
222
388
  }
223
389
  // 上传 PDF 到专题(raw body;文件名/专题走 header,避免 multipart 解析。
@@ -226,205 +392,389 @@ export function registerRoutes(ctx, config) {
226
392
  const topic = decodeHdr(req.headers['x-dpr-topic']);
227
393
  const filename = decodeHdr(req.headers['x-dpr-name']);
228
394
  if (typeof topic !== 'string' || typeof filename !== 'string' || !topic.trim() || !filename.trim()) {
229
- json(res, 400, { error: '缺少 x-dpr-topic / x-dpr-name 头' });
395
+ json(res, 400, { error: '缺少 x-dpr-topic / x-dpr-name 头', code: 'bad-request' });
230
396
  return;
231
397
  }
232
398
  // 词法层:与其余 CRUD 同强度(../ / \ 隐藏名 控制字符 超长 空串)
233
399
  const errName = validateEntryName(topic);
234
400
  if (errName) {
235
- json(res, 400, { error: errName });
401
+ json(res, 400, { error: errName, code: 'bad-name' });
236
402
  return;
237
403
  }
238
404
  if (!filename.toLowerCase().endsWith('.pdf')) {
239
- json(res, 400, { error: '只支持 PDF 文件' });
405
+ json(res, 400, { error: '只支持 PDF 文件', code: 'bad-request' });
240
406
  return;
241
407
  }
242
408
  const safeName = filename.replaceAll('/', '_').replaceAll('\\', '_');
243
- // 文件名也做边界类校验(空/分隔符/../NUL),与删除侧对称:upload 能创建的必须能被删
244
- const stem = safeName.slice(0, -4); // 去掉 .pdf
409
+ // 文件名走**边界类**(上游 #4 口径):前导点 / 超长 / 非 NUL 控制字符都能上传,
410
+ // 与删除侧对称——upload 能落盘的名字必须能被删(library-symmetry R7/R14)。
411
+ const stem = safeName.slice(0, -4);
245
412
  const errStem = validateEntryBoundary(stem);
246
413
  if (errStem) {
247
- json(res, 400, { error: errStem });
414
+ json(res, 400, { error: errStem, code: 'bad-name' });
248
415
  return;
249
416
  }
250
- const chunks = [];
251
- for await (const c of req)
252
- chunks.push(c);
253
- const buf = Buffer.concat(chunks);
254
- if (buf.length < 100) {
255
- json(res, 400, { error: '文件内容为空' });
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' });
256
426
  return;
257
427
  }
258
- // realpath 子路径层:拒绝「data/<topic> → 库外」符号链接逃逸写(与 CRUD 同一套 primitive)
259
- let dir;
428
+ // 我们的加严:上游放行「real 落在库内」的符号链接专题,我们一律拒(评审 §B 点名表扬)。
260
429
  try {
261
- dir = resolveUploadTopic(dataDir, topic.trim());
430
+ if (existsSync(dir))
431
+ resolveTopicDir(dataDir, topic.trim());
432
+ else
433
+ assertInside(realDataDir(dataDir), dir);
262
434
  }
263
435
  catch (err) {
264
- json(res, 400, { error: readableError(err) });
436
+ if (libFail(res, err))
437
+ return;
438
+ throw err;
439
+ }
440
+ const chunks = [];
441
+ for await (const c of req)
442
+ chunks.push(c);
443
+ const buf = Buffer.concat(chunks);
444
+ if (buf.length < 100) {
445
+ json(res, 400, { error: '文件内容为空', code: 'bad-request' });
265
446
  return;
266
447
  }
267
448
  try {
268
449
  await mkdir(dir, { recursive: true });
269
- const target = join(dir, safeName);
270
- await writeFile(target, buf);
450
+ await writeFile(join(dir, safeName), buf);
271
451
  }
272
452
  catch (err) {
273
- json(res, 400, { error: readableError(err) });
453
+ fsError(res, err);
274
454
  return;
275
455
  }
276
- json(res, 200, { ok: true, topic: topic.trim(), name: safeName.slice(0, -4), bytes: buf.length });
456
+ json(res, 200, { ok: true, topic: topic.trim(), name: stem, bytes: buf.length });
277
457
  return;
278
458
  }
279
- /* ---- 库管理:专题/文献 改名与删除。----
280
- * 翻译进行中(busy)一律 409 拒绝——babeldoc 异步跑着会往旧路径写 -zh/-dual 产物,
281
- * 改名/删除会让它写丢;busy 键控在旧路径上,动了就失联。
282
- * Windows 下删除被阅读器 iframe 占用的文件可能 EBUSY/EPERM,转成可读文案。 */
283
- const busyPapersInTopic = (topic) => listPapers(dataDir, topic)
284
- .filter((p) => zhStatus(paperRefFor(dataDir, p.topic, p.name)).busy)
285
- .map((p) => p.name);
286
- const fsError = (res2, err) => {
287
- const e = err;
288
- if (e?.code === 'EBUSY' || e?.code === 'EPERM') {
289
- json(res2, 409, { error: '文件被占用(可能正在阅读器或其他程序中打开),关闭后重试' });
290
- }
291
- else {
292
- json(res2, 400, { error: readableError(err) });
293
- }
294
- };
295
- /** 非法/越界名字 → 400 可读文案(词法层;realpath 子路径层在 library primitive 内兜底)。
296
- * 放在 busyPapersInTopic / resolvePaper 之前,越界输入不会碰任何文件系统。返回 true 表示已响应。 */
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 表示已响应。 */
297
469
  const badEntry = (res, raw) => {
298
470
  const err = typeof raw === 'string' ? validateEntryName(raw) : '名字不能为空';
299
471
  if (!err)
300
472
  return false;
301
- json(res, 400, { error: err });
473
+ json(res, 400, { error: err, code: 'bad-name' });
302
474
  return true;
303
475
  };
304
- /** 删除/改名-from 用:只做边界类校验(安全),不做命名规范类(长度/前导点)。
305
- * 删除是对已存在文件的操作——upload 能创建的名字(前导点、超长、\x01 等)必须能删。 */
476
+ /** 删除 / 改名-from 用(上游 #4 的分层):只做边界类校验(空、分隔符、`..`、NUL),不卡命名规范。 */
306
477
  const badEntryBoundary = (res, raw) => {
307
478
  const err = typeof raw === 'string' ? validateEntryBoundary(raw) : '名字不能为空';
308
479
  if (!err)
309
480
  return false;
310
- json(res, 400, { error: err });
481
+ json(res, 400, { error: err, code: 'bad-name' });
311
482
  return true;
312
483
  };
313
- if (sub === '/api/library/rename-topic' && req.method === 'POST') {
314
- const body = (await readBody(req));
315
- if (typeof body.topic !== 'string' || !body.topic.trim()) {
316
- json(res, 400, { error: '缺少 topic' });
317
- return;
318
- }
319
- if (badEntry(res, body.topic))
320
- return;
321
- if (badEntry(res, body.to ?? ''))
322
- return;
323
- const busy = busyPapersInTopic(body.topic.trim());
324
- if (busy.length) {
325
- json(res, 409, { error: `以下文献正在翻译,稍后再试:${busy.join('、')}` });
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' });
326
490
  return;
327
491
  }
328
492
  try {
329
- 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
+ });
330
507
  }
331
508
  catch (err) {
332
- fsError(res, err);
333
- return;
509
+ if (!libFail(res, err))
510
+ throw err;
334
511
  }
335
- workspaceCache.delete(join(dataDir, body.topic.trim())); // 旧目录的工作区记录失效(残留无害)
336
- json(res, 200, { ok: true, topic: body.to?.trim() });
337
512
  return;
338
513
  }
339
- 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') {
340
517
  const body = (await readBody(req));
341
- if (typeof body.topic !== 'string' || !body.topic.trim()) {
342
- json(res, 400, { error: '缺少 topic' });
518
+ if (badEntry(res, body.topic) || badEntryBoundary(res, body.name))
343
519
  return;
344
- }
345
- if (badEntry(res, body.topic))
520
+ if (rejectWritePath(body, res))
346
521
  return;
347
- const busy = busyPapersInTopic(body.topic.trim());
348
- if (busy.length) {
349
- json(res, 409, { error: `以下文献正在翻译,稍后再试:${busy.join('、')}` });
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))
350
549
  return;
351
- }
352
- try {
353
- deleteTopic(dataDir, body.topic.trim());
354
- }
355
- catch (err) {
356
- fsError(res, err);
550
+ if (rejectWritePath(body, res))
357
551
  return;
358
- }
359
- json(res, 200, { ok: true });
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
+ });
360
573
  return;
361
574
  }
362
- 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') {
363
582
  const body = (await readBody(req));
364
- if (typeof body.topic !== 'string' || !body.topic.trim() || typeof body.name !== 'string' || !body.name.trim()) {
365
- 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);
366
586
  return;
367
587
  }
368
- if (badEntry(res, body.topic))
588
+ if (badEntryBoundary(res, rawName))
369
589
  return;
370
- if (badEntryBoundary(res, body.name))
590
+ // 词法门两段式(4 条路由都必须在文件系统操作之前过 badEntry,见 upstream V12 的结构性断言):
591
+ // ① badEntry(上游 #4 的 validateEntryName)挡**类型 / 空值**——非字符串、空串在任何模式下都非法;
592
+ // ② 命名规范类(长度/前导点/非 NUL 控制字符)只对「创建 / 改名-to」强制;删除/改名-from
593
+ // 走 badEntryBoundary(分层理由见其定义:历史遗留的不规范名必须能清掉,否则是删不掉的孤儿)。
594
+ if (rejectWritePath(body, res))
371
595
  return;
372
- if (badEntry(res, body.to ?? ''))
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);
373
642
  return;
374
- let ref;
375
- try {
376
- ref = resolvePaper(dataDir, { topic: body.topic.trim(), name: body.name.trim() });
377
643
  }
378
- catch (err) {
379
- fsError(res, err);
644
+ // from 侧同 delete-topic:badEntry 挡类型/空值,命名规范只在改名**目标**(badEntry(rawTo))上强制
645
+ // ——这样「历史遗留的怪名专题 → 改成规范名」这条路不被自己的校验堵死。
646
+ if (badEntryBoundary(res, rawName) || badEntry(res, rawTo))
380
647
  return;
381
- }
382
- if (zhStatus(ref).busy) {
383
- json(res, 409, { error: '该文献正在翻译,稍后再试' });
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' });
384
690
  return;
385
691
  }
386
- let renamed;
387
692
  try {
388
- 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
+ });
389
718
  }
390
719
  catch (err) {
391
- fsError(res, err);
392
- return;
720
+ if (!libFail(res, err))
721
+ throw err;
393
722
  }
394
- json(res, 200, { ok: true, topic: ref.topic, name: body.to?.trim(), renamed });
395
723
  return;
396
724
  }
397
- 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') {
398
730
  const body = (await readBody(req));
399
- if (typeof body.topic !== 'string' || !body.topic.trim() || typeof body.name !== 'string' || !body.name.trim()) {
400
- 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);
401
734
  return;
402
735
  }
403
- if (badEntry(res, body.topic))
736
+ if (badEntryBoundary(res, rawTopic))
404
737
  return;
405
- if (badEntryBoundary(res, body.name))
738
+ if (rejectWritePath(body, res))
406
739
  return;
407
- let ref;
408
- try {
409
- ref = resolvePaper(dataDir, { topic: body.topic.trim(), name: body.name.trim() });
410
- }
411
- catch (err) {
412
- fsError(res, err);
740
+ if (body.confirmName !== rawTopic) {
741
+ json(res, 400, { error: '需要 confirmName 与专题名完全一致才允许清空', code: 'confirm-mismatch' });
413
742
  return;
414
743
  }
415
- if (zhStatus(ref).busy) {
416
- json(res, 409, { error: '该文献正在翻译,稍后再试' });
417
- return;
418
- }
419
- let deleted;
420
- try {
421
- deleted = deletePaper(dataDir, ref.topic, ref.name);
422
- }
423
- catch (err) {
424
- fsError(res, err);
425
- return;
426
- }
427
- 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));
428
778
  return;
429
779
  }
430
780
  // 新建伴读会话(会话N+1)。最新可见会话还是空白草稿时直接复用它——
@@ -519,7 +869,16 @@ export function registerRoutes(ctx, config) {
519
869
  // 启动后台翻译(幂等;force=true 为重翻:先删已有译文再启动)。端点:UI 里填的 > profile 的 translate
520
870
  if (sub === '/api/zh/generate' && req.method === 'POST') {
521
871
  const body = (await readBody(req));
522
- 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
+ }
523
882
  const { endpoint } = await resolveTranslateEndpoint(config.translate);
524
883
  const r = body.force
525
884
  ? await restartTranslation(ref, dataDir, endpoint)
@@ -620,11 +979,168 @@ export function registerRoutes(ctx, config) {
620
979
  json(res, 200, { ok: true });
621
980
  return;
622
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
+ }
623
1128
  if (sub === '/api/transcribe' && req.method === 'POST') {
624
1129
  const body = (await readBody(req));
625
- const ref = resolvePaper(dataDir, body);
626
- const t = await transcribePaper(ref);
627
- 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 });
628
1144
  return;
629
1145
  }
630
1146
  // 文献的伴读会话列表(多会话:会话1/会话2/…)
@@ -701,12 +1217,17 @@ export function registerRoutes(ctx, config) {
701
1217
  }
702
1218
  catch { /* 已存在则复用 */ }
703
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';
704
1223
  const where = hasQuote && body.page ? `(选中于第 ${body.page} 页)` : '';
705
1224
  const lines = [
706
1225
  `[论文伴读] 文献:${ref.topic}/${ref.name}(同目录下,search_paper 可直接检索;回答注明页码)`,
707
1226
  ];
708
1227
  if (hasQuote) {
709
- lines.push(`用户在阅读器里选中了一段文字${where}:`, `"""${body.selectedText.trim()}"""`);
1228
+ lines.push(isEquation
1229
+ ? `用户在阅读器里引用了${body.page ? `第 ${body.page} 页的` : ''}一个公式(LaTeX 源,来自 MinerU 版面解析):`
1230
+ : `用户在阅读器里选中了一段文字${where}:`, `"""${body.selectedText.trim()}"""`);
710
1231
  }
711
1232
  lines.push(`用户的问题:${body.question}`);
712
1233
  const prompt = lines.join('\n');
@@ -736,10 +1257,25 @@ export function registerRoutes(ctx, config) {
736
1257
  noteOrigin(req.headers.host);
737
1258
  const url = new URL(req.url ?? '/', 'http://x');
738
1259
  dispatch(req, res, url).catch((err) => {
739
- if (!res.headersSent)
740
- json(res, 500, { error: readableError(err) });
741
- else
1260
+ if (res.headersSent) {
742
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' });
743
1279
  });
744
1280
  },
745
1281
  });