dsh-plugin-lookatstudy 0.21.0 → 0.22.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/lib/index.mjs CHANGED
@@ -95,6 +95,12 @@ const ZH = {
95
95
  "artifact.guess.title": "猜一猜",
96
96
  "artifact.guess.pick": "我选「{label}」",
97
97
  "artifact.guess.wait": "已记下你的直觉——导师下回合揭晓,看看你想得对不对",
98
+ "artifact.guess.revealed": "已揭晓——结论见导师的下一条回复",
99
+ "artifact.fold": "收起卡片(笔记区仍保留)",
100
+ "artifact.unfold": "展开卡片",
101
+ "artifact.kind.compare_table": "对照表",
102
+ "artifact.kind.code_walkthrough": "代码走查",
103
+ "artifact.kind.diagram": "示意图",
98
104
  "artifact.sedimented": "有新的学习产物沉淀进笔记",
99
105
  "zone.artifacts": "AI 产物",
100
106
  "note.quote.ask": "提问这段",
@@ -214,6 +220,16 @@ const ZH = {
214
220
  "palette.empty": "没有匹配项",
215
221
  "threads.open": "{n} 条对话",
216
222
  "threads.now": "当前",
223
+ "threads.new": "新建对话(下一条消息开新线)",
224
+ "threads.actions": "线程操作",
225
+ "rail.export.pack": "导出课程包(可再导入/分享的 Markdown)",
226
+ "rail.export.done": "课程包已下载:{file}",
227
+ "threads.rename": "重命名",
228
+ "threads.archive": "归档",
229
+ "threads.delete": "删除",
230
+ "threads.delete.confirm": "删除「{name}」这条对话线?插件侧记录会移除;宿主会话列表中的原会话保留。",
231
+ "review.banner": "正在复习:{lesson}",
232
+ "review.threadTitle": "复习:{lesson}",
217
233
  "threads.label": "课时线程",
218
234
  "meter.label": "上下文用量",
219
235
  "meter.aria": "上下文已用 {pct}%",
@@ -229,6 +245,7 @@ const ZH = {
229
245
  "settings.budget.on": "开启历史预算",
230
246
  "settings.budget.off": "关闭历史预算",
231
247
  "attach.sent": "我发了一个文件:{name}(工作区路径 {path})——请读取它再继续。",
248
+ "attach.remove": "移除待发附件",
232
249
  "attach.fail": "附件上传失败",
233
250
  "attach.label": "附件(≤20MiB,存入学习工作区)",
234
251
  "rail.mastered": "毕业 {mastered}/{total} 课",
@@ -441,6 +458,12 @@ const EN = {
441
458
  "artifact.guess.title": "Take a guess",
442
459
  "artifact.guess.pick": "I pick \"{label}\"",
443
460
  "artifact.guess.wait": "Your instinct is noted — the tutor reveals next turn",
461
+ "artifact.guess.revealed": "Revealed — see the tutor reply below for the answer",
462
+ "artifact.fold": "Fold card (kept in the notes zone)",
463
+ "artifact.unfold": "Unfold card",
464
+ "artifact.kind.compare_table": "Compare table",
465
+ "artifact.kind.code_walkthrough": "Code walkthrough",
466
+ "artifact.kind.diagram": "Diagram",
444
467
  "artifact.sedimented": "A new artifact settled into the notebook",
445
468
  "zone.artifacts": "AI artifacts",
446
469
  "note.quote.ask": "Ask about this",
@@ -561,6 +584,16 @@ const EN = {
561
584
  "threads.label": "Lesson threads",
562
585
  "threads.open": "{n} threads",
563
586
  "threads.now": "current",
587
+ "threads.new": "New thread (your next message starts it)",
588
+ "threads.actions": "Thread actions",
589
+ "rail.export.pack": "Export course pack (re-importable, shareable Markdown)",
590
+ "rail.export.done": "Course pack downloaded: {file}",
591
+ "threads.rename": "Rename",
592
+ "threads.archive": "Archive",
593
+ "threads.delete": "Delete",
594
+ "threads.delete.confirm": "Delete the thread \"{name}\"? The plugin-side record is removed; the original session stays in the host session list.",
595
+ "review.banner": "Reviewing: {lesson}",
596
+ "review.threadTitle": "Review: {lesson}",
564
597
  "meter.label": "Context usage",
565
598
  "meter.aria": "{pct}% of context used",
566
599
  "meter.brk.system": "System prompt",
@@ -575,6 +608,7 @@ const EN = {
575
608
  "settings.budget.on": "Enable history budget",
576
609
  "settings.budget.off": "Disable history budget",
577
610
  "attach.sent": "I attached a file: {name} (workspace path {path}) — read it before continuing.",
611
+ "attach.remove": "Remove pending attachment",
578
612
  "attach.fail": "Attachment upload failed",
579
613
  "attach.label": "Attach (≤20MiB, lands in the study workspace)",
580
614
  "rail.mastered": "{mastered}/{total} lessons graduated",
@@ -778,10 +812,13 @@ function escapeHtml(text) {
778
812
  }
779
813
  /** Render inline markup (code, bold, italic, links, images) over escaped text.
780
814
  * Image src allowlist: https?:// or data:image/ — folder imports inline local
781
- * images as data URLs (capped), GitHub imports reference jsDelivr; anything
782
- * else stays literal text (no broken relative images, no exotic schemes). */
815
+ * images as data URLs (capped), GitHub imports reference jsDelivr; the
816
+ * plugin's own attachment route (/lookatstudy/api/attachment/<name>, issue #7
817
+ * — same-origin, server-side validated) renders workspace attachments;
818
+ * anything else stays literal text (no broken relative images, no exotic
819
+ * schemes). */
783
820
  function inline(escaped) {
784
- return escaped.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/!\[([^\]]*)\]\((https?:\/\/[^)\s]+|data:image\/[^)\s]+)\)/g, (_m, alt, src) => `<img src="${src}" alt="${alt}" loading="lazy">`).replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<a href=\"$2\" target=\"_blank\" rel=\"noreferrer\">$1</a>");
821
+ return escaped.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/!\[([^\]]*)\]\((https?:\/\/[^)\s]+|data:image\/[^)\s]+|\/lookatstudy\/api\/attachment\/[^)\s]+)\)/g, (_m, alt, src) => `<img src="${src}" alt="${alt}" loading="lazy">`).replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<a href=\"$2\" target=\"_blank\" rel=\"noreferrer\">$1</a>");
785
822
  }
786
823
  /** Interleave one body with its translation, paragraph by paragraph (upstream
787
824
  * translation-layout "microsoft" semantics: original paragraph then its
@@ -918,6 +955,135 @@ function renderMarkdown(md) {
918
955
  return out.join("\n");
919
956
  }
920
957
  //#endregion
958
+ //#region src/vendor/markdown-course.ts
959
+ /**
960
+ * GitHub 风格的 anchor 生成:小写、去一组标点(保留中文等 unicode)、每个空格单独转 -。
961
+ * 注意:**不合并多 -**("A & B" → 去 & 留两空格 → "a--b",与 GitHub slugger 一致)。
962
+ * 与 seed.ts 的锚点对齐。
963
+ */
964
+ function titleToAnchor(title) {
965
+ return title.toLowerCase().trim().replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, "").replace(/ /g, "-").replace(/^-|-$/g, "");
966
+ }
967
+ /**
968
+ * 清洗课时/章节标题 — 去 emoji、多余空格、markdown 格式符号。
969
+ * "🛠 The Modern FDE Stack" → "The Modern FDE Stack"
970
+ * "## [Pre-lecture quiz](url)" → "Pre-lecture quiz"
971
+ */
972
+ function cleanTitle(raw) {
973
+ return raw.replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}]/gu, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/^#+\s*/, "").trim().replace(/\s+/g, " ").replace(/^[·\-.\s]+|[·\-.\s]+$/g, "").trim();
974
+ }
975
+ /**
976
+ * 解析 markdown 为课程树。
977
+ * 容错:H3 出现在任何 H2 之前 → 归到一个 "(前言)" section。
978
+ */
979
+ function parseMarkdownToCourse(md) {
980
+ const lines = md.split(/\r?\n/);
981
+ const sections = [];
982
+ let title = "(untitled)";
983
+ let currentSection = null;
984
+ let bodyBuffer = [];
985
+ let inCodeFence = false;
986
+ const flushLessonBody = () => {
987
+ if (currentSection && currentSection.lessons.length > 0) currentSection.lessons[currentSection.lessons.length - 1].body = bodyBuffer.join("\n").trim();
988
+ bodyBuffer = [];
989
+ };
990
+ for (const line of lines) {
991
+ if (/^(\s*)(```|~~~)/.test(line)) {
992
+ inCodeFence = !inCodeFence;
993
+ bodyBuffer.push(line);
994
+ continue;
995
+ }
996
+ if (inCodeFence) {
997
+ bodyBuffer.push(line);
998
+ continue;
999
+ }
1000
+ if (/^#\s+/.test(line) && title === "(untitled)") {
1001
+ title = cleanTitle(line.replace(/^#\s+/, "").trim());
1002
+ continue;
1003
+ }
1004
+ if (/^##\s+/.test(line)) {
1005
+ flushLessonBody();
1006
+ const sectionTitle = cleanTitle(line.replace(/^##\s+/, "").trim());
1007
+ currentSection = {
1008
+ title: sectionTitle,
1009
+ anchor: titleToAnchor(sectionTitle),
1010
+ lessons: []
1011
+ };
1012
+ sections.push(currentSection);
1013
+ continue;
1014
+ }
1015
+ if (/^###\s+/.test(line)) {
1016
+ flushLessonBody();
1017
+ const lessonTitle = cleanTitle(line.replace(/^###\s+/, "").trim());
1018
+ if (!currentSection) {
1019
+ currentSection = {
1020
+ title: "(前言)",
1021
+ anchor: titleToAnchor("前言"),
1022
+ lessons: []
1023
+ };
1024
+ sections.push(currentSection);
1025
+ }
1026
+ currentSection.lessons.push({
1027
+ title: lessonTitle,
1028
+ anchor: titleToAnchor(lessonTitle),
1029
+ body: ""
1030
+ });
1031
+ continue;
1032
+ }
1033
+ bodyBuffer.push(line);
1034
+ }
1035
+ flushLessonBody();
1036
+ return {
1037
+ title,
1038
+ sections
1039
+ };
1040
+ }
1041
+ //#endregion
1042
+ //#region src/export-pack.ts
1043
+ /** Find the course for export; null when the id is unknown. */
1044
+ function findCourseForPack(state, courseId) {
1045
+ return state.courses.find((c) => c.id === courseId) ?? null;
1046
+ }
1047
+ /** The machine-readable pack: everything needed to restore the course. */
1048
+ function coursePackOf(course, now = /* @__PURE__ */ new Date()) {
1049
+ return {
1050
+ kind: "lookatstudy-course-pack",
1051
+ version: 1,
1052
+ exportedAt: now.toISOString(),
1053
+ course: {
1054
+ title: course.title,
1055
+ languageTarget: course.languageTarget ?? null,
1056
+ sections: course.sections.map((s) => ({
1057
+ title: s.title,
1058
+ lessons: s.lessons.filter((l) => l.kind !== "exam").map((l) => ({
1059
+ title: l.title,
1060
+ kind: l.kind,
1061
+ body: l.body
1062
+ }))
1063
+ }))
1064
+ }
1065
+ };
1066
+ }
1067
+ /** The markdown rendering — the zero-LLM parser's own format. */
1068
+ function coursePackMarkdown(course) {
1069
+ const out = [`# ${course.title}`, ""];
1070
+ for (const section of course.sections) {
1071
+ out.push(`## ${section.title}`, "");
1072
+ for (const lesson of section.lessons) {
1073
+ if (lesson.kind === "exam") continue;
1074
+ out.push(`### ${lesson.title}`, "");
1075
+ const body = lesson.body.replace(/\s+$/, "");
1076
+ if (body !== "") out.push(body, "");
1077
+ }
1078
+ }
1079
+ return `${out.join("\n")}\n`;
1080
+ }
1081
+ /** Upstream's naming: sanitized title, `.lookatstudy-pack.md` suffix. */
1082
+ function packFileName(course) {
1083
+ const base = course.title.replace(/[/\\:*?"<>|#\s]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
1084
+ return `${base !== "" ? base : "course"}.lookatstudy-pack.md`;
1085
+ }
1086
+ //#endregion
921
1087
  //#region src/vendor/math-normalize.ts
922
1088
  /**
923
1089
  * math-normalize —— 数学记法归一(v0.19 数学渲染前置,纯函数)。
@@ -1350,6 +1516,7 @@ function emptyState() {
1350
1516
  artifacts: {},
1351
1517
  proposals: [],
1352
1518
  lessonSessions: {},
1519
+ lessonThreads: {},
1353
1520
  lastConsolidatedAt: null,
1354
1521
  xp: {
1355
1522
  total: 0,
@@ -1423,6 +1590,35 @@ function loadState(path) {
1423
1590
  memoryPatterns: raw.memoryPatterns ?? {},
1424
1591
  proposals: raw.proposals ?? [],
1425
1592
  lessonSessions: raw.lessonSessions ?? {},
1593
+ lessonThreads: (() => {
1594
+ const groups = {};
1595
+ for (const [lessonId, group] of Object.entries(raw.lessonThreads ?? {})) if (group !== null && typeof group === "object" && Array.isArray(group.threads)) groups[lessonId] = {
1596
+ active: typeof group.active === "string" ? group.active : null,
1597
+ threads: group.threads
1598
+ };
1599
+ for (const [lessonId, sessionId] of Object.entries(raw.lessonSessions ?? {})) {
1600
+ if (typeof sessionId !== "string" || groups[lessonId] !== void 0) continue;
1601
+ let title = lessonId;
1602
+ for (const course of courses) for (const section of course.sections) {
1603
+ const hit = section.lessons.find((l) => l.id === lessonId);
1604
+ if (hit !== void 0) {
1605
+ title = hit.title;
1606
+ break;
1607
+ }
1608
+ }
1609
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1610
+ groups[lessonId] = {
1611
+ active: sessionId,
1612
+ threads: [{
1613
+ id: sessionId,
1614
+ title,
1615
+ createdAt: now,
1616
+ lastAt: now
1617
+ }]
1618
+ };
1619
+ }
1620
+ return groups;
1621
+ })(),
1426
1622
  artifacts: raw.artifacts ?? {},
1427
1623
  lastConsolidatedAt: raw.lastConsolidatedAt ?? null,
1428
1624
  xp: raw.xp ?? {
@@ -1496,9 +1692,11 @@ function freshLesson(title, anchor, body, kind = "study") {
1496
1692
  * @returns the imported (or pre-existing) course.
1497
1693
  */
1498
1694
  function importCourse(state, parsed, source, sourceRef, languageTarget) {
1499
- const id = slugify(parsed.title);
1500
- const existing = state.courses.find((c) => c.id === id);
1501
- if (existing) return existing;
1695
+ const base = slugify(parsed.title);
1696
+ const sameTitle = state.courses.find((c) => c.id === base && c.title === parsed.title);
1697
+ if (sameTitle !== void 0) return sameTitle;
1698
+ let id = base;
1699
+ for (let n = 2; state.courses.some((c) => c.id === id); n++) id = `${base}-${String(n)}`;
1502
1700
  const course = {
1503
1701
  id,
1504
1702
  title: parsed.title,
@@ -1956,6 +2154,96 @@ function recordAnswer(state, lessonId, correct, concept, now) {
1956
2154
  * @param now - current time.
1957
2155
  * @returns completion result including the unlocked lessons.
1958
2156
  */
2157
+ /**
2158
+ * Bind a dsh session into a lesson's thread group (the issue-#11 write, the
2159
+ * upstream sendMessage/ensureThreadForSend translation): an UNKNOWN session id
2160
+ * appends a new thread (titled by the caller — the first message's text or a
2161
+ * short action label) and becomes active; a KNOWN id re-activates it (bumping
2162
+ * lastAt); `null` clears the active pointer (the +新建 affordance — the next
2163
+ * send mints a fresh thread). The legacy lessonSessions map stays in lockstep
2164
+ * with the active pointer so pre-0.22 readers never disagree.
2165
+ */
2166
+ function bindLessonThread(state, lessonId, sessionId, title) {
2167
+ state.lessonThreads ??= {};
2168
+ const group = state.lessonThreads[lessonId] ?? {
2169
+ active: null,
2170
+ threads: []
2171
+ };
2172
+ if (sessionId === null) {
2173
+ group.active = null;
2174
+ state.lessonThreads[lessonId] = group;
2175
+ delete state.lessonSessions[lessonId];
2176
+ return group;
2177
+ }
2178
+ const existing = group.threads.find((t) => t.id === sessionId);
2179
+ if (existing === void 0) {
2180
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2181
+ group.threads.push({
2182
+ id: sessionId,
2183
+ title: (title ?? "").trim() !== "" ? title.trim() : lessonId,
2184
+ createdAt: now,
2185
+ lastAt: now
2186
+ });
2187
+ } else {
2188
+ existing.lastAt = (/* @__PURE__ */ new Date()).toISOString();
2189
+ if (existing.status === "archived") existing.status = "active";
2190
+ }
2191
+ group.active = sessionId;
2192
+ state.lessonThreads[lessonId] = group;
2193
+ state.lessonSessions[lessonId] = sessionId;
2194
+ return group;
2195
+ }
2196
+ /** Roll the group's active pointer to the freshest ACTIVE thread (or null)
2197
+ * and keep the legacy lessonSessions mirror in lockstep. Shared by the
2198
+ * archive/delete operations when they take out the current thread. */
2199
+ function rollActiveToFresh(state, lessonId, group) {
2200
+ const live = group.threads.filter((t) => t.status !== "archived");
2201
+ group.active = live.length > 0 ? live.reduce((a, b) => a.lastAt > b.lastAt ? a : b).id : null;
2202
+ if (group.active !== null) state.lessonSessions[lessonId] = group.active;
2203
+ else delete state.lessonSessions[lessonId];
2204
+ }
2205
+ /** Rename a thread's stored title (issue #11 management: upstream gear menu).
2206
+ * The dsh session's own title is renamed host-side by the client (best
2207
+ * effort) — this is the durable plugin-side half. Throws on unknown ids. */
2208
+ function renameLessonThread(state, lessonId, sessionId, title) {
2209
+ const group = state.lessonThreads?.[lessonId];
2210
+ if (group === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread group`);
2211
+ const thread = group.threads.find((t) => t.id === sessionId);
2212
+ if (thread === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread ${JSON.stringify(sessionId)}`);
2213
+ const clean = title.trim();
2214
+ if (clean !== "") thread.title = clean;
2215
+ return group;
2216
+ }
2217
+ /** Archive (or restore) a thread. Archiving the current thread rolls the
2218
+ * active pointer to the freshest remaining live thread — upstream's archived
2219
+ * threads leave the switcher list entirely (no unarchive entry there; the
2220
+ * state API keeps `archived: false` for symmetry/future UI). */
2221
+ function archiveLessonThread(state, lessonId, sessionId, archived) {
2222
+ const group = state.lessonThreads?.[lessonId];
2223
+ if (group === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread group`);
2224
+ const thread = group.threads.find((t) => t.id === sessionId);
2225
+ if (thread === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread ${JSON.stringify(sessionId)}`);
2226
+ thread.status = archived ? "archived" : "active";
2227
+ if (archived && group.active === sessionId) rollActiveToFresh(state, lessonId, group);
2228
+ if (!archived && group.active === null) {
2229
+ group.active = sessionId;
2230
+ state.lessonSessions[lessonId] = sessionId;
2231
+ }
2232
+ return group;
2233
+ }
2234
+ /** Remove a thread from its group (sediment discipline: the OTHER threads and
2235
+ * the group itself survive). The dsh session itself cannot be deleted through
2236
+ * the host session face (no delete primitive) — it stays in the host's session
2237
+ * list; the UI discloses this. Throws on unknown ids. */
2238
+ function deleteLessonThread(state, lessonId, sessionId) {
2239
+ const group = state.lessonThreads?.[lessonId];
2240
+ if (group === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread group`);
2241
+ const idx = group.threads.findIndex((t) => t.id === sessionId);
2242
+ if (idx < 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread ${JSON.stringify(sessionId)}`);
2243
+ group.threads.splice(idx, 1);
2244
+ if (group.active === sessionId) rollActiveToFresh(state, lessonId, group);
2245
+ return group;
2246
+ }
1959
2247
  function completeLesson(state, lessonId, now) {
1960
2248
  const ref = findLesson(state, lessonId);
1961
2249
  if (ref.lesson.status === "locked") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is locked; complete earlier lessons first`);
@@ -2613,6 +2901,7 @@ function workbenchState(state, now) {
2613
2901
  };
2614
2902
  })(),
2615
2903
  lessonSessions: state.lessonSessions,
2904
+ lessonThreads: state.lessonThreads ?? {},
2616
2905
  progress: (() => {
2617
2906
  const xp = levelFromTotalXp(state.xp.total);
2618
2907
  return {
@@ -3075,18 +3364,84 @@ function registerDashboard(webServer, deps) {
3075
3364
  if (req.method === "POST" && pathname === "/lookatstudy/api/lesson-session") {
3076
3365
  const body = await readJsonBodySafe(req, res);
3077
3366
  if (body === void 0) return;
3078
- if (typeof body.lessonId !== "string" || typeof body.sessionId !== "string") {
3367
+ if (typeof body.lessonId !== "string" || body.sessionId !== null && typeof body.sessionId !== "string") {
3079
3368
  sendJson(res, 400, {
3080
3369
  ok: false,
3081
- error: "lessonId and sessionId (strings) required"
3370
+ error: "lessonId and sessionId (string or null) required"
3082
3371
  });
3083
3372
  return;
3084
3373
  }
3085
- deps.store.get().lessonSessions[body.lessonId] = body.sessionId;
3374
+ const title = typeof body.title === "string" ? body.title : null;
3375
+ bindLessonThread(deps.store.get(), body.lessonId, body.sessionId, title);
3086
3376
  deps.store.save();
3087
3377
  sendJson(res, 200, { ok: true });
3088
3378
  return;
3089
3379
  }
3380
+ if (req.method === "POST" && pathname === "/lookatstudy/api/lesson-thread") {
3381
+ const body = await readJsonBodySafe(req, res);
3382
+ if (body === void 0) return;
3383
+ if (typeof body.lessonId !== "string" || typeof body.sessionId !== "string" || body.op !== "rename" && body.op !== "archive" && body.op !== "delete") {
3384
+ sendJson(res, 400, {
3385
+ ok: false,
3386
+ error: "lessonId, sessionId and op (rename|archive|delete) required"
3387
+ });
3388
+ return;
3389
+ }
3390
+ const state = deps.store.get();
3391
+ try {
3392
+ if (body.op === "rename") {
3393
+ if (typeof body.title !== "string" || body.title.trim() === "") {
3394
+ sendJson(res, 400, {
3395
+ ok: false,
3396
+ error: "rename requires a non-empty title"
3397
+ });
3398
+ return;
3399
+ }
3400
+ renameLessonThread(state, body.lessonId, body.sessionId, body.title);
3401
+ } else if (body.op === "archive") {
3402
+ if (typeof body.archived !== "boolean") {
3403
+ sendJson(res, 400, {
3404
+ ok: false,
3405
+ error: "archive requires a boolean archived"
3406
+ });
3407
+ return;
3408
+ }
3409
+ archiveLessonThread(state, body.lessonId, body.sessionId, body.archived);
3410
+ } else deleteLessonThread(state, body.lessonId, body.sessionId);
3411
+ } catch (error) {
3412
+ sendJson(res, 404, {
3413
+ ok: false,
3414
+ error: error instanceof Error ? error.message : String(error)
3415
+ });
3416
+ return;
3417
+ }
3418
+ deps.store.save();
3419
+ const group = state.lessonThreads[body.lessonId];
3420
+ sendJson(res, 200, {
3421
+ ok: true,
3422
+ active: group?.active ?? null,
3423
+ threads: group?.threads.length ?? 0
3424
+ });
3425
+ return;
3426
+ }
3427
+ if (req.method === "GET" && pathname === "/lookatstudy/api/course-pack") {
3428
+ const courseId = new URL(req.url ?? "/", "http://x").searchParams.get("courseId") ?? "";
3429
+ const course = findCourseForPack(deps.store.get(), courseId);
3430
+ if (course === null) {
3431
+ sendJson(res, 404, {
3432
+ ok: false,
3433
+ error: `unknown courseId: ${JSON.stringify(courseId)}`
3434
+ });
3435
+ return;
3436
+ }
3437
+ sendJson(res, 200, {
3438
+ ok: true,
3439
+ fileName: packFileName(course),
3440
+ markdown: coursePackMarkdown(course),
3441
+ pack: coursePackOf(course)
3442
+ });
3443
+ return;
3444
+ }
3090
3445
  if (req.method === "POST" && pathname === "/lookatstudy/api/tts") {
3091
3446
  const body = await readJsonBodySafe(req, res);
3092
3447
  if (body === void 0) return;
@@ -3188,6 +3543,36 @@ function registerDashboard(webServer, deps) {
3188
3543
  }
3189
3544
  return;
3190
3545
  }
3546
+ if (req.method === "GET" && pathname.startsWith("/lookatstudy/api/attachment/")) {
3547
+ let name = "";
3548
+ try {
3549
+ name = decodeURIComponent(pathname.slice(28));
3550
+ } catch {
3551
+ name = "";
3552
+ }
3553
+ if (name === "" || name.includes("..") || /[\\/]/.test(name) || name.length > 250) {
3554
+ sendJson(res, 404, {
3555
+ ok: false,
3556
+ error: "not found"
3557
+ });
3558
+ return;
3559
+ }
3560
+ try {
3561
+ const buf = readFileSync(join(deps.studyAreaPath, "attachments", name));
3562
+ const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
3563
+ const mime = ext === "png" ? "image/png" : ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "gif" ? "image/gif" : ext === "webp" ? "image/webp" : ext === "bmp" ? "image/bmp" : "application/octet-stream";
3564
+ res.writeHead(200, {
3565
+ "content-type": mime,
3566
+ "cache-control": "public, max-age=31536000, immutable"
3567
+ }).end(buf);
3568
+ } catch {
3569
+ sendJson(res, 404, {
3570
+ ok: false,
3571
+ error: "not found"
3572
+ });
3573
+ }
3574
+ return;
3575
+ }
3191
3576
  sendJson(res, 404, {
3192
3577
  ok: false,
3193
3578
  error: "not found"
@@ -3263,90 +3648,6 @@ function registerStudyCommand(commands, deps) {
3263
3648
  });
3264
3649
  }
3265
3650
  //#endregion
3266
- //#region src/vendor/markdown-course.ts
3267
- /**
3268
- * GitHub 风格的 anchor 生成:小写、去一组标点(保留中文等 unicode)、每个空格单独转 -。
3269
- * 注意:**不合并多 -**("A & B" → 去 & 留两空格 → "a--b",与 GitHub slugger 一致)。
3270
- * 与 seed.ts 的锚点对齐。
3271
- */
3272
- function titleToAnchor(title) {
3273
- return title.toLowerCase().trim().replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, "").replace(/ /g, "-").replace(/^-|-$/g, "");
3274
- }
3275
- /**
3276
- * 清洗课时/章节标题 — 去 emoji、多余空格、markdown 格式符号。
3277
- * "🛠 The Modern FDE Stack" → "The Modern FDE Stack"
3278
- * "## [Pre-lecture quiz](url)" → "Pre-lecture quiz"
3279
- */
3280
- function cleanTitle(raw) {
3281
- return raw.replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}]/gu, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/^#+\s*/, "").trim().replace(/\s+/g, " ").replace(/^[·\-.\s]+|[·\-.\s]+$/g, "").trim();
3282
- }
3283
- /**
3284
- * 解析 markdown 为课程树。
3285
- * 容错:H3 出现在任何 H2 之前 → 归到一个 "(前言)" section。
3286
- */
3287
- function parseMarkdownToCourse(md) {
3288
- const lines = md.split(/\r?\n/);
3289
- const sections = [];
3290
- let title = "(untitled)";
3291
- let currentSection = null;
3292
- let bodyBuffer = [];
3293
- let inCodeFence = false;
3294
- const flushLessonBody = () => {
3295
- if (currentSection && currentSection.lessons.length > 0) currentSection.lessons[currentSection.lessons.length - 1].body = bodyBuffer.join("\n").trim();
3296
- bodyBuffer = [];
3297
- };
3298
- for (const line of lines) {
3299
- if (/^(\s*)(```|~~~)/.test(line)) {
3300
- inCodeFence = !inCodeFence;
3301
- bodyBuffer.push(line);
3302
- continue;
3303
- }
3304
- if (inCodeFence) {
3305
- bodyBuffer.push(line);
3306
- continue;
3307
- }
3308
- if (/^#\s+/.test(line) && title === "(untitled)") {
3309
- title = cleanTitle(line.replace(/^#\s+/, "").trim());
3310
- continue;
3311
- }
3312
- if (/^##\s+/.test(line)) {
3313
- flushLessonBody();
3314
- const sectionTitle = cleanTitle(line.replace(/^##\s+/, "").trim());
3315
- currentSection = {
3316
- title: sectionTitle,
3317
- anchor: titleToAnchor(sectionTitle),
3318
- lessons: []
3319
- };
3320
- sections.push(currentSection);
3321
- continue;
3322
- }
3323
- if (/^###\s+/.test(line)) {
3324
- flushLessonBody();
3325
- const lessonTitle = cleanTitle(line.replace(/^###\s+/, "").trim());
3326
- if (!currentSection) {
3327
- currentSection = {
3328
- title: "(前言)",
3329
- anchor: titleToAnchor("前言"),
3330
- lessons: []
3331
- };
3332
- sections.push(currentSection);
3333
- }
3334
- currentSection.lessons.push({
3335
- title: lessonTitle,
3336
- anchor: titleToAnchor(lessonTitle),
3337
- body: ""
3338
- });
3339
- continue;
3340
- }
3341
- bodyBuffer.push(line);
3342
- }
3343
- flushLessonBody();
3344
- return {
3345
- title,
3346
- sections
3347
- };
3348
- }
3349
- //#endregion
3350
3651
  //#region src/vendor/local-folder-scanner.ts
3351
3652
  /**
3352
3653
  * 本地文件夹通用扫描器 —— 把任意课程文件夹(如 Coursera 下载包)递归扫描成文档清单。
@@ -4805,6 +5106,196 @@ function prepareSingleDoc(name, markdown, stem) {
4805
5106
  return chunkHeadinglessText(md, (stem ?? name.replace(/\s+/g, "-").slice(0, 40)) || "text");
4806
5107
  }
4807
5108
  //#endregion
5109
+ //#region src/vendor/bilibili-wbi.ts
5110
+ /**
5111
+ * B站 Wbi 签名(纯函数)——playurl 等 web 接口免登录访问的前提。
5112
+ * 算法来自社区文档 bilibili-API-collect(docs/misc/sign/wbi.md):
5113
+ * nav 接口取 img_key/sub_key → 重排表拼 mixin_key → 参数排序+过滤+chrFilter
5114
+ * → query + wts → MD5 = w_rid。纯函数,verify 用固定 key 断言 w_rid。
5115
+ */
5116
+ /** 重排映射表(社区文档公开常量,2023 引入后保持稳定;漂移时接口回 -352,live-test 可发现) */
5117
+ const MIXIN_KEY_ENC_TAB = [
5118
+ 46,
5119
+ 47,
5120
+ 18,
5121
+ 2,
5122
+ 53,
5123
+ 8,
5124
+ 23,
5125
+ 32,
5126
+ 15,
5127
+ 50,
5128
+ 10,
5129
+ 31,
5130
+ 58,
5131
+ 3,
5132
+ 45,
5133
+ 35,
5134
+ 27,
5135
+ 43,
5136
+ 5,
5137
+ 49,
5138
+ 33,
5139
+ 9,
5140
+ 42,
5141
+ 19,
5142
+ 29,
5143
+ 28,
5144
+ 14,
5145
+ 39,
5146
+ 12,
5147
+ 38,
5148
+ 41,
5149
+ 13,
5150
+ 37,
5151
+ 48,
5152
+ 7,
5153
+ 16,
5154
+ 24,
5155
+ 55,
5156
+ 40,
5157
+ 61,
5158
+ 26,
5159
+ 17,
5160
+ 0,
5161
+ 1,
5162
+ 60,
5163
+ 51,
5164
+ 30,
5165
+ 4,
5166
+ 22,
5167
+ 25,
5168
+ 54,
5169
+ 21,
5170
+ 56,
5171
+ 59,
5172
+ 6,
5173
+ 63,
5174
+ 57,
5175
+ 62,
5176
+ 11,
5177
+ 36,
5178
+ 20,
5179
+ 34,
5180
+ 44,
5181
+ 52
5182
+ ];
5183
+ function getMixinKey(imgKey, subKey) {
5184
+ const raw = imgKey + subKey;
5185
+ return MIXIN_KEY_ENC_TAB.map((n) => raw[n]).join("").slice(0, 32);
5186
+ }
5187
+ /** 从 nav 接口的 img_url/sub_url 里剥文件名(去扩展名)得 img_key/sub_key。 */
5188
+ function extractKeysFromNavUrl(imgUrl, subUrl) {
5189
+ const stem = (u) => {
5190
+ return ((u.split("?")[0] ?? u).split("/").pop() ?? "").replace(/\.[^.]+$/, "");
5191
+ };
5192
+ return {
5193
+ imgKey: stem(imgUrl),
5194
+ subKey: stem(subUrl)
5195
+ };
5196
+ }
5197
+ const CHR_FILTER = /[!'()*]/g;
5198
+ /** 对查询参数做 Wbi 签名,返回完整 query(含 w_rid/wts)。 */
5199
+ function encWbi(params, mixinKey, nowSec = Math.floor(Date.now() / 1e3)) {
5200
+ const filtered = Object.entries(params).filter(([k]) => k !== "w_rid" && k !== "wts").map(([k, v]) => [k, String(v)]);
5201
+ filtered.push(["wts", String(nowSec)]);
5202
+ filtered.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
5203
+ const query = filtered.map(([k, v]) => {
5204
+ const safeVal = v.replace(CHR_FILTER, "");
5205
+ return `${encodeURIComponent(k)}=${encodeURIComponent(safeVal)}`;
5206
+ }).join("&");
5207
+ return `${query}&w_rid=${createHash("md5").update(query + mixinKey, "utf8").digest("hex")}`;
5208
+ }
5209
+ //#endregion
5210
+ //#region src/vendor/subtitle-parse.ts
5211
+ /**
5212
+ * 字幕文件 → 纯文本(vtt/srt)——视频导入的"字幕优先"路径:有 CC/自动字幕的
5213
+ * 视频直接出文本,零转写零模型。纯函数,verify 直测。
5214
+ *
5215
+ * 处理(全部是**机器生成的刚性格式指纹**,高置信度规则;语义判断不在此层):
5216
+ * - WEBVTT 头/时间轴行/CUE 编号/NOTE 块/样式行剥除;
5217
+ * - 行内标签与打词时间戳剥除(`<c>`、`<00:00:01.359>`);
5218
+ * - 滚动字幕去重:YouTube 自动字幕的滚动窗结构是 cue N+1 的首行复述 cue N 的
5219
+ * 尾行,相邻去重不够(隔一行就重复),须近窗(最近 2 行)去重——2026-08-23
5220
+ * 真实样本驱动修复(经典症状:每句话在成文里出现两遍);
5221
+ * - HTML 实体解码(&amp; 等;字幕 XML 转义源);
5222
+ * - 整行自动标记剥除(封闭集 [Music]/[Applause]… 只删整行,不碰行内——
5223
+ * 行内方括号可能是正文);
5224
+ * - 行首 `>>` 换说话人标记剥除(YouTube 自动字幕机器标记);
5225
+ * - CJK 感知接行:汉字/中文标点之间不加空格(句与句直接相连),拉丁文之间
5226
+ * 加空格——中文字幕逐句 join(" ") 会在全文制造 "汉字 空格 汉字" 噪声。
5227
+ */
5228
+ /** 单条 cue 的时间轴行特征:00:00:01.000 --> 00:00:03.000(毫秒分隔符兼容 , 和 .) */
5229
+ const CUE_TIMING = /^\d{1,2}:\d{2}(?::\d{2})?[.,]\d{3}\s*-->/;
5230
+ /** YouTube 自动字幕的整行机器标记(封闭集,只删整行)。 */
5231
+ const AUTO_MARKERS = /^\[(music|applause|laughter|laughs?|silence|inaudible|cheering|foreign|blank[_\s]?audio|music playing|noise)\]$/i;
5232
+ /** 字幕常见的 XML/HTML 实体(未列出的实体原样保留,不做激进猜测)。 */
5233
+ const ENTITIES = {
5234
+ amp: "&",
5235
+ lt: "<",
5236
+ gt: ">",
5237
+ quot: "\"",
5238
+ apos: "'",
5239
+ nbsp: " "
5240
+ };
5241
+ function decodeEntities(s) {
5242
+ return s.replace(/&([a-z]+);/gi, (m, name) => ENTITIES[name.toLowerCase()] ?? m).replace(/&#(\d+);/g, (m, d) => {
5243
+ try {
5244
+ return String.fromCodePoint(Number(d));
5245
+ } catch {
5246
+ return m;
5247
+ }
5248
+ });
5249
+ }
5250
+ /** CJK 统一表意文字/中文标点/全角符号——接行时两侧任一是则不加空格。 */
5251
+ const CJKISH = /[\u3000-\u303f\u4e00-\u9fff\uff00-\uffef]/;
5252
+ /** CJK 感知拼接:空段跳过;相邻两侧任一是 CJK 字符则直连,否则加空格。 */
5253
+ function cjkJoin(parts) {
5254
+ let s = "";
5255
+ for (const p of parts) {
5256
+ if (!s) {
5257
+ s = p;
5258
+ continue;
5259
+ }
5260
+ const last = s[s.length - 1];
5261
+ s += CJKISH.test(last) || CJKISH.test(p[0]) ? p : ` ${p}`;
5262
+ }
5263
+ return s;
5264
+ }
5265
+ function parseSubtitleToText(raw) {
5266
+ const lines = raw.replace(/\r\n/g, "\n").split("\n");
5267
+ const out = [];
5268
+ /** 近窗去重:最近 2 条已输出的行(滚动字幕 cue 间隔行重复)。 */
5269
+ const recent = [];
5270
+ let inNote = false;
5271
+ for (const line of lines) {
5272
+ const t = line.trim();
5273
+ if (!t) {
5274
+ inNote = false;
5275
+ continue;
5276
+ }
5277
+ if (/^WEBVTT/i.test(t)) continue;
5278
+ if (t.startsWith("NOTE")) {
5279
+ inNote = true;
5280
+ continue;
5281
+ }
5282
+ if (inNote) continue;
5283
+ if (CUE_TIMING.test(t)) continue;
5284
+ if (/^\d+$/.test(t)) continue;
5285
+ if (/^(Kind:|Language:|Style:|Region:|NOTE)/i.test(t)) continue;
5286
+ let text = t.replace(/<[^>]+>/g, "").trim();
5287
+ if (!text) continue;
5288
+ if (AUTO_MARKERS.test(text)) continue;
5289
+ text = decodeEntities(text);
5290
+ if (recent.includes(text)) continue;
5291
+ recent.push(text);
5292
+ if (recent.length > 2) recent.shift();
5293
+ const spoken = text.replace(/^>{2,}\s*/, "").trim();
5294
+ if (spoken) out.push(spoken);
5295
+ }
5296
+ return cjkJoin(out).replace(/\s+/g, " ").trim();
5297
+ }
5298
+ //#endregion
4808
5299
  //#region src/vendor/video-meta.ts
4809
5300
  const BILI_HEADERS = {
4810
5301
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
@@ -4836,12 +5327,126 @@ async function fetchBilibiliMeta(url, fetchFn, signal) {
4836
5327
  })).json();
4837
5328
  if (resp.code !== 0 || !resp.data) throw new Error(`B站接口返回错误(${resp.code} ${resp.message ?? ""})`);
4838
5329
  const d = resp.data;
5330
+ const pages = d.pages ?? [];
4839
5331
  return {
4840
5332
  title: (d.title ?? "").trim() || "未命名视频",
4841
5333
  owner: (d.owner?.name ?? "").trim(),
4842
5334
  desc: (d.desc ?? "").trim(),
4843
- parts: (d.pages ?? []).map((p) => (p.part ?? "").trim()).filter(Boolean),
4844
- duration: d.duration ?? 0
5335
+ parts: pages.map((p) => (p.part ?? "").trim()).filter(Boolean),
5336
+ duration: d.duration ?? 0,
5337
+ cids: pages.map((p) => Number(p.cid ?? 0))
5338
+ };
5339
+ }
5340
+ /** 字幕语言优先级:中文(含 AI)→ 英文(含 AI)→ 其他,同档取第一个。 */
5341
+ function pickCcLane(subs) {
5342
+ const usable = subs.filter((s) => typeof s.subtitle_url === "string" && s.subtitle_url !== "");
5343
+ if (usable.length === 0) return null;
5344
+ const rank = (lan) => {
5345
+ if (/^zh-Hans$|^zh-CN$|^zh$/.test(lan)) return 0;
5346
+ if (/^ai-zh/.test(lan)) return 1;
5347
+ if (/^en/.test(lan)) return 2;
5348
+ if (/^ai-en/.test(lan)) return 3;
5349
+ return 4;
5350
+ };
5351
+ const best = usable.map((s) => ({
5352
+ lan: String(s.lan ?? ""),
5353
+ url: String(s.subtitle_url),
5354
+ r: rank(String(s.lan ?? "")),
5355
+ i: usable.indexOf(s)
5356
+ })).sort((a, b) => a.r !== b.r ? a.r - b.r : a.i - b.i)[0];
5357
+ return {
5358
+ lan: best.lan,
5359
+ url: best.url
5360
+ };
5361
+ }
5362
+ /**
5363
+ * 拉取一个 B站视频的 CC 字幕(逐分P)。分P 语义对齐上游:`?p=N` 只导该集,
5364
+ * 未带 p 的多分P导整季(≤maxPages,超出截断并在 missing 里披露)。
5365
+ * 返回 parts(有字幕的分P)与 missing(无字幕的分P名)——parts 为空时调用方
5366
+ * 抛诚实错误。签名材料(nav 取 img/sub_key)整季共用一次。
5367
+ */
5368
+ async function fetchBilibiliSubtitles(url, fetchFn, signal, opts) {
5369
+ const meta = await fetchBilibiliMeta(url, fetchFn, signal);
5370
+ const id = parseBilibiliId(url) ?? await (async () => {
5371
+ return parseBilibiliId((await fetchFn(url, {
5372
+ signal,
5373
+ headers: BILI_HEADERS
5374
+ })).url);
5375
+ })();
5376
+ if (!id) throw new Error("不是可识别的 B站视频链接(BV/av 号缺失)");
5377
+ const view = await (await fetchFn(`https://api.bilibili.com/x/web-interface/view?${id.bvid ? `bvid=${id.bvid}` : `aid=${id.aid}`}`, {
5378
+ signal,
5379
+ headers: BILI_HEADERS
5380
+ })).json();
5381
+ if (view.code !== 0 || !view.data) throw new Error(`B站视频信息获取失败: ${view.message ?? ""}`);
5382
+ const pages = view.data.pages ?? [];
5383
+ const maxPages = Math.max(1, opts?.maxPages ?? 200);
5384
+ const partName = (i) => `P${pages[i]?.page ?? i + 1} ${(pages[i]?.part ?? meta.title).trim()}`.trim();
5385
+ let targets;
5386
+ if (id.page !== void 0) {
5387
+ const idx = pages.length > 0 ? Math.min(id.page, pages.length) - 1 : -1;
5388
+ targets = [{
5389
+ cid: Number(idx >= 0 ? pages[idx].cid : view.data.cid ?? meta.cids[0] ?? 0),
5390
+ name: pages.length > 1 ? partName(idx) : meta.title
5391
+ }];
5392
+ } else if (pages.length > 1) targets = pages.slice(0, maxPages).map((pg, i) => ({
5393
+ cid: Number(pg.cid ?? 0),
5394
+ name: partName(i)
5395
+ }));
5396
+ else targets = [{
5397
+ cid: Number(view.data.cid ?? meta.cids[0] ?? 0),
5398
+ name: meta.title
5399
+ }];
5400
+ const wbiImg = (await (await fetchFn("https://api.bilibili.com/x/web-interface/nav", {
5401
+ signal,
5402
+ headers: BILI_HEADERS
5403
+ })).json()).data?.wbi_img ?? {};
5404
+ const { imgKey, subKey } = extractKeysFromNavUrl(wbiImg.img_url ?? "", wbiImg.sub_url ?? "");
5405
+ const mixinKey = getMixinKey(imgKey, subKey);
5406
+ const idKey = id.bvid ? "bvid" : "aid";
5407
+ const idVal = id.bvid ?? String(id.aid ?? "");
5408
+ const parts = [];
5409
+ const missing = [];
5410
+ for (const t of targets) {
5411
+ if (signal?.aborted) break;
5412
+ const player = await (await fetchFn(`https://api.bilibili.com/x/player/wbi/v2?${encWbi({
5413
+ [idKey]: idVal,
5414
+ cid: t.cid
5415
+ }, mixinKey)}`, {
5416
+ signal,
5417
+ headers: BILI_HEADERS
5418
+ })).json();
5419
+ if (player.code !== 0) throw new Error(`B站播放器接口返回错误(${t.name}): ${player.message ?? ""}(可能需要登录或为付费内容)`);
5420
+ const lane = pickCcLane(player.data?.subtitle?.subtitles ?? []);
5421
+ if (lane === null) {
5422
+ missing.push(t.name);
5423
+ continue;
5424
+ }
5425
+ const raw = await (await fetchFn(lane.url.startsWith("//") ? `https:${lane.url}` : lane.url, {
5426
+ signal,
5427
+ headers: BILI_HEADERS
5428
+ })).text();
5429
+ let text = "";
5430
+ try {
5431
+ text = (JSON.parse(raw).body ?? []).map((b) => (b.content ?? "").trim()).filter(Boolean).join("");
5432
+ } catch {
5433
+ text = parseSubtitleToText(raw);
5434
+ }
5435
+ if (text.replace(/\s+/g, "").length < 50) {
5436
+ missing.push(t.name);
5437
+ continue;
5438
+ }
5439
+ parts.push({
5440
+ name: t.name,
5441
+ text,
5442
+ lan: lane.lan
5443
+ });
5444
+ }
5445
+ return {
5446
+ title: meta.title,
5447
+ owner: meta.owner,
5448
+ parts,
5449
+ missing
4845
5450
  };
4846
5451
  }
4847
5452
  //#endregion
@@ -6067,7 +6672,7 @@ function studyTools(store, deps = {}) {
6067
6672
  });
6068
6673
  const importUrl = defineTool({
6069
6674
  name: "study_import_url",
6070
- description: "Import any learning URL by auto-routing: GitHub repos reuse the repository import; arXiv papers download the PDF and extract its text layer; other http(s) pages get web-article extraction (nav/ads stripped, honest failure on non-article pages). All routes return the same design brief the tutor designs against (apply with study_apply_design). Video links (B站/YouTube/抖音) are classified with title metadata but cannot be transcribed here — ask the learner to paste the transcript/subtitles and use study_import_markdown instead.",
6675
+ description: "Import any learning URL by auto-routing: GitHub repos reuse the repository import; arXiv papers download the PDF and extract its text layer; other http(s) pages get web-article extraction (nav/ads stripped, honest failure on non-article pages). All routes return the same design brief the tutor designs against (apply with study_apply_design). B站 video links import their CC subtitles directly (a multi-part series becomes one document per part; ?p=N picks one episode); videos without CC, and YouTube/抖音 links, still ask the learner to paste the transcript (study_import_markdown).",
6071
6676
  parameters: {
6072
6677
  url: {
6073
6678
  type: "string",
@@ -6090,9 +6695,25 @@ function studyTools(store, deps = {}) {
6090
6695
  const fetchFn = signalFetch(exec.signal, baseFetch);
6091
6696
  if (route.kind === "video") {
6092
6697
  if (route.source === "bilibili") {
6093
- const meta = await fetchBilibiliMeta(route.url, fetchFn, exec.signal);
6094
- const parts = meta.parts.length > 0 ? `, ${meta.parts.length} 分P` : "";
6095
- throw new Error(`lookatstudy-plugin: B站视频《${meta.title}》(UP:${meta.owner}${parts})已识别,但插件内无字幕拉取与音频转写能力。请让学习者把字幕/文稿粘贴进来,用 study_import_markdown 导入。`);
6698
+ const part = args.part ?? 1;
6699
+ const identity = normalizeUrlIdentity(route.url);
6700
+ const existing = part <= 1 ? store.get().courses.find((c) => c.source === "url" && c.sourceRef === identity) : void 0;
6701
+ if (existing !== void 0) return {
6702
+ status: "imported",
6703
+ ...toImportValue(existing)
6704
+ };
6705
+ const cc = await fetchBilibiliSubtitles(route.url, fetchFn, exec.signal);
6706
+ if (cc.parts.length === 0) {
6707
+ const scope = cc.missing.length > 1 ? `共 ${cc.missing.length} 个分P均无` : "无";
6708
+ throw new Error(`lookatstudy-plugin: B站视频《${cc.title}》(UP:${cc.owner})${scope}可用 CC 字幕,插件不拉音轨转写。请让学习者把字幕/文稿粘贴进来,用 study_import_markdown 导入。`);
6709
+ }
6710
+ const docs = cc.parts.flatMap((p, i) => prepareSingleDoc(`bili-p${String(i + 1)}`, `# ${p.name}
6711
+
6712
+ ${p.text}`));
6713
+ if (docs.length === 0) throw new Error("lookatstudy-plugin: CC subtitle text was empty after chunking");
6714
+ pendingDesign = buildPendingDesignFromUrl(identity, cc.title, docs);
6715
+ pendingPart = part;
6716
+ return designRequiredValue(pendingDesign, part);
6096
6717
  }
6097
6718
  throw new Error(`lookatstudy-plugin: ${route.url} 是视频链接(YouTube/抖音需要 yt-dlp,插件环境不可用)。请让学习者把字幕/文稿粘贴进来,用 study_import_markdown 导入。`);
6098
6719
  }
@@ -6129,7 +6750,7 @@ function studyTools(store, deps = {}) {
6129
6750
  pendingPart = part;
6130
6751
  return designRequiredValue(pendingDesign, part);
6131
6752
  },
6132
- timeoutMs: 12e4,
6753
+ timeoutMs: 18e4,
6133
6754
  presentCall: (args) => ({
6134
6755
  card: "generic",
6135
6756
  title: `Import URL: ${args.url}`,
@@ -6223,6 +6844,11 @@ function studyTools(store, deps = {}) {
6223
6844
  type: "integer",
6224
6845
  required: true
6225
6846
  },
6847
+ created: {
6848
+ type: "boolean",
6849
+ required: true,
6850
+ description: "False when an existing course short-circuited the import (same title) — the design did NOT land as a new course."
6851
+ },
6226
6852
  languageTarget: {
6227
6853
  type: "string",
6228
6854
  description: "Present only when the course teaches a language itself (BCP-47); the tutor then keeps quiz material in the target language."
@@ -6231,7 +6857,7 @@ function studyTools(store, deps = {}) {
6231
6857
  },
6232
6858
  render: (_args, value) => [{
6233
6859
  type: "text",
6234
- text: `Imported designed course “${value.title}” (${value.sections} sections, ${value.lessons} lessons${value.droppedLessons > 0 ? `, ${value.droppedLessons} hallucinated lesson(s) dropped` : ""})${typeof value.languageTarget === "string" ? ` — language course (${value.languageTarget}): keep reading passages, example sentences, and quiz language material in the target language` : ""}. First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}). Present the course map to the learner.`
6860
+ text: value.created === false ? `“${value.title}” (id ${value.courseId}) was ALREADY imported — the design did not create a new course. Continue with the existing course, or delete it first (study_delete_course) to rebuild from this design.` : `Imported designed course “${value.title}” (${value.sections} sections, ${value.lessons} lessons${value.droppedLessons > 0 ? `, ${value.droppedLessons} hallucinated lesson(s) dropped` : ""})${typeof value.languageTarget === "string" ? ` — language course (${value.languageTarget}): keep reading passages, example sentences, and quiz language material in the target language` : ""}. First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}). Present the course map to the learner.`
6235
6861
  }]
6236
6862
  },
6237
6863
  async execute(args, exec) {
@@ -6261,7 +6887,13 @@ function studyTools(store, deps = {}) {
6261
6887
  const parsed = buildCourseFromDesign(pd.part !== void 0 && pd.partCount !== void 0 && pd.partCount > 1 ? `${pd.courseTitle} (part ${pd.part})` : pd.courseTitle, validated, contents, pd.translations);
6262
6888
  requireParsedLessons(parsed);
6263
6889
  const languageTarget = typeof args.languageTarget === "string" && args.languageTarget.trim() !== "" ? args.languageTarget.trim().slice(0, 35) : null;
6264
- const value = mutate((state) => toImportValue(importCourse(state, parsed, pd.source, pd.url, languageTarget)));
6890
+ const value = mutate((state) => {
6891
+ const before = state.courses.length;
6892
+ return {
6893
+ ...toImportValue(importCourse(state, parsed, pd.source, pd.url, languageTarget)),
6894
+ created: state.courses.length > before
6895
+ };
6896
+ });
6265
6897
  pendingDesign = null;
6266
6898
  pendingPart = 1;
6267
6899
  return {