dsh-plugin-lookatstudy 0.21.1 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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}%",
@@ -442,6 +458,12 @@ const EN = {
442
458
  "artifact.guess.title": "Take a guess",
443
459
  "artifact.guess.pick": "I pick \"{label}\"",
444
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",
445
467
  "artifact.sedimented": "A new artifact settled into the notebook",
446
468
  "zone.artifacts": "AI artifacts",
447
469
  "note.quote.ask": "Ask about this",
@@ -562,6 +584,16 @@ const EN = {
562
584
  "threads.label": "Lesson threads",
563
585
  "threads.open": "{n} threads",
564
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}",
565
597
  "meter.label": "Context usage",
566
598
  "meter.aria": "{pct}% of context used",
567
599
  "meter.brk.system": "System prompt",
@@ -923,6 +955,135 @@ function renderMarkdown(md) {
923
955
  return out.join("\n");
924
956
  }
925
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
926
1087
  //#region src/vendor/math-normalize.ts
927
1088
  /**
928
1089
  * math-normalize —— 数学记法归一(v0.19 数学渲染前置,纯函数)。
@@ -1355,6 +1516,7 @@ function emptyState() {
1355
1516
  artifacts: {},
1356
1517
  proposals: [],
1357
1518
  lessonSessions: {},
1519
+ lessonThreads: {},
1358
1520
  lastConsolidatedAt: null,
1359
1521
  xp: {
1360
1522
  total: 0,
@@ -1428,6 +1590,35 @@ function loadState(path) {
1428
1590
  memoryPatterns: raw.memoryPatterns ?? {},
1429
1591
  proposals: raw.proposals ?? [],
1430
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
+ })(),
1431
1622
  artifacts: raw.artifacts ?? {},
1432
1623
  lastConsolidatedAt: raw.lastConsolidatedAt ?? null,
1433
1624
  xp: raw.xp ?? {
@@ -1963,6 +2154,96 @@ function recordAnswer(state, lessonId, correct, concept, now) {
1963
2154
  * @param now - current time.
1964
2155
  * @returns completion result including the unlocked lessons.
1965
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
+ }
1966
2247
  function completeLesson(state, lessonId, now) {
1967
2248
  const ref = findLesson(state, lessonId);
1968
2249
  if (ref.lesson.status === "locked") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is locked; complete earlier lessons first`);
@@ -2620,6 +2901,7 @@ function workbenchState(state, now) {
2620
2901
  };
2621
2902
  })(),
2622
2903
  lessonSessions: state.lessonSessions,
2904
+ lessonThreads: state.lessonThreads ?? {},
2623
2905
  progress: (() => {
2624
2906
  const xp = levelFromTotalXp(state.xp.total);
2625
2907
  return {
@@ -3082,18 +3364,84 @@ function registerDashboard(webServer, deps) {
3082
3364
  if (req.method === "POST" && pathname === "/lookatstudy/api/lesson-session") {
3083
3365
  const body = await readJsonBodySafe(req, res);
3084
3366
  if (body === void 0) return;
3085
- if (typeof body.lessonId !== "string" || typeof body.sessionId !== "string") {
3367
+ if (typeof body.lessonId !== "string" || body.sessionId !== null && typeof body.sessionId !== "string") {
3086
3368
  sendJson(res, 400, {
3087
3369
  ok: false,
3088
- error: "lessonId and sessionId (strings) required"
3370
+ error: "lessonId and sessionId (string or null) required"
3089
3371
  });
3090
3372
  return;
3091
3373
  }
3092
- 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);
3093
3376
  deps.store.save();
3094
3377
  sendJson(res, 200, { ok: true });
3095
3378
  return;
3096
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
+ }
3097
3445
  if (req.method === "POST" && pathname === "/lookatstudy/api/tts") {
3098
3446
  const body = await readJsonBodySafe(req, res);
3099
3447
  if (body === void 0) return;
@@ -3300,90 +3648,6 @@ function registerStudyCommand(commands, deps) {
3300
3648
  });
3301
3649
  }
3302
3650
  //#endregion
3303
- //#region src/vendor/markdown-course.ts
3304
- /**
3305
- * GitHub 风格的 anchor 生成:小写、去一组标点(保留中文等 unicode)、每个空格单独转 -。
3306
- * 注意:**不合并多 -**("A & B" → 去 & 留两空格 → "a--b",与 GitHub slugger 一致)。
3307
- * 与 seed.ts 的锚点对齐。
3308
- */
3309
- function titleToAnchor(title) {
3310
- return title.toLowerCase().trim().replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, "").replace(/ /g, "-").replace(/^-|-$/g, "");
3311
- }
3312
- /**
3313
- * 清洗课时/章节标题 — 去 emoji、多余空格、markdown 格式符号。
3314
- * "🛠 The Modern FDE Stack" → "The Modern FDE Stack"
3315
- * "## [Pre-lecture quiz](url)" → "Pre-lecture quiz"
3316
- */
3317
- function cleanTitle(raw) {
3318
- 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();
3319
- }
3320
- /**
3321
- * 解析 markdown 为课程树。
3322
- * 容错:H3 出现在任何 H2 之前 → 归到一个 "(前言)" section。
3323
- */
3324
- function parseMarkdownToCourse(md) {
3325
- const lines = md.split(/\r?\n/);
3326
- const sections = [];
3327
- let title = "(untitled)";
3328
- let currentSection = null;
3329
- let bodyBuffer = [];
3330
- let inCodeFence = false;
3331
- const flushLessonBody = () => {
3332
- if (currentSection && currentSection.lessons.length > 0) currentSection.lessons[currentSection.lessons.length - 1].body = bodyBuffer.join("\n").trim();
3333
- bodyBuffer = [];
3334
- };
3335
- for (const line of lines) {
3336
- if (/^(\s*)(```|~~~)/.test(line)) {
3337
- inCodeFence = !inCodeFence;
3338
- bodyBuffer.push(line);
3339
- continue;
3340
- }
3341
- if (inCodeFence) {
3342
- bodyBuffer.push(line);
3343
- continue;
3344
- }
3345
- if (/^#\s+/.test(line) && title === "(untitled)") {
3346
- title = cleanTitle(line.replace(/^#\s+/, "").trim());
3347
- continue;
3348
- }
3349
- if (/^##\s+/.test(line)) {
3350
- flushLessonBody();
3351
- const sectionTitle = cleanTitle(line.replace(/^##\s+/, "").trim());
3352
- currentSection = {
3353
- title: sectionTitle,
3354
- anchor: titleToAnchor(sectionTitle),
3355
- lessons: []
3356
- };
3357
- sections.push(currentSection);
3358
- continue;
3359
- }
3360
- if (/^###\s+/.test(line)) {
3361
- flushLessonBody();
3362
- const lessonTitle = cleanTitle(line.replace(/^###\s+/, "").trim());
3363
- if (!currentSection) {
3364
- currentSection = {
3365
- title: "(前言)",
3366
- anchor: titleToAnchor("前言"),
3367
- lessons: []
3368
- };
3369
- sections.push(currentSection);
3370
- }
3371
- currentSection.lessons.push({
3372
- title: lessonTitle,
3373
- anchor: titleToAnchor(lessonTitle),
3374
- body: ""
3375
- });
3376
- continue;
3377
- }
3378
- bodyBuffer.push(line);
3379
- }
3380
- flushLessonBody();
3381
- return {
3382
- title,
3383
- sections
3384
- };
3385
- }
3386
- //#endregion
3387
3651
  //#region src/vendor/local-folder-scanner.ts
3388
3652
  /**
3389
3653
  * 本地文件夹通用扫描器 —— 把任意课程文件夹(如 Coursera 下载包)递归扫描成文档清单。
@@ -4703,22 +4967,105 @@ function decodeHexString(s) {
4703
4967
  for (const b of bytes) if (b >= 32 && b < 127) out += String.fromCharCode(b);
4704
4968
  return out;
4705
4969
  }
4706
- /** Pull text out of one decoded content stream. */
4970
+ /** Pull text out of one decoded content stream. Linear hand-rolled scan —
4971
+ * the original regex alternation backtracked catastrophically on real
4972
+ * papers' content streams (an unclosed `[` made the lazy TJ arm rescan
4973
+ * megabytes; found 2026-09-14 when the livetest refresh wedged the host for
4974
+ * 10+ CPU-minutes on a real arXiv PDF while even a watchdog timer could not
4975
+ * fire — the loop blocked the event loop synchronously). */
4976
+ function readLiteral(src, i) {
4977
+ let depth = 1;
4978
+ let j = i + 1;
4979
+ let raw = "";
4980
+ while (j < src.length) {
4981
+ const c = src[j];
4982
+ if (c === "\\") {
4983
+ raw += src.slice(j, j + 2);
4984
+ j += 2;
4985
+ continue;
4986
+ }
4987
+ if (c === "(") {
4988
+ depth++;
4989
+ raw += c;
4990
+ j++;
4991
+ continue;
4992
+ }
4993
+ if (c === ")") {
4994
+ depth--;
4995
+ if (depth === 0) {
4996
+ j++;
4997
+ break;
4998
+ }
4999
+ raw += c;
5000
+ j++;
5001
+ continue;
5002
+ }
5003
+ raw += c;
5004
+ j++;
5005
+ }
5006
+ return {
5007
+ text: decodePdfString(raw),
5008
+ next: j
5009
+ };
5010
+ }
5011
+ function readHex(src, i) {
5012
+ const close = src.indexOf(">", i);
5013
+ if (close === -1) return {
5014
+ text: decodeHexString(src.slice(i + 1)),
5015
+ next: src.length
5016
+ };
5017
+ return {
5018
+ text: decodeHexString(src.slice(i + 1, close)),
5019
+ next: close + 1
5020
+ };
5021
+ }
5022
+ function skipWs(src, i) {
5023
+ while (i < src.length && /\s/.test(src[i])) i++;
5024
+ return i;
5025
+ }
5026
+ function readWord(src, i) {
5027
+ let j = i;
5028
+ while (j < src.length && /[A-Za-z0-9*'"]/.test(src[j])) j++;
5029
+ return src.slice(i, j);
5030
+ }
4707
5031
  function textFromContentStream(content) {
4708
5032
  if (!/BT[\s\S]*?ET/.test(content)) return "";
4709
5033
  let out = "";
4710
- const re = /\(((?:\\.|[^\\()])*)\)\s*(Tj|'|")|<([0-9a-fA-F\s]+)>\s*(Tj|'|")|\[((?:\(.*?\)|<[0-9a-fA-F\s]+>|[^\]])*?)\]\s*TJ/g;
4711
- let m;
4712
- while ((m = re.exec(content)) !== null) if (m[1] !== void 0) {
4713
- out += decodePdfString(m[1]);
4714
- if (m[2] === "'" || m[2] === "\"") out += "\n";
4715
- } else if (m[3] !== void 0) {
4716
- out += decodeHexString(m[3]);
4717
- if (m[4] === "'" || m[4] === "\"") out += "\n";
4718
- } else if (m[5] !== void 0) {
4719
- const partRe = /\(((?:\\.|[^\\()])*)\)|<([0-9a-fA-F\s]+)>/g;
4720
- let pm;
4721
- while ((pm = partRe.exec(m[5])) !== null) out += pm[1] !== void 0 ? decodePdfString(pm[1]) : decodeHexString(pm[2] ?? "");
5034
+ let i = 0;
5035
+ const n = content.length;
5036
+ while (i < n) {
5037
+ const c = content[i];
5038
+ if (c === "[") {
5039
+ const parts = [];
5040
+ let j = i + 1;
5041
+ while (j < n && content[j] !== "]") {
5042
+ const d = content[j];
5043
+ if (d === "(") {
5044
+ const r = readLiteral(content, j);
5045
+ parts.push(r.text);
5046
+ j = r.next;
5047
+ } else if (d === "<") {
5048
+ const r = readHex(content, j);
5049
+ parts.push(r.text);
5050
+ j = r.next;
5051
+ } else j++;
5052
+ }
5053
+ if (j >= n) break;
5054
+ if (readWord(content, skipWs(content, j + 1)) === "TJ") out += parts.join("");
5055
+ i = j + 1;
5056
+ continue;
5057
+ }
5058
+ if (c === "(" || c === "<") {
5059
+ const r = c === "(" ? readLiteral(content, i) : readHex(content, i);
5060
+ const op = readWord(content, skipWs(content, r.next));
5061
+ if (op === "Tj" || op === "'" || op === "\"") {
5062
+ out += r.text;
5063
+ if (op !== "Tj") out += "\n";
5064
+ }
5065
+ i = r.next;
5066
+ continue;
5067
+ }
5068
+ i++;
4722
5069
  }
4723
5070
  return out;
4724
5071
  }
@@ -4842,6 +5189,196 @@ function prepareSingleDoc(name, markdown, stem) {
4842
5189
  return chunkHeadinglessText(md, (stem ?? name.replace(/\s+/g, "-").slice(0, 40)) || "text");
4843
5190
  }
4844
5191
  //#endregion
5192
+ //#region src/vendor/bilibili-wbi.ts
5193
+ /**
5194
+ * B站 Wbi 签名(纯函数)——playurl 等 web 接口免登录访问的前提。
5195
+ * 算法来自社区文档 bilibili-API-collect(docs/misc/sign/wbi.md):
5196
+ * nav 接口取 img_key/sub_key → 重排表拼 mixin_key → 参数排序+过滤+chrFilter
5197
+ * → query + wts → MD5 = w_rid。纯函数,verify 用固定 key 断言 w_rid。
5198
+ */
5199
+ /** 重排映射表(社区文档公开常量,2023 引入后保持稳定;漂移时接口回 -352,live-test 可发现) */
5200
+ const MIXIN_KEY_ENC_TAB = [
5201
+ 46,
5202
+ 47,
5203
+ 18,
5204
+ 2,
5205
+ 53,
5206
+ 8,
5207
+ 23,
5208
+ 32,
5209
+ 15,
5210
+ 50,
5211
+ 10,
5212
+ 31,
5213
+ 58,
5214
+ 3,
5215
+ 45,
5216
+ 35,
5217
+ 27,
5218
+ 43,
5219
+ 5,
5220
+ 49,
5221
+ 33,
5222
+ 9,
5223
+ 42,
5224
+ 19,
5225
+ 29,
5226
+ 28,
5227
+ 14,
5228
+ 39,
5229
+ 12,
5230
+ 38,
5231
+ 41,
5232
+ 13,
5233
+ 37,
5234
+ 48,
5235
+ 7,
5236
+ 16,
5237
+ 24,
5238
+ 55,
5239
+ 40,
5240
+ 61,
5241
+ 26,
5242
+ 17,
5243
+ 0,
5244
+ 1,
5245
+ 60,
5246
+ 51,
5247
+ 30,
5248
+ 4,
5249
+ 22,
5250
+ 25,
5251
+ 54,
5252
+ 21,
5253
+ 56,
5254
+ 59,
5255
+ 6,
5256
+ 63,
5257
+ 57,
5258
+ 62,
5259
+ 11,
5260
+ 36,
5261
+ 20,
5262
+ 34,
5263
+ 44,
5264
+ 52
5265
+ ];
5266
+ function getMixinKey(imgKey, subKey) {
5267
+ const raw = imgKey + subKey;
5268
+ return MIXIN_KEY_ENC_TAB.map((n) => raw[n]).join("").slice(0, 32);
5269
+ }
5270
+ /** 从 nav 接口的 img_url/sub_url 里剥文件名(去扩展名)得 img_key/sub_key。 */
5271
+ function extractKeysFromNavUrl(imgUrl, subUrl) {
5272
+ const stem = (u) => {
5273
+ return ((u.split("?")[0] ?? u).split("/").pop() ?? "").replace(/\.[^.]+$/, "");
5274
+ };
5275
+ return {
5276
+ imgKey: stem(imgUrl),
5277
+ subKey: stem(subUrl)
5278
+ };
5279
+ }
5280
+ const CHR_FILTER = /[!'()*]/g;
5281
+ /** 对查询参数做 Wbi 签名,返回完整 query(含 w_rid/wts)。 */
5282
+ function encWbi(params, mixinKey, nowSec = Math.floor(Date.now() / 1e3)) {
5283
+ const filtered = Object.entries(params).filter(([k]) => k !== "w_rid" && k !== "wts").map(([k, v]) => [k, String(v)]);
5284
+ filtered.push(["wts", String(nowSec)]);
5285
+ filtered.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
5286
+ const query = filtered.map(([k, v]) => {
5287
+ const safeVal = v.replace(CHR_FILTER, "");
5288
+ return `${encodeURIComponent(k)}=${encodeURIComponent(safeVal)}`;
5289
+ }).join("&");
5290
+ return `${query}&w_rid=${createHash("md5").update(query + mixinKey, "utf8").digest("hex")}`;
5291
+ }
5292
+ //#endregion
5293
+ //#region src/vendor/subtitle-parse.ts
5294
+ /**
5295
+ * 字幕文件 → 纯文本(vtt/srt)——视频导入的"字幕优先"路径:有 CC/自动字幕的
5296
+ * 视频直接出文本,零转写零模型。纯函数,verify 直测。
5297
+ *
5298
+ * 处理(全部是**机器生成的刚性格式指纹**,高置信度规则;语义判断不在此层):
5299
+ * - WEBVTT 头/时间轴行/CUE 编号/NOTE 块/样式行剥除;
5300
+ * - 行内标签与打词时间戳剥除(`<c>`、`<00:00:01.359>`);
5301
+ * - 滚动字幕去重:YouTube 自动字幕的滚动窗结构是 cue N+1 的首行复述 cue N 的
5302
+ * 尾行,相邻去重不够(隔一行就重复),须近窗(最近 2 行)去重——2026-08-23
5303
+ * 真实样本驱动修复(经典症状:每句话在成文里出现两遍);
5304
+ * - HTML 实体解码(&amp; 等;字幕 XML 转义源);
5305
+ * - 整行自动标记剥除(封闭集 [Music]/[Applause]… 只删整行,不碰行内——
5306
+ * 行内方括号可能是正文);
5307
+ * - 行首 `>>` 换说话人标记剥除(YouTube 自动字幕机器标记);
5308
+ * - CJK 感知接行:汉字/中文标点之间不加空格(句与句直接相连),拉丁文之间
5309
+ * 加空格——中文字幕逐句 join(" ") 会在全文制造 "汉字 空格 汉字" 噪声。
5310
+ */
5311
+ /** 单条 cue 的时间轴行特征:00:00:01.000 --> 00:00:03.000(毫秒分隔符兼容 , 和 .) */
5312
+ const CUE_TIMING = /^\d{1,2}:\d{2}(?::\d{2})?[.,]\d{3}\s*-->/;
5313
+ /** YouTube 自动字幕的整行机器标记(封闭集,只删整行)。 */
5314
+ const AUTO_MARKERS = /^\[(music|applause|laughter|laughs?|silence|inaudible|cheering|foreign|blank[_\s]?audio|music playing|noise)\]$/i;
5315
+ /** 字幕常见的 XML/HTML 实体(未列出的实体原样保留,不做激进猜测)。 */
5316
+ const ENTITIES = {
5317
+ amp: "&",
5318
+ lt: "<",
5319
+ gt: ">",
5320
+ quot: "\"",
5321
+ apos: "'",
5322
+ nbsp: " "
5323
+ };
5324
+ function decodeEntities(s) {
5325
+ return s.replace(/&([a-z]+);/gi, (m, name) => ENTITIES[name.toLowerCase()] ?? m).replace(/&#(\d+);/g, (m, d) => {
5326
+ try {
5327
+ return String.fromCodePoint(Number(d));
5328
+ } catch {
5329
+ return m;
5330
+ }
5331
+ });
5332
+ }
5333
+ /** CJK 统一表意文字/中文标点/全角符号——接行时两侧任一是则不加空格。 */
5334
+ const CJKISH = /[\u3000-\u303f\u4e00-\u9fff\uff00-\uffef]/;
5335
+ /** CJK 感知拼接:空段跳过;相邻两侧任一是 CJK 字符则直连,否则加空格。 */
5336
+ function cjkJoin(parts) {
5337
+ let s = "";
5338
+ for (const p of parts) {
5339
+ if (!s) {
5340
+ s = p;
5341
+ continue;
5342
+ }
5343
+ const last = s[s.length - 1];
5344
+ s += CJKISH.test(last) || CJKISH.test(p[0]) ? p : ` ${p}`;
5345
+ }
5346
+ return s;
5347
+ }
5348
+ function parseSubtitleToText(raw) {
5349
+ const lines = raw.replace(/\r\n/g, "\n").split("\n");
5350
+ const out = [];
5351
+ /** 近窗去重:最近 2 条已输出的行(滚动字幕 cue 间隔行重复)。 */
5352
+ const recent = [];
5353
+ let inNote = false;
5354
+ for (const line of lines) {
5355
+ const t = line.trim();
5356
+ if (!t) {
5357
+ inNote = false;
5358
+ continue;
5359
+ }
5360
+ if (/^WEBVTT/i.test(t)) continue;
5361
+ if (t.startsWith("NOTE")) {
5362
+ inNote = true;
5363
+ continue;
5364
+ }
5365
+ if (inNote) continue;
5366
+ if (CUE_TIMING.test(t)) continue;
5367
+ if (/^\d+$/.test(t)) continue;
5368
+ if (/^(Kind:|Language:|Style:|Region:|NOTE)/i.test(t)) continue;
5369
+ let text = t.replace(/<[^>]+>/g, "").trim();
5370
+ if (!text) continue;
5371
+ if (AUTO_MARKERS.test(text)) continue;
5372
+ text = decodeEntities(text);
5373
+ if (recent.includes(text)) continue;
5374
+ recent.push(text);
5375
+ if (recent.length > 2) recent.shift();
5376
+ const spoken = text.replace(/^>{2,}\s*/, "").trim();
5377
+ if (spoken) out.push(spoken);
5378
+ }
5379
+ return cjkJoin(out).replace(/\s+/g, " ").trim();
5380
+ }
5381
+ //#endregion
4845
5382
  //#region src/vendor/video-meta.ts
4846
5383
  const BILI_HEADERS = {
4847
5384
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
@@ -4873,12 +5410,126 @@ async function fetchBilibiliMeta(url, fetchFn, signal) {
4873
5410
  })).json();
4874
5411
  if (resp.code !== 0 || !resp.data) throw new Error(`B站接口返回错误(${resp.code} ${resp.message ?? ""})`);
4875
5412
  const d = resp.data;
5413
+ const pages = d.pages ?? [];
4876
5414
  return {
4877
5415
  title: (d.title ?? "").trim() || "未命名视频",
4878
5416
  owner: (d.owner?.name ?? "").trim(),
4879
5417
  desc: (d.desc ?? "").trim(),
4880
- parts: (d.pages ?? []).map((p) => (p.part ?? "").trim()).filter(Boolean),
4881
- duration: d.duration ?? 0
5418
+ parts: pages.map((p) => (p.part ?? "").trim()).filter(Boolean),
5419
+ duration: d.duration ?? 0,
5420
+ cids: pages.map((p) => Number(p.cid ?? 0))
5421
+ };
5422
+ }
5423
+ /** 字幕语言优先级:中文(含 AI)→ 英文(含 AI)→ 其他,同档取第一个。 */
5424
+ function pickCcLane(subs) {
5425
+ const usable = subs.filter((s) => typeof s.subtitle_url === "string" && s.subtitle_url !== "");
5426
+ if (usable.length === 0) return null;
5427
+ const rank = (lan) => {
5428
+ if (/^zh-Hans$|^zh-CN$|^zh$/.test(lan)) return 0;
5429
+ if (/^ai-zh/.test(lan)) return 1;
5430
+ if (/^en/.test(lan)) return 2;
5431
+ if (/^ai-en/.test(lan)) return 3;
5432
+ return 4;
5433
+ };
5434
+ const best = usable.map((s) => ({
5435
+ lan: String(s.lan ?? ""),
5436
+ url: String(s.subtitle_url),
5437
+ r: rank(String(s.lan ?? "")),
5438
+ i: usable.indexOf(s)
5439
+ })).sort((a, b) => a.r !== b.r ? a.r - b.r : a.i - b.i)[0];
5440
+ return {
5441
+ lan: best.lan,
5442
+ url: best.url
5443
+ };
5444
+ }
5445
+ /**
5446
+ * 拉取一个 B站视频的 CC 字幕(逐分P)。分P 语义对齐上游:`?p=N` 只导该集,
5447
+ * 未带 p 的多分P导整季(≤maxPages,超出截断并在 missing 里披露)。
5448
+ * 返回 parts(有字幕的分P)与 missing(无字幕的分P名)——parts 为空时调用方
5449
+ * 抛诚实错误。签名材料(nav 取 img/sub_key)整季共用一次。
5450
+ */
5451
+ async function fetchBilibiliSubtitles(url, fetchFn, signal, opts) {
5452
+ const meta = await fetchBilibiliMeta(url, fetchFn, signal);
5453
+ const id = parseBilibiliId(url) ?? await (async () => {
5454
+ return parseBilibiliId((await fetchFn(url, {
5455
+ signal,
5456
+ headers: BILI_HEADERS
5457
+ })).url);
5458
+ })();
5459
+ if (!id) throw new Error("不是可识别的 B站视频链接(BV/av 号缺失)");
5460
+ const view = await (await fetchFn(`https://api.bilibili.com/x/web-interface/view?${id.bvid ? `bvid=${id.bvid}` : `aid=${id.aid}`}`, {
5461
+ signal,
5462
+ headers: BILI_HEADERS
5463
+ })).json();
5464
+ if (view.code !== 0 || !view.data) throw new Error(`B站视频信息获取失败: ${view.message ?? ""}`);
5465
+ const pages = view.data.pages ?? [];
5466
+ const maxPages = Math.max(1, opts?.maxPages ?? 200);
5467
+ const partName = (i) => `P${pages[i]?.page ?? i + 1} ${(pages[i]?.part ?? meta.title).trim()}`.trim();
5468
+ let targets;
5469
+ if (id.page !== void 0) {
5470
+ const idx = pages.length > 0 ? Math.min(id.page, pages.length) - 1 : -1;
5471
+ targets = [{
5472
+ cid: Number(idx >= 0 ? pages[idx].cid : view.data.cid ?? meta.cids[0] ?? 0),
5473
+ name: pages.length > 1 ? partName(idx) : meta.title
5474
+ }];
5475
+ } else if (pages.length > 1) targets = pages.slice(0, maxPages).map((pg, i) => ({
5476
+ cid: Number(pg.cid ?? 0),
5477
+ name: partName(i)
5478
+ }));
5479
+ else targets = [{
5480
+ cid: Number(view.data.cid ?? meta.cids[0] ?? 0),
5481
+ name: meta.title
5482
+ }];
5483
+ const wbiImg = (await (await fetchFn("https://api.bilibili.com/x/web-interface/nav", {
5484
+ signal,
5485
+ headers: BILI_HEADERS
5486
+ })).json()).data?.wbi_img ?? {};
5487
+ const { imgKey, subKey } = extractKeysFromNavUrl(wbiImg.img_url ?? "", wbiImg.sub_url ?? "");
5488
+ const mixinKey = getMixinKey(imgKey, subKey);
5489
+ const idKey = id.bvid ? "bvid" : "aid";
5490
+ const idVal = id.bvid ?? String(id.aid ?? "");
5491
+ const parts = [];
5492
+ const missing = [];
5493
+ for (const t of targets) {
5494
+ if (signal?.aborted) break;
5495
+ const player = await (await fetchFn(`https://api.bilibili.com/x/player/wbi/v2?${encWbi({
5496
+ [idKey]: idVal,
5497
+ cid: t.cid
5498
+ }, mixinKey)}`, {
5499
+ signal,
5500
+ headers: BILI_HEADERS
5501
+ })).json();
5502
+ if (player.code !== 0) throw new Error(`B站播放器接口返回错误(${t.name}): ${player.message ?? ""}(可能需要登录或为付费内容)`);
5503
+ const lane = pickCcLane(player.data?.subtitle?.subtitles ?? []);
5504
+ if (lane === null) {
5505
+ missing.push(t.name);
5506
+ continue;
5507
+ }
5508
+ const raw = await (await fetchFn(lane.url.startsWith("//") ? `https:${lane.url}` : lane.url, {
5509
+ signal,
5510
+ headers: BILI_HEADERS
5511
+ })).text();
5512
+ let text = "";
5513
+ try {
5514
+ text = (JSON.parse(raw).body ?? []).map((b) => (b.content ?? "").trim()).filter(Boolean).join("");
5515
+ } catch {
5516
+ text = parseSubtitleToText(raw);
5517
+ }
5518
+ if (text.replace(/\s+/g, "").length < 50) {
5519
+ missing.push(t.name);
5520
+ continue;
5521
+ }
5522
+ parts.push({
5523
+ name: t.name,
5524
+ text,
5525
+ lan: lane.lan
5526
+ });
5527
+ }
5528
+ return {
5529
+ title: meta.title,
5530
+ owner: meta.owner,
5531
+ parts,
5532
+ missing
4882
5533
  };
4883
5534
  }
4884
5535
  //#endregion
@@ -6104,7 +6755,7 @@ function studyTools(store, deps = {}) {
6104
6755
  });
6105
6756
  const importUrl = defineTool({
6106
6757
  name: "study_import_url",
6107
- 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.",
6758
+ 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).",
6108
6759
  parameters: {
6109
6760
  url: {
6110
6761
  type: "string",
@@ -6127,9 +6778,25 @@ function studyTools(store, deps = {}) {
6127
6778
  const fetchFn = signalFetch(exec.signal, baseFetch);
6128
6779
  if (route.kind === "video") {
6129
6780
  if (route.source === "bilibili") {
6130
- const meta = await fetchBilibiliMeta(route.url, fetchFn, exec.signal);
6131
- const parts = meta.parts.length > 0 ? `, ${meta.parts.length} 分P` : "";
6132
- throw new Error(`lookatstudy-plugin: B站视频《${meta.title}》(UP:${meta.owner}${parts})已识别,但插件内无字幕拉取与音频转写能力。请让学习者把字幕/文稿粘贴进来,用 study_import_markdown 导入。`);
6781
+ const part = args.part ?? 1;
6782
+ const identity = normalizeUrlIdentity(route.url);
6783
+ const existing = part <= 1 ? store.get().courses.find((c) => c.source === "url" && c.sourceRef === identity) : void 0;
6784
+ if (existing !== void 0) return {
6785
+ status: "imported",
6786
+ ...toImportValue(existing)
6787
+ };
6788
+ const cc = await fetchBilibiliSubtitles(route.url, fetchFn, exec.signal);
6789
+ if (cc.parts.length === 0) {
6790
+ const scope = cc.missing.length > 1 ? `共 ${cc.missing.length} 个分P均无` : "无";
6791
+ throw new Error(`lookatstudy-plugin: B站视频《${cc.title}》(UP:${cc.owner})${scope}可用 CC 字幕,插件不拉音轨转写。请让学习者把字幕/文稿粘贴进来,用 study_import_markdown 导入。`);
6792
+ }
6793
+ const docs = cc.parts.flatMap((p, i) => prepareSingleDoc(`bili-p${String(i + 1)}`, `# ${p.name}
6794
+
6795
+ ${p.text}`));
6796
+ if (docs.length === 0) throw new Error("lookatstudy-plugin: CC subtitle text was empty after chunking");
6797
+ pendingDesign = buildPendingDesignFromUrl(identity, cc.title, docs);
6798
+ pendingPart = part;
6799
+ return designRequiredValue(pendingDesign, part);
6133
6800
  }
6134
6801
  throw new Error(`lookatstudy-plugin: ${route.url} 是视频链接(YouTube/抖音需要 yt-dlp,插件环境不可用)。请让学习者把字幕/文稿粘贴进来,用 study_import_markdown 导入。`);
6135
6802
  }
@@ -6166,7 +6833,7 @@ function studyTools(store, deps = {}) {
6166
6833
  pendingPart = part;
6167
6834
  return designRequiredValue(pendingDesign, part);
6168
6835
  },
6169
- timeoutMs: 12e4,
6836
+ timeoutMs: 18e4,
6170
6837
  presentCall: (args) => ({
6171
6838
  card: "generic",
6172
6839
  title: `Import URL: ${args.url}`,