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/client.js +517 -65
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +734 -102
- package/package.json +1 -1
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;
|
|
782
|
-
*
|
|
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
|
|
1500
|
-
const
|
|
1501
|
-
if (
|
|
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 (
|
|
3370
|
+
error: "lessonId and sessionId (string or null) required"
|
|
3082
3371
|
});
|
|
3083
3372
|
return;
|
|
3084
3373
|
}
|
|
3085
|
-
|
|
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 实体解码(& 等;字幕 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:
|
|
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).
|
|
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
|
|
6094
|
-
const
|
|
6095
|
-
|
|
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:
|
|
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) =>
|
|
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 {
|