dsh-scholar-desk 0.1.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/routes.js ADDED
@@ -0,0 +1,667 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { join } from 'node:path';
5
+ import z from 'schemastery';
6
+ import { allCardTags, allPaperTags, assertRebuildAllowed, conceptId, conceptSubgraph, filterCards, filterPapers, mergeGraph, safeName, } from './store.js';
7
+ import { applyCardPatch, applyPaperPatch, createCard, createPaper, findDuplicate } from './domain.js';
8
+ import { fetchMetadata } from './metadata.js';
9
+ const PDF_MAX_BYTES = 50 * 1024 * 1024;
10
+ const PaperInputSchema = z.object({
11
+ title: z.string().required(),
12
+ authors: z.array(z.string()).default([]),
13
+ year: z.number().step(1).min(1900).max(2100),
14
+ venue: z.string().default(''),
15
+ arxivId: z.string().default(''),
16
+ doi: z.string().default(''),
17
+ url: z.string().default(''),
18
+ abstract: z.string().default(''),
19
+ summary: z.string().default(''),
20
+ tags: z.array(z.string()).default([]),
21
+ importance: z.number().step(1).min(1).max(5),
22
+ notes: z.string().default(''),
23
+ });
24
+ /**
25
+ * PUT / dup+update 专用的部分更新 schema:所有字段可选、无默认值——
26
+ * 未提交的字段不会出现在输出里,applyPatch 视为"保持不变"而不是"清空"。
27
+ * (带 default 的全量 schema 会把缺字段填满,PUT {importance:5} 会静默清空 summary/tags。)
28
+ * 导出供冒烟/手工验证脚本直接使用。
29
+ */
30
+ export const PaperPatchInputSchema = z.object({
31
+ title: z.string(),
32
+ authors: z.array(z.string()),
33
+ year: z.number().step(1).min(1900).max(2100),
34
+ venue: z.string(),
35
+ arxivId: z.string(),
36
+ doi: z.string(),
37
+ url: z.string(),
38
+ abstract: z.string(),
39
+ summary: z.string(),
40
+ tags: z.array(z.string()),
41
+ importance: z.number().step(1).min(1).max(5),
42
+ notes: z.string(),
43
+ });
44
+ const CardInputSchema = z.object({
45
+ title: z.string().required(),
46
+ insight: z.string().required(),
47
+ paperId: z.string().default(''),
48
+ category: z.union(['method', 'theory', 'dataset', 'evaluation', 'engineering', 'other']).default('other'),
49
+ tags: z.array(z.string()).default([]),
50
+ importance: z.number().step(1).min(1).max(5).default(3),
51
+ status: z.union(['pending', 'validated', 'adopted', 'dropped']).default('pending'),
52
+ notes: z.string().default(''),
53
+ });
54
+ /** PUT 卡片的部分更新 schema(语义同 PaperPatchInputSchema,无默认值;导出供验证脚本使用)。 */
55
+ export const CardPatchInputSchema = z.object({
56
+ title: z.string(),
57
+ insight: z.string(),
58
+ paperId: z.string(),
59
+ category: z.union(['method', 'theory', 'dataset', 'evaluation', 'engineering', 'other']),
60
+ tags: z.array(z.string()),
61
+ importance: z.number().step(1).min(1).max(5),
62
+ status: z.union(['pending', 'validated', 'adopted', 'dropped']),
63
+ notes: z.string(),
64
+ });
65
+ /**
66
+ * schemastery 对未提交的可选数组字段会隐式填 [](等效默认值,实测确认),
67
+ * 会把 patch 语义破坏成"清空该字段"。这里把校验输出裁剪为"实际出现在
68
+ * 提交 body 里的字段":既保留类型校验,又保证 PUT 只动用户提交的字段。
69
+ */
70
+ export function pickSubmitted(schema, body) {
71
+ const out = { ...schema(body) };
72
+ for (const k of Object.keys(out)) {
73
+ if (!(k in body))
74
+ delete out[k];
75
+ }
76
+ return out;
77
+ }
78
+ function readBody(req) {
79
+ return new Promise((resolve, reject) => {
80
+ const ct = String(req.headers['content-type'] ?? '');
81
+ if (!ct.includes('application/json')) {
82
+ reject(new Error('请求必须是 application/json'));
83
+ return;
84
+ }
85
+ let data = '';
86
+ req.on('data', (chunk) => {
87
+ data += chunk;
88
+ if (data.length > 1_000_000) {
89
+ // 超限即断流:继续收 chunk 会让恶意/失控客户端无限灌内存(OOM)
90
+ req.destroy();
91
+ reject(new Error('请求体过大'));
92
+ return;
93
+ }
94
+ });
95
+ req.on('end', () => resolve(data));
96
+ req.on('error', reject);
97
+ });
98
+ }
99
+ /** Raw binary body reader for the PDF upload route (octet-stream only, size-capped). */
100
+ function readRawBody(req, maxBytes) {
101
+ return new Promise((resolve, reject) => {
102
+ const ct = String(req.headers['content-type'] ?? '');
103
+ if (!ct.includes('application/octet-stream')) {
104
+ reject(new Error('上传必须是 application/octet-stream'));
105
+ return;
106
+ }
107
+ const chunks = [];
108
+ let total = 0;
109
+ req.on('data', (chunk) => {
110
+ total += chunk.length;
111
+ if (total > maxBytes) {
112
+ reject(new Error(`文件过大(上限 ${Math.floor(maxBytes / 1024 / 1024)}MB)`));
113
+ req.destroy();
114
+ return;
115
+ }
116
+ chunks.push(chunk);
117
+ });
118
+ req.on('end', () => resolve(Buffer.concat(chunks)));
119
+ req.on('error', reject);
120
+ });
121
+ }
122
+ function sendJson(res, code, payload) {
123
+ res.statusCode = code;
124
+ res.setHeader('content-type', 'application/json; charset=utf-8');
125
+ res.end(JSON.stringify(payload));
126
+ }
127
+ /** 403 unless loopback(与 dsh-server-dashboard 同款):0.1.5 的 Web 认证门不覆盖
128
+ * 插件命名路由,/scholar/* 需要自己的本机围栏 */
129
+ function guardRoute(req, res) {
130
+ const addr = req.socket.remoteAddress ?? '';
131
+ if (addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1')
132
+ return true;
133
+ sendJson(res, 403, { error: '仅允许本机(loopback)访问' });
134
+ return false;
135
+ }
136
+ function num(s) {
137
+ if (s === null || s === '')
138
+ return undefined;
139
+ const n = Number(s);
140
+ return Number.isFinite(n) ? n : undefined;
141
+ }
142
+ function pathInfo(req, prefix) {
143
+ const u = new URL(req.url ?? '/', 'http://localhost');
144
+ return { rest: u.pathname.slice(prefix.length), params: u.searchParams };
145
+ }
146
+ export function registerScholarRoutes(ctx, getStore, getConfig, updateConfig) {
147
+ /* ---------- config ---------- */
148
+ ctx.effect(() => ctx.webServer.register({
149
+ kind: 'exact',
150
+ path: '/scholar/config',
151
+ handler: async (req, res) => {
152
+ if (!guardRoute(req, res))
153
+ return;
154
+ try {
155
+ if (req.method === 'GET') {
156
+ sendJson(res, 200, { config: getConfig() });
157
+ return;
158
+ }
159
+ if (req.method === 'PUT') {
160
+ const body = JSON.parse(await readBody(req));
161
+ if (typeof body.paperDir !== 'string' || !body.paperDir.trim()) {
162
+ return sendJson(res, 400, { error: 'paperDir 必须是非空字符串' });
163
+ }
164
+ const patch = { paperDir: body.paperDir.trim() };
165
+ if (Array.isArray(body.defaultTags)) {
166
+ patch.defaultTags = body.defaultTags.filter((t) => typeof t === 'string');
167
+ }
168
+ if (body.fetchProxy === undefined || typeof body.fetchProxy === 'string') {
169
+ patch.fetchProxy = typeof body.fetchProxy === 'string' ? body.fetchProxy.trim() : '';
170
+ }
171
+ await updateConfig(patch);
172
+ sendJson(res, 200, { ok: true });
173
+ return;
174
+ }
175
+ sendJson(res, 405, { error: 'method not allowed' });
176
+ }
177
+ catch (err) {
178
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
179
+ }
180
+ },
181
+ }));
182
+ /* ---------- stats ---------- */
183
+ ctx.effect(() => ctx.webServer.register({
184
+ kind: 'exact',
185
+ path: '/scholar/stats',
186
+ handler: async (req, res) => {
187
+ if (!guardRoute(req, res))
188
+ return;
189
+ try {
190
+ const store = await getStore();
191
+ sendJson(res, 200, { ...store.stats(), dir: store.dir });
192
+ }
193
+ catch (err) {
194
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
195
+ }
196
+ },
197
+ }));
198
+ /* ---------- metadata fetch (arXiv / DOI) ---------- */
199
+ ctx.effect(() => ctx.webServer.register({
200
+ kind: 'exact',
201
+ path: '/scholar/fetch',
202
+ handler: async (req, res) => {
203
+ if (!guardRoute(req, res))
204
+ return;
205
+ try {
206
+ if (req.method !== 'GET')
207
+ return sendJson(res, 405, { error: 'method not allowed' });
208
+ const params = new URL(req.url ?? '/', 'http://localhost').searchParams;
209
+ const meta = await fetchMetadata(params.get('input') ?? '', getConfig().fetchProxy || undefined);
210
+ sendJson(res, 200, { meta });
211
+ }
212
+ catch (err) {
213
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
214
+ }
215
+ },
216
+ }));
217
+ /* ---------- collections (folders) ---------- */
218
+ ctx.effect(() => ctx.webServer.register({
219
+ kind: 'prefix',
220
+ path: '/scholar/collections',
221
+ handler: async (req, res) => {
222
+ if (!guardRoute(req, res))
223
+ return;
224
+ try {
225
+ const store = await getStore();
226
+ const { rest } = pathInfo(req, '/scholar/collections');
227
+ if (!rest || rest === '/') {
228
+ if (req.method === 'GET') {
229
+ sendJson(res, 200, { collections: store.listCollections() });
230
+ return;
231
+ }
232
+ if (req.method === 'POST') {
233
+ const body = JSON.parse(await readBody(req));
234
+ if (typeof body.name !== 'string' || !body.name.trim()) {
235
+ return sendJson(res, 400, { error: 'name 必须是非空字符串' });
236
+ }
237
+ const { collection, existed } = await store.upsertCollection(body.name, typeof body.color === 'string' && body.color ? body.color : undefined);
238
+ sendJson(res, existed ? 200 : 201, { created: !existed, collection });
239
+ return;
240
+ }
241
+ return sendJson(res, 405, { error: 'method not allowed' });
242
+ }
243
+ const id = decodeURIComponent(rest.slice(1));
244
+ if (!id || id.includes('/'))
245
+ return sendJson(res, 404, { error: 'not found' });
246
+ if (req.method === 'PUT') {
247
+ const body = JSON.parse(await readBody(req));
248
+ const next = await store.renameCollection(id, {
249
+ name: typeof body.name === 'string' ? body.name : undefined,
250
+ color: typeof body.color === 'string' ? body.color : undefined,
251
+ });
252
+ if (!next)
253
+ return sendJson(res, 404, { error: '分区不存在' });
254
+ sendJson(res, 200, { collection: next });
255
+ return;
256
+ }
257
+ if (req.method === 'DELETE') {
258
+ // membership is stripped from papers; papers themselves are kept
259
+ const ok = await store.deleteCollection(id);
260
+ sendJson(res, ok ? 200 : 404, ok ? { deleted: true } : { error: '分区不存在' });
261
+ return;
262
+ }
263
+ sendJson(res, 405, { error: 'method not allowed' });
264
+ }
265
+ catch (err) {
266
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
267
+ }
268
+ },
269
+ }));
270
+ /* ---------- papers ---------- */
271
+ ctx.effect(() => ctx.webServer.register({
272
+ kind: 'prefix',
273
+ path: '/scholar/papers',
274
+ handler: async (req, res) => {
275
+ if (!guardRoute(req, res))
276
+ return;
277
+ try {
278
+ const store = await getStore();
279
+ const { rest, params } = pathInfo(req, '/scholar/papers');
280
+ if (!rest) {
281
+ if (req.method === 'GET') {
282
+ const query = {
283
+ q: params.get('q') || undefined,
284
+ tag: params.get('tag') || undefined,
285
+ yearFrom: num(params.get('yearFrom')),
286
+ yearTo: num(params.get('yearTo')),
287
+ importance: num(params.get('importance')),
288
+ collection: params.get('collection') || undefined,
289
+ sort: params.get('sort') || undefined,
290
+ };
291
+ // 可选分页:limit/offset(均须为非负整数);不传 = 全量(默认行为不变)
292
+ const limit = num(params.get('limit'));
293
+ const offset = num(params.get('offset'));
294
+ const badPage = [limit, offset].some((v) => v !== undefined && (!Number.isInteger(v) || v < 0));
295
+ if (badPage)
296
+ return sendJson(res, 400, { error: 'limit/offset 必须是非负整数' });
297
+ const papers = filterPapers([...store.papers.values()], query);
298
+ sendJson(res, 200, {
299
+ papers: limit !== undefined || offset !== undefined
300
+ ? papers.slice(offset ?? 0, (offset ?? 0) + (limit ?? papers.length))
301
+ : papers,
302
+ total: papers.length,
303
+ tags: allPaperTags(store.papers.values()),
304
+ });
305
+ return;
306
+ }
307
+ if (req.method === 'POST') {
308
+ const body = JSON.parse(await readBody(req));
309
+ const input = PaperInputSchema(body);
310
+ // collection membership: only meaningful when caller sends explicit arrays
311
+ const wantCol = Array.isArray(body.collectionIds) || Array.isArray(body.collectionNames);
312
+ const colIds = Array.isArray(body.collectionIds)
313
+ ? store.filterExistingCollectionIds(body.collectionIds)
314
+ : Array.isArray(body.collectionNames)
315
+ ? await store.ensureCollectionNames(body.collectionNames)
316
+ : undefined;
317
+ const dup = findDuplicate(store.papers.values(), input);
318
+ if (dup && !body.update) {
319
+ sendJson(res, 200, { created: false, duplicate: true, paper: dup });
320
+ return;
321
+ }
322
+ if (dup) {
323
+ // 更新走部分更新语义:只合并实际提交的字段,未提交字段保持不变
324
+ const patch = pickSubmitted(PaperPatchInputSchema, body);
325
+ const paper = applyPaperPatch(dup, {
326
+ ...patch,
327
+ ...(wantCol ? { collectionIds: colIds ?? [] } : {}),
328
+ });
329
+ await store.upsertPaper(paper);
330
+ sendJson(res, 200, { created: false, duplicate: true, updated: true, paper });
331
+ return;
332
+ }
333
+ const tags = [...new Set([...(getConfig().defaultTags ?? []), ...input.tags])];
334
+ const paper = createPaper({
335
+ ...input, tags, source: 'manual',
336
+ ...(colIds !== undefined ? { collectionIds: colIds } : {}),
337
+ });
338
+ await store.upsertPaper(paper);
339
+ sendJson(res, 201, { created: true, paper });
340
+ return;
341
+ }
342
+ sendJson(res, 405, { error: 'method not allowed' });
343
+ return;
344
+ }
345
+ /* ---------- reading reports (list / serve) ---------- */
346
+ const reportsMatch = /^\/(.+)\/reports(?:\/([^/]+))?$/i.exec(rest);
347
+ if (reportsMatch) {
348
+ const pid = decodeURIComponent(reportsMatch[1]);
349
+ if (!store.papers.get(pid))
350
+ return sendJson(res, 404, { error: '论文不存在' });
351
+ const prefix = `${safeName(pid)}-`;
352
+ const reportsDir = join(store.dir, 'reports');
353
+ if (req.method === 'GET' && !reportsMatch[2]) {
354
+ try {
355
+ const files = (await Promise.all((await readdir(reportsDir))
356
+ .filter((f) => f.startsWith(prefix) && f.endsWith('.html'))
357
+ .map(async (f) => {
358
+ const st = await stat(join(reportsDir, f));
359
+ return { file: f, size: st.size, savedAt: st.mtimeMs };
360
+ }))).sort((a, b) => b.savedAt - a.savedAt);
361
+ return sendJson(res, 200, { reports: files });
362
+ }
363
+ catch {
364
+ return sendJson(res, 200, { reports: [] });
365
+ }
366
+ }
367
+ if (req.method === 'GET' && reportsMatch[2]) {
368
+ const fname = safeName(decodeURIComponent(reportsMatch[2]));
369
+ if (!fname.startsWith(prefix) || !fname.endsWith('.html')) {
370
+ return sendJson(res, 400, { error: '非法的报告文件名' });
371
+ }
372
+ try {
373
+ const data = await readFile(join(reportsDir, fname));
374
+ res.statusCode = 200;
375
+ res.setHeader('content-type', 'text/html; charset=utf-8');
376
+ // 报告内容源自 LLM 生成的 HTML(可能被论文正文提示注入携带脚本):
377
+ // sandbox CSP 让浏览器以唯一源沙箱打开(脚本禁用、同源隔离),保持在线阅读可用
378
+ res.setHeader('content-security-policy', 'sandbox');
379
+ res.setHeader('x-content-type-options', 'nosniff');
380
+ res.setHeader('content-length', data.length);
381
+ res.end(data);
382
+ }
383
+ catch {
384
+ return sendJson(res, 404, { error: '报告文件不存在' });
385
+ }
386
+ return;
387
+ }
388
+ return sendJson(res, 405, { error: 'method not allowed' });
389
+ }
390
+ /* ---------- PDF attachment (upload / open) ---------- */
391
+ const pdfMatch = /^\/(.+)\/pdf$/i.exec(rest);
392
+ if (pdfMatch) {
393
+ const pid = decodeURIComponent(pdfMatch[1]);
394
+ const paper = store.papers.get(pid);
395
+ if (!paper)
396
+ return sendJson(res, 404, { error: '论文不存在' });
397
+ const file = join(store.dir, 'attachments', `${safeName(pid)}.pdf`);
398
+ if (req.method === 'PUT') {
399
+ try {
400
+ const buf = await readRawBody(req, PDF_MAX_BYTES);
401
+ if (buf.length === 0)
402
+ return sendJson(res, 400, { error: '空文件' });
403
+ // 魔数校验:坏文件入档会毁掉 deepread 动线
404
+ if (buf.subarray(0, 4).toString('latin1') !== '%PDF') {
405
+ return sendJson(res, 400, { error: '文件内容不是有效的 PDF(缺少 %PDF 魔数)' });
406
+ }
407
+ await mkdir(join(store.dir, 'attachments'), { recursive: true });
408
+ const tmp = `${file}.${randomUUID().slice(0, 8)}.tmp`;
409
+ await writeFile(tmp, buf);
410
+ await rename(tmp, file).catch(async (err) => {
411
+ await unlink(tmp).catch(() => { });
412
+ throw err;
413
+ });
414
+ paper.pdfPath = `attachments/${safeName(pid)}.pdf`;
415
+ paper.updatedAt = Date.now();
416
+ await store.upsertPaper(paper);
417
+ return sendJson(res, 200, { ok: true, pdfPath: paper.pdfPath });
418
+ }
419
+ catch (err) {
420
+ return sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
421
+ }
422
+ }
423
+ if (req.method === 'GET') {
424
+ if (!paper.pdfPath)
425
+ return sendJson(res, 404, { error: '该论文没有 PDF 附件' });
426
+ try {
427
+ const info = await stat(file);
428
+ res.statusCode = 200;
429
+ res.setHeader('content-type', 'application/pdf');
430
+ res.setHeader('x-content-type-options', 'nosniff');
431
+ res.setHeader('content-length', info.size);
432
+ // 读取中途失败(文件被删/锁定)时收尾响应,而不是未捕获异常或挂死
433
+ const stream = createReadStream(file);
434
+ stream.on('error', (err) => {
435
+ if (res.headersSent)
436
+ res.end();
437
+ else
438
+ sendJson(res, 500, { error: `PDF 读取失败: ${err instanceof Error ? err.message : String(err)}` });
439
+ });
440
+ stream.pipe(res);
441
+ }
442
+ catch {
443
+ sendJson(res, 404, { error: 'PDF 文件丢失(可重新上传)' });
444
+ }
445
+ return;
446
+ }
447
+ return sendJson(res, 405, { error: 'method not allowed' });
448
+ }
449
+ const id = decodeURIComponent(rest.slice(1));
450
+ if (!id || id.includes('/'))
451
+ return sendJson(res, 404, { error: 'not found' });
452
+ if (req.method === 'GET') {
453
+ const paper = store.papers.get(id);
454
+ if (!paper)
455
+ return sendJson(res, 404, { error: '论文不存在' });
456
+ sendJson(res, 200, { paper });
457
+ return;
458
+ }
459
+ if (req.method === 'PUT') {
460
+ const existing = store.papers.get(id);
461
+ if (!existing)
462
+ return sendJson(res, 404, { error: '论文不存在' });
463
+ const body = JSON.parse(await readBody(req));
464
+ // PUT 为部分更新语义:只合并实际提交的字段,未提交字段(如 summary/tags)保持不变
465
+ const input = pickSubmitted(PaperPatchInputSchema, body);
466
+ const colIds = Array.isArray(body.collectionIds)
467
+ ? store.filterExistingCollectionIds(body.collectionIds)
468
+ : Array.isArray(body.collectionNames)
469
+ ? await store.ensureCollectionNames(body.collectionNames)
470
+ : undefined;
471
+ const paper = applyPaperPatch(existing, {
472
+ ...input,
473
+ ...(colIds !== undefined ? { collectionIds: colIds } : {}),
474
+ });
475
+ await store.upsertPaper(paper);
476
+ sendJson(res, 200, { paper });
477
+ return;
478
+ }
479
+ if (req.method === 'DELETE') {
480
+ const ok = await store.deletePaper(id);
481
+ sendJson(res, ok ? 200 : 404, ok ? { deleted: true } : { error: '论文不存在' });
482
+ return;
483
+ }
484
+ sendJson(res, 405, { error: 'method not allowed' });
485
+ }
486
+ catch (err) {
487
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
488
+ }
489
+ },
490
+ }));
491
+ /* ---------- idea cards ---------- */
492
+ ctx.effect(() => ctx.webServer.register({
493
+ kind: 'prefix',
494
+ path: '/scholar/cards',
495
+ handler: async (req, res) => {
496
+ if (!guardRoute(req, res))
497
+ return;
498
+ try {
499
+ const store = await getStore();
500
+ const { rest, params } = pathInfo(req, '/scholar/cards');
501
+ if (!rest) {
502
+ if (req.method === 'GET') {
503
+ const query = {
504
+ q: params.get('q') || undefined,
505
+ category: params.get('category') || undefined,
506
+ tag: params.get('tag') || undefined,
507
+ importance: num(params.get('importance')),
508
+ status: params.get('status') || undefined,
509
+ paperId: params.get('paperId') || undefined,
510
+ sort: params.get('sort') || undefined,
511
+ };
512
+ const cards = filterCards([...store.cards.values()], query);
513
+ sendJson(res, 200, { cards, tags: allCardTags(store.cards.values()) });
514
+ return;
515
+ }
516
+ if (req.method === 'POST') {
517
+ const body = JSON.parse(await readBody(req));
518
+ const input = CardInputSchema(body);
519
+ if (input.paperId && !store.papers.has(input.paperId)) {
520
+ return sendJson(res, 400, { error: `论文 ${input.paperId} 不在论文库中` });
521
+ }
522
+ const relIds = Array.isArray(body.relatedCardIds)
523
+ ? store.filterExistingCardIds(body.relatedCardIds)
524
+ : undefined;
525
+ const card = createCard({
526
+ ...input, paperId: input.paperId || undefined,
527
+ ...(relIds !== undefined ? { relatedCardIds: relIds } : {}),
528
+ ...(body.plain !== undefined ? { plain: String(body.plain) } : {}),
529
+ ...(Array.isArray(body.steps) ? { steps: body.steps } : {}),
530
+ });
531
+ await store.upsertCard(card);
532
+ sendJson(res, 201, { created: true, card });
533
+ return;
534
+ }
535
+ sendJson(res, 405, { error: 'method not allowed' });
536
+ return;
537
+ }
538
+ const id = decodeURIComponent(rest.slice(1));
539
+ if (!id || id.includes('/'))
540
+ return sendJson(res, 404, { error: 'not found' });
541
+ if (req.method === 'GET') {
542
+ const card = store.cards.get(id);
543
+ if (!card)
544
+ return sendJson(res, 404, { error: '卡片不存在' });
545
+ sendJson(res, 200, { card });
546
+ return;
547
+ }
548
+ if (req.method === 'PUT') {
549
+ const existing = store.cards.get(id);
550
+ if (!existing)
551
+ return sendJson(res, 404, { error: '卡片不存在' });
552
+ const body = JSON.parse(await readBody(req));
553
+ // PUT 为部分更新语义:只合并实际提交的字段(与工具层 idea_card_update 一致)
554
+ const input = pickSubmitted(CardPatchInputSchema, body);
555
+ if (input.paperId && !store.papers.has(input.paperId)) {
556
+ return sendJson(res, 400, { error: `论文 ${input.paperId} 不在论文库中` });
557
+ }
558
+ const relIds = Array.isArray(body.relatedCardIds)
559
+ ? store.filterExistingCardIds(body.relatedCardIds)
560
+ : undefined;
561
+ const card = applyCardPatch(existing, {
562
+ ...input,
563
+ ...(relIds !== undefined ? { relatedCardIds: relIds } : {}),
564
+ ...(body.plain !== undefined ? { plain: String(body.plain) } : {}),
565
+ ...(Array.isArray(body.steps) ? { steps: body.steps } : {}),
566
+ });
567
+ await store.upsertCard(card);
568
+ sendJson(res, 200, { card });
569
+ return;
570
+ }
571
+ if (req.method === 'DELETE') {
572
+ const ok = await store.deleteCard(id);
573
+ sendJson(res, ok ? 200 : 404, ok ? { deleted: true } : { error: '卡片不存在' });
574
+ return;
575
+ }
576
+ sendJson(res, 405, { error: 'method not allowed' });
577
+ }
578
+ catch (err) {
579
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
580
+ }
581
+ },
582
+ }));
583
+ /* ---------- tag auto-sync (heuristic, no AI) ---------- */
584
+ ctx.effect(() => ctx.webServer.register({
585
+ kind: 'exact',
586
+ path: '/scholar/graph/auto-sync',
587
+ handler: async (req, res) => {
588
+ if (!guardRoute(req, res))
589
+ return;
590
+ try {
591
+ if (req.method !== 'POST')
592
+ return sendJson(res, 405, { error: 'method not allowed' });
593
+ const store = await getStore();
594
+ const patch = store.tagSyncPatch([...store.papers.values()]);
595
+ const graph = mergeGraph(store.graph, patch, 'append', new Set(store.papers.keys()));
596
+ await store.saveGraph(graph);
597
+ sendJson(res, 200, {
598
+ addedNodes: patch.nodes.length,
599
+ addedEdges: patch.edges.length,
600
+ nodes: graph.nodes.length,
601
+ edges: graph.edges.length,
602
+ });
603
+ }
604
+ catch (err) {
605
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
606
+ }
607
+ },
608
+ }));
609
+ /* ---------- knowledge graph ---------- */
610
+ ctx.effect(() => ctx.webServer.register({
611
+ kind: 'exact',
612
+ path: '/scholar/graph',
613
+ handler: async (req, res) => {
614
+ if (!guardRoute(req, res))
615
+ return;
616
+ try {
617
+ const store = await getStore();
618
+ if (req.method === 'GET') {
619
+ sendJson(res, 200, { graph: store.graph });
620
+ return;
621
+ }
622
+ if (req.method === 'PUT') {
623
+ const body = JSON.parse(await readBody(req));
624
+ if (!Array.isArray(body.nodes) || !Array.isArray(body.edges)) {
625
+ return sendJson(res, 400, { error: 'nodes 与 edges 必须是数组' });
626
+ }
627
+ const mode = body.mode === 'rebuild' ? 'rebuild' : 'append';
628
+ const incoming = {
629
+ nodes: body.nodes,
630
+ edges: body.edges,
631
+ };
632
+ // rebuild 保护:缩减型 rebuild(详见 assertRebuildAllowed)需 force=true 显式确认
633
+ if (mode === 'rebuild') {
634
+ try {
635
+ assertRebuildAllowed(store.graph, incoming.nodes.length + incoming.edges.length, body.force === true);
636
+ }
637
+ catch (err) {
638
+ return sendJson(res, 409, { error: err instanceof Error ? err.message : String(err) });
639
+ }
640
+ }
641
+ const knownPapers = new Set(store.papers.keys());
642
+ const graph = mergeGraph(store.graph, incoming, mode, knownPapers);
643
+ // rebuild 前把旧图谱备份为 graph.json.bak(保留一代)
644
+ await (mode === 'rebuild' ? store.saveGraphRebuild(graph) : store.saveGraph(graph));
645
+ sendJson(res, 200, { graph });
646
+ return;
647
+ }
648
+ sendJson(res, 405, { error: 'method not allowed' });
649
+ }
650
+ catch (err) {
651
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
652
+ }
653
+ },
654
+ }));
655
+ }
656
+ /** Resolve a concept id from a model-supplied label (direct or canonical). */
657
+ export function resolveConceptId(graph, input) {
658
+ const canonical = conceptId(input);
659
+ const direct = graph.nodes.find((n) => n.kind === 'concept' && n.id === canonical);
660
+ if (direct)
661
+ return direct.id;
662
+ const byLabel = graph.nodes.find((n) => n.kind === 'concept' && n.label.toLowerCase() === input.toLowerCase());
663
+ if (byLabel)
664
+ return byLabel.id;
665
+ const sub = conceptSubgraph(graph, canonical);
666
+ return sub.nodes.length > 0 ? canonical : undefined;
667
+ }
@@ -0,0 +1,4 @@
1
+ /** dsh-scholar shared data model (used by both host and client halves). */
2
+ export const CARD_CATEGORIES = ['method', 'theory', 'dataset', 'evaluation', 'engineering', 'other'];
3
+ export const CARD_STATUSES = ['pending', 'validated', 'adopted', 'dropped'];
4
+ export const GRAPH_EDGE_KINDS = ['proposes', 'improves', 'extends', 'builds_on', 'compares', 'uses'];