bell-reader 0.0.0-stage → 1.0.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.
Files changed (46) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +75 -2
  4. package/README.zh-TW.md +55 -0
  5. package/TRADEMARKS.md +32 -0
  6. package/bin/bell-reader.mjs +202 -0
  7. package/dist/assets/caveat-cyrillic-400-normal-9cDH9rLW.woff2 +0 -0
  8. package/dist/assets/caveat-cyrillic-400-normal-CebvvJET.woff +0 -0
  9. package/dist/assets/caveat-cyrillic-500-normal-DLSfp5IU.woff +0 -0
  10. package/dist/assets/caveat-cyrillic-500-normal-DpMZbbjM.woff2 +0 -0
  11. package/dist/assets/caveat-cyrillic-ext-400-normal-3iEGd-c5.woff2 +0 -0
  12. package/dist/assets/caveat-cyrillic-ext-400-normal-Cg0RnRQ5.woff +0 -0
  13. package/dist/assets/caveat-cyrillic-ext-500-normal-DcrB4vVx.woff2 +0 -0
  14. package/dist/assets/caveat-cyrillic-ext-500-normal-DqTXtm5N.woff +0 -0
  15. package/dist/assets/caveat-latin-400-normal-BzhAQZkN.woff +0 -0
  16. package/dist/assets/caveat-latin-400-normal-D6LQsQ_v.woff2 +0 -0
  17. package/dist/assets/caveat-latin-500-normal-B9SDL8cy.woff2 +0 -0
  18. package/dist/assets/caveat-latin-500-normal-Do9veZfZ.woff +0 -0
  19. package/dist/assets/caveat-latin-ext-400-normal-D7hBUiug.woff2 +0 -0
  20. package/dist/assets/caveat-latin-ext-400-normal-DtiRFvw0.woff +0 -0
  21. package/dist/assets/caveat-latin-ext-500-normal-jKqJW7bC.woff2 +0 -0
  22. package/dist/assets/caveat-latin-ext-500-normal-wUUXxlbS.woff +0 -0
  23. package/dist/assets/index-BO29bkMJ.js +84 -0
  24. package/dist/assets/index-DtC09ml8.css +1 -0
  25. package/dist/assets/newsreader-latin-ext-opsz-normal-BQn1nviT.woff2 +0 -0
  26. package/dist/assets/newsreader-latin-opsz-normal-s-izfB6B.woff2 +0 -0
  27. package/dist/assets/newsreader-vietnamese-opsz-normal-BjhtXyW2.woff2 +0 -0
  28. package/dist/fonts/LICENSE-geist-mono.txt +93 -0
  29. package/dist/fonts/LICENSE-geist.txt +93 -0
  30. package/dist/fonts/geist-latin-wght.woff2 +0 -0
  31. package/dist/fonts/geist-mono-latin-wght.woff2 +0 -0
  32. package/dist/fonts/iansui-tc.ttf +0 -0
  33. package/dist/fonts/naikai-tc.woff2 +0 -0
  34. package/dist/index.html +13 -0
  35. package/package.json +79 -4
  36. package/server/ai.ts +1391 -0
  37. package/server/app.ts +644 -0
  38. package/server/config.ts +140 -0
  39. package/server/errors.ts +22 -0
  40. package/server/explain-bridge.ts +320 -0
  41. package/server/extractor.ts +548 -0
  42. package/server/index.ts +28 -0
  43. package/server/security.ts +78 -0
  44. package/server/store.ts +595 -0
  45. package/shared/suite.ts +248 -0
  46. package/shared/types.ts +132 -0
package/server/app.ts ADDED
@@ -0,0 +1,644 @@
1
+ import express, { type NextFunction, type Request, type Response } from "express";
2
+ import compression from "compression";
3
+ import { existsSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { z } from "zod";
7
+ import {
8
+ AI_PROVIDERS,
9
+ ANNOTATION_DENSITIES,
10
+ INK_COLORS,
11
+ INK_STYLES,
12
+ SKETCH_CURVE_SHAPES,
13
+ SKETCH_KINDS,
14
+ } from "../shared/types.js";
15
+ import {
16
+ SUITE_LIMITS,
17
+ SUITE_OBJECT_ID,
18
+ SUITE_UNSAFE_TEXT,
19
+ type SuiteLinks,
20
+ type SuiteManifest,
21
+ type SuiteTodayFeed,
22
+ type SuiteTodayItem,
23
+ } from "../shared/suite.js";
24
+ import {
25
+ annotatePageWithAI,
26
+ annotateSelectionWithAI,
27
+ translatePageWithAI,
28
+ type JsonPromptRunner,
29
+ } from "./ai.js";
30
+ import { AppError, publicError } from "./errors.js";
31
+ import { ExplainBridge } from "./explain-bridge.js";
32
+ import {
33
+ extractBlocksFromHtml,
34
+ fetchPublicHtml,
35
+ parsePastedContent,
36
+ resolveQuoteAnchor,
37
+ validatePublicUrl,
38
+ } from "./extractor.js";
39
+ import { securityMiddleware } from "./security.js";
40
+ import type { ReaderStore } from "./store.js";
41
+
42
+ export interface CreateAppOptions {
43
+ store: ReaderStore;
44
+ allowedOrigins?: string[];
45
+ suiteLinks?: SuiteLinks;
46
+ suiteFetch?: Partial<Record<"explain" | "pr", string>>;
47
+ aiRunner?: JsonPromptRunner;
48
+ htmlFetcher?: typeof fetchPublicHtml;
49
+ distDir?: string;
50
+ }
51
+
52
+ const createFolderSchema = z
53
+ .object({
54
+ name: z.string().trim().min(1).max(48),
55
+ color: z.enum(INK_COLORS).optional(),
56
+ })
57
+ .strict();
58
+
59
+ const createPageSchema = z
60
+ .object({
61
+ url: z.string().trim().max(4000).optional(),
62
+ content: z.string().max(120_000).optional(),
63
+ title: z.string().trim().max(120).optional(),
64
+ folderId: z.string().trim().nullable().optional(),
65
+ forceRefetch: z.boolean().optional(),
66
+ })
67
+ .strict();
68
+
69
+ const updatePageSchema = z
70
+ .object({
71
+ title: z.string().trim().min(1).max(120).optional(),
72
+ folderId: z.string().trim().nullable().optional(),
73
+ topic: z.string().trim().min(1).max(40).optional(),
74
+ tags: z.array(z.string().trim().min(1).max(24)).max(6).optional(),
75
+ })
76
+ .strict();
77
+
78
+ const sketchElementSchema = z.discriminatedUnion("type", [
79
+ z
80
+ .object({
81
+ type: z.literal("rect"),
82
+ x: z.number().min(0).max(360),
83
+ y: z.number().min(0).max(220),
84
+ w: z.number().min(8).max(360),
85
+ h: z.number().min(8).max(220),
86
+ fill: z.boolean().optional(),
87
+ dashed: z.boolean().optional(),
88
+ label: z.string().trim().max(32).optional(),
89
+ })
90
+ .strict(),
91
+ z
92
+ .object({
93
+ type: z.literal("ellipse"),
94
+ x: z.number().min(0).max(360),
95
+ y: z.number().min(0).max(220),
96
+ rx: z.number().min(6).max(180),
97
+ ry: z.number().min(6).max(110),
98
+ fill: z.boolean().optional(),
99
+ dashed: z.boolean().optional(),
100
+ label: z.string().trim().max(32).optional(),
101
+ })
102
+ .strict(),
103
+ z
104
+ .object({
105
+ type: z.literal("arrow"),
106
+ x1: z.number().min(0).max(360),
107
+ y1: z.number().min(0).max(220),
108
+ x2: z.number().min(0).max(360),
109
+ y2: z.number().min(0).max(220),
110
+ bend: z.number().min(-120).max(120).optional(),
111
+ dashed: z.boolean().optional(),
112
+ label: z.string().trim().max(28).optional(),
113
+ })
114
+ .strict(),
115
+ z
116
+ .object({
117
+ type: z.literal("line"),
118
+ x1: z.number().min(0).max(360),
119
+ y1: z.number().min(0).max(220),
120
+ x2: z.number().min(0).max(360),
121
+ y2: z.number().min(0).max(220),
122
+ dashed: z.boolean().optional(),
123
+ label: z.string().trim().max(28).optional(),
124
+ })
125
+ .strict(),
126
+ z
127
+ .object({
128
+ type: z.literal("curve"),
129
+ points: z.array(z.tuple([z.number().min(0).max(360), z.number().min(0).max(220)])).min(2).max(12),
130
+ fill: z.boolean().optional(),
131
+ dashed: z.boolean().optional(),
132
+ closed: z.boolean().optional(),
133
+ label: z.string().trim().max(28).optional(),
134
+ })
135
+ .strict(),
136
+ z
137
+ .object({
138
+ type: z.literal("dot"),
139
+ x: z.number().min(0).max(360),
140
+ y: z.number().min(0).max(220),
141
+ label: z.string().trim().max(28).optional(),
142
+ })
143
+ .strict(),
144
+ z
145
+ .object({
146
+ type: z.literal("text"),
147
+ x: z.number().min(0).max(360),
148
+ y: z.number().min(0).max(220),
149
+ text: z.string().trim().min(1).max(36),
150
+ size: z.enum(["sm", "md", "lg"]).optional(),
151
+ align: z.enum(["start", "middle", "end"]).optional(),
152
+ })
153
+ .strict(),
154
+ ]);
155
+
156
+ const manualAnnotationSchema = z
157
+ .object({
158
+ blockId: z.string().trim().min(1),
159
+ quote: z.string().trim().min(1).max(300),
160
+ style: z.enum(INK_STYLES),
161
+ color: z.enum(INK_COLORS),
162
+ label: z.string().trim().max(16).optional(),
163
+ note: z.string().trim().min(1).max(320),
164
+ topic: z.string().trim().max(28).optional(),
165
+ anchorX: z.number().min(0).max(1).optional(),
166
+ anchorY: z.number().min(0).max(1).optional(),
167
+ sketch: z
168
+ .object({
169
+ kind: z.enum(SKETCH_KINDS),
170
+ caption: z.string().trim().max(48).optional(),
171
+ nodes: z.array(z.string().trim().min(1).max(24)).max(5),
172
+ shape: z.enum(SKETCH_CURVE_SHAPES).optional(),
173
+ elements: z.array(sketchElementSchema).max(24).optional(),
174
+ })
175
+ .strict()
176
+ .refine(
177
+ (s) => (s.kind === "canvas" ? (s.elements?.length ?? 0) >= 2 : s.nodes.length >= 2),
178
+ "手繪圖解至少需要兩個元素或節點。",
179
+ )
180
+ .optional(),
181
+ })
182
+ .strict();
183
+
184
+ const aiSelectionSchema = z
185
+ .object({
186
+ blockId: z.string().trim().min(1),
187
+ quote: z.string().trim().min(2).max(240),
188
+ style: z.enum(INK_STYLES).optional(),
189
+ color: z.enum(INK_COLORS).optional(),
190
+ })
191
+ .strict();
192
+
193
+ const settingsPatchSchema = z
194
+ .object({
195
+ provider: z.enum(AI_PROVIDERS).optional(),
196
+ model: z.string().max(160).optional(),
197
+ autoFolder: z.boolean().optional(),
198
+ annotationDensity: z.enum(ANNOTATION_DENSITIES).optional(),
199
+ })
200
+ .strict();
201
+
202
+ const annotateRequestSchema = z
203
+ .object({
204
+ density: z.enum(ANNOTATION_DENSITIES).optional(),
205
+ })
206
+ .strict()
207
+ .optional();
208
+
209
+ export function createApp(options: CreateAppOptions) {
210
+ const {
211
+ store,
212
+ allowedOrigins = [],
213
+ suiteLinks = {},
214
+ suiteFetch = {},
215
+ aiRunner,
216
+ htmlFetcher = fetchPublicHtml,
217
+ distDir = fileURLToPath(new URL("../dist", import.meta.url)),
218
+ } = options;
219
+
220
+ const explainBridge = new ExplainBridge(suiteFetch.explain, suiteLinks.explain);
221
+ const app = express();
222
+ app.disable("x-powered-by");
223
+ app.use(compression());
224
+ app.use(securityMiddleware(allowedOrigins));
225
+ app.use(express.json({ limit: "256kb" }));
226
+
227
+ app.use("/api", (_req, res, next) => {
228
+ res.setHeader("Cache-Control", "no-store");
229
+ next();
230
+ });
231
+
232
+ // ── Suite Contract Endpoints ─────────────────────────────────────────────
233
+ app.get("/api/health", (_req, res) => {
234
+ res.json({ status: "ok", appId: "reader" });
235
+ });
236
+
237
+ app.get("/api/suite/manifest", (_req, res) => {
238
+ const manifest: SuiteManifest = {
239
+ schemaVersion: 1,
240
+ appId: "reader",
241
+ displayName: "BELL Web Reader",
242
+ summary: "網頁閱讀與標註",
243
+ capabilities: [
244
+ {
245
+ id: "reader.draft",
246
+ kind: "accept-handoff",
247
+ route: "#/read",
248
+ accepts: ["text"],
249
+ maxChars: SUITE_LIMITS.handoffText,
250
+ },
251
+ {
252
+ id: "reader.today",
253
+ kind: "today-feed",
254
+ api: "/api/suite/today",
255
+ },
256
+ {
257
+ id: "reader.page",
258
+ kind: "object-route",
259
+ route: "#/page/{id}",
260
+ },
261
+ ],
262
+ links: suiteLinks,
263
+ };
264
+ res.json(manifest);
265
+ });
266
+
267
+ app.get("/api/suite/today", (_req, res) => {
268
+ const all = store.listPages().filter(
269
+ (p) =>
270
+ SUITE_OBJECT_ID.test(p.id) &&
271
+ p.id !== "." &&
272
+ p.id !== ".." &&
273
+ p.title.trim().length > 0 &&
274
+ !SUITE_UNSAFE_TEXT.test(p.title),
275
+ );
276
+ const annotatedCount = all.filter((p) => p.annotationCount > 0).length;
277
+ const unannotatedCount = all.length - annotatedCount;
278
+ const items: SuiteTodayItem[] = all.slice(0, SUITE_LIMITS.items).map((p) => {
279
+ const detailText =
280
+ p.topic && p.topic !== "未分類" && !SUITE_UNSAFE_TEXT.test(p.topic)
281
+ ? `${p.topic} · ${p.annotationCount} 則標註`.slice(0, SUITE_LIMITS.detail)
282
+ : `${p.annotationCount} 則標註`;
283
+ return {
284
+ sourceApp: "reader",
285
+ type: "page",
286
+ id: p.id,
287
+ title: [...p.title].slice(0, SUITE_LIMITS.title).join(""),
288
+ href: `#/page/${p.id}`,
289
+ state: p.annotationCount > 0 ? "new" : "waiting",
290
+ updatedAt: p.updatedAt,
291
+ detail: detailText,
292
+ };
293
+ });
294
+ const feed: SuiteTodayFeed = {
295
+ schemaVersion: 1,
296
+ appId: "reader",
297
+ generatedAt: new Date().toISOString(),
298
+ counts: {
299
+ saved: all.length,
300
+ annotated: annotatedCount,
301
+ unread: unannotatedCount,
302
+ },
303
+ items,
304
+ truncated: all.length > SUITE_LIMITS.items,
305
+ };
306
+ res.json(feed);
307
+ });
308
+
309
+ // ── Settings ─────────────────────────────────────────────────────────────
310
+ app.get("/api/settings", (_req, res) => {
311
+ res.json(store.getSettings());
312
+ });
313
+
314
+ app.patch("/api/settings", (req, res) => {
315
+ const parsed = settingsPatchSchema.safeParse(req.body);
316
+ if (!parsed.success) throw new AppError(400, "設定格式不正確。");
317
+ res.json(store.saveSettings(parsed.data));
318
+ });
319
+
320
+ // ── Folders ──────────────────────────────────────────────────────────────
321
+ app.get("/api/folders", (_req, res) => {
322
+ res.json(store.listFolders());
323
+ });
324
+
325
+ app.post("/api/folders", (req, res) => {
326
+ const parsed = createFolderSchema.safeParse(req.body);
327
+ if (!parsed.success) throw new AppError(400, "請輸入有效的資料夾名稱。");
328
+ res.status(201).json(store.createFolder(parsed.data.name, parsed.data.color));
329
+ });
330
+
331
+ app.patch("/api/folders/:id", (req, res) => {
332
+ const parsed = createFolderSchema.safeParse(req.body);
333
+ if (!parsed.success) throw new AppError(400, "請輸入有效的資料夾名稱。");
334
+ res.json(store.renameFolder(req.params.id, parsed.data.name, parsed.data.color));
335
+ });
336
+
337
+ app.delete("/api/folders/:id", (req, res) => {
338
+ store.deleteFolder(req.params.id);
339
+ res.json({ ok: true });
340
+ });
341
+
342
+ // ── Pages ────────────────────────────────────────────────────────────────
343
+ app.get("/api/pages", (_req, res) => {
344
+ res.json({
345
+ pages: store.listPages(),
346
+ folders: store.listFolders(),
347
+ });
348
+ });
349
+
350
+ app.get("/api/pages/:id", (req, res) => {
351
+ const page = store.getPage(req.params.id);
352
+ if (!page) throw new AppError(404, "找不到此頁面。");
353
+ res.json(page);
354
+ });
355
+
356
+ app.post("/api/pages", async (req, res) => {
357
+ const parsed = createPageSchema.safeParse(req.body);
358
+ if (!parsed.success) throw new AppError(400, "請輸入網址或貼上內容。");
359
+
360
+ const { url, content, title, folderId, forceRefetch } = parsed.data;
361
+ const trimmedInput = (url ?? content ?? "").trim();
362
+ if (!trimmedInput) throw new AppError(400, "請輸入網址或貼上想閱讀的內容。");
363
+
364
+ const looksLikeUrl = Boolean(url) || (!content && /^https?:\/\/\S+$/i.test(trimmedInput));
365
+ if (looksLikeUrl) {
366
+ const targetUrl = validatePublicUrl(url || trimmedInput);
367
+ if (!forceRefetch) {
368
+ const existing = store.findByUrl(targetUrl.href);
369
+ if (existing) {
370
+ res.status(200).json({ page: existing, reused: true });
371
+ return;
372
+ }
373
+ }
374
+ const controller = new AbortController();
375
+ const onClose = () => {
376
+ if (!res.writableEnded) controller.abort();
377
+ };
378
+ res.on("close", onClose);
379
+ let fetched: Awaited<ReturnType<typeof htmlFetcher>>;
380
+ try {
381
+ fetched = await htmlFetcher(targetUrl.href, controller.signal);
382
+ } finally {
383
+ res.off("close", onClose);
384
+ }
385
+ if (!forceRefetch) {
386
+ const existingFinal = store.findByUrl(fetched.finalUrl.href);
387
+ if (existingFinal) {
388
+ res.status(200).json({ page: existingFinal, reused: true });
389
+ return;
390
+ }
391
+ }
392
+ const extracted = extractBlocksFromHtml(fetched.html, fetched.finalUrl, fetched.contentType);
393
+ const saved = store.createPage({
394
+ sourceKind: "url",
395
+ url: fetched.finalUrl.href,
396
+ siteName: extracted.siteName,
397
+ title: title?.trim() || extracted.title,
398
+ excerpt: extracted.excerpt,
399
+ blocks: extracted.blocks,
400
+ folderId: folderId ?? null,
401
+ });
402
+ res.status(201).json({ page: saved, reused: false });
403
+ return;
404
+ }
405
+
406
+ const extracted = parsePastedContent(content || trimmedInput, title);
407
+ const saved = store.createPage({
408
+ sourceKind: "paste",
409
+ title: extracted.title,
410
+ excerpt: extracted.excerpt,
411
+ blocks: extracted.blocks,
412
+ folderId: folderId ?? null,
413
+ });
414
+ res.status(201).json({ page: saved, reused: false });
415
+ });
416
+
417
+ app.patch("/api/pages/:id", (req, res) => {
418
+ const parsed = updatePageSchema.safeParse(req.body);
419
+ if (!parsed.success) throw new AppError(400, "更新欄位格式不正確。");
420
+ res.json(store.updatePageMeta(req.params.id, parsed.data));
421
+ });
422
+
423
+ app.delete("/api/pages/:id", (req, res) => {
424
+ store.deletePage(req.params.id);
425
+ res.json({ ok: true });
426
+ });
427
+
428
+ // ── AI Doodle Markup & Manual Annotations ────────────────────────────────
429
+ app.post("/api/pages/:id/annotate", async (req, res) => {
430
+ const page = store.getPage(req.params.id);
431
+ if (!page) throw new AppError(404, "找不到此頁面。");
432
+
433
+ const parsedBody = annotateRequestSchema.safeParse(req.body);
434
+ if (!parsedBody.success) throw new AppError(400, "標註密度參數格式不正確。");
435
+
436
+ const baseSettings = store.getSettings();
437
+ const settings = {
438
+ ...baseSettings,
439
+ annotationDensity: parsedBody.data?.density ?? baseSettings.annotationDensity ?? "balanced",
440
+ };
441
+ const folders = store.listFolders();
442
+ const controller = new AbortController();
443
+ const onClose = () => {
444
+ if (!res.writableEnded) controller.abort();
445
+ };
446
+ res.on("close", onClose);
447
+
448
+ const wantsStream = String(req.headers.accept ?? "").includes("application/x-ndjson");
449
+ const stages = [
450
+ "正在拆解頁面段落與圖表內容…",
451
+ "正在以螢光筆與手繪線條標記重點文句與圖形座標…",
452
+ "正在撰寫頁邊手寫筆記並分類主題…",
453
+ "正在比對 BELL Explain 知識庫連結…",
454
+ ];
455
+ let stageIdx = 0;
456
+ let stageTimer: NodeJS.Timeout | undefined;
457
+
458
+ const sendEvent = (payload: unknown) => {
459
+ if (!res.destroyed && !res.writableEnded) {
460
+ res.write(`${JSON.stringify(payload)}\n`);
461
+ }
462
+ };
463
+
464
+ if (wantsStream) {
465
+ res.status(200);
466
+ res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8");
467
+ res.setHeader("X-Accel-Buffering", "no");
468
+ res.flushHeaders();
469
+ sendEvent({ type: "stage", message: stages[0] });
470
+ stageTimer = setInterval(() => {
471
+ stageIdx = Math.min(stageIdx + 1, stages.length - 1);
472
+ sendEvent({ type: "stage", message: stages[stageIdx] });
473
+ }, 3800);
474
+ }
475
+
476
+ try {
477
+ const aiResult = await annotatePageWithAI(
478
+ page,
479
+ folders,
480
+ settings,
481
+ controller.signal,
482
+ aiRunner,
483
+ );
484
+
485
+ let assignedFolderId = page.folderId;
486
+ if (assignedFolderId === null && settings.autoFolder && aiResult.suggestedFolder) {
487
+ const folder = store.createFolder(aiResult.suggestedFolder);
488
+ assignedFolderId = folder.id;
489
+ }
490
+
491
+ const updated = store.replaceAiAnnotations(page.id, {
492
+ topic: aiResult.topic,
493
+ tags: aiResult.tags,
494
+ summary: aiResult.summary,
495
+ folderId: assignedFolderId,
496
+ annotations: aiResult.annotations,
497
+ });
498
+
499
+ const explain = await explainBridge.matchForPage(updated);
500
+ const result = { page: updated, folders: store.listFolders(), explain };
501
+ if (wantsStream) {
502
+ sendEvent({ type: "complete", ...result });
503
+ res.end();
504
+ } else {
505
+ res.json(result);
506
+ }
507
+ } catch (err) {
508
+ if (!wantsStream) throw err;
509
+ if (!controller.signal.aborted) {
510
+ const { message } = publicError(err);
511
+ sendEvent({ type: "error", message });
512
+ }
513
+ if (!res.writableEnded) res.end();
514
+ } finally {
515
+ if (stageTimer) clearInterval(stageTimer);
516
+ res.off("close", onClose);
517
+ }
518
+ });
519
+
520
+ app.post("/api/pages/:id/annotate-selection", async (req, res) => {
521
+ const page = store.getPage(req.params.id);
522
+ if (!page) throw new AppError(404, "找不到此頁面。");
523
+ const parsed = aiSelectionSchema.safeParse(req.body);
524
+ if (!parsed.success) throw new AppError(400, "請選取一段有效的文字。");
525
+
526
+ const settings = store.getSettings();
527
+ const controller = new AbortController();
528
+ const onClose = () => {
529
+ if (!res.writableEnded) controller.abort();
530
+ };
531
+ res.on("close", onClose);
532
+
533
+ try {
534
+ const item = await annotateSelectionWithAI(
535
+ page,
536
+ parsed.data,
537
+ settings,
538
+ controller.signal,
539
+ aiRunner,
540
+ );
541
+ const created = store.addAnnotation(page.id, item);
542
+ res.status(201).json({ annotation: created, page: store.getPage(page.id)! });
543
+ } finally {
544
+ res.off("close", onClose);
545
+ }
546
+ });
547
+
548
+ app.post("/api/pages/:id/translate", async (req, res) => {
549
+ const page = store.getPage(req.params.id);
550
+ if (!page) throw new AppError(404, "找不到此頁面。");
551
+
552
+ const settings = store.getSettings();
553
+ const controller = new AbortController();
554
+ const onClose = () => {
555
+ if (!res.writableEnded) controller.abort();
556
+ };
557
+ res.on("close", onClose);
558
+
559
+ try {
560
+ const translated = await translatePageWithAI(page, settings, controller.signal, aiRunner);
561
+ const updated = store.saveTranslation(page.id, translated);
562
+ res.json({ page: updated });
563
+ } finally {
564
+ res.off("close", onClose);
565
+ }
566
+ });
567
+
568
+ app.post("/api/pages/:id/annotations", (req, res) => {
569
+ const page = store.getPage(req.params.id);
570
+ if (!page) throw new AppError(404, "找不到此頁面。");
571
+ const parsed = manualAnnotationSchema.safeParse(req.body);
572
+ if (!parsed.success) throw new AppError(400, "批註內容格式不正確。");
573
+
574
+ const block = page.blocks.find((b) => b.id === parsed.data.blockId);
575
+ if (!block) throw new AppError(400, "找不到對應段落。");
576
+ const isImage = block.kind === "image";
577
+ const anchor = isImage
578
+ ? {
579
+ exactQuote: parsed.data.quote || block.alt || block.text || "圖片註記",
580
+ startOffset: 0,
581
+ endOffset: block.text.length,
582
+ }
583
+ : resolveQuoteAnchor(block.text, parsed.data.quote);
584
+ if (!anchor) throw new AppError(400, "選取文字無法對齊段落內容。");
585
+
586
+ const created = store.addAnnotation(page.id, {
587
+ blockId: block.id,
588
+ quote: anchor.exactQuote,
589
+ startOffset: anchor.startOffset,
590
+ endOffset: anchor.endOffset,
591
+ style: parsed.data.style,
592
+ color: parsed.data.color,
593
+ ...(parsed.data.label ? { label: parsed.data.label } : {}),
594
+ note: parsed.data.note,
595
+ ...(parsed.data.topic ? { topic: parsed.data.topic } : {}),
596
+ ...(isImage && parsed.data.anchorX !== undefined && parsed.data.anchorY !== undefined
597
+ ? { anchorX: parsed.data.anchorX, anchorY: parsed.data.anchorY }
598
+ : {}),
599
+ ...(parsed.data.sketch ? { sketch: parsed.data.sketch } : {}),
600
+ source: "user",
601
+ });
602
+ res.status(201).json({ annotation: created, page: store.getPage(page.id)! });
603
+ });
604
+
605
+ app.delete("/api/pages/:id/annotations/:annotationId", (req, res) => {
606
+ store.deleteAnnotation(req.params.id, req.params.annotationId);
607
+ res.json({ ok: true, page: store.getPage(req.params.id) });
608
+ });
609
+
610
+ // ── BELL Explain Topic Connection ────────────────────────────────────────
611
+ app.get("/api/pages/:id/explain-links", async (req, res) => {
612
+ const page = store.getPage(req.params.id);
613
+ if (!page) throw new AppError(404, "找不到此頁面。");
614
+ const refresh = req.query.refresh === "1";
615
+ res.json(await explainBridge.matchForPage(page, refresh));
616
+ });
617
+
618
+ app.get("/api/explain/topics", async (req, res) => {
619
+ const refresh = req.query.refresh === "1";
620
+ res.json(await explainBridge.listAllTopics(refresh));
621
+ });
622
+
623
+ app.use("/api", (_req, res) => {
624
+ res.status(404).json({ error: "找不到此 API 路徑。" });
625
+ });
626
+
627
+ if (existsSync(distDir)) {
628
+ app.use(express.static(distDir));
629
+ app.get("/{*path}", (_req, res) => {
630
+ res.sendFile(resolve(distDir, "index.html"));
631
+ });
632
+ }
633
+
634
+ app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
635
+ if (err instanceof SyntaxError && "body" in err) {
636
+ res.status(400).json({ error: "JSON 格式不正確。" });
637
+ return;
638
+ }
639
+ const { status, message } = publicError(err);
640
+ res.status(status).json({ error: message });
641
+ });
642
+
643
+ return app;
644
+ }