dsh-plugin-lookatstudy 0.11.0 → 0.12.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/README.md +28 -7
- package/lib/client.js +1460 -109
- package/lib/client.js.map +1 -1
- package/lib/docx-parser-BhyqPImb.mjs +55 -0
- package/lib/epub-parser-oH96guBW.mjs +306 -0
- package/lib/html-article-Da8ksU0i.mjs +460 -0
- package/lib/index.d.mts +5 -4
- package/lib/index.mjs +3098 -1287
- package/lib/inflate-DIKUrTRi.mjs +311 -0
- package/lib/pptx-parser-B9IDkiGM.mjs +96 -0
- package/lib/zip-reader-KnRrq0av.mjs +67 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -21,7 +21,7 @@ window.__ModuleLoader__.load({
|
|
|
21
21
|
* The study tab's stylesheet. Class names are `lks-*` namespaced; colors,
|
|
22
22
|
* fonts, and radii come from `--dsw-*` tokens wherever the concept exists.
|
|
23
23
|
*/
|
|
24
|
-
const
|
|
24
|
+
const STUDY_CSS = `
|
|
25
25
|
.lks-root{font-family:var(--dsw-font-family)}
|
|
26
26
|
.lks-root :where(button){font-family:var(--dsw-font-family);cursor:pointer;border:none;background:none;padding:0}
|
|
27
27
|
.lks-root :where(a){color:var(--dsw-alias-state-business-primary)}
|
|
@@ -32,24 +32,28 @@ window.__ModuleLoader__.load({
|
|
|
32
32
|
non-growing host and the composer floats over the tab's bottom band, so
|
|
33
33
|
the tab itself never feeds the page scroll. The bottom padding clears the
|
|
34
34
|
floating composer via the host-published live --dsh-composer-height. The
|
|
35
|
-
|
|
36
|
-
(--dsh-composer-card-max-width inherits from ConversationRoot's root)
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
tutor column shares the conversation column's width axis
|
|
36
|
+
(--dsh-composer-card-max-width inherits from ConversationRoot's root) but
|
|
37
|
+
is capped at 42% of the tab so the BLACKBOARD — the reading surface —
|
|
38
|
+
keeps the lion's share at every wide width; the composer card follows the
|
|
39
|
+
tutor column's real measured width (views.tsx), so the two stay visually
|
|
40
|
+
tied. Flanking columns absorb the remaining width and scroll independently. */
|
|
39
41
|
.lks-study{height:100%;min-height:0;width:100%;box-sizing:border-box;overflow:hidden;display:flex;flex-direction:column;padding:10px 14px calc(var(--dsh-composer-height, 152px) + 12px);color:var(--dsw-alias-label-primary);container:lksstudy/inline-size}
|
|
40
42
|
/* Direction carrier (a container query cannot style the container itself). */
|
|
41
43
|
.lks-body{flex:1;min-height:0;min-width:0;display:flex;gap:16px}
|
|
42
44
|
.lks-col{display:flex;flex-direction:column;min-width:0}
|
|
43
45
|
.lks-colhead{flex:none;font-size:12px;font-weight:600;color:var(--dsw-alias-label-tertiary);letter-spacing:.06em;text-transform:uppercase;margin-bottom:10px}
|
|
44
46
|
.lks-col-rail{flex:0 1 260px;min-width:180px;overflow-y:auto;padding:2px 6px}
|
|
45
|
-
.lks-col-tutor{flex:0
|
|
46
|
-
.lks-col-bb{flex:1 1 0;min-width:
|
|
47
|
+
.lks-col-tutor{flex:0 1 auto;width:min(100%,var(--dsh-composer-card-max-width,780px),42cqi);min-width:360px}
|
|
48
|
+
.lks-col-bb{flex:1 1 0;min-width:320px;overflow-y:auto;padding:2px 6px}
|
|
47
49
|
/* While the study view is mounted in wide mode, the host composer card is
|
|
48
50
|
shifted under the tutor column (the transcript's width axis) instead of the
|
|
49
|
-
scroll body's center — views.tsx measures the offset, sets the
|
|
50
|
-
toggles the class, and removes
|
|
51
|
-
pane, hidden view) so the host's natural centering
|
|
52
|
-
|
|
51
|
+
scroll body's center — views.tsx measures the offset, sets the variables
|
|
52
|
+
and toggles the class, and removes all three whenever the shift collapses
|
|
53
|
+
(narrow pane, hidden view) so the host's natural centering and width come
|
|
54
|
+
back. max-width tracks the tutor column's live width: the tutor shrinks
|
|
55
|
+
proportionally below ~1857px containers, and the card shrinks with it. */
|
|
56
|
+
[data-composer-card].lks-composer-follow{align-self:flex-start;margin-left:var(--lks-composer-shift,0px);max-width:var(--lks-composer-follow-width,var(--dsh-composer-card-max-width,780px))}
|
|
53
57
|
/* narrow (<1220px): one composer-width pane at a time, chosen by a centered
|
|
54
58
|
segmented pill group (joined buttons in one capsule — button language, not
|
|
55
59
|
tab language, against the host's view tabs right above). */
|
|
@@ -184,12 +188,18 @@ window.__ModuleLoader__.load({
|
|
|
184
188
|
.lks-prose pre{background:var(--dsw-alias-markdown-code-block);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px;overflow-x:auto;margin:8px 0;font-size:13.5px;font-family:var(--dsw-font-markdown-code-block-small)}
|
|
185
189
|
.lks-prose code{font-family:var(--dsw-font-markdown-code);background:var(--dsw-alias-bg-layer-3);border-radius:4px;padding:1px 5px;font-size:.92em}
|
|
186
190
|
.lks-prose pre code{background:none;padding:0}
|
|
187
|
-
.lks-prose table{border-collapse:collapse;margin:8px 0}
|
|
191
|
+
.lks-prose table{border-collapse:collapse;margin:8px 0;display:block;max-width:100%;overflow-x:auto}
|
|
188
192
|
.lks-prose th,.lks-prose td{border:1px solid var(--dsw-alias-border-l2);padding:5px 11px;font-size:14px}
|
|
189
193
|
.lks-prose th{background:var(--dsw-alias-bg-layer-2)}
|
|
190
194
|
.lks-prose blockquote{border-left:3px solid var(--dsw-alias-state-business-primary);padding:2px 12px;color:var(--dsw-alias-label-secondary);margin:8px 0}
|
|
191
195
|
.lks-prose ul,.lks-prose ol{padding-left:22px;margin:6px 0}
|
|
192
196
|
.lks-prose img{max-width:100%}
|
|
197
|
+
/* width guards for CDN-rendered artifacts (both teach and diagram panes are
|
|
198
|
+
.lks-prose): mermaid/elk SVGs carry fixed width attributes; KaTeX
|
|
199
|
+
display math can exceed the column; both must degrade to fit/scroll
|
|
200
|
+
internally instead of stretching the blackboard. */
|
|
201
|
+
.lks-prose svg{max-width:100%;height:auto}
|
|
202
|
+
.lks-prose .katex-display{overflow-x:auto;overflow-y:hidden;padding:2px 0}
|
|
193
203
|
|
|
194
204
|
/* Cornell notes zones */
|
|
195
205
|
.lks-bb-notes{margin-top:14px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1)}
|
|
@@ -203,6 +213,29 @@ window.__ModuleLoader__.load({
|
|
|
203
213
|
|
|
204
214
|
/* inline error text (write-action failures) */
|
|
205
215
|
.lks-propcard-err{color:var(--dsw-alias-state-error-primary);font-size:12px;margin-top:6px;flex:none}
|
|
216
|
+
|
|
217
|
+
/* settings section (settings.section entry inside the host settings shell) */
|
|
218
|
+
.lks-settings{display:flex;flex-direction:column;gap:18px;font-family:var(--dsw-font-family);color:var(--dsw-alias-label-primary)}
|
|
219
|
+
.lks-set-row h3{margin:0 0 4px;font-size:15px;font-weight:600}
|
|
220
|
+
.lks-set-hint{margin:0 0 10px;font-size:13px;color:var(--dsw-alias-label-tertiary)}
|
|
221
|
+
.lks-set-state{margin-left:10px;font-size:13px;color:var(--dsw-alias-label-secondary)}
|
|
222
|
+
.lks-set-stats{margin:0;padding-left:18px;font-size:14px;line-height:1.9}
|
|
223
|
+
.lks-set-path{font-family:var(--dsw-font-markdown-code);font-size:12.5px;background:var(--dsw-alias-bg-layer-3);border-radius:6px;padding:3px 8px;word-break:break-all}
|
|
224
|
+
|
|
225
|
+
/* composer dock status pill (conversation.composer.dock entry) */
|
|
226
|
+
.lks-dockpill{display:inline-flex;gap:10px;align-items:center;font-size:12px;color:var(--dsw-alias-label-tertiary)}
|
|
227
|
+
.lks-dockseg{white-space:nowrap}
|
|
228
|
+
.lks-dock-due{color:var(--dsw-alias-state-warn-label)}
|
|
229
|
+
.lks-dock-streak{color:var(--dsw-alias-state-business-primary)}
|
|
230
|
+
|
|
231
|
+
/* keyed tool.call.toolview cards (conversation tab tool rows) */
|
|
232
|
+
.lks-tv{display:flex;flex-direction:column;gap:4px;padding:6px 10px;border-radius:10px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);font-size:13px}
|
|
233
|
+
.lks-tv.err{border-color:var(--dsw-alias-state-error-primary)}
|
|
234
|
+
.lks-tv-head{font-weight:600;color:var(--dsw-alias-label-secondary);font-size:12px}
|
|
235
|
+
.lks-tv-chip{align-self:flex-start;border-radius:999px;padding:2px 10px;font-size:12.5px;font-weight:600}
|
|
236
|
+
.lks-tv-chip.ok{background:var(--dsw-alias-state-success-tertiary);color:var(--dsw-alias-state-success-label)}
|
|
237
|
+
.lks-tv-chip.bad{background:var(--dsw-alias-state-error-tertiary);color:var(--dsw-alias-state-error-label)}
|
|
238
|
+
.lks-tv-line{color:var(--dsw-alias-label-secondary);line-height:1.6;white-space:pre-wrap}
|
|
206
239
|
`;
|
|
207
240
|
/**
|
|
208
241
|
* Inject the study stylesheet into `document.head` (idempotent).
|
|
@@ -213,7 +246,7 @@ window.__ModuleLoader__.load({
|
|
|
213
246
|
if (existing !== null) return existing;
|
|
214
247
|
const style = document.createElement("style");
|
|
215
248
|
style.setAttribute(STYLE_ID, "");
|
|
216
|
-
style.textContent =
|
|
249
|
+
style.textContent = STUDY_CSS;
|
|
217
250
|
document.head.appendChild(style);
|
|
218
251
|
return style;
|
|
219
252
|
}
|
|
@@ -358,6 +391,10 @@ window.__ModuleLoader__.load({
|
|
|
358
391
|
});
|
|
359
392
|
this.refresh();
|
|
360
393
|
}
|
|
394
|
+
/** Full-text lesson search (rail fallback when local title matching misses). */
|
|
395
|
+
async searchLessons(query) {
|
|
396
|
+
return (await fetchJson(`/lookatstudy/api/search?q=${encodeURIComponent(query)}`)).matches ?? [];
|
|
397
|
+
}
|
|
361
398
|
/** Point the tutor's focus at one lesson. */
|
|
362
399
|
async setFocus(lessonId) {
|
|
363
400
|
await fetchJson("/lookatstudy/api/focus", {
|
|
@@ -398,6 +435,7 @@ window.__ModuleLoader__.load({
|
|
|
398
435
|
activate: studyStore.activate.bind(studyStore),
|
|
399
436
|
setMode: studyStore.setMode.bind(studyStore),
|
|
400
437
|
setFocus: studyStore.setFocus.bind(studyStore),
|
|
438
|
+
searchLessons: studyStore.searchLessons.bind(studyStore),
|
|
401
439
|
deleteCourse: studyStore.deleteCourse.bind(studyStore),
|
|
402
440
|
bindLessonSession: studyStore.bindLessonSession.bind(studyStore)
|
|
403
441
|
};
|
|
@@ -416,9 +454,12 @@ window.__ModuleLoader__.load({
|
|
|
416
454
|
function escapeHtml(text) {
|
|
417
455
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
418
456
|
}
|
|
419
|
-
/** Render inline markup (code, bold, italic, links) over escaped text.
|
|
457
|
+
/** Render inline markup (code, bold, italic, links, images) over escaped text.
|
|
458
|
+
* Image src allowlist: https?:// or data:image/ — folder imports inline local
|
|
459
|
+
* images as data URLs (capped), GitHub imports reference jsDelivr; anything
|
|
460
|
+
* else stays literal text (no broken relative images, no exotic schemes). */
|
|
420
461
|
function inline(escaped) {
|
|
421
|
-
return escaped.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<a href=\"$2\" target=\"_blank\" rel=\"noreferrer\">$1</a>");
|
|
462
|
+
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>");
|
|
422
463
|
}
|
|
423
464
|
/** True when the line opens a GFM table row (pipes with a delimiter row next). */
|
|
424
465
|
function isTableRow(line) {
|
|
@@ -540,6 +581,1021 @@ window.__ModuleLoader__.load({
|
|
|
540
581
|
return out.join("\n");
|
|
541
582
|
}
|
|
542
583
|
//#endregion
|
|
584
|
+
//#region src/vendor/mermaid-elk-rewrite.ts
|
|
585
|
+
/**
|
|
586
|
+
* mermaid-elk-rewrite —— flowchart → flowchart-elk 前缀改写(v0.21 纯函数)。
|
|
587
|
+
*
|
|
588
|
+
* 只在渲染层改写:LLM 出题提示词(draw_diagram)与语法修复回路
|
|
589
|
+
* (diagram-repair-service)都继续产出/接收**原版 flowchart 语法**,零改动;
|
|
590
|
+
* renderMermaid 喂给 mermaid 前把 flowchart/graph 声明行改成 flowchart-elk ——
|
|
591
|
+
* 同一套语法换 ELK 布局引擎(正交路由,复杂流程图边交叉显著少于 dagre)。
|
|
592
|
+
*
|
|
593
|
+
* mermaid 机制:flowchart-elk 前缀 → detector 置 config.layout="elk" → 查
|
|
594
|
+
* registerLayoutLoaders 注册的 "elk" 算法;未注册则静默退 dagre 并 console.warn
|
|
595
|
+
* (ui-test 靠该 warn 断言 ELK 真实生效)。
|
|
596
|
+
*
|
|
597
|
+
* 范围:只改首个内容行的 flowchart 家族声明;sequenceDiagram/stateDiagram-v2/
|
|
598
|
+
* classDiagram 等其余图类型原样返回(ELK 布局只对 flowchart 家族注册)。
|
|
599
|
+
*/
|
|
600
|
+
/** 首个内容行(跳过空行与 %% 注释/`%%{init}%%` 指令行)的 flowchart 声明改为
|
|
601
|
+
* flowchart-elk;已是 flowchart-elk 则幂等返回;其他图类型不动。 */
|
|
602
|
+
function rewriteFlowchartToElk(code) {
|
|
603
|
+
const lines = code.split("\n");
|
|
604
|
+
for (let i = 0; i < lines.length; i++) {
|
|
605
|
+
const line = lines[i] ?? "";
|
|
606
|
+
const trimmed = line.trim();
|
|
607
|
+
if (!trimmed || trimmed.startsWith("%%")) continue;
|
|
608
|
+
if (/^flowchart-elk\b/i.test(trimmed)) return code;
|
|
609
|
+
if (/^(flowchart|graph)\b/i.test(trimmed)) {
|
|
610
|
+
lines[i] = line.replace(/^(\s*)(flowchart|graph)\b/i, "$1flowchart-elk");
|
|
611
|
+
return lines.join("\n");
|
|
612
|
+
}
|
|
613
|
+
return code;
|
|
614
|
+
}
|
|
615
|
+
return code;
|
|
616
|
+
}
|
|
617
|
+
//#endregion
|
|
618
|
+
//#region src/client/enhance.ts
|
|
619
|
+
/**
|
|
620
|
+
* Post-render blackboard enhancement (SPEC Phase 2): math (KaTeX), code
|
|
621
|
+
* highlighting (shiki), and mermaid diagrams — every dependency loaded from a
|
|
622
|
+
* CDN at runtime (zero-dependency mandate: nothing enters the bundle), every
|
|
623
|
+
* failure degrading silently to the existing plain rendering. Upstream parity:
|
|
624
|
+
* math-normalize runs host-side before renderMarkdown; the CDN + lazy-singleton
|
|
625
|
+
* pattern follows upstream lazy-shiki/lazy-mermaid, pointed at esm.sh/cdnjs.
|
|
626
|
+
*
|
|
627
|
+
* Test seams: every loader is injectable via `setEnhanceDeps` so node:test
|
|
628
|
+
* proves the enhance/degrade paths offline (the structural-test discipline:
|
|
629
|
+
* prove a gate fails first, then trust its green).
|
|
630
|
+
*/
|
|
631
|
+
/** Map a lang-xxx class to shiki's grammar id (upstream's curated set, aliases included). */
|
|
632
|
+
const LANG_ALIAS = {
|
|
633
|
+
ts: "typescript",
|
|
634
|
+
tsx: "tsx",
|
|
635
|
+
js: "javascript",
|
|
636
|
+
jsx: "jsx",
|
|
637
|
+
mjs: "javascript",
|
|
638
|
+
cjs: "javascript",
|
|
639
|
+
py: "python",
|
|
640
|
+
rb: "ruby",
|
|
641
|
+
rs: "rust",
|
|
642
|
+
"c++": "cpp",
|
|
643
|
+
cs: "csharp",
|
|
644
|
+
kt: "kotlin",
|
|
645
|
+
sh: "bash",
|
|
646
|
+
shell: "bash",
|
|
647
|
+
zsh: "bash",
|
|
648
|
+
yml: "yaml",
|
|
649
|
+
md: "markdown",
|
|
650
|
+
tex: "latex",
|
|
651
|
+
docker: "dockerfile"
|
|
652
|
+
};
|
|
653
|
+
let deps = {};
|
|
654
|
+
const CDN = {
|
|
655
|
+
katexJs: "https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js",
|
|
656
|
+
katexCss: "https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css",
|
|
657
|
+
shiki: "https://esm.sh/shiki@1.29.2",
|
|
658
|
+
mermaid: "https://esm.sh/mermaid@11.4.1",
|
|
659
|
+
mermaidElk: "https://esm.sh/@mermaid-js/layout-elk@0.1.7"
|
|
660
|
+
};
|
|
661
|
+
function loadScript(src) {
|
|
662
|
+
return new Promise((resolve, reject) => {
|
|
663
|
+
const el = document.createElement("script");
|
|
664
|
+
el.src = src;
|
|
665
|
+
el.onload = () => {
|
|
666
|
+
resolve();
|
|
667
|
+
};
|
|
668
|
+
el.onerror = () => {
|
|
669
|
+
reject(/* @__PURE__ */ new Error(`script load failed: ${src}`));
|
|
670
|
+
};
|
|
671
|
+
document.head.append(el);
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
let katexPromise = null;
|
|
675
|
+
function defaultLoadKatex() {
|
|
676
|
+
katexPromise ??= (async () => {
|
|
677
|
+
if (!document.querySelector(`link[data-lks-katex]`)) {
|
|
678
|
+
const link = document.createElement("link");
|
|
679
|
+
link.rel = "stylesheet";
|
|
680
|
+
link.href = CDN.katexCss;
|
|
681
|
+
link.setAttribute("data-lks-katex", "1");
|
|
682
|
+
document.head.append(link);
|
|
683
|
+
}
|
|
684
|
+
if (window.katex === void 0) await loadScript(CDN.katexJs);
|
|
685
|
+
const katex = window.katex;
|
|
686
|
+
if (katex === void 0) throw new Error("katex missing after load");
|
|
687
|
+
return katex;
|
|
688
|
+
})();
|
|
689
|
+
return katexPromise;
|
|
690
|
+
}
|
|
691
|
+
let shikiPromise = null;
|
|
692
|
+
function defaultLoadShiki() {
|
|
693
|
+
shikiPromise ??= import(
|
|
694
|
+
/* @vite-ignore */
|
|
695
|
+
CDN.shiki
|
|
696
|
+
).then((mod) => {
|
|
697
|
+
const create = mod.createHighlighter;
|
|
698
|
+
if (create === void 0) throw new Error("shiki createHighlighter missing");
|
|
699
|
+
return create({
|
|
700
|
+
themes: ["github-dark", "github-light"],
|
|
701
|
+
langs: [
|
|
702
|
+
"typescript",
|
|
703
|
+
"javascript",
|
|
704
|
+
"python",
|
|
705
|
+
"bash",
|
|
706
|
+
"json",
|
|
707
|
+
"yaml",
|
|
708
|
+
"css",
|
|
709
|
+
"html",
|
|
710
|
+
"markdown",
|
|
711
|
+
"cpp",
|
|
712
|
+
"go",
|
|
713
|
+
"rust",
|
|
714
|
+
"java",
|
|
715
|
+
"sql"
|
|
716
|
+
]
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
return shikiPromise;
|
|
720
|
+
}
|
|
721
|
+
let mermaidPromise = null;
|
|
722
|
+
function defaultLoadMermaid() {
|
|
723
|
+
mermaidPromise ??= (async () => {
|
|
724
|
+
const mermaid = (await import(
|
|
725
|
+
/* @vite-ignore */
|
|
726
|
+
CDN.mermaid
|
|
727
|
+
)).default;
|
|
728
|
+
try {
|
|
729
|
+
const elk = await import(
|
|
730
|
+
/* @vite-ignore */
|
|
731
|
+
CDN.mermaidElk
|
|
732
|
+
);
|
|
733
|
+
mermaid.registerLayoutLoaders(elk.default);
|
|
734
|
+
} catch {}
|
|
735
|
+
mermaid.initialize({
|
|
736
|
+
startOnLoad: false,
|
|
737
|
+
theme: "base",
|
|
738
|
+
securityLevel: "loose"
|
|
739
|
+
});
|
|
740
|
+
return mermaid;
|
|
741
|
+
})();
|
|
742
|
+
return mermaidPromise;
|
|
743
|
+
}
|
|
744
|
+
/** Split one text node's data around the FIRST math span; returns null when none. */
|
|
745
|
+
function findMathSpan(text) {
|
|
746
|
+
const display = /\$\$([^$]+)\$\$/.exec(text);
|
|
747
|
+
const inline = /\$([^$\n]+)\$/.exec(text);
|
|
748
|
+
if (display === null && inline === null) return null;
|
|
749
|
+
const m = display !== null && (inline === null || display.index <= inline.index) ? {
|
|
750
|
+
...display,
|
|
751
|
+
display: true
|
|
752
|
+
} : inline === null ? null : {
|
|
753
|
+
...inline,
|
|
754
|
+
display: false
|
|
755
|
+
};
|
|
756
|
+
if (m === null) return null;
|
|
757
|
+
return {
|
|
758
|
+
before: text.slice(0, m.index),
|
|
759
|
+
tex: m[1],
|
|
760
|
+
after: text.slice(m.index + m[0].length),
|
|
761
|
+
display: m.display
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
/** KaTeX every $..$ / $$..$$ in the container; degrades to plain text on any failure. */
|
|
765
|
+
async function enhanceMath(container) {
|
|
766
|
+
let katex;
|
|
767
|
+
try {
|
|
768
|
+
katex = await (deps.loadKatex ?? defaultLoadKatex)();
|
|
769
|
+
} catch {
|
|
770
|
+
return 0;
|
|
771
|
+
}
|
|
772
|
+
let replaced = 0;
|
|
773
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
|
774
|
+
const targets = [];
|
|
775
|
+
let node;
|
|
776
|
+
while ((node = walker.nextNode()) !== null) if (findMathSpan(node.data) !== null && node.parentElement?.tagName !== "CODE") targets.push(node);
|
|
777
|
+
for (const t of targets) {
|
|
778
|
+
const span = findMathSpan(t.data);
|
|
779
|
+
if (span === null) continue;
|
|
780
|
+
try {
|
|
781
|
+
const html = katex.renderToString(span.tex, {
|
|
782
|
+
displayMode: span.display,
|
|
783
|
+
throwOnError: false
|
|
784
|
+
});
|
|
785
|
+
const holder = document.createElement("span");
|
|
786
|
+
holder.innerHTML = html;
|
|
787
|
+
if (span.before !== "") t.before(document.createTextNode(span.before));
|
|
788
|
+
t.before(holder);
|
|
789
|
+
t.data = span.after;
|
|
790
|
+
replaced++;
|
|
791
|
+
} catch {}
|
|
792
|
+
}
|
|
793
|
+
return replaced;
|
|
794
|
+
}
|
|
795
|
+
/** shiki-highlight every fenced code block; unknown langs and failures keep the plain <pre>. */
|
|
796
|
+
async function enhanceCode(container) {
|
|
797
|
+
let shiki;
|
|
798
|
+
try {
|
|
799
|
+
shiki = await (deps.loadShiki ?? defaultLoadShiki)();
|
|
800
|
+
} catch {
|
|
801
|
+
return 0;
|
|
802
|
+
}
|
|
803
|
+
let replaced = 0;
|
|
804
|
+
const blocks = [...container.querySelectorAll("pre > code")];
|
|
805
|
+
for (const code of blocks) {
|
|
806
|
+
if (code.dataset.lksEnhanced === "1") continue;
|
|
807
|
+
const langClass = [...code.classList].find((c) => c.startsWith("lang-"));
|
|
808
|
+
if (langClass === void 0) continue;
|
|
809
|
+
const lang = LANG_ALIAS[langClass.slice(5)] ?? langClass.slice(5);
|
|
810
|
+
try {
|
|
811
|
+
const html = await shiki.codeToHtml(code.textContent ?? "", {
|
|
812
|
+
lang,
|
|
813
|
+
themes: {
|
|
814
|
+
light: "github-light",
|
|
815
|
+
dark: "github-dark"
|
|
816
|
+
},
|
|
817
|
+
defaultColor: false,
|
|
818
|
+
cssVariables: { "--shiki-dark": "var(--shiki-dark, #e6edf3)" }
|
|
819
|
+
});
|
|
820
|
+
code.dataset.lksEnhanced = "1";
|
|
821
|
+
const pre = code.parentElement;
|
|
822
|
+
const wrap = document.createElement("div");
|
|
823
|
+
wrap.className = "lks-shiki";
|
|
824
|
+
wrap.innerHTML = html;
|
|
825
|
+
pre.replaceWith(wrap);
|
|
826
|
+
replaced++;
|
|
827
|
+
} catch {
|
|
828
|
+
code.dataset.lksEnhanced = "1";
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return replaced;
|
|
832
|
+
}
|
|
833
|
+
/** Render mermaid fence blocks to SVG (ELK layout via the registered loader);
|
|
834
|
+
* the flowchart prefix rewrite is upstream's mermaid-elk-rewrite, vendored. */
|
|
835
|
+
async function enhanceDiagrams(container) {
|
|
836
|
+
let mermaid;
|
|
837
|
+
try {
|
|
838
|
+
mermaid = await (deps.loadMermaid ?? defaultLoadMermaid)();
|
|
839
|
+
} catch {
|
|
840
|
+
return 0;
|
|
841
|
+
}
|
|
842
|
+
let replaced = 0;
|
|
843
|
+
const blocks = [...container.querySelectorAll("pre > code.lang-mermaid")];
|
|
844
|
+
for (const code of blocks) {
|
|
845
|
+
if (code.dataset.lksEnhanced === "1") continue;
|
|
846
|
+
code.dataset.lksEnhanced = "1";
|
|
847
|
+
try {
|
|
848
|
+
const { svg } = await mermaid.render(`lks-mermaid-${replaced}`, rewriteFlowchartToElk(code.textContent ?? ""));
|
|
849
|
+
const wrap = document.createElement("div");
|
|
850
|
+
wrap.className = "lks-mermaid";
|
|
851
|
+
wrap.innerHTML = svg;
|
|
852
|
+
code.parentElement.replaceWith(wrap);
|
|
853
|
+
replaced++;
|
|
854
|
+
} catch {}
|
|
855
|
+
}
|
|
856
|
+
return replaced;
|
|
857
|
+
}
|
|
858
|
+
/** The full pass over one freshly-rendered container. Never throws. */
|
|
859
|
+
async function enhanceRendered(container) {
|
|
860
|
+
try {
|
|
861
|
+
await enhanceDiagrams(container);
|
|
862
|
+
} catch {}
|
|
863
|
+
try {
|
|
864
|
+
await enhanceCode(container);
|
|
865
|
+
} catch {}
|
|
866
|
+
try {
|
|
867
|
+
await enhanceMath(container);
|
|
868
|
+
} catch {}
|
|
869
|
+
}
|
|
870
|
+
//#endregion
|
|
871
|
+
//#region src/vendor/cmap-elk-layout.ts
|
|
872
|
+
/** 字符宽度估算:CJK 全宽,ASCII 约 0.58em,其余(BBOP…)同 ASCII。 */
|
|
873
|
+
function estTextWidth(text, fontSize = 13) {
|
|
874
|
+
let w = 0;
|
|
875
|
+
for (const ch of text) w += /[\u2e80-\u9fff\uf900-\ufaff\uff00-\uffef\u3000-\u303f]/.test(ch) ? fontSize : fontSize * .58;
|
|
876
|
+
return w;
|
|
877
|
+
}
|
|
878
|
+
const LINE_MAX_PX = 118;
|
|
879
|
+
/** 标签包裹:≤2 行;优先自然断点(空格///-),CJK 连续按宽度一半硬拆;仍超宽截断。 */
|
|
880
|
+
function wrapLabel(label) {
|
|
881
|
+
if (estTextWidth(label) <= LINE_MAX_PX) return [label];
|
|
882
|
+
const breaks = [];
|
|
883
|
+
for (let i = 0; i < label.length; i++) if (/[\s/\-—·]/.test(label[i] ?? "")) breaks.push(i);
|
|
884
|
+
const mid = label.length / 2;
|
|
885
|
+
let split = -1;
|
|
886
|
+
let best = Infinity;
|
|
887
|
+
for (const b of breaks) {
|
|
888
|
+
const d = Math.abs(b + 1 - mid);
|
|
889
|
+
if (d < best) {
|
|
890
|
+
best = d;
|
|
891
|
+
split = b;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
let l1;
|
|
895
|
+
let l2;
|
|
896
|
+
if (split >= 0) {
|
|
897
|
+
l1 = label.slice(0, split + 1).trim();
|
|
898
|
+
l2 = label.slice(split + 1).trim();
|
|
899
|
+
} else {
|
|
900
|
+
let acc = 0;
|
|
901
|
+
let idx = 0;
|
|
902
|
+
for (const ch of label) {
|
|
903
|
+
acc += estTextWidth(ch);
|
|
904
|
+
idx++;
|
|
905
|
+
if (acc >= estTextWidth(label) / 2) break;
|
|
906
|
+
}
|
|
907
|
+
l1 = label.slice(0, idx);
|
|
908
|
+
l2 = label.slice(idx);
|
|
909
|
+
}
|
|
910
|
+
if (estTextWidth(l1) <= LINE_MAX_PX && estTextWidth(l2) <= LINE_MAX_PX) return [l1, l2];
|
|
911
|
+
const fit = (s) => {
|
|
912
|
+
let out = "";
|
|
913
|
+
for (const ch of s) {
|
|
914
|
+
if (estTextWidth(out + ch + "…") > LINE_MAX_PX) break;
|
|
915
|
+
out += ch;
|
|
916
|
+
}
|
|
917
|
+
return out + "…";
|
|
918
|
+
};
|
|
919
|
+
return [estTextWidth(l1) > LINE_MAX_PX ? fit(l1) : l1, estTextWidth(l2) > LINE_MAX_PX ? fit(l2) : l2];
|
|
920
|
+
}
|
|
921
|
+
/** 节点盒:hub(度数最大或 ≥4)字号/内边距更大;宽高由包裹后行宽决定。 */
|
|
922
|
+
function cmNodeBox(label, hub) {
|
|
923
|
+
const lines = wrapLabel(label);
|
|
924
|
+
const fs = hub ? 14 : 13;
|
|
925
|
+
const textW = Math.max(...lines.map((l) => estTextWidth(l, fs)));
|
|
926
|
+
const padX = hub ? 30 : 24;
|
|
927
|
+
const lh = 17;
|
|
928
|
+
const padY = hub ? 18 : 14;
|
|
929
|
+
return {
|
|
930
|
+
width: Math.min(hub ? 200 : 180, Math.max(hub ? 92 : 76, textW + padX)),
|
|
931
|
+
height: padY + lines.length * lh,
|
|
932
|
+
lines
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* 净化 LLM groups:丢弃指向不存在节点的 nodeIds、去重(先到先得)、
|
|
937
|
+
* 剩余成员 < 2 的组丢弃;唯一覆盖全部节点的单组视为无效(一个巨容器没有信息量)。
|
|
938
|
+
* 净化后为空 → clusterByAdjacency 兜底。
|
|
939
|
+
*/
|
|
940
|
+
function resolveGroups(nodes, edges, input) {
|
|
941
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
942
|
+
if (Array.isArray(input) && input.length > 0) {
|
|
943
|
+
const assigned = /* @__PURE__ */ new Set();
|
|
944
|
+
const groups = [];
|
|
945
|
+
for (const g of input) {
|
|
946
|
+
if (!g || typeof g.id !== "string" || !g.id) continue;
|
|
947
|
+
const nodeIds = Array.isArray(g.nodeIds) ? [...new Set(g.nodeIds.filter((x) => typeof x === "string" && ids.has(x) && !assigned.has(x)))] : [];
|
|
948
|
+
if (nodeIds.length < 2) continue;
|
|
949
|
+
nodeIds.forEach((x) => assigned.add(x));
|
|
950
|
+
groups.push({
|
|
951
|
+
id: g.id,
|
|
952
|
+
label: typeof g.label === "string" && g.label ? g.label : "分组",
|
|
953
|
+
nodeIds
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
if (groups.length > 0 && !(groups.length === 1 && assigned.size === nodes.length && nodes.length > 2)) return {
|
|
957
|
+
groups,
|
|
958
|
+
fallback: false
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
return {
|
|
962
|
+
groups: clusterByAdjacency(nodes, edges),
|
|
963
|
+
fallback: true
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* 邻接聚类兜底(确定性):度数 top-k 节点做种子(k = clamp(ceil(n/4),1,3)),
|
|
968
|
+
* 多源 BFS 就近归属(平手先出现的种子赢);孤立节点不归组(根层漂浮,不造假组)。
|
|
969
|
+
* 组名 = 种子标签(≤5 字加"相关")。
|
|
970
|
+
*/
|
|
971
|
+
function clusterByAdjacency(nodes, edges) {
|
|
972
|
+
if (nodes.length < 4) return [];
|
|
973
|
+
const index = new Map(nodes.map((n, i) => [n.id, i]));
|
|
974
|
+
const adj = /* @__PURE__ */ new Map();
|
|
975
|
+
for (const n of nodes) adj.set(n.id, []);
|
|
976
|
+
const seenPair = /* @__PURE__ */ new Set();
|
|
977
|
+
for (const e of edges) {
|
|
978
|
+
if (!index.has(e.from) || !index.has(e.to) || e.from === e.to) continue;
|
|
979
|
+
const key = [e.from, e.to].sort().join("→");
|
|
980
|
+
if (seenPair.has(key)) continue;
|
|
981
|
+
seenPair.add(key);
|
|
982
|
+
adj.get(e.from).push(e.to);
|
|
983
|
+
adj.get(e.to).push(e.from);
|
|
984
|
+
}
|
|
985
|
+
const ranked = [...nodes].sort((a, b) => {
|
|
986
|
+
const da = adj.get(a.id).length;
|
|
987
|
+
const db = adj.get(b.id).length;
|
|
988
|
+
return da !== db ? db - da : (index.get(a.id) ?? 0) - (index.get(b.id) ?? 0);
|
|
989
|
+
});
|
|
990
|
+
const k = Math.min(3, Math.max(1, Math.ceil(nodes.length / 4)));
|
|
991
|
+
const seeds = ranked.slice(0, k).filter((s) => (adj.get(s.id)?.length ?? 0) > 0);
|
|
992
|
+
if (seeds.length === 0) return [];
|
|
993
|
+
const owner = /* @__PURE__ */ new Map();
|
|
994
|
+
seeds.forEach((s, si) => owner.set(s.id, si));
|
|
995
|
+
const queue = seeds.map((s) => s.id);
|
|
996
|
+
while (queue.length) {
|
|
997
|
+
const cur = queue.shift();
|
|
998
|
+
const si = owner.get(cur);
|
|
999
|
+
for (const nb of adj.get(cur) ?? []) {
|
|
1000
|
+
if (owner.has(nb)) continue;
|
|
1001
|
+
owner.set(nb, si);
|
|
1002
|
+
queue.push(nb);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
const groups = [];
|
|
1006
|
+
seeds.forEach((s, si) => {
|
|
1007
|
+
const nodeIds = nodes.filter((n) => owner.get(n.id) === si).map((n) => n.id);
|
|
1008
|
+
if (nodeIds.length < 2) return;
|
|
1009
|
+
groups.push({
|
|
1010
|
+
id: `grp-${s.id}`,
|
|
1011
|
+
label: s.label.length <= 5 ? `${s.label}相关` : s.label,
|
|
1012
|
+
nodeIds
|
|
1013
|
+
});
|
|
1014
|
+
});
|
|
1015
|
+
return groups;
|
|
1016
|
+
}
|
|
1017
|
+
function buildElkGraph(nodes, edges, groups) {
|
|
1018
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
1019
|
+
const degree = /* @__PURE__ */ new Map();
|
|
1020
|
+
for (const n of nodes) degree.set(n.id, 0);
|
|
1021
|
+
const seenPair = /* @__PURE__ */ new Set();
|
|
1022
|
+
const drawEdges = [];
|
|
1023
|
+
for (const e of edges) {
|
|
1024
|
+
if (!ids.has(e.from) || !ids.has(e.to) || e.from === e.to) continue;
|
|
1025
|
+
const key = [e.from, e.to].sort().join("→");
|
|
1026
|
+
if (seenPair.has(key)) continue;
|
|
1027
|
+
seenPair.add(key);
|
|
1028
|
+
drawEdges.push(e);
|
|
1029
|
+
degree.set(e.from, degree.get(e.from) + 1);
|
|
1030
|
+
degree.set(e.to, degree.get(e.to) + 1);
|
|
1031
|
+
}
|
|
1032
|
+
let hubId = nodes[0]?.id ?? "";
|
|
1033
|
+
for (const n of nodes) if ((degree.get(n.id) ?? 0) > (degree.get(hubId) ?? 0)) hubId = n.id;
|
|
1034
|
+
const inGroup = /* @__PURE__ */ new Map();
|
|
1035
|
+
for (const g of groups) for (const nid of g.nodeIds) if (ids.has(nid) && !inGroup.has(nid)) inGroup.set(nid, g.id);
|
|
1036
|
+
const nodeChild = (n) => {
|
|
1037
|
+
const box = cmNodeBox(n.label, n.id === hubId || (degree.get(n.id) ?? 0) >= 4);
|
|
1038
|
+
return {
|
|
1039
|
+
id: n.id,
|
|
1040
|
+
width: Math.ceil(box.width),
|
|
1041
|
+
height: Math.ceil(box.height)
|
|
1042
|
+
};
|
|
1043
|
+
};
|
|
1044
|
+
const children = [];
|
|
1045
|
+
for (const g of groups) {
|
|
1046
|
+
const members = g.nodeIds.filter((nid) => inGroup.get(nid) === g.id && ids.has(nid));
|
|
1047
|
+
if (members.length === 0) continue;
|
|
1048
|
+
children.push({
|
|
1049
|
+
id: `g:${g.id}`,
|
|
1050
|
+
layoutOptions: { "elk.padding": `[top=34,left=14,bottom=12,right=14]` },
|
|
1051
|
+
children: members.map((nid) => nodeChild(nodes.find((n) => n.id === nid)))
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
for (const n of nodes) if (!inGroup.has(n.id)) children.push(nodeChild(n));
|
|
1055
|
+
return {
|
|
1056
|
+
id: "root",
|
|
1057
|
+
layoutOptions: {
|
|
1058
|
+
"elk.algorithm": "layered",
|
|
1059
|
+
"elk.direction": "DOWN",
|
|
1060
|
+
"elk.edgeRouting": "ORTHOGONAL",
|
|
1061
|
+
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
|
|
1062
|
+
"elk.spacing.nodeNode": "34",
|
|
1063
|
+
"elk.spacing.edgeNode": "26",
|
|
1064
|
+
"elk.spacing.componentComponent": "48",
|
|
1065
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": "56"
|
|
1066
|
+
},
|
|
1067
|
+
children,
|
|
1068
|
+
edges: drawEdges.map((e, i) => ({
|
|
1069
|
+
id: `e${i}`,
|
|
1070
|
+
sources: [e.from],
|
|
1071
|
+
targets: [e.to]
|
|
1072
|
+
}))
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
/** 折线弧长中点(标签锚点)。 */
|
|
1076
|
+
function polylineMidpoint(pts) {
|
|
1077
|
+
if (pts.length === 0) return {
|
|
1078
|
+
x: 0,
|
|
1079
|
+
y: 0
|
|
1080
|
+
};
|
|
1081
|
+
if (pts.length === 1) return pts[0];
|
|
1082
|
+
const segLens = [];
|
|
1083
|
+
let total = 0;
|
|
1084
|
+
for (let i = 1; i < pts.length; i++) {
|
|
1085
|
+
const l = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
|
|
1086
|
+
segLens.push(l);
|
|
1087
|
+
total += l;
|
|
1088
|
+
}
|
|
1089
|
+
let remain = total / 2;
|
|
1090
|
+
for (let i = 0; i < segLens.length; i++) {
|
|
1091
|
+
if (remain <= segLens[i] || i === segLens.length - 1) {
|
|
1092
|
+
const t = segLens[i] === 0 ? 0 : remain / segLens[i];
|
|
1093
|
+
return {
|
|
1094
|
+
x: pts[i].x + (pts[i + 1].x - pts[i].x) * t,
|
|
1095
|
+
y: pts[i].y + (pts[i + 1].y - pts[i].y) * t
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
remain -= segLens[i];
|
|
1099
|
+
}
|
|
1100
|
+
return pts[pts.length - 1];
|
|
1101
|
+
}
|
|
1102
|
+
function flattenElkResult(elkOut, nodes, edges, groups, groupsFallback, hubId) {
|
|
1103
|
+
const nodesById = new Map(nodes.map((n) => [n.id, n]));
|
|
1104
|
+
const groupColor = /* @__PURE__ */ new Map();
|
|
1105
|
+
groups.forEach((g, i) => groupColor.set(g.id, i % 5));
|
|
1106
|
+
const groupById = new Map(groups.map((g) => [g.id, g]));
|
|
1107
|
+
const memberGroup = /* @__PURE__ */ new Map();
|
|
1108
|
+
for (const g of groups) for (const nid of g.nodeIds) if (!memberGroup.has(nid)) memberGroup.set(nid, g.id);
|
|
1109
|
+
const outNodes = [];
|
|
1110
|
+
const outGroups = [];
|
|
1111
|
+
const outEdges = [];
|
|
1112
|
+
const containerOff = /* @__PURE__ */ new Map([["root", {
|
|
1113
|
+
x: 0,
|
|
1114
|
+
y: 0
|
|
1115
|
+
}]]);
|
|
1116
|
+
const rawEdges = [];
|
|
1117
|
+
const walk = (elk, offX, offY, containerId) => {
|
|
1118
|
+
for (const child of elk.children ?? []) {
|
|
1119
|
+
const cx = offX + (child.x ?? 0);
|
|
1120
|
+
const cy = offY + (child.y ?? 0);
|
|
1121
|
+
if (child.children?.length) {
|
|
1122
|
+
const gid = child.id.startsWith("g:") ? child.id.slice(2) : child.id;
|
|
1123
|
+
const meta = groupById.get(gid);
|
|
1124
|
+
outGroups.push({
|
|
1125
|
+
id: gid,
|
|
1126
|
+
label: meta?.label ?? "分组",
|
|
1127
|
+
x: cx,
|
|
1128
|
+
y: cy,
|
|
1129
|
+
w: child.width ?? 0,
|
|
1130
|
+
h: child.height ?? 0,
|
|
1131
|
+
colorIdx: groupColor.get(gid) ?? 0
|
|
1132
|
+
});
|
|
1133
|
+
containerOff.set(child.id, {
|
|
1134
|
+
x: cx,
|
|
1135
|
+
y: cy
|
|
1136
|
+
});
|
|
1137
|
+
walk(child, cx, cy, gid);
|
|
1138
|
+
} else {
|
|
1139
|
+
const n = nodesById.get(child.id);
|
|
1140
|
+
if (!n) continue;
|
|
1141
|
+
const box = cmNodeBox(n.label, n.id === hubId);
|
|
1142
|
+
const gid = memberGroup.get(n.id) ?? null;
|
|
1143
|
+
outNodes.push({
|
|
1144
|
+
id: n.id,
|
|
1145
|
+
x: cx,
|
|
1146
|
+
y: cy,
|
|
1147
|
+
w: child.width ?? Math.ceil(box.width),
|
|
1148
|
+
h: child.height ?? Math.ceil(box.height),
|
|
1149
|
+
lines: box.lines,
|
|
1150
|
+
hub: n.id === hubId,
|
|
1151
|
+
groupId: containerId,
|
|
1152
|
+
colorIdx: gid != null ? groupColor.get(gid) ?? 0 : -1
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
for (const e of elk.edges ?? []) rawEdges.push({
|
|
1157
|
+
e,
|
|
1158
|
+
fallback: {
|
|
1159
|
+
x: offX,
|
|
1160
|
+
y: offY
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
};
|
|
1164
|
+
walk(elkOut, 0, 0, null);
|
|
1165
|
+
for (const { e, fallback } of rawEdges) {
|
|
1166
|
+
const sec = e.sections?.[0];
|
|
1167
|
+
if (!sec) continue;
|
|
1168
|
+
const off = (e.container != null ? containerOff.get(e.container) : void 0) ?? fallback;
|
|
1169
|
+
const pts = [
|
|
1170
|
+
sec.startPoint,
|
|
1171
|
+
...sec.bendPoints ?? [],
|
|
1172
|
+
sec.endPoint
|
|
1173
|
+
].map((p) => ({
|
|
1174
|
+
x: p.x + off.x,
|
|
1175
|
+
y: p.y + off.y
|
|
1176
|
+
}));
|
|
1177
|
+
const src = edges[Number(e.id.slice(1))];
|
|
1178
|
+
if (!src) continue;
|
|
1179
|
+
outEdges.push({
|
|
1180
|
+
from: src.from,
|
|
1181
|
+
to: src.to,
|
|
1182
|
+
label: src.label,
|
|
1183
|
+
pts,
|
|
1184
|
+
labelPt: src.label ? polylineMidpoint(pts) : null
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
return {
|
|
1188
|
+
width: Math.ceil(elkOut.width ?? 0),
|
|
1189
|
+
height: Math.ceil(elkOut.height ?? 0),
|
|
1190
|
+
nodes: outNodes,
|
|
1191
|
+
groups: outGroups,
|
|
1192
|
+
edges: outEdges,
|
|
1193
|
+
hubId,
|
|
1194
|
+
groupsFallback
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
let elkPromise = null;
|
|
1198
|
+
/** elkjs 单例:elk.bundled.js 主线程版(无 Worker,打包器友好,React Flow 生态同款)。 */
|
|
1199
|
+
function loadElk() {
|
|
1200
|
+
elkPromise ??= import(
|
|
1201
|
+
/* @vite-ignore */
|
|
1202
|
+
"https://esm.sh/elkjs@0.12/lib/elk.bundled.js"
|
|
1203
|
+
).then((mod) => {
|
|
1204
|
+
const m = mod;
|
|
1205
|
+
return new (m.default ?? m)();
|
|
1206
|
+
});
|
|
1207
|
+
return elkPromise;
|
|
1208
|
+
}
|
|
1209
|
+
/** 概念图 → 分组解析 → ELK 布局 → 绝对坐标几何。输入相同则输出逐字段全等。 */
|
|
1210
|
+
async function layoutConceptMap(nodes, edges, groups) {
|
|
1211
|
+
const resolved = resolveGroups(nodes, edges, groups);
|
|
1212
|
+
const graph = buildElkGraph(nodes, edges, resolved.groups);
|
|
1213
|
+
const hubId = computeHubId(nodes, edges);
|
|
1214
|
+
return flattenElkResult(await (await loadElk()).layout(graph), nodes, edges, resolved.groups, resolved.fallback, hubId);
|
|
1215
|
+
}
|
|
1216
|
+
function computeHubId(nodes, edges) {
|
|
1217
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
1218
|
+
const degree = /* @__PURE__ */ new Map();
|
|
1219
|
+
for (const n of nodes) degree.set(n.id, 0);
|
|
1220
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1221
|
+
for (const e of edges) {
|
|
1222
|
+
if (!ids.has(e.from) || !ids.has(e.to) || e.from === e.to) continue;
|
|
1223
|
+
const key = [e.from, e.to].sort().join("→");
|
|
1224
|
+
if (seen.has(key)) continue;
|
|
1225
|
+
seen.add(key);
|
|
1226
|
+
degree.set(e.from, degree.get(e.from) + 1);
|
|
1227
|
+
degree.set(e.to, degree.get(e.to) + 1);
|
|
1228
|
+
}
|
|
1229
|
+
let hubId = nodes[0]?.id ?? "";
|
|
1230
|
+
for (const n of nodes) if ((degree.get(n.id) ?? 0) > (degree.get(hubId) ?? 0)) hubId = n.id;
|
|
1231
|
+
return hubId;
|
|
1232
|
+
}
|
|
1233
|
+
//#endregion
|
|
1234
|
+
//#region src/client/diagrams.ts
|
|
1235
|
+
/**
|
|
1236
|
+
* Blackboard diagram views (SPEC Phase 2): the lesson concept map (ELK layered
|
|
1237
|
+
* layout via the vendored cmap-elk-layout + CDN elkjs, draw.io-flavored SVG
|
|
1238
|
+
* skin rendered plugin-side). Degrades honestly: any CDN/ELK failure leaves a
|
|
1239
|
+
* one-line notice instead of a broken pane.
|
|
1240
|
+
*
|
|
1241
|
+
* The lesson mind map view was RETIRED with upstream v0.26.0 (2026-09-01
|
|
1242
|
+
* port): body text that has headings already shows its structure, text without
|
|
1243
|
+
* headings yields a first-line-truncated node graph nobody can read — both
|
|
1244
|
+
* lose — and this concept map already covers the semantic-structure need at
|
|
1245
|
+
* higher quality. Its three CDN imports went with it.
|
|
1246
|
+
*/
|
|
1247
|
+
/** Lesson concept map: the lesson node + its concepts (weak ones accented),
|
|
1248
|
+
* laid out by the vendored ELK pipeline, skinned draw.io-style (plugin-original,
|
|
1249
|
+
* upstream's skin is coupled to its token pipeline). Mastery shades the nodes. */
|
|
1250
|
+
async function renderLessonConceptMap(container, lessonTitle, concepts) {
|
|
1251
|
+
const layout = await layoutConceptMap([{
|
|
1252
|
+
id: "lesson",
|
|
1253
|
+
label: lessonTitle
|
|
1254
|
+
}, ...concepts.map((c) => ({
|
|
1255
|
+
id: `c-${c.title}`,
|
|
1256
|
+
label: c.title
|
|
1257
|
+
}))], concepts.map((c) => ({
|
|
1258
|
+
from: "lesson",
|
|
1259
|
+
to: `c-${c.title}`
|
|
1260
|
+
})));
|
|
1261
|
+
const masteryBy = new Map(concepts.map((c) => [`c-${c.title}`, c.masteryPct]));
|
|
1262
|
+
const ns = "http://www.w3.org/2000/svg";
|
|
1263
|
+
const svg = document.createElementNS(ns, "svg");
|
|
1264
|
+
svg.setAttribute("width", String(Math.max(320, layout.width)));
|
|
1265
|
+
svg.setAttribute("height", String(Math.max(200, layout.height)));
|
|
1266
|
+
svg.setAttribute("viewBox", `0 0 ${layout.width} ${layout.height}`);
|
|
1267
|
+
svg.setAttribute("style", "max-width:100%;background:var(--dsw-surface-1, #fff);border:1px solid var(--dsw-border-1, #d0d7de);border-radius:8px");
|
|
1268
|
+
for (const e of layout.edges) {
|
|
1269
|
+
const path = document.createElementNS(ns, "path");
|
|
1270
|
+
path.setAttribute("d", e.pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" "));
|
|
1271
|
+
path.setAttribute("fill", "none");
|
|
1272
|
+
path.setAttribute("stroke", "var(--dsw-border-2, #8b949e)");
|
|
1273
|
+
path.setAttribute("stroke-width", "1.5");
|
|
1274
|
+
svg.append(path);
|
|
1275
|
+
}
|
|
1276
|
+
for (const n of layout.nodes) {
|
|
1277
|
+
const g = document.createElementNS(ns, "g");
|
|
1278
|
+
g.setAttribute("transform", `translate(${n.x},${n.y})`);
|
|
1279
|
+
const rect = document.createElementNS(ns, "rect");
|
|
1280
|
+
const isLesson = n.id === "lesson";
|
|
1281
|
+
const mastery = masteryBy.get(n.id);
|
|
1282
|
+
rect.setAttribute("width", String(n.w));
|
|
1283
|
+
rect.setAttribute("height", String(n.h));
|
|
1284
|
+
rect.setAttribute("rx", "6");
|
|
1285
|
+
rect.setAttribute("fill", mastery !== void 0 && mastery < 70 ? "#fff3d9" : "var(--dsw-surface-2, #f6f8fa)");
|
|
1286
|
+
rect.setAttribute("stroke", isLesson ? "var(--dsw-accent-1, #0969da)" : "var(--dsw-border-2, #8b949e)");
|
|
1287
|
+
rect.setAttribute("stroke-width", isLesson ? "2" : "1");
|
|
1288
|
+
g.append(rect);
|
|
1289
|
+
n.lines.forEach((line, li) => {
|
|
1290
|
+
const label = document.createElementNS(ns, "text");
|
|
1291
|
+
label.setAttribute("x", String(n.w / 2));
|
|
1292
|
+
label.setAttribute("y", String(n.h / 2 - (n.lines.length - 1) * 7 + li * 14 + 4));
|
|
1293
|
+
label.setAttribute("text-anchor", "middle");
|
|
1294
|
+
label.setAttribute("font-size", isLesson ? "13" : "12");
|
|
1295
|
+
label.setAttribute("font-weight", isLesson ? "600" : "400");
|
|
1296
|
+
label.setAttribute("fill", "var(--dsw-ink-1, #1f2328)");
|
|
1297
|
+
label.textContent = line;
|
|
1298
|
+
g.append(label);
|
|
1299
|
+
});
|
|
1300
|
+
svg.append(g);
|
|
1301
|
+
}
|
|
1302
|
+
container.append(svg);
|
|
1303
|
+
}
|
|
1304
|
+
//#endregion
|
|
1305
|
+
//#region src/client/locale.ts
|
|
1306
|
+
/** Simplified Chinese dictionary (also the fallback locale). */
|
|
1307
|
+
const ZH = {
|
|
1308
|
+
"tab.label": "学习",
|
|
1309
|
+
"col.rail": "课程",
|
|
1310
|
+
"col.tutor": "导师",
|
|
1311
|
+
"col.bb": "黑板",
|
|
1312
|
+
"pane.rail": "课程",
|
|
1313
|
+
"pane.tutor": "导师",
|
|
1314
|
+
"pane.bb": "黑板",
|
|
1315
|
+
"viewtab.teach": "讲解",
|
|
1316
|
+
"viewtab.cmap": "🕸 概念图",
|
|
1317
|
+
"viewtab.cmap.title": "本课概念关系(ELK 布局)",
|
|
1318
|
+
"loading": "加载中…",
|
|
1319
|
+
"start.title": "一键准备学习:建立学习工作区、开启会话并让导师就位",
|
|
1320
|
+
"start.go": "开始学习",
|
|
1321
|
+
"start.enter": "进入学习",
|
|
1322
|
+
"start.busy": "正在准备学习区…",
|
|
1323
|
+
"soul.direct": "直讲",
|
|
1324
|
+
"soul.direct.hint": "direct 精讲:先讲清楚,再确认懂没懂",
|
|
1325
|
+
"soul.guide": "引导",
|
|
1326
|
+
"soul.guide.hint": "guide 引导:让你自己往前推一步,导师递台阶",
|
|
1327
|
+
"soul.practice": "实战",
|
|
1328
|
+
"soul.practice.hint": "practice 实战:在真实世界的乱问题里学",
|
|
1329
|
+
"zone.understand": "🧠 理解区 — 知识结构",
|
|
1330
|
+
"zone.record": "📝 记录区 — 我的话",
|
|
1331
|
+
"zone.practice": "✍️ 练习区 — 答题日志",
|
|
1332
|
+
"rail.empty.title": "暂无课程",
|
|
1333
|
+
"rail.empty.hint": "粘贴 markdown → 说「导入为课程」",
|
|
1334
|
+
"rail.empty.hint2": "本地文件夹 → 说「导入 D:/path/to/folder」",
|
|
1335
|
+
"rail.empty.placeholder": "GitHub 仓库链接,如 microsoft/AI-For-Beginners",
|
|
1336
|
+
"rail.empty.button": "导入",
|
|
1337
|
+
"rail.empty.demo": "导入示例课程",
|
|
1338
|
+
"rail.mastered": "{mastered}/{total} 已掌握",
|
|
1339
|
+
"rail.avg": "平均掌握度 {pct}%",
|
|
1340
|
+
"rail.avg.none": "尚无掌握度数据",
|
|
1341
|
+
"rail.search": "搜索课时…(多关键词空格分隔)",
|
|
1342
|
+
"rail.locate.title": "在课程树中定位当前焦点课时(自动展开所在章节)",
|
|
1343
|
+
"rail.locate": "📍 回到当前课时",
|
|
1344
|
+
"rail.due": "🔁 待复习 {count}",
|
|
1345
|
+
"rail.due.over": "超{days}天",
|
|
1346
|
+
"rail.due.start": "开始复习",
|
|
1347
|
+
"rail.due.tag": "这课时的复习今天到期(SM-2)",
|
|
1348
|
+
"rail.delete": "删除本课程",
|
|
1349
|
+
"rail.delete.confirm": "确认删除?",
|
|
1350
|
+
"rail.delete.title.confirm": "再点一次确认删除(含全部进度与笔记)",
|
|
1351
|
+
"rail.section.collapse": "折叠本章节",
|
|
1352
|
+
"rail.section.expand": "展开本章节({count} 课时)",
|
|
1353
|
+
"rail.section.count": "{count} 课",
|
|
1354
|
+
"rail.lesson.opening": "正在打开课时会话…",
|
|
1355
|
+
"rail.import.toggle": "+ 导入课程",
|
|
1356
|
+
"rail.import.close": "收起导入",
|
|
1357
|
+
"tag.weak": "{count} 个薄弱知识点,测验会优先考察",
|
|
1358
|
+
"tag.friction": "{count} 次卡点记录(你说\"不懂\"时导师记下的)",
|
|
1359
|
+
"tag.mastery": "课时掌握度 {pct}%(取最薄弱知识点)",
|
|
1360
|
+
"tag.mastery.short": "课时掌握度 = 最薄弱知识点的掌握度",
|
|
1361
|
+
"status.exam": "章节测验:本节全部课时掌握度 ≥50% 后开放",
|
|
1362
|
+
"status.mastered": "已毕业:掌握度 ≥90%(或你接受了掌握提案)",
|
|
1363
|
+
"status.in_progress": "学习中:已开课,掌握度从 50% 起步",
|
|
1364
|
+
"status.available": "可开始:已解锁,尚未学习",
|
|
1365
|
+
"status.locked": "未解锁:先完成前面的课时",
|
|
1366
|
+
"chip.unattributed": "未归因",
|
|
1367
|
+
"chip.correct": "✓ 答对 · {concept}",
|
|
1368
|
+
"chip.wrong": "✗ 答错 · {concept}",
|
|
1369
|
+
"chip.import": "📦 导入课程",
|
|
1370
|
+
"chip.concepts": "🧠 提炼知识点",
|
|
1371
|
+
"row.thinking": "导师思考中…",
|
|
1372
|
+
"row.thinking.title": "导师正在推理,回复马上就来",
|
|
1373
|
+
"quiz.title": "点击选项作答,也可以直接打字回答",
|
|
1374
|
+
"quiz.answer": "选 {letter}:{text}",
|
|
1375
|
+
"tutor.empty": "对话会出现在这里",
|
|
1376
|
+
"tutor.empty.hint": "在下方输入框和导师说话",
|
|
1377
|
+
"proposal.text": "导师提议你已掌握「{lesson}」:{rationale}",
|
|
1378
|
+
"proposal.accept": "接受",
|
|
1379
|
+
"proposal.decline": "再练练",
|
|
1380
|
+
"bb.empty": "黑板还空着",
|
|
1381
|
+
"bb.empty.hint": "在左侧课程树选择一课",
|
|
1382
|
+
"bb.mastery": "掌握度 {pct}%",
|
|
1383
|
+
"bb.fallback.cmap.empty": "概念图需要先由导师定义本课概念",
|
|
1384
|
+
"bb.fallback.cmap": "概念图渲染不可用(布局引擎加载失败) — 已回退讲解视图",
|
|
1385
|
+
"bb.notes": "笔记",
|
|
1386
|
+
"bb.notes.empty": "这一课还没有笔记",
|
|
1387
|
+
"active.off.title": "注册 study 工具并载入导师人格,之后普通对话和现在一样",
|
|
1388
|
+
"active.on.title": "注销 study 工具与导师人格;学习进度保留,可随时重新开启",
|
|
1389
|
+
"active.off": "▶ 开始学习",
|
|
1390
|
+
"active.on": "⏻ 退出学习模式",
|
|
1391
|
+
"prompt.kickoff": "开始学习:查看我的学习状态并打开当前焦点课时。如果我还没有课程,推荐我导入示例课程(AI-For-Beginners)并说明怎么开始。回复最后请提醒我点上方「学习」页签进入学习界面。",
|
|
1392
|
+
"settings.nav": "学习",
|
|
1393
|
+
"settings.mode": "教学风格",
|
|
1394
|
+
"settings.mode.hint": "导师的讲解风格;也可在学习页随时切换",
|
|
1395
|
+
"settings.studyMode": "学习模式",
|
|
1396
|
+
"settings.studyMode.hint": "开启后注册 study_* 工具并载入导师人格;进度与笔记始终保留",
|
|
1397
|
+
"settings.on": "已开启",
|
|
1398
|
+
"settings.off": "已关闭",
|
|
1399
|
+
"settings.turnOn": "开启",
|
|
1400
|
+
"settings.turnOff": "关闭",
|
|
1401
|
+
"settings.stats": "学习统计",
|
|
1402
|
+
"settings.stats.courses": "课程 {count} 个",
|
|
1403
|
+
"settings.stats.xp": "XP {xp} · Lv{level}({pct}%)",
|
|
1404
|
+
"settings.stats.today": "今日 {xp}/{goal}",
|
|
1405
|
+
"settings.stats.streak": "连续 {days} 天(最长 {best},剩冻结 {freeze})",
|
|
1406
|
+
"settings.about": "关于",
|
|
1407
|
+
"settings.about.hint": "当前运行的构建版本,点击查看该版更新内容。",
|
|
1408
|
+
"settings.stateFile": "状态文件",
|
|
1409
|
+
"settings.stateFile.hint": "学习进度存放于本机 JSON,换机可迁移",
|
|
1410
|
+
"dock.title": "学习状态:待复习 {due} · 连续 {streak} 天 · Lv{level} — 点上方「学习」页签进入",
|
|
1411
|
+
"dock.due": "⚡{count}",
|
|
1412
|
+
"dock.streak": "🔥{days}d",
|
|
1413
|
+
"dock.lv": "Lv{level}",
|
|
1414
|
+
"prompt.import": "导入课程:用 study_import_github 抓取 {url}",
|
|
1415
|
+
"prompt.lesson": "学习「{title}」:用 study_lesson 打开这一课开始学习。",
|
|
1416
|
+
"prompt.exam": "开始「{section}」的章节测验:按本节课时出题,答完逐题判分",
|
|
1417
|
+
"prompt.review": "开始今天的复习,从最到期的课时开始",
|
|
1418
|
+
"prompt.proposal.accept": "接受提案 {id} —— 确认标记这课为已掌握",
|
|
1419
|
+
"prompt.proposal.decline": "拒绝提案 {id} —— 我想再练练"
|
|
1420
|
+
};
|
|
1421
|
+
/** English dictionary — key set must equal {@link ZH}'s (test-asserted). */
|
|
1422
|
+
const EN = {
|
|
1423
|
+
"tab.label": "Study",
|
|
1424
|
+
"col.rail": "Courses",
|
|
1425
|
+
"col.tutor": "Tutor",
|
|
1426
|
+
"col.bb": "Blackboard",
|
|
1427
|
+
"pane.rail": "Courses",
|
|
1428
|
+
"pane.tutor": "Tutor",
|
|
1429
|
+
"pane.bb": "Board",
|
|
1430
|
+
"viewtab.teach": "Teach",
|
|
1431
|
+
"viewtab.cmap": "🕸 Concepts",
|
|
1432
|
+
"viewtab.cmap.title": "The lesson's concept relations (ELK layout)",
|
|
1433
|
+
"loading": "Loading…",
|
|
1434
|
+
"start.title": "One click to study: workspace + session + tutor, all set up",
|
|
1435
|
+
"start.go": "Start learning",
|
|
1436
|
+
"start.enter": "Enter study",
|
|
1437
|
+
"start.busy": "Preparing the study area…",
|
|
1438
|
+
"soul.direct": "Direct",
|
|
1439
|
+
"soul.direct.hint": "direct: explain first, then check understanding",
|
|
1440
|
+
"soul.guide": "Guide",
|
|
1441
|
+
"soul.guide.hint": "guide: you push one step forward, the tutor hands you the stairs",
|
|
1442
|
+
"soul.practice": "Practice",
|
|
1443
|
+
"soul.practice.hint": "practice: learn inside real messy problems",
|
|
1444
|
+
"zone.understand": "🧠 Understand — knowledge structure",
|
|
1445
|
+
"zone.record": "📝 Record — in my own words",
|
|
1446
|
+
"zone.practice": "✍️ Practice — answer log",
|
|
1447
|
+
"rail.empty.title": "No courses yet",
|
|
1448
|
+
"rail.empty.hint": "Paste markdown → say \"import as a course\"",
|
|
1449
|
+
"rail.empty.hint2": "Local folder → say \"import D:/path/to/folder\"",
|
|
1450
|
+
"rail.empty.placeholder": "GitHub repo link, e.g. microsoft/AI-For-Beginners",
|
|
1451
|
+
"rail.empty.button": "Import",
|
|
1452
|
+
"rail.empty.demo": "Import the demo course",
|
|
1453
|
+
"rail.mastered": "{mastered}/{total} mastered",
|
|
1454
|
+
"rail.avg": "avg mastery {pct}%",
|
|
1455
|
+
"rail.avg.none": "no mastery data yet",
|
|
1456
|
+
"rail.search": "Search lessons… (space-separated keywords)",
|
|
1457
|
+
"rail.locate.title": "Locate the current focus lesson in the tree (auto-expands its section)",
|
|
1458
|
+
"rail.locate": "📍 Back to current lesson",
|
|
1459
|
+
"rail.due": "🔁 {count} due",
|
|
1460
|
+
"rail.due.over": "{days}d overdue",
|
|
1461
|
+
"rail.due.start": "Start reviewing",
|
|
1462
|
+
"rail.due.tag": "this lesson's review is due today (SM-2)",
|
|
1463
|
+
"rail.delete": "Delete this course",
|
|
1464
|
+
"rail.delete.confirm": "Delete?",
|
|
1465
|
+
"rail.delete.title.confirm": "Click again to confirm (all progress and notes included)",
|
|
1466
|
+
"rail.section.collapse": "Collapse this section",
|
|
1467
|
+
"rail.section.expand": "Expand this section ({count} lessons)",
|
|
1468
|
+
"rail.section.count": "{count} lessons",
|
|
1469
|
+
"rail.lesson.opening": "Opening the lesson session…",
|
|
1470
|
+
"rail.import.toggle": "+ Import a course",
|
|
1471
|
+
"rail.import.close": "Collapse import",
|
|
1472
|
+
"tag.weak": "{count} weak concepts — quizzes target them first",
|
|
1473
|
+
"tag.friction": "{count} friction marks (logged when you said you were stuck)",
|
|
1474
|
+
"tag.mastery": "lesson mastery {pct}% (the weakest concept)",
|
|
1475
|
+
"tag.mastery.short": "lesson mastery = the weakest concept's mastery",
|
|
1476
|
+
"status.exam": "Section exam: opens when every lesson in the section reaches ≥50% mastery",
|
|
1477
|
+
"status.mastered": "Graduated: mastery ≥90% (or you accepted a mastery proposal)",
|
|
1478
|
+
"status.in_progress": "Learning: opened, mastery starts at 50%",
|
|
1479
|
+
"status.available": "Ready: unlocked, not started",
|
|
1480
|
+
"status.locked": "Locked: finish the preceding lessons first",
|
|
1481
|
+
"chip.unattributed": "unattributed",
|
|
1482
|
+
"chip.correct": "✓ correct · {concept}",
|
|
1483
|
+
"chip.wrong": "✗ wrong · {concept}",
|
|
1484
|
+
"chip.import": "📦 course import",
|
|
1485
|
+
"chip.concepts": "🧠 concepts defined",
|
|
1486
|
+
"row.thinking": "tutor is thinking…",
|
|
1487
|
+
"row.thinking.title": "The tutor is reasoning; the reply is coming",
|
|
1488
|
+
"quiz.title": "Click an option to answer, or just type",
|
|
1489
|
+
"quiz.answer": "Choose {letter}: {text}",
|
|
1490
|
+
"tutor.empty": "The conversation will appear here",
|
|
1491
|
+
"tutor.empty.hint": "Talk to the tutor in the input below",
|
|
1492
|
+
"proposal.text": "The tutor proposes you have mastered \"{lesson}\": {rationale}",
|
|
1493
|
+
"proposal.accept": "Accept",
|
|
1494
|
+
"proposal.decline": "Keep practicing",
|
|
1495
|
+
"bb.empty": "The blackboard is empty",
|
|
1496
|
+
"bb.empty.hint": "Pick a lesson in the course tree on the left",
|
|
1497
|
+
"bb.mastery": "mastery {pct}%",
|
|
1498
|
+
"bb.fallback.cmap.empty": "The concept map needs the tutor to define this lesson's concepts first",
|
|
1499
|
+
"bb.fallback.cmap": "Concept map unavailable (layout engine failed to load) — fell back to the teach view",
|
|
1500
|
+
"bb.notes": "Notes",
|
|
1501
|
+
"bb.notes.empty": "No notes for this lesson yet",
|
|
1502
|
+
"active.off.title": "Register the study tools and load the tutor persona; plain chats stay as they are",
|
|
1503
|
+
"active.on.title": "Retire the study tools and tutor persona; progress is kept and you can re-enable anytime",
|
|
1504
|
+
"active.off": "▶ Start learning",
|
|
1505
|
+
"active.on": "⏻ Exit study mode",
|
|
1506
|
+
"prompt.kickoff": "Start learning: check my study state and open the current focus lesson. If I have no course yet, suggest importing the demo course (AI-For-Beginners) and explain how to begin. Remind me at the end to open the \"Study\" tab above to enter the study UI.",
|
|
1507
|
+
"settings.nav": "Study",
|
|
1508
|
+
"settings.mode": "Teaching style",
|
|
1509
|
+
"settings.mode.hint": "The tutor's explanation style; switchable anytime in the study tab too",
|
|
1510
|
+
"settings.studyMode": "Study mode",
|
|
1511
|
+
"settings.studyMode.hint": "On registers the study_* tools and the tutor persona; progress and notes are always kept",
|
|
1512
|
+
"settings.on": "On",
|
|
1513
|
+
"settings.off": "Off",
|
|
1514
|
+
"settings.turnOn": "Turn on",
|
|
1515
|
+
"settings.turnOff": "Turn off",
|
|
1516
|
+
"settings.stats": "Study stats",
|
|
1517
|
+
"settings.stats.courses": "{count} courses",
|
|
1518
|
+
"settings.stats.xp": "XP {xp} · Lv{level} ({pct}%)",
|
|
1519
|
+
"settings.stats.today": "Today {xp}/{goal}",
|
|
1520
|
+
"settings.stats.streak": "Streak {days}d (best {best}, {freeze} freeze left)",
|
|
1521
|
+
"settings.about": "About",
|
|
1522
|
+
"settings.about.hint": "The version of the running build — click to see its release notes.",
|
|
1523
|
+
"settings.stateFile": "State file",
|
|
1524
|
+
"settings.stateFile.hint": "Your progress lives in a local JSON file — copy it to migrate machines",
|
|
1525
|
+
"dock.title": "Study status: {due} due · {streak}d streak · Lv{level} — open the Study tab above to continue",
|
|
1526
|
+
"dock.due": "⚡{count}",
|
|
1527
|
+
"dock.streak": "🔥{days}d",
|
|
1528
|
+
"dock.lv": "Lv{level}",
|
|
1529
|
+
"prompt.import": "Import a course: fetch {url} with study_import_github",
|
|
1530
|
+
"prompt.lesson": "Study \"{title}\": open this lesson with study_lesson and start teaching.",
|
|
1531
|
+
"prompt.exam": "Start the section exam for \"{section}\": quiz me on this section's lessons and grade each answer",
|
|
1532
|
+
"prompt.review": "Start today's review, from the most overdue lesson first",
|
|
1533
|
+
"prompt.proposal.accept": "Accept proposal {id} — confirm marking this lesson as mastered",
|
|
1534
|
+
"prompt.proposal.decline": "Reject proposal {id} — I want to practice more"
|
|
1535
|
+
};
|
|
1536
|
+
/** Both dictionaries under their locale ids (the service's register shape). */
|
|
1537
|
+
const STUDY_DICTS = {
|
|
1538
|
+
zh: ZH,
|
|
1539
|
+
en: EN
|
|
1540
|
+
};
|
|
1541
|
+
/** The namespace id this plugin registers with the framework locale service. */
|
|
1542
|
+
const STUDY_NS = "lookatstudy";
|
|
1543
|
+
/**
|
|
1544
|
+
* Pure translator with the service's semantics: own-locale dict → zh
|
|
1545
|
+
* fallback → the key itself, `{name}` interpolation left untouched when the
|
|
1546
|
+
* param is missing.
|
|
1547
|
+
* @param locale - active locale id ('zh'/'en'; unknown ids fall back to zh).
|
|
1548
|
+
* @returns a stable-shaped translate function.
|
|
1549
|
+
*/
|
|
1550
|
+
function makeT(locale) {
|
|
1551
|
+
const dict = locale === "en" ? EN : ZH;
|
|
1552
|
+
return (key, params) => {
|
|
1553
|
+
const template = dict[key] ?? ZH[key] ?? key;
|
|
1554
|
+
if (params === void 0) return template;
|
|
1555
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match);
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Register the namespace with the framework locale service.
|
|
1560
|
+
* @param locale - the client locale service when present.
|
|
1561
|
+
* @returns the registration disposer (a no-op when the service is absent or
|
|
1562
|
+
* a hostile re-registration was refused).
|
|
1563
|
+
*/
|
|
1564
|
+
function registerStudyLocale(locale) {
|
|
1565
|
+
if (locale === void 0) return () => {};
|
|
1566
|
+
try {
|
|
1567
|
+
return locale.register(STUDY_NS, STUDY_DICTS);
|
|
1568
|
+
} catch {
|
|
1569
|
+
return () => {};
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* The live translator: the service's stable `bind` when present (reads the
|
|
1574
|
+
* active locale at call time, so a locale-change re-render picks new
|
|
1575
|
+
* strings), the pure zh translator otherwise.
|
|
1576
|
+
* @param locale - the client locale service when present.
|
|
1577
|
+
*/
|
|
1578
|
+
function studyTranslator(locale) {
|
|
1579
|
+
return locale === void 0 ? makeT("zh") : locale.bind(STUDY_NS);
|
|
1580
|
+
}
|
|
1581
|
+
/** The module's active translator — zh until the view factory installs the live bind. */
|
|
1582
|
+
let activeT = makeT("zh");
|
|
1583
|
+
/**
|
|
1584
|
+
* Install the client-wide translator (called once at client apply, before
|
|
1585
|
+
* any component renders).
|
|
1586
|
+
*/
|
|
1587
|
+
function setStudyTranslator(t) {
|
|
1588
|
+
activeT = t;
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* Translate through the active translator — the import every client module
|
|
1592
|
+
* uses for user-visible copy. Pure helpers take an optional explicit `t`
|
|
1593
|
+
* defaulting to the current active translator, evaluated per call.
|
|
1594
|
+
*/
|
|
1595
|
+
function tr(key, params) {
|
|
1596
|
+
return activeT(key, params);
|
|
1597
|
+
}
|
|
1598
|
+
//#endregion
|
|
543
1599
|
//#region src/client/views.tsx
|
|
544
1600
|
/**
|
|
545
1601
|
* The study tab: one `conversation.view` entry rendering the whole plugin —
|
|
@@ -554,28 +1610,28 @@ window.__ModuleLoader__.load({
|
|
|
554
1610
|
* @module dsh-plugin-lookatstudy/client/views
|
|
555
1611
|
*/
|
|
556
1612
|
/** The three souls, in pill order (labels from LookatStudy's mode switcher). */
|
|
557
|
-
const MODES = [
|
|
1613
|
+
const MODES$1 = [
|
|
558
1614
|
{
|
|
559
1615
|
id: "direct",
|
|
560
|
-
|
|
561
|
-
|
|
1616
|
+
labelKey: "soul.direct",
|
|
1617
|
+
hintKey: "soul.direct.hint"
|
|
562
1618
|
},
|
|
563
1619
|
{
|
|
564
1620
|
id: "guide",
|
|
565
|
-
|
|
566
|
-
|
|
1621
|
+
labelKey: "soul.guide",
|
|
1622
|
+
hintKey: "soul.guide.hint"
|
|
567
1623
|
},
|
|
568
1624
|
{
|
|
569
1625
|
id: "practice",
|
|
570
|
-
|
|
571
|
-
|
|
1626
|
+
labelKey: "soul.practice",
|
|
1627
|
+
hintKey: "soul.practice.hint"
|
|
572
1628
|
}
|
|
573
1629
|
];
|
|
574
|
-
/** Zone
|
|
1630
|
+
/** Zone keys for the Cornell notebook (understand / record / practice). */
|
|
575
1631
|
const ZONES = [
|
|
576
|
-
["understand", "
|
|
577
|
-
["record", "
|
|
578
|
-
["practice", "
|
|
1632
|
+
["understand", "zone.understand"],
|
|
1633
|
+
["record", "zone.record"],
|
|
1634
|
+
["practice", "zone.practice"]
|
|
579
1635
|
];
|
|
580
1636
|
/** Status glyph for one lesson row (LookatStudy's map icons). */
|
|
581
1637
|
function glyph(kind, status) {
|
|
@@ -610,34 +1666,36 @@ window.__ModuleLoader__.load({
|
|
|
610
1666
|
return (0, react.createElement)("div", { className: "lks-import" }, (0, react.createElement)("div", { className: "lks-inputrow" }, (0, react.createElement)("input", {
|
|
611
1667
|
className: "lks-input",
|
|
612
1668
|
type: "url",
|
|
613
|
-
placeholder: "
|
|
1669
|
+
placeholder: tr("rail.empty.placeholder"),
|
|
614
1670
|
value: url,
|
|
615
1671
|
onChange: (e) => {
|
|
616
1672
|
setUrl(e.target.value);
|
|
617
1673
|
},
|
|
618
1674
|
onKeyDown: (e) => {
|
|
619
|
-
if (e.key === "Enter" && url.trim() !== "") send(
|
|
1675
|
+
if (e.key === "Enter" && url.trim() !== "") send(tr("prompt.import", { url: url.trim() }));
|
|
620
1676
|
}
|
|
621
1677
|
}), (0, react.createElement)("button", {
|
|
622
1678
|
className: "lks-btn primary",
|
|
623
1679
|
disabled: url.trim() === "",
|
|
624
1680
|
onClick: () => {
|
|
625
|
-
send(
|
|
1681
|
+
send(tr("prompt.import", { url: url.trim() }));
|
|
626
1682
|
}
|
|
627
|
-
}, "
|
|
1683
|
+
}, tr("rail.empty.button"))), (0, react.createElement)("div", { className: "lks-import-hint" }, tr("rail.empty.hint"), (0, react.createElement)("br"), tr("rail.empty.hint2")));
|
|
628
1684
|
}
|
|
629
1685
|
/**
|
|
630
|
-
* Tooltip text for one course-tree status glyph. Pure
|
|
1686
|
+
* Tooltip text for one course-tree status glyph. Pure (the translator is an
|
|
1687
|
+
* optional parameter defaulting to the active one, evaluated per call).
|
|
631
1688
|
* @param kind - lesson kind ('study' | 'practice' | 'exam').
|
|
632
1689
|
* @param status - lesson status ('locked' | 'available' | 'in_progress' | 'mastered').
|
|
1690
|
+
* @param t - translator (defaults to the module's active translator).
|
|
633
1691
|
*/
|
|
634
|
-
function statusTitle(kind, status) {
|
|
635
|
-
if (kind === "exam") return "
|
|
1692
|
+
function statusTitle(kind, status, t = tr) {
|
|
1693
|
+
if (kind === "exam") return t("status.exam");
|
|
636
1694
|
switch (status) {
|
|
637
|
-
case "mastered": return "
|
|
638
|
-
case "in_progress": return "
|
|
639
|
-
case "available": return "
|
|
640
|
-
default: return "
|
|
1695
|
+
case "mastered": return t("status.mastered");
|
|
1696
|
+
case "in_progress": return t("status.in_progress");
|
|
1697
|
+
case "available": return t("status.available");
|
|
1698
|
+
default: return t("status.locked");
|
|
641
1699
|
}
|
|
642
1700
|
}
|
|
643
1701
|
/**
|
|
@@ -678,13 +1736,14 @@ window.__ModuleLoader__.load({
|
|
|
678
1736
|
}
|
|
679
1737
|
/**
|
|
680
1738
|
* Learning-aware label for one settled tool call; null keeps the generic
|
|
681
|
-
* 🔧-name chip. Pure.
|
|
1739
|
+
* 🔧-name chip. Pure (translator defaults to the active one, per call).
|
|
682
1740
|
* @param name - tool call name.
|
|
683
1741
|
* @param argsRaw - the call's raw JSON args.
|
|
1742
|
+
* @param t - translator.
|
|
684
1743
|
*/
|
|
685
|
-
function toolChipLabel(name, argsRaw) {
|
|
1744
|
+
function toolChipLabel(name, argsRaw, t = tr) {
|
|
686
1745
|
if (name === "study_record_answer") {
|
|
687
|
-
let concept = "
|
|
1746
|
+
let concept = t("chip.unattributed");
|
|
688
1747
|
let correct = false;
|
|
689
1748
|
try {
|
|
690
1749
|
const args = JSON.parse(argsRaw);
|
|
@@ -692,19 +1751,19 @@ window.__ModuleLoader__.load({
|
|
|
692
1751
|
correct = args.correct === true;
|
|
693
1752
|
} catch {}
|
|
694
1753
|
return correct ? {
|
|
695
|
-
label:
|
|
1754
|
+
label: t("chip.correct", { concept }),
|
|
696
1755
|
tone: "ok"
|
|
697
1756
|
} : {
|
|
698
|
-
label:
|
|
1757
|
+
label: t("chip.wrong", { concept }),
|
|
699
1758
|
tone: "bad"
|
|
700
1759
|
};
|
|
701
1760
|
}
|
|
702
1761
|
if (name === "study_import_github" || name === "study_import_markdown" || name === "study_import_folder") return {
|
|
703
|
-
label: "
|
|
1762
|
+
label: t("chip.import"),
|
|
704
1763
|
tone: "ok"
|
|
705
1764
|
};
|
|
706
1765
|
if (name === "study_define_concepts") return {
|
|
707
|
-
label: "
|
|
1766
|
+
label: t("chip.concepts"),
|
|
708
1767
|
tone: "ok"
|
|
709
1768
|
};
|
|
710
1769
|
return null;
|
|
@@ -720,9 +1779,10 @@ window.__ModuleLoader__.load({
|
|
|
720
1779
|
* tool activity condenses to one muted chip per settled call.
|
|
721
1780
|
* @param nodes - finalized conversation nodes from the snapshot.
|
|
722
1781
|
* @param partial - the in-flight assistant partial, or null.
|
|
1782
|
+
* @param t - translator (defaults to the active one, per call).
|
|
723
1783
|
* @returns ordered rows; never mutates its inputs.
|
|
724
1784
|
*/
|
|
725
|
-
function transcriptRows(nodes, partial) {
|
|
1785
|
+
function transcriptRows(nodes, partial, t = tr) {
|
|
726
1786
|
const rows = [];
|
|
727
1787
|
for (const node of nodes) switch (node.kind) {
|
|
728
1788
|
case "user":
|
|
@@ -768,12 +1828,12 @@ window.__ModuleLoader__.load({
|
|
|
768
1828
|
else rows.push({
|
|
769
1829
|
key: "thinking",
|
|
770
1830
|
role: "thinking",
|
|
771
|
-
text: "
|
|
1831
|
+
text: t("row.thinking")
|
|
772
1832
|
});
|
|
773
1833
|
}
|
|
774
1834
|
return rows;
|
|
775
1835
|
}
|
|
776
|
-
/** Small inline error surface for failed write actions. */
|
|
1836
|
+
/** Small inline error surface for failed write actions (shared with the settings page). */
|
|
777
1837
|
function ActionError({ error }) {
|
|
778
1838
|
if (error === null) return null;
|
|
779
1839
|
return (0, react.createElement)("div", { className: "lks-propcard-err" }, error);
|
|
@@ -792,6 +1852,7 @@ window.__ModuleLoader__.load({
|
|
|
792
1852
|
return "tutor";
|
|
793
1853
|
}
|
|
794
1854
|
}
|
|
1855
|
+
const sessionId = (id) => id;
|
|
795
1856
|
/** The whole study tab. Wide = three columns (课程 | 导师 | 黑板); narrow (<1220px) = one composer-width pane with a three-way switcher. */
|
|
796
1857
|
function studyView(ctx) {
|
|
797
1858
|
return function StudyView(props) {
|
|
@@ -804,7 +1865,7 @@ window.__ModuleLoader__.load({
|
|
|
804
1865
|
}
|
|
805
1866
|
/** Tab body: the factory-bound ctx carries workspaces/sessions for the per-lesson session jumps. */
|
|
806
1867
|
function StudyTab({ useSession, inputActions, ctx }) {
|
|
807
|
-
const { data, activate, setMode, setFocus, deleteCourse, bindLessonSession } = useStudy();
|
|
1868
|
+
const { data, activate, setMode, setFocus, searchLessons, deleteCourse, bindLessonSession } = useStudy();
|
|
808
1869
|
const snapshot = useSession((s) => s);
|
|
809
1870
|
const [pane, setPane] = (0, react.useState)(storedPane);
|
|
810
1871
|
const rootRef = (0, react.useRef)(null);
|
|
@@ -816,6 +1877,7 @@ window.__ModuleLoader__.load({
|
|
|
816
1877
|
const clear = () => {
|
|
817
1878
|
card.classList.remove("lks-composer-follow");
|
|
818
1879
|
card.style.removeProperty("--lks-composer-shift");
|
|
1880
|
+
card.style.removeProperty("--lks-composer-follow-width");
|
|
819
1881
|
};
|
|
820
1882
|
const apply = () => {
|
|
821
1883
|
const tutor = root.querySelector(".lks-col-tutor");
|
|
@@ -830,6 +1892,7 @@ window.__ModuleLoader__.load({
|
|
|
830
1892
|
const shift = Math.round(Math.max(0, Math.min(tutorRect.left - (seatRect.left + pad), max)));
|
|
831
1893
|
if (shift < 2) return clear();
|
|
832
1894
|
card.style.setProperty("--lks-composer-shift", `${shift}px`);
|
|
1895
|
+
card.style.setProperty("--lks-composer-follow-width", `${Math.round(tutorRect.width)}px`);
|
|
833
1896
|
card.classList.add("lks-composer-follow");
|
|
834
1897
|
};
|
|
835
1898
|
apply();
|
|
@@ -850,6 +1913,14 @@ window.__ModuleLoader__.load({
|
|
|
850
1913
|
inputActions.submit();
|
|
851
1914
|
})();
|
|
852
1915
|
};
|
|
1916
|
+
const [, bumpLocale] = (0, react.useState)(0);
|
|
1917
|
+
(0, react.useEffect)(() => {
|
|
1918
|
+
const localeSvc = ctx.locale;
|
|
1919
|
+
if (localeSvc === void 0) return;
|
|
1920
|
+
return localeSvc.subscribe(() => {
|
|
1921
|
+
bumpLocale((v) => v + 1);
|
|
1922
|
+
});
|
|
1923
|
+
}, [ctx]);
|
|
853
1924
|
return (0, react.createElement)("div", {
|
|
854
1925
|
ref: rootRef,
|
|
855
1926
|
className: "lks-root lks-study",
|
|
@@ -857,22 +1928,22 @@ window.__ModuleLoader__.load({
|
|
|
857
1928
|
"data-pane": pane
|
|
858
1929
|
}, (0, react.createElement)("div", { className: "lks-actbar" }, data === null ? null : (0, react.createElement)("button", {
|
|
859
1930
|
className: `lks-btn ${data.active ? "ghost" : "primary"}`,
|
|
860
|
-
title: data.active ? "
|
|
1931
|
+
title: data.active ? tr("active.on.title") : tr("active.off.title"),
|
|
861
1932
|
onClick: () => {
|
|
862
1933
|
activate(!data.active);
|
|
863
1934
|
}
|
|
864
|
-
}, data.active ? "
|
|
1935
|
+
}, data.active ? tr("active.on") : tr("active.off"))), (0, react.createElement)("div", { className: "lks-body" }, (0, react.createElement)("div", { className: "lks-switch" }, ...[
|
|
865
1936
|
{
|
|
866
1937
|
id: "rail",
|
|
867
|
-
|
|
1938
|
+
labelKey: "pane.rail"
|
|
868
1939
|
},
|
|
869
1940
|
{
|
|
870
1941
|
id: "tutor",
|
|
871
|
-
|
|
1942
|
+
labelKey: "pane.tutor"
|
|
872
1943
|
},
|
|
873
1944
|
{
|
|
874
1945
|
id: "bb",
|
|
875
|
-
|
|
1946
|
+
labelKey: "pane.bb"
|
|
876
1947
|
}
|
|
877
1948
|
].map((p) => (0, react.createElement)("button", {
|
|
878
1949
|
key: p.id,
|
|
@@ -883,10 +1954,11 @@ window.__ModuleLoader__.load({
|
|
|
883
1954
|
sessionStorage.setItem(PANE_KEY, p.id);
|
|
884
1955
|
} catch {}
|
|
885
1956
|
}
|
|
886
|
-
}, p.
|
|
1957
|
+
}, tr(p.labelKey)))), (0, react.createElement)(CourseRail, {
|
|
887
1958
|
data,
|
|
888
1959
|
activate,
|
|
889
1960
|
setFocus,
|
|
1961
|
+
searchLessons,
|
|
890
1962
|
deleteCourse,
|
|
891
1963
|
bindLessonSession,
|
|
892
1964
|
send,
|
|
@@ -900,7 +1972,7 @@ window.__ModuleLoader__.load({
|
|
|
900
1972
|
}), (0, react.createElement)(BlackboardColumn, { data })));
|
|
901
1973
|
}
|
|
902
1974
|
/** Left column: course management (pick/delete/search/import), lesson tree, due box. */
|
|
903
|
-
function CourseRail({ data, activate, setFocus, deleteCourse, bindLessonSession, send, ctx, currentSessionId }) {
|
|
1975
|
+
function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, bindLessonSession, send, ctx, currentSessionId }) {
|
|
904
1976
|
const [jumping, setJumping] = (0, react.useState)(null);
|
|
905
1977
|
/** Open (or mint) the lesson's own session — the simplified thread system. */
|
|
906
1978
|
const openLessonThread = (lesson) => {
|
|
@@ -910,9 +1982,9 @@ window.__ModuleLoader__.load({
|
|
|
910
1982
|
setFocus(lesson.id).catch(reportError);
|
|
911
1983
|
return;
|
|
912
1984
|
}
|
|
913
|
-
if (mapped !== void 0 && ctx.sessions.binding(mapped) !== void 0) {
|
|
1985
|
+
if (mapped !== void 0 && ctx.sessions.binding(sessionId(mapped)) !== void 0) {
|
|
914
1986
|
setFocus(lesson.id).catch(reportError);
|
|
915
|
-
ctx.sessions.open(mapped);
|
|
1987
|
+
ctx.sessions.open(sessionId(mapped));
|
|
916
1988
|
return;
|
|
917
1989
|
}
|
|
918
1990
|
setJumping(lesson.id);
|
|
@@ -928,7 +2000,7 @@ window.__ModuleLoader__.load({
|
|
|
928
2000
|
if (face === void 0) throw new Error("lesson session is not addressable yet");
|
|
929
2001
|
const result = await face.prompt([{
|
|
930
2002
|
type: "text",
|
|
931
|
-
text:
|
|
2003
|
+
text: tr("prompt.lesson", { title: lesson.title })
|
|
932
2004
|
}], "queue");
|
|
933
2005
|
if (!result.ok) throw new Error(`lesson prompt rejected: ${result.error.code}: ${result.error.message}`);
|
|
934
2006
|
await bindLessonSession(lesson.id, sessionId);
|
|
@@ -950,13 +2022,13 @@ window.__ModuleLoader__.load({
|
|
|
950
2022
|
const reportError = (err) => {
|
|
951
2023
|
setError(err instanceof Error ? err.message : String(err));
|
|
952
2024
|
};
|
|
953
|
-
const body = data === null ? (0, react.createElement)("div", { className: "lks-empty" }, "
|
|
2025
|
+
const body = data === null ? (0, react.createElement)("div", { className: "lks-empty" }, tr("loading")) : data.courses.length === 0 ? (0, react.createElement)("div", { className: "lks-empty" }, tr("rail.empty.title"), (0, react.createElement)("br"), (0, react.createElement)("button", {
|
|
954
2026
|
className: "lks-btn primary",
|
|
955
2027
|
style: { margin: "10px 0" },
|
|
956
2028
|
onClick: () => {
|
|
957
|
-
send("
|
|
2029
|
+
send(tr("prompt.import", { url: "https://github.com/microsoft/AI-For-Beginners" }));
|
|
958
2030
|
}
|
|
959
|
-
}, (0, react.createElement)(IconDownloadOutline16, null), "
|
|
2031
|
+
}, (0, react.createElement)(IconDownloadOutline16, null), tr("rail.empty.demo")), (0, react.createElement)(ImportRow, { send })) : (() => {
|
|
960
2032
|
const courseId = data.courses.some((c) => c.courseId === selectedCourse) ? selectedCourse : data.courses[0].courseId;
|
|
961
2033
|
const course = data.courses.find((c) => c.courseId === courseId);
|
|
962
2034
|
return (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "lks-rail-head" }, data.courses.length > 1 ? (0, react.createElement)("select", {
|
|
@@ -971,7 +2043,7 @@ window.__ModuleLoader__.load({
|
|
|
971
2043
|
value: c.courseId
|
|
972
2044
|
}, c.title))) : (0, react.createElement)("div", { className: "lks-rail-title" }, course.title), (0, react.createElement)("button", {
|
|
973
2045
|
className: `lks-btn ${confirmDelete ? "primary" : "ghost"}`,
|
|
974
|
-
title: confirmDelete ? "
|
|
2046
|
+
title: confirmDelete ? tr("rail.delete.title.confirm") : tr("rail.delete"),
|
|
975
2047
|
onClick: () => {
|
|
976
2048
|
if (!confirmDelete) {
|
|
977
2049
|
setConfirmDelete(true);
|
|
@@ -982,13 +2054,16 @@ window.__ModuleLoader__.load({
|
|
|
982
2054
|
setSelectedCourse("");
|
|
983
2055
|
}, reportError);
|
|
984
2056
|
}
|
|
985
|
-
}, confirmDelete ? "
|
|
2057
|
+
}, confirmDelete ? tr("rail.delete.confirm") : "🗑")), (0, react.createElement)("div", { className: "lks-rail-sub" }, tr("rail.mastered", {
|
|
2058
|
+
mastered: course.mastered,
|
|
2059
|
+
total: course.total
|
|
2060
|
+
})), (0, react.createElement)("div", {
|
|
986
2061
|
className: `lks-masterybar${course.avgMasteryPct === 100 ? " gold" : ""}`,
|
|
987
|
-
title: course.avgMasteryPct === null ? "
|
|
2062
|
+
title: course.avgMasteryPct === null ? tr("rail.avg.none") : tr("rail.avg", { pct: course.avgMasteryPct })
|
|
988
2063
|
}, (0, react.createElement)("i", { style: { width: `${course.avgMasteryPct ?? 0}%` } })), (0, react.createElement)("input", {
|
|
989
2064
|
className: "lks-search",
|
|
990
2065
|
type: "search",
|
|
991
|
-
placeholder: "
|
|
2066
|
+
placeholder: tr("rail.search"),
|
|
992
2067
|
value: query,
|
|
993
2068
|
onChange: (e) => {
|
|
994
2069
|
setQuery(e.target.value);
|
|
@@ -1002,6 +2077,13 @@ window.__ModuleLoader__.load({
|
|
|
1002
2077
|
return;
|
|
1003
2078
|
}
|
|
1004
2079
|
}
|
|
2080
|
+
searchLessons(query).then((hits) => {
|
|
2081
|
+
const hit = hits.find((h) => h.lessonId.startsWith(`${courseId}:`)) ?? hits[0];
|
|
2082
|
+
if (hit !== void 0) {
|
|
2083
|
+
setQuery("");
|
|
2084
|
+
setFocus(hit.lessonId).catch(reportError);
|
|
2085
|
+
}
|
|
2086
|
+
}).catch(reportError);
|
|
1005
2087
|
}
|
|
1006
2088
|
}), course.sections.some((s) => s.lessons.some((l) => l.focus)) ? (0, react.createElement)("button", {
|
|
1007
2089
|
className: "lks-btn ghost",
|
|
@@ -1010,7 +2092,7 @@ window.__ModuleLoader__.load({
|
|
|
1010
2092
|
padding: "3px 8px",
|
|
1011
2093
|
fontSize: "13px"
|
|
1012
2094
|
},
|
|
1013
|
-
title: "
|
|
2095
|
+
title: tr("rail.locate.title"),
|
|
1014
2096
|
onClick: () => {
|
|
1015
2097
|
const focusSection = course.sections.find((s) => s.lessons.some((l) => l.focus));
|
|
1016
2098
|
if (focusSection === void 0) return;
|
|
@@ -1025,16 +2107,16 @@ window.__ModuleLoader__.load({
|
|
|
1025
2107
|
});
|
|
1026
2108
|
}, 60);
|
|
1027
2109
|
}
|
|
1028
|
-
}, "
|
|
2110
|
+
}, tr("rail.locate")) : null, data.dueCount > 0 ? (0, react.createElement)("div", { className: "lks-duebox" }, tr("rail.due", { count: data.dueCount }), ...data.due.map((d) => (0, react.createElement)("div", {
|
|
1029
2111
|
key: d.lessonId,
|
|
1030
2112
|
className: "lks-due-item"
|
|
1031
|
-
}, (0, react.createElement)("span", null, d.lessonTitle), d.overdueDays > 0 ? (0, react.createElement)("span", { className: "lks-over" },
|
|
2113
|
+
}, (0, react.createElement)("span", null, d.lessonTitle), d.overdueDays > 0 ? (0, react.createElement)("span", { className: "lks-over" }, tr("rail.due.over", { days: d.overdueDays })) : null)), (0, react.createElement)("button", {
|
|
1032
2114
|
className: "lks-btn ghost",
|
|
1033
2115
|
style: { marginTop: "6px" },
|
|
1034
2116
|
onClick: () => {
|
|
1035
|
-
send("
|
|
2117
|
+
send(tr("prompt.review"));
|
|
1036
2118
|
}
|
|
1037
|
-
}, "
|
|
2119
|
+
}, tr("rail.due.start"))) : null, ...course.sections.flatMap((section) => {
|
|
1038
2120
|
const examAllowed = examOpen(section.lessons);
|
|
1039
2121
|
const lessons = section.lessons.filter((l) => query.trim() === "" || titleMatches(l.title, query) || l.focus);
|
|
1040
2122
|
if (lessons.length === 0) return [];
|
|
@@ -1045,45 +2127,45 @@ window.__ModuleLoader__.load({
|
|
|
1045
2127
|
type: "button",
|
|
1046
2128
|
className: "lks-sechead",
|
|
1047
2129
|
"aria-expanded": String(open),
|
|
1048
|
-
title: open ? "
|
|
2130
|
+
title: open ? tr("rail.section.collapse") : tr("rail.section.expand", { count: section.lessons.length }),
|
|
1049
2131
|
onClick: () => {
|
|
1050
2132
|
setSecOpen((m) => ({
|
|
1051
2133
|
...m,
|
|
1052
2134
|
[secKey]: !open
|
|
1053
2135
|
}));
|
|
1054
2136
|
}
|
|
1055
|
-
}, (0, react.createElement)("span", { className: "lks-sec-num" }, String(section.index + 1)), (0, react.createElement)("span", { className: "lks-sechead-t" }, section.title), open ? null : (0, react.createElement)("span", { className: "lks-sechead-n" },
|
|
2137
|
+
}, (0, react.createElement)("span", { className: "lks-sec-num" }, String(section.index + 1)), (0, react.createElement)("span", { className: "lks-sechead-t" }, section.title), open ? null : (0, react.createElement)("span", { className: "lks-sechead-n" }, tr("rail.section.count", { count: section.lessons.length })), (0, react.createElement)("span", { className: "lks-sechead-c" }, open ? "▾" : "▸")), ...open ? lessons.map((lesson) => {
|
|
1056
2138
|
const locked = lesson.status === "locked" || lesson.kind === "exam" && !examAllowed;
|
|
1057
2139
|
return (0, react.createElement)("button", {
|
|
1058
2140
|
key: lesson.id,
|
|
1059
2141
|
type: "button",
|
|
1060
2142
|
className: `lks-node${lesson.focus ? " focus" : ""}`,
|
|
1061
2143
|
"aria-disabled": locked || void 0,
|
|
1062
|
-
title: jumping === lesson.id ? "
|
|
2144
|
+
title: jumping === lesson.id ? tr("rail.lesson.opening") : statusTitle(lesson.kind, locked ? "locked" : lesson.status),
|
|
1063
2145
|
onClick: () => {
|
|
1064
2146
|
if (locked) return;
|
|
1065
2147
|
if (lesson.kind === "exam") {
|
|
1066
2148
|
setFocus(lesson.id).catch(reportError);
|
|
1067
|
-
send(
|
|
2149
|
+
send(tr("prompt.exam", { section: section.title }));
|
|
1068
2150
|
return;
|
|
1069
2151
|
}
|
|
1070
2152
|
openLessonThread(lesson);
|
|
1071
2153
|
}
|
|
1072
2154
|
}, (0, react.createElement)("span", { className: "lks-g" }, jumping === lesson.id ? "⏳" : glyph(lesson.kind, locked ? "locked" : lesson.status)), (0, react.createElement)("span", { className: "lks-t" }, lesson.title), lesson.due ? (0, react.createElement)("span", {
|
|
1073
2155
|
className: "lks-tag due",
|
|
1074
|
-
title: "
|
|
2156
|
+
title: tr("rail.due.tag")
|
|
1075
2157
|
}, "🔁") : null, lesson.weakConcepts > 0 ? (0, react.createElement)("span", {
|
|
1076
2158
|
className: "lks-tag weak",
|
|
1077
|
-
title:
|
|
2159
|
+
title: tr("tag.weak", { count: lesson.weakConcepts })
|
|
1078
2160
|
}, `⚡${lesson.weakConcepts}`) : null, lesson.frictionCount > 0 ? (0, react.createElement)("span", {
|
|
1079
2161
|
className: "lks-tag fric",
|
|
1080
|
-
title:
|
|
2162
|
+
title: tr("tag.friction", { count: lesson.frictionCount })
|
|
1081
2163
|
}, `😣${lesson.frictionCount}`) : null, lesson.masteryPct !== null ? (0, react.createElement)("span", {
|
|
1082
2164
|
className: "lks-bar",
|
|
1083
|
-
title:
|
|
2165
|
+
title: tr("tag.mastery", { pct: lesson.masteryPct })
|
|
1084
2166
|
}, (0, react.createElement)("i", { style: { width: `${lesson.masteryPct}%` } })) : null, lesson.masteryPct !== null ? (0, react.createElement)("span", {
|
|
1085
2167
|
className: "lks-pct",
|
|
1086
|
-
title: "
|
|
2168
|
+
title: tr("tag.mastery.short")
|
|
1087
2169
|
}, `${lesson.masteryPct}%`) : null);
|
|
1088
2170
|
}) : []];
|
|
1089
2171
|
}), (0, react.createElement)("button", {
|
|
@@ -1092,9 +2174,9 @@ window.__ModuleLoader__.load({
|
|
|
1092
2174
|
onClick: () => {
|
|
1093
2175
|
setShowImport(!showImport);
|
|
1094
2176
|
}
|
|
1095
|
-
}, showImport ? "
|
|
2177
|
+
}, showImport ? tr("rail.import.close") : tr("rail.import.toggle")), showImport ? (0, react.createElement)(ImportRow, { send }) : null);
|
|
1096
2178
|
})();
|
|
1097
|
-
return (0, react.createElement)("div", { className: "lks-col lks-col-rail" }, (0, react.createElement)("div", { className: "lks-colhead" }, "
|
|
2179
|
+
return (0, react.createElement)("div", { className: "lks-col lks-col-rail" }, (0, react.createElement)("div", { className: "lks-colhead" }, tr("col.rail")), body, (0, react.createElement)(ActionError, { error }));
|
|
1098
2180
|
}
|
|
1099
2181
|
/** One transcript row's element; `interactive` adds the quiz-answer buttons to an assistant row. */
|
|
1100
2182
|
function chatRowElement(row, interactive) {
|
|
@@ -1120,7 +2202,7 @@ window.__ModuleLoader__.load({
|
|
|
1120
2202
|
if (row.role === "thinking") return (0, react.createElement)("div", {
|
|
1121
2203
|
key: row.key,
|
|
1122
2204
|
className: "lks-msg thinking",
|
|
1123
|
-
title: "
|
|
2205
|
+
title: tr("row.thinking.title")
|
|
1124
2206
|
}, (0, react.createElement)(IconLoadingOutline16, {
|
|
1125
2207
|
size: 14,
|
|
1126
2208
|
className: "lks-spin"
|
|
@@ -1138,12 +2220,15 @@ window.__ModuleLoader__.load({
|
|
|
1138
2220
|
className: "lks-turn"
|
|
1139
2221
|
}, body, (0, react.createElement)("div", {
|
|
1140
2222
|
className: "lks-quiz",
|
|
1141
|
-
title: "
|
|
2223
|
+
title: tr("quiz.title")
|
|
1142
2224
|
}, ...options.map((opt) => (0, react.createElement)("button", {
|
|
1143
2225
|
key: opt.letter,
|
|
1144
2226
|
className: "lks-opt",
|
|
1145
2227
|
onClick: () => {
|
|
1146
|
-
interactive.send(
|
|
2228
|
+
interactive.send(tr("quiz.answer", {
|
|
2229
|
+
letter: opt.letter,
|
|
2230
|
+
text: opt.text
|
|
2231
|
+
}));
|
|
1147
2232
|
}
|
|
1148
2233
|
}, (0, react.createElement)("span", { className: "lks-optletter" }, opt.letter), (0, react.createElement)("span", null, opt.text)))));
|
|
1149
2234
|
}
|
|
@@ -1157,6 +2242,10 @@ window.__ModuleLoader__.load({
|
|
|
1157
2242
|
const el = scrollRef.current;
|
|
1158
2243
|
if (el !== null) el.scrollTop = el.scrollHeight;
|
|
1159
2244
|
}, [rows.length]);
|
|
2245
|
+
(0, react.useEffect)(() => {
|
|
2246
|
+
const el = scrollRef.current;
|
|
2247
|
+
if (el !== null && rows.length > 0) enhanceRendered(el).catch(() => {});
|
|
2248
|
+
}, [rows.length]);
|
|
1160
2249
|
const proposal = data?.pendingProposals[0] ?? null;
|
|
1161
2250
|
const lesson = data?.lesson ?? null;
|
|
1162
2251
|
/** Mode switches are the only fallible write left here (host route); surface failures inline. */
|
|
@@ -1167,33 +2256,36 @@ window.__ModuleLoader__.load({
|
|
|
1167
2256
|
setError(err instanceof Error ? err.message : String(err));
|
|
1168
2257
|
});
|
|
1169
2258
|
};
|
|
1170
|
-
return (0, react.createElement)("div", { className: "lks-col lks-col-tutor" }, (0, react.createElement)("div", { className: "lks-colhead" }, "
|
|
2259
|
+
return (0, react.createElement)("div", { className: "lks-col lks-col-tutor" }, (0, react.createElement)("div", { className: "lks-colhead" }, tr("col.tutor")), (0, react.createElement)("div", {
|
|
1171
2260
|
className: "lks-transcript",
|
|
1172
2261
|
ref: scrollRef
|
|
1173
|
-
}, rows.length === 0 ? (0, react.createElement)("div", { className: "lks-empty" }, "
|
|
2262
|
+
}, rows.length === 0 ? (0, react.createElement)("div", { className: "lks-empty" }, tr("tutor.empty"), (0, react.createElement)("br"), tr("tutor.empty.hint")) : rows.map((row, i) => chatRowElement(row, i === lastAssistant ? { send } : void 0))), proposal !== null ? (0, react.createElement)("div", { className: "lks-banner" }, (0, react.createElement)("span", null, "🎓"), (0, react.createElement)("span", { className: "lks-why" }, tr("proposal.text", {
|
|
2263
|
+
lesson: proposal.lessonTitle,
|
|
2264
|
+
rationale: proposal.rationale
|
|
2265
|
+
})), (0, react.createElement)("button", {
|
|
1174
2266
|
className: "lks-btn primary",
|
|
1175
2267
|
onClick: () => {
|
|
1176
|
-
send(
|
|
2268
|
+
send(tr("prompt.proposal.accept", { id: proposal.id }));
|
|
1177
2269
|
}
|
|
1178
|
-
}, "
|
|
2270
|
+
}, tr("proposal.accept")), (0, react.createElement)("button", {
|
|
1179
2271
|
className: "lks-btn ghost",
|
|
1180
2272
|
onClick: () => {
|
|
1181
|
-
send(
|
|
2273
|
+
send(tr("prompt.proposal.decline", { id: proposal.id }));
|
|
1182
2274
|
}
|
|
1183
|
-
}, "
|
|
2275
|
+
}, tr("proposal.decline"))) : null, (0, react.createElement)("div", {
|
|
1184
2276
|
className: "lks-pills",
|
|
1185
2277
|
style: {
|
|
1186
2278
|
height: "auto",
|
|
1187
2279
|
padding: "4px 0"
|
|
1188
2280
|
}
|
|
1189
|
-
}, ...MODES.map((mode) => (0, react.createElement)("button", {
|
|
2281
|
+
}, ...MODES$1.map((mode) => (0, react.createElement)("button", {
|
|
1190
2282
|
key: mode.id,
|
|
1191
2283
|
className: `lks-pill${data?.mode === mode.id ? " on" : ""}`,
|
|
1192
|
-
title: mode.
|
|
2284
|
+
title: tr(mode.hintKey),
|
|
1193
2285
|
onClick: () => {
|
|
1194
2286
|
fire(setMode(mode.id));
|
|
1195
2287
|
}
|
|
1196
|
-
}, mode.
|
|
2288
|
+
}, tr(mode.labelKey)))), lesson !== null && lesson.starters.length > 0 ? (0, react.createElement)("div", {
|
|
1197
2289
|
className: "lks-dock",
|
|
1198
2290
|
style: { padding: "0 0 6px" }
|
|
1199
2291
|
}, ...lesson.starters.map((s) => (0, react.createElement)("button", {
|
|
@@ -1205,26 +2297,65 @@ window.__ModuleLoader__.load({
|
|
|
1205
2297
|
}
|
|
1206
2298
|
}, s.label))) : null, (0, react.createElement)(ActionError, { error }));
|
|
1207
2299
|
}
|
|
1208
|
-
/** Right column: the blackboard — focus-lesson
|
|
2300
|
+
/** Right column: the blackboard — focus-lesson 讲解/脑图/概念图 plus the Cornell 笔记. */
|
|
1209
2301
|
function BlackboardColumn({ data }) {
|
|
1210
2302
|
const lesson = data?.lesson ?? null;
|
|
1211
|
-
const
|
|
2303
|
+
const [pane, setPane] = (0, react.useState)("teach");
|
|
2304
|
+
const proseRef = (0, react.useRef)(null);
|
|
2305
|
+
const diagRef = (0, react.useRef)(null);
|
|
2306
|
+
(0, react.useEffect)(() => {
|
|
2307
|
+
if (pane !== "teach" || proseRef.current === null || lesson === null) return;
|
|
2308
|
+
const el = proseRef.current;
|
|
2309
|
+
enhanceRendered(el).catch(() => {});
|
|
2310
|
+
}, [pane, lesson?.html]);
|
|
2311
|
+
(0, react.useEffect)(() => {
|
|
2312
|
+
if (pane !== "cmap" || diagRef.current === null || lesson === null) return;
|
|
2313
|
+
const el = diagRef.current;
|
|
2314
|
+
el.textContent = "";
|
|
2315
|
+
(lesson.concepts.length === 0 ? Promise.reject(/* @__PURE__ */ new Error("no concepts")) : renderLessonConceptMap(el, lesson.title, lesson.concepts.map((c) => ({
|
|
2316
|
+
title: c.title,
|
|
2317
|
+
masteryPct: c.masteryPct
|
|
2318
|
+
})))).catch((err) => {
|
|
2319
|
+
console.error("lks diagram pane failed:", pane, err);
|
|
2320
|
+
el.textContent = lesson.concepts.length === 0 ? tr("bb.fallback.cmap.empty") : tr("bb.fallback.cmap");
|
|
2321
|
+
});
|
|
2322
|
+
}, [
|
|
2323
|
+
pane,
|
|
2324
|
+
lesson?.lessonId,
|
|
2325
|
+
lesson?.concepts.length
|
|
2326
|
+
]);
|
|
2327
|
+
const body = lesson === null ? (0, react.createElement)("div", { className: "lks-empty" }, tr("bb.empty"), (0, react.createElement)("br"), tr("bb.empty.hint")) : (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "lks-lessonhead" }, (0, react.createElement)("h2", null, lesson.title), (0, react.createElement)("div", { className: "lks-meta" }, `${lesson.courseTitle} · ${lesson.status}` + (lesson.masteryPct === null ? "" : ` · ${tr("bb.mastery", { pct: lesson.masteryPct })}`) + ` · ${lesson.strategy}`), lesson.concepts.length > 0 ? (0, react.createElement)("div", { className: "lks-chips" }, ...lesson.concepts.map((c) => (0, react.createElement)("span", {
|
|
1212
2328
|
key: c.title,
|
|
1213
2329
|
className: `lks-chip${c.weak ? " weak" : ""}`
|
|
1214
|
-
}, `${c.title} ${c.masteryPct}%${c.weak ? " ⚡" : ""}`))) : null), (0, react.createElement)("div", {
|
|
2330
|
+
}, `${c.title} ${c.masteryPct}%${c.weak ? " ⚡" : ""}`))) : null), (0, react.createElement)("div", { className: "lks-viewtabs" }, (0, react.createElement)("button", {
|
|
2331
|
+
className: `lks-viewtab${pane === "teach" ? " on" : ""}`,
|
|
2332
|
+
onClick: () => {
|
|
2333
|
+
setPane("teach");
|
|
2334
|
+
}
|
|
2335
|
+
}, tr("viewtab.teach")), (0, react.createElement)("button", {
|
|
2336
|
+
className: `lks-viewtab${pane === "cmap" ? " on" : ""}`,
|
|
2337
|
+
title: tr("viewtab.cmap.title"),
|
|
2338
|
+
onClick: () => {
|
|
2339
|
+
setPane("cmap");
|
|
2340
|
+
}
|
|
2341
|
+
}, tr("viewtab.cmap"))), pane === "teach" ? (0, react.createElement)("div", {
|
|
1215
2342
|
className: "lks-prose",
|
|
2343
|
+
ref: proseRef,
|
|
1216
2344
|
dangerouslySetInnerHTML: { __html: lesson.html }
|
|
1217
|
-
})
|
|
2345
|
+
}) : (0, react.createElement)("div", {
|
|
2346
|
+
className: "lks-prose",
|
|
2347
|
+
ref: diagRef
|
|
2348
|
+
}), (0, react.createElement)("div", { className: "lks-bb-notes" }, (0, react.createElement)("div", { className: "lks-sec" }, tr("bb.notes")), lesson.notes.length === 0 ? (0, react.createElement)("div", {
|
|
1218
2349
|
className: "lks-empty",
|
|
1219
2350
|
style: { padding: "16px 0" }
|
|
1220
|
-
}, "
|
|
2351
|
+
}, tr("bb.notes.empty")) : ZONES.filter(([zone]) => lesson.notes.some((n) => n.zone === zone)).map(([zone, labelKey]) => (0, react.createElement)("div", {
|
|
1221
2352
|
key: zone,
|
|
1222
2353
|
className: "lks-zone"
|
|
1223
|
-
}, (0, react.createElement)("h4", null,
|
|
2354
|
+
}, (0, react.createElement)("h4", null, tr(labelKey)), ...lesson.notes.filter((n) => n.zone === zone).map((n) => (0, react.createElement)("div", {
|
|
1224
2355
|
key: n.id,
|
|
1225
2356
|
className: "lks-note"
|
|
1226
2357
|
}, (0, react.createElement)("span", { className: "lks-note-src" }, n.source), (0, react.createElement)("div", { className: "lks-note-title" }, n.title), (0, react.createElement)("div", { className: "lks-note-text" }, n.text), n.quote !== null ? (0, react.createElement)("div", { className: "lks-note-q" }, `“${n.quote}”`) : null))))));
|
|
1227
|
-
return (0, react.createElement)("div", { className: "lks-col lks-col-bb" }, (0, react.createElement)("div", { className: "lks-colhead" }, "
|
|
2358
|
+
return (0, react.createElement)("div", { className: "lks-col lks-col-bb" }, (0, react.createElement)("div", { className: "lks-colhead" }, tr("col.bb")), body);
|
|
1228
2359
|
}
|
|
1229
2360
|
//#endregion
|
|
1230
2361
|
//#region src/client/starter.tsx
|
|
@@ -1238,8 +2369,6 @@ window.__ModuleLoader__.load({
|
|
|
1238
2369
|
* not reachable from an additive plugin).
|
|
1239
2370
|
* @module dsh-plugin-lookatstudy/client/starter
|
|
1240
2371
|
*/
|
|
1241
|
-
/** The kickoff prompt: starts the tutoring loop and points at the study tab. */
|
|
1242
|
-
const KICKOFF = "开始学习:查看我的学习状态并打开当前焦点课时。如果我还没有课程,推荐我导入示例课程(AI-For-Beginners)并说明怎么开始。回复最后请提醒我点上方「学习」页签进入学习界面。";
|
|
1243
2372
|
/**
|
|
1244
2373
|
* Build the hero starter button bound to the framework services.
|
|
1245
2374
|
* @param ctx - client root context (workspaces + sessions injected).
|
|
@@ -1276,7 +2405,7 @@ window.__ModuleLoader__.load({
|
|
|
1276
2405
|
if (face === void 0) throw new Error("study session is not addressable yet");
|
|
1277
2406
|
const result = await face.prompt([{
|
|
1278
2407
|
type: "text",
|
|
1279
|
-
text:
|
|
2408
|
+
text: tr("prompt.kickoff")
|
|
1280
2409
|
}], "queue");
|
|
1281
2410
|
if (!result.ok) throw new Error(`kickoff prompt rejected: ${result.error.code}: ${result.error.message}`);
|
|
1282
2411
|
ctx.sessions.open(sessionId);
|
|
@@ -1295,41 +2424,263 @@ window.__ModuleLoader__.load({
|
|
|
1295
2424
|
}
|
|
1296
2425
|
}, (0, react.createElement)("button", {
|
|
1297
2426
|
className: "lks-pill on",
|
|
1298
|
-
title: "
|
|
2427
|
+
title: tr("start.title"),
|
|
1299
2428
|
disabled: busy,
|
|
1300
2429
|
onClick: start
|
|
1301
|
-
}, busy ? (0, react.createElement)(IconLoadingOutline16, { className: "lks-spin" }) : (0, react.createElement)(IconThinkOutline16, null), busy ? "
|
|
2430
|
+
}, busy ? (0, react.createElement)(IconLoadingOutline16, { className: "lks-spin" }) : (0, react.createElement)(IconThinkOutline16, null), busy ? tr("start.busy") : data?.active === true ? tr("start.enter") : tr("start.go")), error !== null ? (0, react.createElement)("span", {
|
|
1302
2431
|
className: "lks-propcard-err",
|
|
1303
2432
|
style: { marginTop: 0 }
|
|
1304
2433
|
}, error) : null);
|
|
1305
2434
|
}
|
|
1306
2435
|
//#endregion
|
|
2436
|
+
//#region src/client/settings.tsx
|
|
2437
|
+
/**
|
|
2438
|
+
* The plugin's settings page: one `settings.section` entry riding the host's
|
|
2439
|
+
* settings shell. Teaching style and study mode write through the same host
|
|
2440
|
+
* routes the study tab uses (POST /api/mode, /api/active — the latter syncs
|
|
2441
|
+
* the tool registry before responding); stats and the state-file path are
|
|
2442
|
+
* read-only projections of the shared poll store. Pure display + the store's
|
|
2443
|
+
* existing write actions; no new routes, no new requests beyond the poll.
|
|
2444
|
+
* @module dsh-plugin-lookatstudy/client/settings
|
|
2445
|
+
*/
|
|
2446
|
+
/** The three souls in pill order (same shape as the study tab's pills). */
|
|
2447
|
+
const MODES = [
|
|
2448
|
+
{
|
|
2449
|
+
id: "direct",
|
|
2450
|
+
labelKey: "soul.direct",
|
|
2451
|
+
hintKey: "soul.direct.hint"
|
|
2452
|
+
},
|
|
2453
|
+
{
|
|
2454
|
+
id: "guide",
|
|
2455
|
+
labelKey: "soul.guide",
|
|
2456
|
+
hintKey: "soul.guide.hint"
|
|
2457
|
+
},
|
|
2458
|
+
{
|
|
2459
|
+
id: "practice",
|
|
2460
|
+
labelKey: "soul.practice",
|
|
2461
|
+
hintKey: "soul.practice.hint"
|
|
2462
|
+
}
|
|
2463
|
+
];
|
|
2464
|
+
/**
|
|
2465
|
+
* The settings section component. Props from the settings shell (`close`) are
|
|
2466
|
+
* unused — every control stays on the page.
|
|
2467
|
+
*/
|
|
2468
|
+
function StudySettingsSection() {
|
|
2469
|
+
const { data, setMode, activate } = useStudy();
|
|
2470
|
+
const [error, setError] = (0, react.useState)(null);
|
|
2471
|
+
const fire = (action) => {
|
|
2472
|
+
action.then(() => {
|
|
2473
|
+
setError(null);
|
|
2474
|
+
}, (err) => {
|
|
2475
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
2476
|
+
});
|
|
2477
|
+
};
|
|
2478
|
+
const progress = data?.progress;
|
|
2479
|
+
return (0, react.createElement)("div", { className: "lks-root lks-settings" }, (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.mode")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.mode.hint")), (0, react.createElement)("div", { className: "lks-pills" }, ...MODES.map((mode) => (0, react.createElement)("button", {
|
|
2480
|
+
key: mode.id,
|
|
2481
|
+
className: `lks-pill${data?.mode === mode.id ? " on" : ""}`,
|
|
2482
|
+
title: tr(mode.hintKey),
|
|
2483
|
+
onClick: () => {
|
|
2484
|
+
fire(setMode(mode.id));
|
|
2485
|
+
}
|
|
2486
|
+
}, tr(mode.labelKey))))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.studyMode")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.studyMode.hint")), (0, react.createElement)("div", null, (0, react.createElement)("button", {
|
|
2487
|
+
className: `lks-btn ${data?.active === true ? "ghost" : "primary"}`,
|
|
2488
|
+
onClick: () => {
|
|
2489
|
+
if (data !== null) fire(activate(!data.active));
|
|
2490
|
+
}
|
|
2491
|
+
}, data?.active === true ? tr("settings.turnOff") : tr("settings.turnOn")), (0, react.createElement)("span", { className: "lks-set-state" }, data?.active === true ? tr("settings.on") : tr("settings.off")))), progress === void 0 ? null : (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.stats")), (0, react.createElement)("ul", { className: "lks-set-stats" }, (0, react.createElement)("li", null, tr("settings.stats.courses", { count: data?.courses.length ?? 0 })), (0, react.createElement)("li", null, tr("settings.stats.xp", {
|
|
2492
|
+
xp: progress.totalXp,
|
|
2493
|
+
level: progress.level,
|
|
2494
|
+
pct: progress.levelPct
|
|
2495
|
+
})), (0, react.createElement)("li", null, tr("settings.stats.today", {
|
|
2496
|
+
xp: progress.todayXp,
|
|
2497
|
+
goal: progress.dailyGoal
|
|
2498
|
+
})), (0, react.createElement)("li", null, tr("settings.stats.streak", {
|
|
2499
|
+
days: progress.streak,
|
|
2500
|
+
best: progress.longestStreak,
|
|
2501
|
+
freeze: progress.freezeCount
|
|
2502
|
+
})))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.about")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.about.hint")), data?.version ? (0, react.createElement)("a", {
|
|
2503
|
+
href: `https://github.com/Kaiji-Z/dsh-plugin-lookatstudy/releases/tag/v${data.version}`,
|
|
2504
|
+
target: "_blank",
|
|
2505
|
+
rel: "noreferrer"
|
|
2506
|
+
}, `v${data.version}`) : null), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.stateFile")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.stateFile.hint")), data === null ? null : (0, react.createElement)("code", { className: "lks-set-path" }, data.statePath)), (0, react.createElement)(ActionError, { error }));
|
|
2507
|
+
}
|
|
2508
|
+
//#endregion
|
|
2509
|
+
//#region src/client/dock.tsx
|
|
2510
|
+
/**
|
|
2511
|
+
* The composer dock status pill: one `conversation.composer.dock` entry (the
|
|
2512
|
+
* ambient readout band under the composer, next to the token stats) showing
|
|
2513
|
+
* due reviews / streak / level while the study surface is ACTIVE — dormant
|
|
2514
|
+
* installs render nothing at all, the same visibility the tool registry has.
|
|
2515
|
+
* Data rides the shared 3 s poll store (no extra requests). Display-only:
|
|
2516
|
+
* conversation-view tab switching is ui-conversation-private (the starter
|
|
2517
|
+
* button's limitation), so the tooltip points at the Study tab instead.
|
|
2518
|
+
* @module dsh-plugin-lookatstudy/client/dock
|
|
2519
|
+
*/
|
|
2520
|
+
/** Pure projection of the pill's segments (testable without React). */
|
|
2521
|
+
function dockSegments(progress) {
|
|
2522
|
+
return [
|
|
2523
|
+
{
|
|
2524
|
+
key: "due",
|
|
2525
|
+
text: tr("dock.due", { count: progress.dueCount })
|
|
2526
|
+
},
|
|
2527
|
+
{
|
|
2528
|
+
key: "streak",
|
|
2529
|
+
text: tr("dock.streak", { days: progress.streak })
|
|
2530
|
+
},
|
|
2531
|
+
{
|
|
2532
|
+
key: "lv",
|
|
2533
|
+
text: tr("dock.lv", { level: progress.level })
|
|
2534
|
+
}
|
|
2535
|
+
];
|
|
2536
|
+
}
|
|
2537
|
+
/** The dock entry component; renders nothing while data is loading or dormant. */
|
|
2538
|
+
function StudyDockPill() {
|
|
2539
|
+
const { data } = useStudy();
|
|
2540
|
+
if (data === null || data.active !== true) return null;
|
|
2541
|
+
const segments = dockSegments({
|
|
2542
|
+
dueCount: data.dueCount,
|
|
2543
|
+
streak: data.progress.streak,
|
|
2544
|
+
level: data.progress.level
|
|
2545
|
+
});
|
|
2546
|
+
return (0, react.createElement)("span", {
|
|
2547
|
+
className: "lks-root lks-dockpill",
|
|
2548
|
+
title: tr("dock.title", {
|
|
2549
|
+
due: data.dueCount,
|
|
2550
|
+
streak: data.progress.streak,
|
|
2551
|
+
level: data.progress.level
|
|
2552
|
+
})
|
|
2553
|
+
}, ...segments.map((s) => (0, react.createElement)("span", {
|
|
2554
|
+
key: s.key,
|
|
2555
|
+
className: `lks-dockseg lks-dock-${s.key}`
|
|
2556
|
+
}, s.text)));
|
|
2557
|
+
}
|
|
2558
|
+
//#endregion
|
|
2559
|
+
//#region src/client/toolviews.tsx
|
|
2560
|
+
/**
|
|
2561
|
+
* Keyed `tool.call.toolview` entries: learning-aware cards for the
|
|
2562
|
+
* conversation tab's tool rows (our own study tab renders its own chips, but
|
|
2563
|
+
* learning also happens in the plain 对话 view). One generic card body over
|
|
2564
|
+
* the result's `meta` (the tool's presentationMeta lines — replay-safe by
|
|
2565
|
+
* construction) with a per-tool header; `study_record_answer` additionally
|
|
2566
|
+
* parses its args for a live ✓/✗ tone while the call is still running.
|
|
2567
|
+
* @module dsh-plugin-lookatstudy/client/toolviews
|
|
2568
|
+
*/
|
|
2569
|
+
/** Header glyph per tool (missing tools simply use the generic row). */
|
|
2570
|
+
const TOOL_GLYPHS = {
|
|
2571
|
+
study_record_answer: "✍️",
|
|
2572
|
+
study_lesson: "📘",
|
|
2573
|
+
study_due_reviews: "🔁",
|
|
2574
|
+
study_exam_result: "🎯"
|
|
2575
|
+
};
|
|
2576
|
+
/** The ✓/✗ tone from record_answer's args; null for other shapes. Pure. */
|
|
2577
|
+
function answerTone(argsRaw) {
|
|
2578
|
+
if (argsRaw === void 0) return null;
|
|
2579
|
+
try {
|
|
2580
|
+
const args = JSON.parse(argsRaw);
|
|
2581
|
+
return {
|
|
2582
|
+
correct: args.correct === true,
|
|
2583
|
+
concept: typeof args.concept === "string" && args.concept !== "" ? args.concept : tr("chip.unattributed")
|
|
2584
|
+
};
|
|
2585
|
+
} catch {
|
|
2586
|
+
return null;
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
/** Card lines: the result's meta (string[]) when present, else its text content. Pure. */
|
|
2590
|
+
function metaLines(block) {
|
|
2591
|
+
if (Array.isArray(block.meta)) {
|
|
2592
|
+
const lines = block.meta.filter((x) => typeof x === "string");
|
|
2593
|
+
if (lines.length > 0) return lines;
|
|
2594
|
+
}
|
|
2595
|
+
return (block.content ?? []).filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text.split("\n")).flat().filter((l) => l.trim() !== "");
|
|
2596
|
+
}
|
|
2597
|
+
/** Shared card body: header + lines; error results keep their text visible. */
|
|
2598
|
+
function studyToolCard(toolName, block, extra) {
|
|
2599
|
+
const lines = metaLines(block);
|
|
2600
|
+
const glyph = TOOL_GLYPHS[toolName] ?? "🔧";
|
|
2601
|
+
return (0, react.createElement)("div", { className: `lks-tv${block.isError === true ? " err" : ""}` }, extra ?? null, (0, react.createElement)("span", { className: "lks-tv-head" }, `${glyph} ${toolName}`), lines.length === 0 ? null : (0, react.createElement)("div", { className: "lks-tv-lines" }, ...lines.map((line, i) => (0, react.createElement)("div", {
|
|
2602
|
+
key: String(i),
|
|
2603
|
+
className: "lks-tv-line"
|
|
2604
|
+
}, line))));
|
|
2605
|
+
}
|
|
2606
|
+
/** study_record_answer: live ✓/✗ tone from args + the graded line from meta. */
|
|
2607
|
+
function StudyAnswerView({ toolName, block }) {
|
|
2608
|
+
const tone = answerTone(block.argsRaw);
|
|
2609
|
+
return studyToolCard(toolName, block, tone === null ? null : (0, react.createElement)("span", { className: `lks-tv-chip ${tone.correct ? "ok" : "bad"}` }, tr(tone.correct ? "chip.correct" : "chip.wrong", { concept: tone.concept })));
|
|
2610
|
+
}
|
|
2611
|
+
/** Generic meta-line card for the read-type study tools. */
|
|
2612
|
+
function StudyLessonView({ toolName, block }) {
|
|
2613
|
+
return studyToolCard(toolName, block);
|
|
2614
|
+
}
|
|
2615
|
+
/** study_due_reviews list card. */
|
|
2616
|
+
function StudyDueView({ toolName, block }) {
|
|
2617
|
+
return studyToolCard(toolName, block);
|
|
2618
|
+
}
|
|
2619
|
+
/** study_exam_result star card. */
|
|
2620
|
+
function StudyExamView({ toolName, block }) {
|
|
2621
|
+
return studyToolCard(toolName, block);
|
|
2622
|
+
}
|
|
2623
|
+
//#endregion
|
|
1307
2624
|
//#region src/client/index.ts
|
|
1308
2625
|
const inject = [
|
|
1309
2626
|
"slots",
|
|
1310
2627
|
"workspaces",
|
|
1311
|
-
"sessions"
|
|
2628
|
+
"sessions",
|
|
2629
|
+
"locale"
|
|
1312
2630
|
];
|
|
1313
2631
|
/**
|
|
1314
2632
|
* Register the study surfaces: styles inject once; the single
|
|
1315
2633
|
* `conversation.view` tab carries the whole plugin; the hero starter button
|
|
1316
2634
|
* (`conversation.input.left`, blank sessions only) automates the onboarding —
|
|
1317
|
-
* study workspace + session + kickoff prompt in one click.
|
|
2635
|
+
* study workspace + session + kickoff prompt in one click. The `lookatstudy`
|
|
2636
|
+
* locale namespace rides the framework locale service (zh/en) and the
|
|
2637
|
+
* client-wide translator is installed before any component renders; both
|
|
2638
|
+
* slot entries declare the namespace so the framework synthesizes their `t`
|
|
2639
|
+
* seat and follows locale switches for the labels.
|
|
1318
2640
|
* @param ctx - client root context.
|
|
1319
2641
|
*/
|
|
1320
2642
|
function apply(ctx) {
|
|
1321
2643
|
ensureStudyStyles();
|
|
2644
|
+
const localeSvc = ctx.locale;
|
|
2645
|
+
ctx.effect(() => registerStudyLocale(localeSvc), "lookatstudy.locale()");
|
|
2646
|
+
setStudyTranslator(studyTranslator(localeSvc));
|
|
1322
2647
|
ctx.slots.inject("conversation.view", () => ctx.slots.register({
|
|
1323
2648
|
name: "conversation.view",
|
|
1324
2649
|
id: "lookatstudy-study",
|
|
1325
2650
|
order: 15,
|
|
1326
|
-
label: "
|
|
2651
|
+
label: () => tr("tab.label"),
|
|
2652
|
+
locale: "lookatstudy"
|
|
1327
2653
|
}, studyView(ctx)));
|
|
1328
2654
|
ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
|
|
1329
2655
|
name: "conversation.input.left",
|
|
1330
2656
|
id: "lookatstudy-start",
|
|
1331
|
-
order: 10
|
|
2657
|
+
order: 10,
|
|
2658
|
+
locale: "lookatstudy"
|
|
1332
2659
|
}, studyStartButton(ctx)));
|
|
2660
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
2661
|
+
name: "settings.section",
|
|
2662
|
+
id: "lookatstudy",
|
|
2663
|
+
order: 30,
|
|
2664
|
+
label: () => tr("settings.nav"),
|
|
2665
|
+
locale: "lookatstudy"
|
|
2666
|
+
}, StudySettingsSection));
|
|
2667
|
+
ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
|
|
2668
|
+
name: "conversation.composer.dock",
|
|
2669
|
+
id: "lookatstudy-status",
|
|
2670
|
+
order: 10,
|
|
2671
|
+
locale: "lookatstudy"
|
|
2672
|
+
}, StudyDockPill));
|
|
2673
|
+
const toolviews = [
|
|
2674
|
+
["study_record_answer", StudyAnswerView],
|
|
2675
|
+
["study_lesson", StudyLessonView],
|
|
2676
|
+
["study_due_reviews", StudyDueView],
|
|
2677
|
+
["study_exam_result", StudyExamView]
|
|
2678
|
+
];
|
|
2679
|
+
for (const [tool, component] of toolviews) ctx.slots.inject("tool.call.toolview", () => ctx.slots.register({
|
|
2680
|
+
name: "tool.call.toolview",
|
|
2681
|
+
key: tool,
|
|
2682
|
+
locale: "lookatstudy"
|
|
2683
|
+
}, component));
|
|
1333
2684
|
}
|
|
1334
2685
|
//#endregion
|
|
1335
2686
|
exports.apply = apply;
|