dsh-plugin-lookatstudy 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -7
- package/lib/client.js +1529 -109
- package/lib/client.js.map +1 -1
- package/lib/docx-parser-BhyqPImb.mjs +55 -0
- package/lib/epub-parser-DvlKap-d.mjs +112 -0
- package/lib/html-article-C6nhqJbX.mjs +426 -0
- package/lib/index.d.mts +5 -4
- package/lib/index.mjs +3011 -1230
- package/lib/inflate-DIKUrTRi.mjs +311 -0
- package/lib/pptx-parser-CD5pR2cj.mjs +48 -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/markmap/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,1088 @@ 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$1 = {
|
|
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$1.katexCss;
|
|
681
|
+
link.setAttribute("data-lks-katex", "1");
|
|
682
|
+
document.head.append(link);
|
|
683
|
+
}
|
|
684
|
+
if (window.katex === void 0) await loadScript(CDN$1.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$1.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$1.mermaid
|
|
727
|
+
)).default;
|
|
728
|
+
try {
|
|
729
|
+
const elk = await import(
|
|
730
|
+
/* @vite-ignore */
|
|
731
|
+
CDN$1.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/mindmap-markdown.ts
|
|
872
|
+
/**
|
|
873
|
+
* mindmap-markdown —— 讲解 markdown → 思维导图源文本的纯函数预处理(v0.21)。
|
|
874
|
+
*
|
|
875
|
+
* 思维导图是**结构概览**:代码块整段进脑图节点只是噪声(markmap-lib 还会为它
|
|
876
|
+
* 内嵌 hljs 高亮 HTML),图片也无处安放。喂给 markmap 前剥离:
|
|
877
|
+
* - 围栏代码块(``` 或 ~~~,含缩进围栏)→ 单行「代码块」占位(保住结构位置)
|
|
878
|
+
* - 图片  → alt 文字
|
|
879
|
+
* - HTML 注释整段剥除
|
|
880
|
+
* 其余(标题层级/列表/粗体/链接文字)原样保留 —— 层级就是脑图骨架。
|
|
881
|
+
*/
|
|
882
|
+
function mindmapMarkdown(md) {
|
|
883
|
+
const out = [];
|
|
884
|
+
let fence = null;
|
|
885
|
+
let fenceLen = 0;
|
|
886
|
+
for (const line of md.split("\n")) {
|
|
887
|
+
const open = /^(\s*)(`{3,}|~{3,})/.exec(line);
|
|
888
|
+
if (fence == null) {
|
|
889
|
+
if (open) {
|
|
890
|
+
fence = (open[2] ?? "")[0] ?? "`";
|
|
891
|
+
fenceLen = (open[2] ?? "").length;
|
|
892
|
+
out.push(`${open[1] ?? ""}- 代码块`);
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
out.push(line);
|
|
896
|
+
} else if (new RegExp(`^\\s*\\${fence === "~" ? "~" : "`"}{${fenceLen},}\\s*$`).test(line)) fence = null;
|
|
897
|
+
}
|
|
898
|
+
return out.join("\n").replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<!--[\s\S]*?-->/g, "");
|
|
899
|
+
}
|
|
900
|
+
//#endregion
|
|
901
|
+
//#region src/vendor/cmap-elk-layout.ts
|
|
902
|
+
/** 字符宽度估算:CJK 全宽,ASCII 约 0.58em,其余(BBOP…)同 ASCII。 */
|
|
903
|
+
function estTextWidth(text, fontSize = 13) {
|
|
904
|
+
let w = 0;
|
|
905
|
+
for (const ch of text) w += /[\u2e80-\u9fff\uf900-\ufaff\uff00-\uffef\u3000-\u303f]/.test(ch) ? fontSize : fontSize * .58;
|
|
906
|
+
return w;
|
|
907
|
+
}
|
|
908
|
+
const LINE_MAX_PX = 118;
|
|
909
|
+
/** 标签包裹:≤2 行;优先自然断点(空格///-),CJK 连续按宽度一半硬拆;仍超宽截断。 */
|
|
910
|
+
function wrapLabel(label) {
|
|
911
|
+
if (estTextWidth(label) <= LINE_MAX_PX) return [label];
|
|
912
|
+
const breaks = [];
|
|
913
|
+
for (let i = 0; i < label.length; i++) if (/[\s/\-—·]/.test(label[i] ?? "")) breaks.push(i);
|
|
914
|
+
const mid = label.length / 2;
|
|
915
|
+
let split = -1;
|
|
916
|
+
let best = Infinity;
|
|
917
|
+
for (const b of breaks) {
|
|
918
|
+
const d = Math.abs(b + 1 - mid);
|
|
919
|
+
if (d < best) {
|
|
920
|
+
best = d;
|
|
921
|
+
split = b;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
let l1;
|
|
925
|
+
let l2;
|
|
926
|
+
if (split >= 0) {
|
|
927
|
+
l1 = label.slice(0, split + 1).trim();
|
|
928
|
+
l2 = label.slice(split + 1).trim();
|
|
929
|
+
} else {
|
|
930
|
+
let acc = 0;
|
|
931
|
+
let idx = 0;
|
|
932
|
+
for (const ch of label) {
|
|
933
|
+
acc += estTextWidth(ch);
|
|
934
|
+
idx++;
|
|
935
|
+
if (acc >= estTextWidth(label) / 2) break;
|
|
936
|
+
}
|
|
937
|
+
l1 = label.slice(0, idx);
|
|
938
|
+
l2 = label.slice(idx);
|
|
939
|
+
}
|
|
940
|
+
if (estTextWidth(l1) <= LINE_MAX_PX && estTextWidth(l2) <= LINE_MAX_PX) return [l1, l2];
|
|
941
|
+
const fit = (s) => {
|
|
942
|
+
let out = "";
|
|
943
|
+
for (const ch of s) {
|
|
944
|
+
if (estTextWidth(out + ch + "…") > LINE_MAX_PX) break;
|
|
945
|
+
out += ch;
|
|
946
|
+
}
|
|
947
|
+
return out + "…";
|
|
948
|
+
};
|
|
949
|
+
return [estTextWidth(l1) > LINE_MAX_PX ? fit(l1) : l1, estTextWidth(l2) > LINE_MAX_PX ? fit(l2) : l2];
|
|
950
|
+
}
|
|
951
|
+
/** 节点盒:hub(度数最大或 ≥4)字号/内边距更大;宽高由包裹后行宽决定。 */
|
|
952
|
+
function cmNodeBox(label, hub) {
|
|
953
|
+
const lines = wrapLabel(label);
|
|
954
|
+
const fs = hub ? 14 : 13;
|
|
955
|
+
const textW = Math.max(...lines.map((l) => estTextWidth(l, fs)));
|
|
956
|
+
const padX = hub ? 30 : 24;
|
|
957
|
+
const lh = 17;
|
|
958
|
+
const padY = hub ? 18 : 14;
|
|
959
|
+
return {
|
|
960
|
+
width: Math.min(hub ? 200 : 180, Math.max(hub ? 92 : 76, textW + padX)),
|
|
961
|
+
height: padY + lines.length * lh,
|
|
962
|
+
lines
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* 净化 LLM groups:丢弃指向不存在节点的 nodeIds、去重(先到先得)、
|
|
967
|
+
* 剩余成员 < 2 的组丢弃;唯一覆盖全部节点的单组视为无效(一个巨容器没有信息量)。
|
|
968
|
+
* 净化后为空 → clusterByAdjacency 兜底。
|
|
969
|
+
*/
|
|
970
|
+
function resolveGroups(nodes, edges, input) {
|
|
971
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
972
|
+
if (Array.isArray(input) && input.length > 0) {
|
|
973
|
+
const assigned = /* @__PURE__ */ new Set();
|
|
974
|
+
const groups = [];
|
|
975
|
+
for (const g of input) {
|
|
976
|
+
if (!g || typeof g.id !== "string" || !g.id) continue;
|
|
977
|
+
const nodeIds = Array.isArray(g.nodeIds) ? [...new Set(g.nodeIds.filter((x) => typeof x === "string" && ids.has(x) && !assigned.has(x)))] : [];
|
|
978
|
+
if (nodeIds.length < 2) continue;
|
|
979
|
+
nodeIds.forEach((x) => assigned.add(x));
|
|
980
|
+
groups.push({
|
|
981
|
+
id: g.id,
|
|
982
|
+
label: typeof g.label === "string" && g.label ? g.label : "分组",
|
|
983
|
+
nodeIds
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
if (groups.length > 0 && !(groups.length === 1 && assigned.size === nodes.length && nodes.length > 2)) return {
|
|
987
|
+
groups,
|
|
988
|
+
fallback: false
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
return {
|
|
992
|
+
groups: clusterByAdjacency(nodes, edges),
|
|
993
|
+
fallback: true
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* 邻接聚类兜底(确定性):度数 top-k 节点做种子(k = clamp(ceil(n/4),1,3)),
|
|
998
|
+
* 多源 BFS 就近归属(平手先出现的种子赢);孤立节点不归组(根层漂浮,不造假组)。
|
|
999
|
+
* 组名 = 种子标签(≤5 字加"相关")。
|
|
1000
|
+
*/
|
|
1001
|
+
function clusterByAdjacency(nodes, edges) {
|
|
1002
|
+
if (nodes.length < 4) return [];
|
|
1003
|
+
const index = new Map(nodes.map((n, i) => [n.id, i]));
|
|
1004
|
+
const adj = /* @__PURE__ */ new Map();
|
|
1005
|
+
for (const n of nodes) adj.set(n.id, []);
|
|
1006
|
+
const seenPair = /* @__PURE__ */ new Set();
|
|
1007
|
+
for (const e of edges) {
|
|
1008
|
+
if (!index.has(e.from) || !index.has(e.to) || e.from === e.to) continue;
|
|
1009
|
+
const key = [e.from, e.to].sort().join("→");
|
|
1010
|
+
if (seenPair.has(key)) continue;
|
|
1011
|
+
seenPair.add(key);
|
|
1012
|
+
adj.get(e.from).push(e.to);
|
|
1013
|
+
adj.get(e.to).push(e.from);
|
|
1014
|
+
}
|
|
1015
|
+
const ranked = [...nodes].sort((a, b) => {
|
|
1016
|
+
const da = adj.get(a.id).length;
|
|
1017
|
+
const db = adj.get(b.id).length;
|
|
1018
|
+
return da !== db ? db - da : (index.get(a.id) ?? 0) - (index.get(b.id) ?? 0);
|
|
1019
|
+
});
|
|
1020
|
+
const k = Math.min(3, Math.max(1, Math.ceil(nodes.length / 4)));
|
|
1021
|
+
const seeds = ranked.slice(0, k).filter((s) => (adj.get(s.id)?.length ?? 0) > 0);
|
|
1022
|
+
if (seeds.length === 0) return [];
|
|
1023
|
+
const owner = /* @__PURE__ */ new Map();
|
|
1024
|
+
seeds.forEach((s, si) => owner.set(s.id, si));
|
|
1025
|
+
const queue = seeds.map((s) => s.id);
|
|
1026
|
+
while (queue.length) {
|
|
1027
|
+
const cur = queue.shift();
|
|
1028
|
+
const si = owner.get(cur);
|
|
1029
|
+
for (const nb of adj.get(cur) ?? []) {
|
|
1030
|
+
if (owner.has(nb)) continue;
|
|
1031
|
+
owner.set(nb, si);
|
|
1032
|
+
queue.push(nb);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
const groups = [];
|
|
1036
|
+
seeds.forEach((s, si) => {
|
|
1037
|
+
const nodeIds = nodes.filter((n) => owner.get(n.id) === si).map((n) => n.id);
|
|
1038
|
+
if (nodeIds.length < 2) return;
|
|
1039
|
+
groups.push({
|
|
1040
|
+
id: `grp-${s.id}`,
|
|
1041
|
+
label: s.label.length <= 5 ? `${s.label}相关` : s.label,
|
|
1042
|
+
nodeIds
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
return groups;
|
|
1046
|
+
}
|
|
1047
|
+
function buildElkGraph(nodes, edges, groups) {
|
|
1048
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
1049
|
+
const degree = /* @__PURE__ */ new Map();
|
|
1050
|
+
for (const n of nodes) degree.set(n.id, 0);
|
|
1051
|
+
const seenPair = /* @__PURE__ */ new Set();
|
|
1052
|
+
const drawEdges = [];
|
|
1053
|
+
for (const e of edges) {
|
|
1054
|
+
if (!ids.has(e.from) || !ids.has(e.to) || e.from === e.to) continue;
|
|
1055
|
+
const key = [e.from, e.to].sort().join("→");
|
|
1056
|
+
if (seenPair.has(key)) continue;
|
|
1057
|
+
seenPair.add(key);
|
|
1058
|
+
drawEdges.push(e);
|
|
1059
|
+
degree.set(e.from, degree.get(e.from) + 1);
|
|
1060
|
+
degree.set(e.to, degree.get(e.to) + 1);
|
|
1061
|
+
}
|
|
1062
|
+
let hubId = nodes[0]?.id ?? "";
|
|
1063
|
+
for (const n of nodes) if ((degree.get(n.id) ?? 0) > (degree.get(hubId) ?? 0)) hubId = n.id;
|
|
1064
|
+
const inGroup = /* @__PURE__ */ new Map();
|
|
1065
|
+
for (const g of groups) for (const nid of g.nodeIds) if (ids.has(nid) && !inGroup.has(nid)) inGroup.set(nid, g.id);
|
|
1066
|
+
const nodeChild = (n) => {
|
|
1067
|
+
const box = cmNodeBox(n.label, n.id === hubId || (degree.get(n.id) ?? 0) >= 4);
|
|
1068
|
+
return {
|
|
1069
|
+
id: n.id,
|
|
1070
|
+
width: Math.ceil(box.width),
|
|
1071
|
+
height: Math.ceil(box.height)
|
|
1072
|
+
};
|
|
1073
|
+
};
|
|
1074
|
+
const children = [];
|
|
1075
|
+
for (const g of groups) {
|
|
1076
|
+
const members = g.nodeIds.filter((nid) => inGroup.get(nid) === g.id && ids.has(nid));
|
|
1077
|
+
if (members.length === 0) continue;
|
|
1078
|
+
children.push({
|
|
1079
|
+
id: `g:${g.id}`,
|
|
1080
|
+
layoutOptions: { "elk.padding": `[top=34,left=14,bottom=12,right=14]` },
|
|
1081
|
+
children: members.map((nid) => nodeChild(nodes.find((n) => n.id === nid)))
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
for (const n of nodes) if (!inGroup.has(n.id)) children.push(nodeChild(n));
|
|
1085
|
+
return {
|
|
1086
|
+
id: "root",
|
|
1087
|
+
layoutOptions: {
|
|
1088
|
+
"elk.algorithm": "layered",
|
|
1089
|
+
"elk.direction": "DOWN",
|
|
1090
|
+
"elk.edgeRouting": "ORTHOGONAL",
|
|
1091
|
+
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
|
|
1092
|
+
"elk.spacing.nodeNode": "34",
|
|
1093
|
+
"elk.spacing.edgeNode": "26",
|
|
1094
|
+
"elk.spacing.componentComponent": "48",
|
|
1095
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": "56"
|
|
1096
|
+
},
|
|
1097
|
+
children,
|
|
1098
|
+
edges: drawEdges.map((e, i) => ({
|
|
1099
|
+
id: `e${i}`,
|
|
1100
|
+
sources: [e.from],
|
|
1101
|
+
targets: [e.to]
|
|
1102
|
+
}))
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
/** 折线弧长中点(标签锚点)。 */
|
|
1106
|
+
function polylineMidpoint(pts) {
|
|
1107
|
+
if (pts.length === 0) return {
|
|
1108
|
+
x: 0,
|
|
1109
|
+
y: 0
|
|
1110
|
+
};
|
|
1111
|
+
if (pts.length === 1) return pts[0];
|
|
1112
|
+
const segLens = [];
|
|
1113
|
+
let total = 0;
|
|
1114
|
+
for (let i = 1; i < pts.length; i++) {
|
|
1115
|
+
const l = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
|
|
1116
|
+
segLens.push(l);
|
|
1117
|
+
total += l;
|
|
1118
|
+
}
|
|
1119
|
+
let remain = total / 2;
|
|
1120
|
+
for (let i = 0; i < segLens.length; i++) {
|
|
1121
|
+
if (remain <= segLens[i] || i === segLens.length - 1) {
|
|
1122
|
+
const t = segLens[i] === 0 ? 0 : remain / segLens[i];
|
|
1123
|
+
return {
|
|
1124
|
+
x: pts[i].x + (pts[i + 1].x - pts[i].x) * t,
|
|
1125
|
+
y: pts[i].y + (pts[i + 1].y - pts[i].y) * t
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
remain -= segLens[i];
|
|
1129
|
+
}
|
|
1130
|
+
return pts[pts.length - 1];
|
|
1131
|
+
}
|
|
1132
|
+
function flattenElkResult(elkOut, nodes, edges, groups, groupsFallback, hubId) {
|
|
1133
|
+
const nodesById = new Map(nodes.map((n) => [n.id, n]));
|
|
1134
|
+
const groupColor = /* @__PURE__ */ new Map();
|
|
1135
|
+
groups.forEach((g, i) => groupColor.set(g.id, i % 5));
|
|
1136
|
+
const groupById = new Map(groups.map((g) => [g.id, g]));
|
|
1137
|
+
const memberGroup = /* @__PURE__ */ new Map();
|
|
1138
|
+
for (const g of groups) for (const nid of g.nodeIds) if (!memberGroup.has(nid)) memberGroup.set(nid, g.id);
|
|
1139
|
+
const outNodes = [];
|
|
1140
|
+
const outGroups = [];
|
|
1141
|
+
const outEdges = [];
|
|
1142
|
+
const containerOff = /* @__PURE__ */ new Map([["root", {
|
|
1143
|
+
x: 0,
|
|
1144
|
+
y: 0
|
|
1145
|
+
}]]);
|
|
1146
|
+
const rawEdges = [];
|
|
1147
|
+
const walk = (elk, offX, offY, containerId) => {
|
|
1148
|
+
for (const child of elk.children ?? []) {
|
|
1149
|
+
const cx = offX + (child.x ?? 0);
|
|
1150
|
+
const cy = offY + (child.y ?? 0);
|
|
1151
|
+
if (child.children?.length) {
|
|
1152
|
+
const gid = child.id.startsWith("g:") ? child.id.slice(2) : child.id;
|
|
1153
|
+
const meta = groupById.get(gid);
|
|
1154
|
+
outGroups.push({
|
|
1155
|
+
id: gid,
|
|
1156
|
+
label: meta?.label ?? "分组",
|
|
1157
|
+
x: cx,
|
|
1158
|
+
y: cy,
|
|
1159
|
+
w: child.width ?? 0,
|
|
1160
|
+
h: child.height ?? 0,
|
|
1161
|
+
colorIdx: groupColor.get(gid) ?? 0
|
|
1162
|
+
});
|
|
1163
|
+
containerOff.set(child.id, {
|
|
1164
|
+
x: cx,
|
|
1165
|
+
y: cy
|
|
1166
|
+
});
|
|
1167
|
+
walk(child, cx, cy, gid);
|
|
1168
|
+
} else {
|
|
1169
|
+
const n = nodesById.get(child.id);
|
|
1170
|
+
if (!n) continue;
|
|
1171
|
+
const box = cmNodeBox(n.label, n.id === hubId);
|
|
1172
|
+
const gid = memberGroup.get(n.id) ?? null;
|
|
1173
|
+
outNodes.push({
|
|
1174
|
+
id: n.id,
|
|
1175
|
+
x: cx,
|
|
1176
|
+
y: cy,
|
|
1177
|
+
w: child.width ?? Math.ceil(box.width),
|
|
1178
|
+
h: child.height ?? Math.ceil(box.height),
|
|
1179
|
+
lines: box.lines,
|
|
1180
|
+
hub: n.id === hubId,
|
|
1181
|
+
groupId: containerId,
|
|
1182
|
+
colorIdx: gid != null ? groupColor.get(gid) ?? 0 : -1
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
for (const e of elk.edges ?? []) rawEdges.push({
|
|
1187
|
+
e,
|
|
1188
|
+
fallback: {
|
|
1189
|
+
x: offX,
|
|
1190
|
+
y: offY
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
};
|
|
1194
|
+
walk(elkOut, 0, 0, null);
|
|
1195
|
+
for (const { e, fallback } of rawEdges) {
|
|
1196
|
+
const sec = e.sections?.[0];
|
|
1197
|
+
if (!sec) continue;
|
|
1198
|
+
const off = (e.container != null ? containerOff.get(e.container) : void 0) ?? fallback;
|
|
1199
|
+
const pts = [
|
|
1200
|
+
sec.startPoint,
|
|
1201
|
+
...sec.bendPoints ?? [],
|
|
1202
|
+
sec.endPoint
|
|
1203
|
+
].map((p) => ({
|
|
1204
|
+
x: p.x + off.x,
|
|
1205
|
+
y: p.y + off.y
|
|
1206
|
+
}));
|
|
1207
|
+
const src = edges[Number(e.id.slice(1))];
|
|
1208
|
+
if (!src) continue;
|
|
1209
|
+
outEdges.push({
|
|
1210
|
+
from: src.from,
|
|
1211
|
+
to: src.to,
|
|
1212
|
+
label: src.label,
|
|
1213
|
+
pts,
|
|
1214
|
+
labelPt: src.label ? polylineMidpoint(pts) : null
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
return {
|
|
1218
|
+
width: Math.ceil(elkOut.width ?? 0),
|
|
1219
|
+
height: Math.ceil(elkOut.height ?? 0),
|
|
1220
|
+
nodes: outNodes,
|
|
1221
|
+
groups: outGroups,
|
|
1222
|
+
edges: outEdges,
|
|
1223
|
+
hubId,
|
|
1224
|
+
groupsFallback
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
let elkPromise = null;
|
|
1228
|
+
/** elkjs 单例:elk.bundled.js 主线程版(无 Worker,打包器友好,React Flow 生态同款)。 */
|
|
1229
|
+
function loadElk() {
|
|
1230
|
+
elkPromise ??= import(
|
|
1231
|
+
/* @vite-ignore */
|
|
1232
|
+
"https://esm.sh/elkjs@0.12/lib/elk.bundled.js"
|
|
1233
|
+
).then((mod) => {
|
|
1234
|
+
const m = mod;
|
|
1235
|
+
return new (m.default ?? m)();
|
|
1236
|
+
});
|
|
1237
|
+
return elkPromise;
|
|
1238
|
+
}
|
|
1239
|
+
/** 概念图 → 分组解析 → ELK 布局 → 绝对坐标几何。输入相同则输出逐字段全等。 */
|
|
1240
|
+
async function layoutConceptMap(nodes, edges, groups) {
|
|
1241
|
+
const resolved = resolveGroups(nodes, edges, groups);
|
|
1242
|
+
const graph = buildElkGraph(nodes, edges, resolved.groups);
|
|
1243
|
+
const hubId = computeHubId(nodes, edges);
|
|
1244
|
+
return flattenElkResult(await (await loadElk()).layout(graph), nodes, edges, resolved.groups, resolved.fallback, hubId);
|
|
1245
|
+
}
|
|
1246
|
+
function computeHubId(nodes, edges) {
|
|
1247
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
1248
|
+
const degree = /* @__PURE__ */ new Map();
|
|
1249
|
+
for (const n of nodes) degree.set(n.id, 0);
|
|
1250
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1251
|
+
for (const e of edges) {
|
|
1252
|
+
if (!ids.has(e.from) || !ids.has(e.to) || e.from === e.to) continue;
|
|
1253
|
+
const key = [e.from, e.to].sort().join("→");
|
|
1254
|
+
if (seen.has(key)) continue;
|
|
1255
|
+
seen.add(key);
|
|
1256
|
+
degree.set(e.from, degree.get(e.from) + 1);
|
|
1257
|
+
degree.set(e.to, degree.get(e.to) + 1);
|
|
1258
|
+
}
|
|
1259
|
+
let hubId = nodes[0]?.id ?? "";
|
|
1260
|
+
for (const n of nodes) if ((degree.get(n.id) ?? 0) > (degree.get(hubId) ?? 0)) hubId = n.id;
|
|
1261
|
+
return hubId;
|
|
1262
|
+
}
|
|
1263
|
+
//#endregion
|
|
1264
|
+
//#region src/client/diagrams.ts
|
|
1265
|
+
/**
|
|
1266
|
+
* Blackboard diagram views (SPEC Phase 2): the lesson mind map (markmap via
|
|
1267
|
+
* CDN, markdown preprocessed by the vendored mindmap-markdown) and the lesson
|
|
1268
|
+
* concept map (ELK layered layout via the vendored cmap-elk-layout + CDN elkjs,
|
|
1269
|
+
* draw.io-flavored SVG skin rendered plugin-side). Both degrade honestly: any
|
|
1270
|
+
* CDN/ELK failure leaves a one-line notice instead of a broken pane.
|
|
1271
|
+
*/
|
|
1272
|
+
const CDN = {
|
|
1273
|
+
markmapLib: "https://esm.sh/markmap-lib@0.18.12",
|
|
1274
|
+
markmapView: "https://esm.sh/markmap-view@0.18.10"
|
|
1275
|
+
};
|
|
1276
|
+
let markmapPromise = null;
|
|
1277
|
+
async function loadMarkmap() {
|
|
1278
|
+
markmapPromise ??= (async () => {
|
|
1279
|
+
const lib = await import(
|
|
1280
|
+
/* @vite-ignore */
|
|
1281
|
+
CDN.markmapLib
|
|
1282
|
+
);
|
|
1283
|
+
const view = await import(
|
|
1284
|
+
/* @vite-ignore */
|
|
1285
|
+
CDN.markmapView
|
|
1286
|
+
);
|
|
1287
|
+
const Transformer = lib.Transformer ?? {};
|
|
1288
|
+
const d3 = await import(
|
|
1289
|
+
/* @vite-ignore */
|
|
1290
|
+
"https://esm.sh/d3@7"
|
|
1291
|
+
);
|
|
1292
|
+
return {
|
|
1293
|
+
transformer: new Transformer(),
|
|
1294
|
+
Markmap: view.Markmap,
|
|
1295
|
+
d3
|
|
1296
|
+
};
|
|
1297
|
+
})();
|
|
1298
|
+
return markmapPromise;
|
|
1299
|
+
}
|
|
1300
|
+
/** Render the lesson markdown as a mind map into an empty container. */
|
|
1301
|
+
async function renderMindmap(container, markdown) {
|
|
1302
|
+
const { transformer, Markmap, d3 } = await loadMarkmap();
|
|
1303
|
+
const { root } = transformer.transform(mindmapMarkdown(markdown));
|
|
1304
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
1305
|
+
svg.setAttribute("style", "width:100%;min-height:420px");
|
|
1306
|
+
container.append(svg);
|
|
1307
|
+
window["markmap"] = { d3 };
|
|
1308
|
+
const mm = Markmap.create(svg, { autoFit: true });
|
|
1309
|
+
mm.setData(root);
|
|
1310
|
+
mm.fit();
|
|
1311
|
+
}
|
|
1312
|
+
/** Lesson concept map: the lesson node + its concepts (weak ones accented),
|
|
1313
|
+
* laid out by the vendored ELK pipeline, skinned draw.io-style (plugin-original,
|
|
1314
|
+
* upstream's skin is coupled to its token pipeline). Mastery shades the nodes. */
|
|
1315
|
+
async function renderLessonConceptMap(container, lessonTitle, concepts) {
|
|
1316
|
+
const layout = await layoutConceptMap([{
|
|
1317
|
+
id: "lesson",
|
|
1318
|
+
label: lessonTitle
|
|
1319
|
+
}, ...concepts.map((c) => ({
|
|
1320
|
+
id: `c-${c.title}`,
|
|
1321
|
+
label: c.title
|
|
1322
|
+
}))], concepts.map((c) => ({
|
|
1323
|
+
from: "lesson",
|
|
1324
|
+
to: `c-${c.title}`
|
|
1325
|
+
})));
|
|
1326
|
+
const masteryBy = new Map(concepts.map((c) => [`c-${c.title}`, c.masteryPct]));
|
|
1327
|
+
const ns = "http://www.w3.org/2000/svg";
|
|
1328
|
+
const svg = document.createElementNS(ns, "svg");
|
|
1329
|
+
svg.setAttribute("width", String(Math.max(320, layout.width)));
|
|
1330
|
+
svg.setAttribute("height", String(Math.max(200, layout.height)));
|
|
1331
|
+
svg.setAttribute("viewBox", `0 0 ${layout.width} ${layout.height}`);
|
|
1332
|
+
svg.setAttribute("style", "max-width:100%;background:var(--dsw-surface-1, #fff);border:1px solid var(--dsw-border-1, #d0d7de);border-radius:8px");
|
|
1333
|
+
for (const e of layout.edges) {
|
|
1334
|
+
const path = document.createElementNS(ns, "path");
|
|
1335
|
+
path.setAttribute("d", e.pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" "));
|
|
1336
|
+
path.setAttribute("fill", "none");
|
|
1337
|
+
path.setAttribute("stroke", "var(--dsw-border-2, #8b949e)");
|
|
1338
|
+
path.setAttribute("stroke-width", "1.5");
|
|
1339
|
+
svg.append(path);
|
|
1340
|
+
}
|
|
1341
|
+
for (const n of layout.nodes) {
|
|
1342
|
+
const g = document.createElementNS(ns, "g");
|
|
1343
|
+
g.setAttribute("transform", `translate(${n.x},${n.y})`);
|
|
1344
|
+
const rect = document.createElementNS(ns, "rect");
|
|
1345
|
+
const isLesson = n.id === "lesson";
|
|
1346
|
+
const mastery = masteryBy.get(n.id);
|
|
1347
|
+
rect.setAttribute("width", String(n.w));
|
|
1348
|
+
rect.setAttribute("height", String(n.h));
|
|
1349
|
+
rect.setAttribute("rx", "6");
|
|
1350
|
+
rect.setAttribute("fill", mastery !== void 0 && mastery < 70 ? "#fff3d9" : "var(--dsw-surface-2, #f6f8fa)");
|
|
1351
|
+
rect.setAttribute("stroke", isLesson ? "var(--dsw-accent-1, #0969da)" : "var(--dsw-border-2, #8b949e)");
|
|
1352
|
+
rect.setAttribute("stroke-width", isLesson ? "2" : "1");
|
|
1353
|
+
g.append(rect);
|
|
1354
|
+
n.lines.forEach((line, li) => {
|
|
1355
|
+
const label = document.createElementNS(ns, "text");
|
|
1356
|
+
label.setAttribute("x", String(n.w / 2));
|
|
1357
|
+
label.setAttribute("y", String(n.h / 2 - (n.lines.length - 1) * 7 + li * 14 + 4));
|
|
1358
|
+
label.setAttribute("text-anchor", "middle");
|
|
1359
|
+
label.setAttribute("font-size", isLesson ? "13" : "12");
|
|
1360
|
+
label.setAttribute("font-weight", isLesson ? "600" : "400");
|
|
1361
|
+
label.setAttribute("fill", "var(--dsw-ink-1, #1f2328)");
|
|
1362
|
+
label.textContent = line;
|
|
1363
|
+
g.append(label);
|
|
1364
|
+
});
|
|
1365
|
+
svg.append(g);
|
|
1366
|
+
}
|
|
1367
|
+
container.append(svg);
|
|
1368
|
+
}
|
|
1369
|
+
//#endregion
|
|
1370
|
+
//#region src/client/locale.ts
|
|
1371
|
+
/** Simplified Chinese dictionary (also the fallback locale). */
|
|
1372
|
+
const ZH = {
|
|
1373
|
+
"tab.label": "学习",
|
|
1374
|
+
"col.rail": "课程",
|
|
1375
|
+
"col.tutor": "导师",
|
|
1376
|
+
"col.bb": "黑板",
|
|
1377
|
+
"pane.rail": "课程",
|
|
1378
|
+
"pane.tutor": "导师",
|
|
1379
|
+
"pane.bb": "黑板",
|
|
1380
|
+
"viewtab.teach": "讲解",
|
|
1381
|
+
"viewtab.mind": "🧠 脑图",
|
|
1382
|
+
"viewtab.cmap": "🕸 概念图",
|
|
1383
|
+
"viewtab.mind.title": "本课结构一图流(markmap)",
|
|
1384
|
+
"viewtab.cmap.title": "本课概念关系(ELK 布局)",
|
|
1385
|
+
"loading": "加载中…",
|
|
1386
|
+
"start.title": "一键准备学习:建立学习工作区、开启会话并让导师就位",
|
|
1387
|
+
"start.go": "开始学习",
|
|
1388
|
+
"start.enter": "进入学习",
|
|
1389
|
+
"start.busy": "正在准备学习区…",
|
|
1390
|
+
"soul.direct": "直讲",
|
|
1391
|
+
"soul.direct.hint": "direct 精讲:先讲清楚,再确认懂没懂",
|
|
1392
|
+
"soul.guide": "引导",
|
|
1393
|
+
"soul.guide.hint": "guide 引导:让你自己往前推一步,导师递台阶",
|
|
1394
|
+
"soul.practice": "实战",
|
|
1395
|
+
"soul.practice.hint": "practice 实战:在真实世界的乱问题里学",
|
|
1396
|
+
"zone.understand": "🧠 理解区 — 知识结构",
|
|
1397
|
+
"zone.record": "📝 记录区 — 我的话",
|
|
1398
|
+
"zone.practice": "✍️ 练习区 — 答题日志",
|
|
1399
|
+
"rail.empty.title": "暂无课程",
|
|
1400
|
+
"rail.empty.hint": "粘贴 markdown → 说「导入为课程」",
|
|
1401
|
+
"rail.empty.hint2": "本地文件夹 → 说「导入 D:/path/to/folder」",
|
|
1402
|
+
"rail.empty.placeholder": "GitHub 仓库链接,如 microsoft/AI-For-Beginners",
|
|
1403
|
+
"rail.empty.button": "导入",
|
|
1404
|
+
"rail.empty.demo": "导入示例课程",
|
|
1405
|
+
"rail.mastered": "{mastered}/{total} 已掌握",
|
|
1406
|
+
"rail.avg": "平均掌握度 {pct}%",
|
|
1407
|
+
"rail.avg.none": "尚无掌握度数据",
|
|
1408
|
+
"rail.search": "搜索课时…(多关键词空格分隔)",
|
|
1409
|
+
"rail.locate.title": "在课程树中定位当前焦点课时(自动展开所在章节)",
|
|
1410
|
+
"rail.locate": "📍 回到当前课时",
|
|
1411
|
+
"rail.due": "🔁 待复习 {count}",
|
|
1412
|
+
"rail.due.over": "超{days}天",
|
|
1413
|
+
"rail.due.start": "开始复习",
|
|
1414
|
+
"rail.due.tag": "这课时的复习今天到期(SM-2)",
|
|
1415
|
+
"rail.delete": "删除本课程",
|
|
1416
|
+
"rail.delete.confirm": "确认删除?",
|
|
1417
|
+
"rail.delete.title.confirm": "再点一次确认删除(含全部进度与笔记)",
|
|
1418
|
+
"rail.section.collapse": "折叠本章节",
|
|
1419
|
+
"rail.section.expand": "展开本章节({count} 课时)",
|
|
1420
|
+
"rail.section.count": "{count} 课",
|
|
1421
|
+
"rail.lesson.opening": "正在打开课时会话…",
|
|
1422
|
+
"rail.import.toggle": "+ 导入课程",
|
|
1423
|
+
"rail.import.close": "收起导入",
|
|
1424
|
+
"tag.weak": "{count} 个薄弱知识点,测验会优先考察",
|
|
1425
|
+
"tag.friction": "{count} 次卡点记录(你说\"不懂\"时导师记下的)",
|
|
1426
|
+
"tag.mastery": "课时掌握度 {pct}%(取最薄弱知识点)",
|
|
1427
|
+
"tag.mastery.short": "课时掌握度 = 最薄弱知识点的掌握度",
|
|
1428
|
+
"status.exam": "章节测验:本节全部课时掌握度 ≥50% 后开放",
|
|
1429
|
+
"status.mastered": "已毕业:掌握度 ≥90%(或你接受了掌握提案)",
|
|
1430
|
+
"status.in_progress": "学习中:已开课,掌握度从 50% 起步",
|
|
1431
|
+
"status.available": "可开始:已解锁,尚未学习",
|
|
1432
|
+
"status.locked": "未解锁:先完成前面的课时",
|
|
1433
|
+
"chip.unattributed": "未归因",
|
|
1434
|
+
"chip.correct": "✓ 答对 · {concept}",
|
|
1435
|
+
"chip.wrong": "✗ 答错 · {concept}",
|
|
1436
|
+
"chip.import": "📦 导入课程",
|
|
1437
|
+
"chip.concepts": "🧠 提炼知识点",
|
|
1438
|
+
"row.thinking": "导师思考中…",
|
|
1439
|
+
"row.thinking.title": "导师正在推理,回复马上就来",
|
|
1440
|
+
"quiz.title": "点击选项作答,也可以直接打字回答",
|
|
1441
|
+
"quiz.answer": "选 {letter}:{text}",
|
|
1442
|
+
"tutor.empty": "对话会出现在这里",
|
|
1443
|
+
"tutor.empty.hint": "在下方输入框和导师说话",
|
|
1444
|
+
"proposal.text": "导师提议你已掌握「{lesson}」:{rationale}",
|
|
1445
|
+
"proposal.accept": "接受",
|
|
1446
|
+
"proposal.decline": "再练练",
|
|
1447
|
+
"bb.empty": "黑板还空着",
|
|
1448
|
+
"bb.empty.hint": "在左侧课程树选择一课",
|
|
1449
|
+
"bb.mastery": "掌握度 {pct}%",
|
|
1450
|
+
"bb.fallback.mind": "脑图渲染不可用(网络受限) — 已回退讲解视图",
|
|
1451
|
+
"bb.fallback.cmap.empty": "概念图需要先由导师定义本课概念",
|
|
1452
|
+
"bb.fallback.cmap": "概念图渲染不可用(布局引擎加载失败) — 已回退讲解视图",
|
|
1453
|
+
"bb.notes": "笔记",
|
|
1454
|
+
"bb.notes.empty": "这一课还没有笔记",
|
|
1455
|
+
"active.off.title": "注册 study 工具并载入导师人格,之后普通对话和现在一样",
|
|
1456
|
+
"active.on.title": "注销 study 工具与导师人格;学习进度保留,可随时重新开启",
|
|
1457
|
+
"active.off": "▶ 开始学习",
|
|
1458
|
+
"active.on": "⏻ 退出学习模式",
|
|
1459
|
+
"prompt.kickoff": "开始学习:查看我的学习状态并打开当前焦点课时。如果我还没有课程,推荐我导入示例课程(AI-For-Beginners)并说明怎么开始。回复最后请提醒我点上方「学习」页签进入学习界面。",
|
|
1460
|
+
"settings.nav": "学习",
|
|
1461
|
+
"settings.mode": "教学风格",
|
|
1462
|
+
"settings.mode.hint": "导师的讲解风格;也可在学习页随时切换",
|
|
1463
|
+
"settings.studyMode": "学习模式",
|
|
1464
|
+
"settings.studyMode.hint": "开启后注册 study_* 工具并载入导师人格;进度与笔记始终保留",
|
|
1465
|
+
"settings.on": "已开启",
|
|
1466
|
+
"settings.off": "已关闭",
|
|
1467
|
+
"settings.turnOn": "开启",
|
|
1468
|
+
"settings.turnOff": "关闭",
|
|
1469
|
+
"settings.stats": "学习统计",
|
|
1470
|
+
"settings.stats.courses": "课程 {count} 个",
|
|
1471
|
+
"settings.stats.xp": "XP {xp} · Lv{level}({pct}%)",
|
|
1472
|
+
"settings.stats.today": "今日 {xp}/{goal}",
|
|
1473
|
+
"settings.stats.streak": "连续 {days} 天(最长 {best},剩冻结 {freeze})",
|
|
1474
|
+
"settings.stateFile": "状态文件",
|
|
1475
|
+
"settings.stateFile.hint": "学习进度存放于本机 JSON,换机可迁移",
|
|
1476
|
+
"dock.title": "学习状态:待复习 {due} · 连续 {streak} 天 · Lv{level} — 点上方「学习」页签进入",
|
|
1477
|
+
"dock.due": "⚡{count}",
|
|
1478
|
+
"dock.streak": "🔥{days}d",
|
|
1479
|
+
"dock.lv": "Lv{level}",
|
|
1480
|
+
"prompt.import": "导入课程:用 study_import_github 抓取 {url}",
|
|
1481
|
+
"prompt.lesson": "学习「{title}」:用 study_lesson 打开这一课开始学习。",
|
|
1482
|
+
"prompt.exam": "开始「{section}」的章节测验:按本节课时出题,答完逐题判分",
|
|
1483
|
+
"prompt.review": "开始今天的复习,从最到期的课时开始",
|
|
1484
|
+
"prompt.proposal.accept": "接受提案 {id} —— 确认标记这课为已掌握",
|
|
1485
|
+
"prompt.proposal.decline": "拒绝提案 {id} —— 我想再练练"
|
|
1486
|
+
};
|
|
1487
|
+
/** English dictionary — key set must equal {@link ZH}'s (test-asserted). */
|
|
1488
|
+
const EN = {
|
|
1489
|
+
"tab.label": "Study",
|
|
1490
|
+
"col.rail": "Courses",
|
|
1491
|
+
"col.tutor": "Tutor",
|
|
1492
|
+
"col.bb": "Blackboard",
|
|
1493
|
+
"pane.rail": "Courses",
|
|
1494
|
+
"pane.tutor": "Tutor",
|
|
1495
|
+
"pane.bb": "Board",
|
|
1496
|
+
"viewtab.teach": "Teach",
|
|
1497
|
+
"viewtab.mind": "🧠 Mind map",
|
|
1498
|
+
"viewtab.cmap": "🕸 Concepts",
|
|
1499
|
+
"viewtab.mind.title": "The lesson as one markmap graph",
|
|
1500
|
+
"viewtab.cmap.title": "The lesson's concept relations (ELK layout)",
|
|
1501
|
+
"loading": "Loading…",
|
|
1502
|
+
"start.title": "One click to study: workspace + session + tutor, all set up",
|
|
1503
|
+
"start.go": "Start learning",
|
|
1504
|
+
"start.enter": "Enter study",
|
|
1505
|
+
"start.busy": "Preparing the study area…",
|
|
1506
|
+
"soul.direct": "Direct",
|
|
1507
|
+
"soul.direct.hint": "direct: explain first, then check understanding",
|
|
1508
|
+
"soul.guide": "Guide",
|
|
1509
|
+
"soul.guide.hint": "guide: you push one step forward, the tutor hands you the stairs",
|
|
1510
|
+
"soul.practice": "Practice",
|
|
1511
|
+
"soul.practice.hint": "practice: learn inside real messy problems",
|
|
1512
|
+
"zone.understand": "🧠 Understand — knowledge structure",
|
|
1513
|
+
"zone.record": "📝 Record — in my own words",
|
|
1514
|
+
"zone.practice": "✍️ Practice — answer log",
|
|
1515
|
+
"rail.empty.title": "No courses yet",
|
|
1516
|
+
"rail.empty.hint": "Paste markdown → say \"import as a course\"",
|
|
1517
|
+
"rail.empty.hint2": "Local folder → say \"import D:/path/to/folder\"",
|
|
1518
|
+
"rail.empty.placeholder": "GitHub repo link, e.g. microsoft/AI-For-Beginners",
|
|
1519
|
+
"rail.empty.button": "Import",
|
|
1520
|
+
"rail.empty.demo": "Import the demo course",
|
|
1521
|
+
"rail.mastered": "{mastered}/{total} mastered",
|
|
1522
|
+
"rail.avg": "avg mastery {pct}%",
|
|
1523
|
+
"rail.avg.none": "no mastery data yet",
|
|
1524
|
+
"rail.search": "Search lessons… (space-separated keywords)",
|
|
1525
|
+
"rail.locate.title": "Locate the current focus lesson in the tree (auto-expands its section)",
|
|
1526
|
+
"rail.locate": "📍 Back to current lesson",
|
|
1527
|
+
"rail.due": "🔁 {count} due",
|
|
1528
|
+
"rail.due.over": "{days}d overdue",
|
|
1529
|
+
"rail.due.start": "Start reviewing",
|
|
1530
|
+
"rail.due.tag": "this lesson's review is due today (SM-2)",
|
|
1531
|
+
"rail.delete": "Delete this course",
|
|
1532
|
+
"rail.delete.confirm": "Delete?",
|
|
1533
|
+
"rail.delete.title.confirm": "Click again to confirm (all progress and notes included)",
|
|
1534
|
+
"rail.section.collapse": "Collapse this section",
|
|
1535
|
+
"rail.section.expand": "Expand this section ({count} lessons)",
|
|
1536
|
+
"rail.section.count": "{count} lessons",
|
|
1537
|
+
"rail.lesson.opening": "Opening the lesson session…",
|
|
1538
|
+
"rail.import.toggle": "+ Import a course",
|
|
1539
|
+
"rail.import.close": "Collapse import",
|
|
1540
|
+
"tag.weak": "{count} weak concepts — quizzes target them first",
|
|
1541
|
+
"tag.friction": "{count} friction marks (logged when you said you were stuck)",
|
|
1542
|
+
"tag.mastery": "lesson mastery {pct}% (the weakest concept)",
|
|
1543
|
+
"tag.mastery.short": "lesson mastery = the weakest concept's mastery",
|
|
1544
|
+
"status.exam": "Section exam: opens when every lesson in the section reaches ≥50% mastery",
|
|
1545
|
+
"status.mastered": "Graduated: mastery ≥90% (or you accepted a mastery proposal)",
|
|
1546
|
+
"status.in_progress": "Learning: opened, mastery starts at 50%",
|
|
1547
|
+
"status.available": "Ready: unlocked, not started",
|
|
1548
|
+
"status.locked": "Locked: finish the preceding lessons first",
|
|
1549
|
+
"chip.unattributed": "unattributed",
|
|
1550
|
+
"chip.correct": "✓ correct · {concept}",
|
|
1551
|
+
"chip.wrong": "✗ wrong · {concept}",
|
|
1552
|
+
"chip.import": "📦 course import",
|
|
1553
|
+
"chip.concepts": "🧠 concepts defined",
|
|
1554
|
+
"row.thinking": "tutor is thinking…",
|
|
1555
|
+
"row.thinking.title": "The tutor is reasoning; the reply is coming",
|
|
1556
|
+
"quiz.title": "Click an option to answer, or just type",
|
|
1557
|
+
"quiz.answer": "Choose {letter}: {text}",
|
|
1558
|
+
"tutor.empty": "The conversation will appear here",
|
|
1559
|
+
"tutor.empty.hint": "Talk to the tutor in the input below",
|
|
1560
|
+
"proposal.text": "The tutor proposes you have mastered \"{lesson}\": {rationale}",
|
|
1561
|
+
"proposal.accept": "Accept",
|
|
1562
|
+
"proposal.decline": "Keep practicing",
|
|
1563
|
+
"bb.empty": "The blackboard is empty",
|
|
1564
|
+
"bb.empty.hint": "Pick a lesson in the course tree on the left",
|
|
1565
|
+
"bb.mastery": "mastery {pct}%",
|
|
1566
|
+
"bb.fallback.mind": "Mind map unavailable (network-restricted) — fell back to the teach view",
|
|
1567
|
+
"bb.fallback.cmap.empty": "The concept map needs the tutor to define this lesson's concepts first",
|
|
1568
|
+
"bb.fallback.cmap": "Concept map unavailable (layout engine failed to load) — fell back to the teach view",
|
|
1569
|
+
"bb.notes": "Notes",
|
|
1570
|
+
"bb.notes.empty": "No notes for this lesson yet",
|
|
1571
|
+
"active.off.title": "Register the study tools and load the tutor persona; plain chats stay as they are",
|
|
1572
|
+
"active.on.title": "Retire the study tools and tutor persona; progress is kept and you can re-enable anytime",
|
|
1573
|
+
"active.off": "▶ Start learning",
|
|
1574
|
+
"active.on": "⏻ Exit study mode",
|
|
1575
|
+
"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.",
|
|
1576
|
+
"settings.nav": "Study",
|
|
1577
|
+
"settings.mode": "Teaching style",
|
|
1578
|
+
"settings.mode.hint": "The tutor's explanation style; switchable anytime in the study tab too",
|
|
1579
|
+
"settings.studyMode": "Study mode",
|
|
1580
|
+
"settings.studyMode.hint": "On registers the study_* tools and the tutor persona; progress and notes are always kept",
|
|
1581
|
+
"settings.on": "On",
|
|
1582
|
+
"settings.off": "Off",
|
|
1583
|
+
"settings.turnOn": "Turn on",
|
|
1584
|
+
"settings.turnOff": "Turn off",
|
|
1585
|
+
"settings.stats": "Study stats",
|
|
1586
|
+
"settings.stats.courses": "{count} courses",
|
|
1587
|
+
"settings.stats.xp": "XP {xp} · Lv{level} ({pct}%)",
|
|
1588
|
+
"settings.stats.today": "Today {xp}/{goal}",
|
|
1589
|
+
"settings.stats.streak": "Streak {days}d (best {best}, {freeze} freeze left)",
|
|
1590
|
+
"settings.stateFile": "State file",
|
|
1591
|
+
"settings.stateFile.hint": "Your progress lives in a local JSON file — copy it to migrate machines",
|
|
1592
|
+
"dock.title": "Study status: {due} due · {streak}d streak · Lv{level} — open the Study tab above to continue",
|
|
1593
|
+
"dock.due": "⚡{count}",
|
|
1594
|
+
"dock.streak": "🔥{days}d",
|
|
1595
|
+
"dock.lv": "Lv{level}",
|
|
1596
|
+
"prompt.import": "Import a course: fetch {url} with study_import_github",
|
|
1597
|
+
"prompt.lesson": "Study \"{title}\": open this lesson with study_lesson and start teaching.",
|
|
1598
|
+
"prompt.exam": "Start the section exam for \"{section}\": quiz me on this section's lessons and grade each answer",
|
|
1599
|
+
"prompt.review": "Start today's review, from the most overdue lesson first",
|
|
1600
|
+
"prompt.proposal.accept": "Accept proposal {id} — confirm marking this lesson as mastered",
|
|
1601
|
+
"prompt.proposal.decline": "Reject proposal {id} — I want to practice more"
|
|
1602
|
+
};
|
|
1603
|
+
/** Both dictionaries under their locale ids (the service's register shape). */
|
|
1604
|
+
const STUDY_DICTS = {
|
|
1605
|
+
zh: ZH,
|
|
1606
|
+
en: EN
|
|
1607
|
+
};
|
|
1608
|
+
/** The namespace id this plugin registers with the framework locale service. */
|
|
1609
|
+
const STUDY_NS = "lookatstudy";
|
|
1610
|
+
/**
|
|
1611
|
+
* Pure translator with the service's semantics: own-locale dict → zh
|
|
1612
|
+
* fallback → the key itself, `{name}` interpolation left untouched when the
|
|
1613
|
+
* param is missing.
|
|
1614
|
+
* @param locale - active locale id ('zh'/'en'; unknown ids fall back to zh).
|
|
1615
|
+
* @returns a stable-shaped translate function.
|
|
1616
|
+
*/
|
|
1617
|
+
function makeT(locale) {
|
|
1618
|
+
const dict = locale === "en" ? EN : ZH;
|
|
1619
|
+
return (key, params) => {
|
|
1620
|
+
const template = dict[key] ?? ZH[key] ?? key;
|
|
1621
|
+
if (params === void 0) return template;
|
|
1622
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match);
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Register the namespace with the framework locale service.
|
|
1627
|
+
* @param locale - the client locale service when present.
|
|
1628
|
+
* @returns the registration disposer (a no-op when the service is absent or
|
|
1629
|
+
* a hostile re-registration was refused).
|
|
1630
|
+
*/
|
|
1631
|
+
function registerStudyLocale(locale) {
|
|
1632
|
+
if (locale === void 0) return () => {};
|
|
1633
|
+
try {
|
|
1634
|
+
return locale.register(STUDY_NS, STUDY_DICTS);
|
|
1635
|
+
} catch {
|
|
1636
|
+
return () => {};
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
/**
|
|
1640
|
+
* The live translator: the service's stable `bind` when present (reads the
|
|
1641
|
+
* active locale at call time, so a locale-change re-render picks new
|
|
1642
|
+
* strings), the pure zh translator otherwise.
|
|
1643
|
+
* @param locale - the client locale service when present.
|
|
1644
|
+
*/
|
|
1645
|
+
function studyTranslator(locale) {
|
|
1646
|
+
return locale === void 0 ? makeT("zh") : locale.bind(STUDY_NS);
|
|
1647
|
+
}
|
|
1648
|
+
/** The module's active translator — zh until the view factory installs the live bind. */
|
|
1649
|
+
let activeT = makeT("zh");
|
|
1650
|
+
/**
|
|
1651
|
+
* Install the client-wide translator (called once at client apply, before
|
|
1652
|
+
* any component renders).
|
|
1653
|
+
*/
|
|
1654
|
+
function setStudyTranslator(t) {
|
|
1655
|
+
activeT = t;
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Translate through the active translator — the import every client module
|
|
1659
|
+
* uses for user-visible copy. Pure helpers take an optional explicit `t`
|
|
1660
|
+
* defaulting to the current active translator, evaluated per call.
|
|
1661
|
+
*/
|
|
1662
|
+
function tr(key, params) {
|
|
1663
|
+
return activeT(key, params);
|
|
1664
|
+
}
|
|
1665
|
+
//#endregion
|
|
543
1666
|
//#region src/client/views.tsx
|
|
544
1667
|
/**
|
|
545
1668
|
* The study tab: one `conversation.view` entry rendering the whole plugin —
|
|
@@ -554,28 +1677,28 @@ window.__ModuleLoader__.load({
|
|
|
554
1677
|
* @module dsh-plugin-lookatstudy/client/views
|
|
555
1678
|
*/
|
|
556
1679
|
/** The three souls, in pill order (labels from LookatStudy's mode switcher). */
|
|
557
|
-
const MODES = [
|
|
1680
|
+
const MODES$1 = [
|
|
558
1681
|
{
|
|
559
1682
|
id: "direct",
|
|
560
|
-
|
|
561
|
-
|
|
1683
|
+
labelKey: "soul.direct",
|
|
1684
|
+
hintKey: "soul.direct.hint"
|
|
562
1685
|
},
|
|
563
1686
|
{
|
|
564
1687
|
id: "guide",
|
|
565
|
-
|
|
566
|
-
|
|
1688
|
+
labelKey: "soul.guide",
|
|
1689
|
+
hintKey: "soul.guide.hint"
|
|
567
1690
|
},
|
|
568
1691
|
{
|
|
569
1692
|
id: "practice",
|
|
570
|
-
|
|
571
|
-
|
|
1693
|
+
labelKey: "soul.practice",
|
|
1694
|
+
hintKey: "soul.practice.hint"
|
|
572
1695
|
}
|
|
573
1696
|
];
|
|
574
|
-
/** Zone
|
|
1697
|
+
/** Zone keys for the Cornell notebook (understand / record / practice). */
|
|
575
1698
|
const ZONES = [
|
|
576
|
-
["understand", "
|
|
577
|
-
["record", "
|
|
578
|
-
["practice", "
|
|
1699
|
+
["understand", "zone.understand"],
|
|
1700
|
+
["record", "zone.record"],
|
|
1701
|
+
["practice", "zone.practice"]
|
|
579
1702
|
];
|
|
580
1703
|
/** Status glyph for one lesson row (LookatStudy's map icons). */
|
|
581
1704
|
function glyph(kind, status) {
|
|
@@ -610,34 +1733,36 @@ window.__ModuleLoader__.load({
|
|
|
610
1733
|
return (0, react.createElement)("div", { className: "lks-import" }, (0, react.createElement)("div", { className: "lks-inputrow" }, (0, react.createElement)("input", {
|
|
611
1734
|
className: "lks-input",
|
|
612
1735
|
type: "url",
|
|
613
|
-
placeholder: "
|
|
1736
|
+
placeholder: tr("rail.empty.placeholder"),
|
|
614
1737
|
value: url,
|
|
615
1738
|
onChange: (e) => {
|
|
616
1739
|
setUrl(e.target.value);
|
|
617
1740
|
},
|
|
618
1741
|
onKeyDown: (e) => {
|
|
619
|
-
if (e.key === "Enter" && url.trim() !== "") send(
|
|
1742
|
+
if (e.key === "Enter" && url.trim() !== "") send(tr("prompt.import", { url: url.trim() }));
|
|
620
1743
|
}
|
|
621
1744
|
}), (0, react.createElement)("button", {
|
|
622
1745
|
className: "lks-btn primary",
|
|
623
1746
|
disabled: url.trim() === "",
|
|
624
1747
|
onClick: () => {
|
|
625
|
-
send(
|
|
1748
|
+
send(tr("prompt.import", { url: url.trim() }));
|
|
626
1749
|
}
|
|
627
|
-
}, "
|
|
1750
|
+
}, 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
1751
|
}
|
|
629
1752
|
/**
|
|
630
|
-
* Tooltip text for one course-tree status glyph. Pure
|
|
1753
|
+
* Tooltip text for one course-tree status glyph. Pure (the translator is an
|
|
1754
|
+
* optional parameter defaulting to the active one, evaluated per call).
|
|
631
1755
|
* @param kind - lesson kind ('study' | 'practice' | 'exam').
|
|
632
1756
|
* @param status - lesson status ('locked' | 'available' | 'in_progress' | 'mastered').
|
|
1757
|
+
* @param t - translator (defaults to the module's active translator).
|
|
633
1758
|
*/
|
|
634
|
-
function statusTitle(kind, status) {
|
|
635
|
-
if (kind === "exam") return "
|
|
1759
|
+
function statusTitle(kind, status, t = tr) {
|
|
1760
|
+
if (kind === "exam") return t("status.exam");
|
|
636
1761
|
switch (status) {
|
|
637
|
-
case "mastered": return "
|
|
638
|
-
case "in_progress": return "
|
|
639
|
-
case "available": return "
|
|
640
|
-
default: return "
|
|
1762
|
+
case "mastered": return t("status.mastered");
|
|
1763
|
+
case "in_progress": return t("status.in_progress");
|
|
1764
|
+
case "available": return t("status.available");
|
|
1765
|
+
default: return t("status.locked");
|
|
641
1766
|
}
|
|
642
1767
|
}
|
|
643
1768
|
/**
|
|
@@ -678,13 +1803,14 @@ window.__ModuleLoader__.load({
|
|
|
678
1803
|
}
|
|
679
1804
|
/**
|
|
680
1805
|
* Learning-aware label for one settled tool call; null keeps the generic
|
|
681
|
-
* 🔧-name chip. Pure.
|
|
1806
|
+
* 🔧-name chip. Pure (translator defaults to the active one, per call).
|
|
682
1807
|
* @param name - tool call name.
|
|
683
1808
|
* @param argsRaw - the call's raw JSON args.
|
|
1809
|
+
* @param t - translator.
|
|
684
1810
|
*/
|
|
685
|
-
function toolChipLabel(name, argsRaw) {
|
|
1811
|
+
function toolChipLabel(name, argsRaw, t = tr) {
|
|
686
1812
|
if (name === "study_record_answer") {
|
|
687
|
-
let concept = "
|
|
1813
|
+
let concept = t("chip.unattributed");
|
|
688
1814
|
let correct = false;
|
|
689
1815
|
try {
|
|
690
1816
|
const args = JSON.parse(argsRaw);
|
|
@@ -692,19 +1818,19 @@ window.__ModuleLoader__.load({
|
|
|
692
1818
|
correct = args.correct === true;
|
|
693
1819
|
} catch {}
|
|
694
1820
|
return correct ? {
|
|
695
|
-
label:
|
|
1821
|
+
label: t("chip.correct", { concept }),
|
|
696
1822
|
tone: "ok"
|
|
697
1823
|
} : {
|
|
698
|
-
label:
|
|
1824
|
+
label: t("chip.wrong", { concept }),
|
|
699
1825
|
tone: "bad"
|
|
700
1826
|
};
|
|
701
1827
|
}
|
|
702
1828
|
if (name === "study_import_github" || name === "study_import_markdown" || name === "study_import_folder") return {
|
|
703
|
-
label: "
|
|
1829
|
+
label: t("chip.import"),
|
|
704
1830
|
tone: "ok"
|
|
705
1831
|
};
|
|
706
1832
|
if (name === "study_define_concepts") return {
|
|
707
|
-
label: "
|
|
1833
|
+
label: t("chip.concepts"),
|
|
708
1834
|
tone: "ok"
|
|
709
1835
|
};
|
|
710
1836
|
return null;
|
|
@@ -720,9 +1846,10 @@ window.__ModuleLoader__.load({
|
|
|
720
1846
|
* tool activity condenses to one muted chip per settled call.
|
|
721
1847
|
* @param nodes - finalized conversation nodes from the snapshot.
|
|
722
1848
|
* @param partial - the in-flight assistant partial, or null.
|
|
1849
|
+
* @param t - translator (defaults to the active one, per call).
|
|
723
1850
|
* @returns ordered rows; never mutates its inputs.
|
|
724
1851
|
*/
|
|
725
|
-
function transcriptRows(nodes, partial) {
|
|
1852
|
+
function transcriptRows(nodes, partial, t = tr) {
|
|
726
1853
|
const rows = [];
|
|
727
1854
|
for (const node of nodes) switch (node.kind) {
|
|
728
1855
|
case "user":
|
|
@@ -768,12 +1895,12 @@ window.__ModuleLoader__.load({
|
|
|
768
1895
|
else rows.push({
|
|
769
1896
|
key: "thinking",
|
|
770
1897
|
role: "thinking",
|
|
771
|
-
text: "
|
|
1898
|
+
text: t("row.thinking")
|
|
772
1899
|
});
|
|
773
1900
|
}
|
|
774
1901
|
return rows;
|
|
775
1902
|
}
|
|
776
|
-
/** Small inline error surface for failed write actions. */
|
|
1903
|
+
/** Small inline error surface for failed write actions (shared with the settings page). */
|
|
777
1904
|
function ActionError({ error }) {
|
|
778
1905
|
if (error === null) return null;
|
|
779
1906
|
return (0, react.createElement)("div", { className: "lks-propcard-err" }, error);
|
|
@@ -792,6 +1919,7 @@ window.__ModuleLoader__.load({
|
|
|
792
1919
|
return "tutor";
|
|
793
1920
|
}
|
|
794
1921
|
}
|
|
1922
|
+
const sessionId = (id) => id;
|
|
795
1923
|
/** The whole study tab. Wide = three columns (课程 | 导师 | 黑板); narrow (<1220px) = one composer-width pane with a three-way switcher. */
|
|
796
1924
|
function studyView(ctx) {
|
|
797
1925
|
return function StudyView(props) {
|
|
@@ -804,7 +1932,7 @@ window.__ModuleLoader__.load({
|
|
|
804
1932
|
}
|
|
805
1933
|
/** Tab body: the factory-bound ctx carries workspaces/sessions for the per-lesson session jumps. */
|
|
806
1934
|
function StudyTab({ useSession, inputActions, ctx }) {
|
|
807
|
-
const { data, activate, setMode, setFocus, deleteCourse, bindLessonSession } = useStudy();
|
|
1935
|
+
const { data, activate, setMode, setFocus, searchLessons, deleteCourse, bindLessonSession } = useStudy();
|
|
808
1936
|
const snapshot = useSession((s) => s);
|
|
809
1937
|
const [pane, setPane] = (0, react.useState)(storedPane);
|
|
810
1938
|
const rootRef = (0, react.useRef)(null);
|
|
@@ -816,6 +1944,7 @@ window.__ModuleLoader__.load({
|
|
|
816
1944
|
const clear = () => {
|
|
817
1945
|
card.classList.remove("lks-composer-follow");
|
|
818
1946
|
card.style.removeProperty("--lks-composer-shift");
|
|
1947
|
+
card.style.removeProperty("--lks-composer-follow-width");
|
|
819
1948
|
};
|
|
820
1949
|
const apply = () => {
|
|
821
1950
|
const tutor = root.querySelector(".lks-col-tutor");
|
|
@@ -830,6 +1959,7 @@ window.__ModuleLoader__.load({
|
|
|
830
1959
|
const shift = Math.round(Math.max(0, Math.min(tutorRect.left - (seatRect.left + pad), max)));
|
|
831
1960
|
if (shift < 2) return clear();
|
|
832
1961
|
card.style.setProperty("--lks-composer-shift", `${shift}px`);
|
|
1962
|
+
card.style.setProperty("--lks-composer-follow-width", `${Math.round(tutorRect.width)}px`);
|
|
833
1963
|
card.classList.add("lks-composer-follow");
|
|
834
1964
|
};
|
|
835
1965
|
apply();
|
|
@@ -850,6 +1980,14 @@ window.__ModuleLoader__.load({
|
|
|
850
1980
|
inputActions.submit();
|
|
851
1981
|
})();
|
|
852
1982
|
};
|
|
1983
|
+
const [, bumpLocale] = (0, react.useState)(0);
|
|
1984
|
+
(0, react.useEffect)(() => {
|
|
1985
|
+
const localeSvc = ctx.locale;
|
|
1986
|
+
if (localeSvc === void 0) return;
|
|
1987
|
+
return localeSvc.subscribe(() => {
|
|
1988
|
+
bumpLocale((v) => v + 1);
|
|
1989
|
+
});
|
|
1990
|
+
}, [ctx]);
|
|
853
1991
|
return (0, react.createElement)("div", {
|
|
854
1992
|
ref: rootRef,
|
|
855
1993
|
className: "lks-root lks-study",
|
|
@@ -857,22 +1995,22 @@ window.__ModuleLoader__.load({
|
|
|
857
1995
|
"data-pane": pane
|
|
858
1996
|
}, (0, react.createElement)("div", { className: "lks-actbar" }, data === null ? null : (0, react.createElement)("button", {
|
|
859
1997
|
className: `lks-btn ${data.active ? "ghost" : "primary"}`,
|
|
860
|
-
title: data.active ? "
|
|
1998
|
+
title: data.active ? tr("active.on.title") : tr("active.off.title"),
|
|
861
1999
|
onClick: () => {
|
|
862
2000
|
activate(!data.active);
|
|
863
2001
|
}
|
|
864
|
-
}, data.active ? "
|
|
2002
|
+
}, data.active ? tr("active.on") : tr("active.off"))), (0, react.createElement)("div", { className: "lks-body" }, (0, react.createElement)("div", { className: "lks-switch" }, ...[
|
|
865
2003
|
{
|
|
866
2004
|
id: "rail",
|
|
867
|
-
|
|
2005
|
+
labelKey: "pane.rail"
|
|
868
2006
|
},
|
|
869
2007
|
{
|
|
870
2008
|
id: "tutor",
|
|
871
|
-
|
|
2009
|
+
labelKey: "pane.tutor"
|
|
872
2010
|
},
|
|
873
2011
|
{
|
|
874
2012
|
id: "bb",
|
|
875
|
-
|
|
2013
|
+
labelKey: "pane.bb"
|
|
876
2014
|
}
|
|
877
2015
|
].map((p) => (0, react.createElement)("button", {
|
|
878
2016
|
key: p.id,
|
|
@@ -883,10 +2021,11 @@ window.__ModuleLoader__.load({
|
|
|
883
2021
|
sessionStorage.setItem(PANE_KEY, p.id);
|
|
884
2022
|
} catch {}
|
|
885
2023
|
}
|
|
886
|
-
}, p.
|
|
2024
|
+
}, tr(p.labelKey)))), (0, react.createElement)(CourseRail, {
|
|
887
2025
|
data,
|
|
888
2026
|
activate,
|
|
889
2027
|
setFocus,
|
|
2028
|
+
searchLessons,
|
|
890
2029
|
deleteCourse,
|
|
891
2030
|
bindLessonSession,
|
|
892
2031
|
send,
|
|
@@ -900,7 +2039,7 @@ window.__ModuleLoader__.load({
|
|
|
900
2039
|
}), (0, react.createElement)(BlackboardColumn, { data })));
|
|
901
2040
|
}
|
|
902
2041
|
/** Left column: course management (pick/delete/search/import), lesson tree, due box. */
|
|
903
|
-
function CourseRail({ data, activate, setFocus, deleteCourse, bindLessonSession, send, ctx, currentSessionId }) {
|
|
2042
|
+
function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, bindLessonSession, send, ctx, currentSessionId }) {
|
|
904
2043
|
const [jumping, setJumping] = (0, react.useState)(null);
|
|
905
2044
|
/** Open (or mint) the lesson's own session — the simplified thread system. */
|
|
906
2045
|
const openLessonThread = (lesson) => {
|
|
@@ -910,9 +2049,9 @@ window.__ModuleLoader__.load({
|
|
|
910
2049
|
setFocus(lesson.id).catch(reportError);
|
|
911
2050
|
return;
|
|
912
2051
|
}
|
|
913
|
-
if (mapped !== void 0 && ctx.sessions.binding(mapped) !== void 0) {
|
|
2052
|
+
if (mapped !== void 0 && ctx.sessions.binding(sessionId(mapped)) !== void 0) {
|
|
914
2053
|
setFocus(lesson.id).catch(reportError);
|
|
915
|
-
ctx.sessions.open(mapped);
|
|
2054
|
+
ctx.sessions.open(sessionId(mapped));
|
|
916
2055
|
return;
|
|
917
2056
|
}
|
|
918
2057
|
setJumping(lesson.id);
|
|
@@ -928,7 +2067,7 @@ window.__ModuleLoader__.load({
|
|
|
928
2067
|
if (face === void 0) throw new Error("lesson session is not addressable yet");
|
|
929
2068
|
const result = await face.prompt([{
|
|
930
2069
|
type: "text",
|
|
931
|
-
text:
|
|
2070
|
+
text: tr("prompt.lesson", { title: lesson.title })
|
|
932
2071
|
}], "queue");
|
|
933
2072
|
if (!result.ok) throw new Error(`lesson prompt rejected: ${result.error.code}: ${result.error.message}`);
|
|
934
2073
|
await bindLessonSession(lesson.id, sessionId);
|
|
@@ -950,13 +2089,13 @@ window.__ModuleLoader__.load({
|
|
|
950
2089
|
const reportError = (err) => {
|
|
951
2090
|
setError(err instanceof Error ? err.message : String(err));
|
|
952
2091
|
};
|
|
953
|
-
const body = data === null ? (0, react.createElement)("div", { className: "lks-empty" }, "
|
|
2092
|
+
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
2093
|
className: "lks-btn primary",
|
|
955
2094
|
style: { margin: "10px 0" },
|
|
956
2095
|
onClick: () => {
|
|
957
|
-
send("
|
|
2096
|
+
send(tr("prompt.import", { url: "https://github.com/microsoft/AI-For-Beginners" }));
|
|
958
2097
|
}
|
|
959
|
-
}, (0, react.createElement)(IconDownloadOutline16, null), "
|
|
2098
|
+
}, (0, react.createElement)(IconDownloadOutline16, null), tr("rail.empty.demo")), (0, react.createElement)(ImportRow, { send })) : (() => {
|
|
960
2099
|
const courseId = data.courses.some((c) => c.courseId === selectedCourse) ? selectedCourse : data.courses[0].courseId;
|
|
961
2100
|
const course = data.courses.find((c) => c.courseId === courseId);
|
|
962
2101
|
return (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "lks-rail-head" }, data.courses.length > 1 ? (0, react.createElement)("select", {
|
|
@@ -971,7 +2110,7 @@ window.__ModuleLoader__.load({
|
|
|
971
2110
|
value: c.courseId
|
|
972
2111
|
}, c.title))) : (0, react.createElement)("div", { className: "lks-rail-title" }, course.title), (0, react.createElement)("button", {
|
|
973
2112
|
className: `lks-btn ${confirmDelete ? "primary" : "ghost"}`,
|
|
974
|
-
title: confirmDelete ? "
|
|
2113
|
+
title: confirmDelete ? tr("rail.delete.title.confirm") : tr("rail.delete"),
|
|
975
2114
|
onClick: () => {
|
|
976
2115
|
if (!confirmDelete) {
|
|
977
2116
|
setConfirmDelete(true);
|
|
@@ -982,13 +2121,16 @@ window.__ModuleLoader__.load({
|
|
|
982
2121
|
setSelectedCourse("");
|
|
983
2122
|
}, reportError);
|
|
984
2123
|
}
|
|
985
|
-
}, confirmDelete ? "
|
|
2124
|
+
}, confirmDelete ? tr("rail.delete.confirm") : "🗑")), (0, react.createElement)("div", { className: "lks-rail-sub" }, tr("rail.mastered", {
|
|
2125
|
+
mastered: course.mastered,
|
|
2126
|
+
total: course.total
|
|
2127
|
+
})), (0, react.createElement)("div", {
|
|
986
2128
|
className: `lks-masterybar${course.avgMasteryPct === 100 ? " gold" : ""}`,
|
|
987
|
-
title: course.avgMasteryPct === null ? "
|
|
2129
|
+
title: course.avgMasteryPct === null ? tr("rail.avg.none") : tr("rail.avg", { pct: course.avgMasteryPct })
|
|
988
2130
|
}, (0, react.createElement)("i", { style: { width: `${course.avgMasteryPct ?? 0}%` } })), (0, react.createElement)("input", {
|
|
989
2131
|
className: "lks-search",
|
|
990
2132
|
type: "search",
|
|
991
|
-
placeholder: "
|
|
2133
|
+
placeholder: tr("rail.search"),
|
|
992
2134
|
value: query,
|
|
993
2135
|
onChange: (e) => {
|
|
994
2136
|
setQuery(e.target.value);
|
|
@@ -1002,6 +2144,13 @@ window.__ModuleLoader__.load({
|
|
|
1002
2144
|
return;
|
|
1003
2145
|
}
|
|
1004
2146
|
}
|
|
2147
|
+
searchLessons(query).then((hits) => {
|
|
2148
|
+
const hit = hits.find((h) => h.lessonId.startsWith(`${courseId}:`)) ?? hits[0];
|
|
2149
|
+
if (hit !== void 0) {
|
|
2150
|
+
setQuery("");
|
|
2151
|
+
setFocus(hit.lessonId).catch(reportError);
|
|
2152
|
+
}
|
|
2153
|
+
}).catch(reportError);
|
|
1005
2154
|
}
|
|
1006
2155
|
}), course.sections.some((s) => s.lessons.some((l) => l.focus)) ? (0, react.createElement)("button", {
|
|
1007
2156
|
className: "lks-btn ghost",
|
|
@@ -1010,7 +2159,7 @@ window.__ModuleLoader__.load({
|
|
|
1010
2159
|
padding: "3px 8px",
|
|
1011
2160
|
fontSize: "13px"
|
|
1012
2161
|
},
|
|
1013
|
-
title: "
|
|
2162
|
+
title: tr("rail.locate.title"),
|
|
1014
2163
|
onClick: () => {
|
|
1015
2164
|
const focusSection = course.sections.find((s) => s.lessons.some((l) => l.focus));
|
|
1016
2165
|
if (focusSection === void 0) return;
|
|
@@ -1025,16 +2174,16 @@ window.__ModuleLoader__.load({
|
|
|
1025
2174
|
});
|
|
1026
2175
|
}, 60);
|
|
1027
2176
|
}
|
|
1028
|
-
}, "
|
|
2177
|
+
}, 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
2178
|
key: d.lessonId,
|
|
1030
2179
|
className: "lks-due-item"
|
|
1031
|
-
}, (0, react.createElement)("span", null, d.lessonTitle), d.overdueDays > 0 ? (0, react.createElement)("span", { className: "lks-over" },
|
|
2180
|
+
}, (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
2181
|
className: "lks-btn ghost",
|
|
1033
2182
|
style: { marginTop: "6px" },
|
|
1034
2183
|
onClick: () => {
|
|
1035
|
-
send("
|
|
2184
|
+
send(tr("prompt.review"));
|
|
1036
2185
|
}
|
|
1037
|
-
}, "
|
|
2186
|
+
}, tr("rail.due.start"))) : null, ...course.sections.flatMap((section) => {
|
|
1038
2187
|
const examAllowed = examOpen(section.lessons);
|
|
1039
2188
|
const lessons = section.lessons.filter((l) => query.trim() === "" || titleMatches(l.title, query) || l.focus);
|
|
1040
2189
|
if (lessons.length === 0) return [];
|
|
@@ -1045,45 +2194,45 @@ window.__ModuleLoader__.load({
|
|
|
1045
2194
|
type: "button",
|
|
1046
2195
|
className: "lks-sechead",
|
|
1047
2196
|
"aria-expanded": String(open),
|
|
1048
|
-
title: open ? "
|
|
2197
|
+
title: open ? tr("rail.section.collapse") : tr("rail.section.expand", { count: section.lessons.length }),
|
|
1049
2198
|
onClick: () => {
|
|
1050
2199
|
setSecOpen((m) => ({
|
|
1051
2200
|
...m,
|
|
1052
2201
|
[secKey]: !open
|
|
1053
2202
|
}));
|
|
1054
2203
|
}
|
|
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" },
|
|
2204
|
+
}, (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
2205
|
const locked = lesson.status === "locked" || lesson.kind === "exam" && !examAllowed;
|
|
1057
2206
|
return (0, react.createElement)("button", {
|
|
1058
2207
|
key: lesson.id,
|
|
1059
2208
|
type: "button",
|
|
1060
2209
|
className: `lks-node${lesson.focus ? " focus" : ""}`,
|
|
1061
2210
|
"aria-disabled": locked || void 0,
|
|
1062
|
-
title: jumping === lesson.id ? "
|
|
2211
|
+
title: jumping === lesson.id ? tr("rail.lesson.opening") : statusTitle(lesson.kind, locked ? "locked" : lesson.status),
|
|
1063
2212
|
onClick: () => {
|
|
1064
2213
|
if (locked) return;
|
|
1065
2214
|
if (lesson.kind === "exam") {
|
|
1066
2215
|
setFocus(lesson.id).catch(reportError);
|
|
1067
|
-
send(
|
|
2216
|
+
send(tr("prompt.exam", { section: section.title }));
|
|
1068
2217
|
return;
|
|
1069
2218
|
}
|
|
1070
2219
|
openLessonThread(lesson);
|
|
1071
2220
|
}
|
|
1072
2221
|
}, (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
2222
|
className: "lks-tag due",
|
|
1074
|
-
title: "
|
|
2223
|
+
title: tr("rail.due.tag")
|
|
1075
2224
|
}, "🔁") : null, lesson.weakConcepts > 0 ? (0, react.createElement)("span", {
|
|
1076
2225
|
className: "lks-tag weak",
|
|
1077
|
-
title:
|
|
2226
|
+
title: tr("tag.weak", { count: lesson.weakConcepts })
|
|
1078
2227
|
}, `⚡${lesson.weakConcepts}`) : null, lesson.frictionCount > 0 ? (0, react.createElement)("span", {
|
|
1079
2228
|
className: "lks-tag fric",
|
|
1080
|
-
title:
|
|
2229
|
+
title: tr("tag.friction", { count: lesson.frictionCount })
|
|
1081
2230
|
}, `😣${lesson.frictionCount}`) : null, lesson.masteryPct !== null ? (0, react.createElement)("span", {
|
|
1082
2231
|
className: "lks-bar",
|
|
1083
|
-
title:
|
|
2232
|
+
title: tr("tag.mastery", { pct: lesson.masteryPct })
|
|
1084
2233
|
}, (0, react.createElement)("i", { style: { width: `${lesson.masteryPct}%` } })) : null, lesson.masteryPct !== null ? (0, react.createElement)("span", {
|
|
1085
2234
|
className: "lks-pct",
|
|
1086
|
-
title: "
|
|
2235
|
+
title: tr("tag.mastery.short")
|
|
1087
2236
|
}, `${lesson.masteryPct}%`) : null);
|
|
1088
2237
|
}) : []];
|
|
1089
2238
|
}), (0, react.createElement)("button", {
|
|
@@ -1092,9 +2241,9 @@ window.__ModuleLoader__.load({
|
|
|
1092
2241
|
onClick: () => {
|
|
1093
2242
|
setShowImport(!showImport);
|
|
1094
2243
|
}
|
|
1095
|
-
}, showImport ? "
|
|
2244
|
+
}, showImport ? tr("rail.import.close") : tr("rail.import.toggle")), showImport ? (0, react.createElement)(ImportRow, { send }) : null);
|
|
1096
2245
|
})();
|
|
1097
|
-
return (0, react.createElement)("div", { className: "lks-col lks-col-rail" }, (0, react.createElement)("div", { className: "lks-colhead" }, "
|
|
2246
|
+
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
2247
|
}
|
|
1099
2248
|
/** One transcript row's element; `interactive` adds the quiz-answer buttons to an assistant row. */
|
|
1100
2249
|
function chatRowElement(row, interactive) {
|
|
@@ -1120,7 +2269,7 @@ window.__ModuleLoader__.load({
|
|
|
1120
2269
|
if (row.role === "thinking") return (0, react.createElement)("div", {
|
|
1121
2270
|
key: row.key,
|
|
1122
2271
|
className: "lks-msg thinking",
|
|
1123
|
-
title: "
|
|
2272
|
+
title: tr("row.thinking.title")
|
|
1124
2273
|
}, (0, react.createElement)(IconLoadingOutline16, {
|
|
1125
2274
|
size: 14,
|
|
1126
2275
|
className: "lks-spin"
|
|
@@ -1138,12 +2287,15 @@ window.__ModuleLoader__.load({
|
|
|
1138
2287
|
className: "lks-turn"
|
|
1139
2288
|
}, body, (0, react.createElement)("div", {
|
|
1140
2289
|
className: "lks-quiz",
|
|
1141
|
-
title: "
|
|
2290
|
+
title: tr("quiz.title")
|
|
1142
2291
|
}, ...options.map((opt) => (0, react.createElement)("button", {
|
|
1143
2292
|
key: opt.letter,
|
|
1144
2293
|
className: "lks-opt",
|
|
1145
2294
|
onClick: () => {
|
|
1146
|
-
interactive.send(
|
|
2295
|
+
interactive.send(tr("quiz.answer", {
|
|
2296
|
+
letter: opt.letter,
|
|
2297
|
+
text: opt.text
|
|
2298
|
+
}));
|
|
1147
2299
|
}
|
|
1148
2300
|
}, (0, react.createElement)("span", { className: "lks-optletter" }, opt.letter), (0, react.createElement)("span", null, opt.text)))));
|
|
1149
2301
|
}
|
|
@@ -1157,6 +2309,10 @@ window.__ModuleLoader__.load({
|
|
|
1157
2309
|
const el = scrollRef.current;
|
|
1158
2310
|
if (el !== null) el.scrollTop = el.scrollHeight;
|
|
1159
2311
|
}, [rows.length]);
|
|
2312
|
+
(0, react.useEffect)(() => {
|
|
2313
|
+
const el = scrollRef.current;
|
|
2314
|
+
if (el !== null && rows.length > 0) enhanceRendered(el).catch(() => {});
|
|
2315
|
+
}, [rows.length]);
|
|
1160
2316
|
const proposal = data?.pendingProposals[0] ?? null;
|
|
1161
2317
|
const lesson = data?.lesson ?? null;
|
|
1162
2318
|
/** Mode switches are the only fallible write left here (host route); surface failures inline. */
|
|
@@ -1167,33 +2323,36 @@ window.__ModuleLoader__.load({
|
|
|
1167
2323
|
setError(err instanceof Error ? err.message : String(err));
|
|
1168
2324
|
});
|
|
1169
2325
|
};
|
|
1170
|
-
return (0, react.createElement)("div", { className: "lks-col lks-col-tutor" }, (0, react.createElement)("div", { className: "lks-colhead" }, "
|
|
2326
|
+
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
2327
|
className: "lks-transcript",
|
|
1172
2328
|
ref: scrollRef
|
|
1173
|
-
}, rows.length === 0 ? (0, react.createElement)("div", { className: "lks-empty" }, "
|
|
2329
|
+
}, 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", {
|
|
2330
|
+
lesson: proposal.lessonTitle,
|
|
2331
|
+
rationale: proposal.rationale
|
|
2332
|
+
})), (0, react.createElement)("button", {
|
|
1174
2333
|
className: "lks-btn primary",
|
|
1175
2334
|
onClick: () => {
|
|
1176
|
-
send(
|
|
2335
|
+
send(tr("prompt.proposal.accept", { id: proposal.id }));
|
|
1177
2336
|
}
|
|
1178
|
-
}, "
|
|
2337
|
+
}, tr("proposal.accept")), (0, react.createElement)("button", {
|
|
1179
2338
|
className: "lks-btn ghost",
|
|
1180
2339
|
onClick: () => {
|
|
1181
|
-
send(
|
|
2340
|
+
send(tr("prompt.proposal.decline", { id: proposal.id }));
|
|
1182
2341
|
}
|
|
1183
|
-
}, "
|
|
2342
|
+
}, tr("proposal.decline"))) : null, (0, react.createElement)("div", {
|
|
1184
2343
|
className: "lks-pills",
|
|
1185
2344
|
style: {
|
|
1186
2345
|
height: "auto",
|
|
1187
2346
|
padding: "4px 0"
|
|
1188
2347
|
}
|
|
1189
|
-
}, ...MODES.map((mode) => (0, react.createElement)("button", {
|
|
2348
|
+
}, ...MODES$1.map((mode) => (0, react.createElement)("button", {
|
|
1190
2349
|
key: mode.id,
|
|
1191
2350
|
className: `lks-pill${data?.mode === mode.id ? " on" : ""}`,
|
|
1192
|
-
title: mode.
|
|
2351
|
+
title: tr(mode.hintKey),
|
|
1193
2352
|
onClick: () => {
|
|
1194
2353
|
fire(setMode(mode.id));
|
|
1195
2354
|
}
|
|
1196
|
-
}, mode.
|
|
2355
|
+
}, tr(mode.labelKey)))), lesson !== null && lesson.starters.length > 0 ? (0, react.createElement)("div", {
|
|
1197
2356
|
className: "lks-dock",
|
|
1198
2357
|
style: { padding: "0 0 6px" }
|
|
1199
2358
|
}, ...lesson.starters.map((s) => (0, react.createElement)("button", {
|
|
@@ -1205,26 +2364,71 @@ window.__ModuleLoader__.load({
|
|
|
1205
2364
|
}
|
|
1206
2365
|
}, s.label))) : null, (0, react.createElement)(ActionError, { error }));
|
|
1207
2366
|
}
|
|
1208
|
-
/** Right column: the blackboard — focus-lesson
|
|
2367
|
+
/** Right column: the blackboard — focus-lesson 讲解/脑图/概念图 plus the Cornell 笔记. */
|
|
1209
2368
|
function BlackboardColumn({ data }) {
|
|
1210
2369
|
const lesson = data?.lesson ?? null;
|
|
1211
|
-
const
|
|
2370
|
+
const [pane, setPane] = (0, react.useState)("teach");
|
|
2371
|
+
const proseRef = (0, react.useRef)(null);
|
|
2372
|
+
const diagRef = (0, react.useRef)(null);
|
|
2373
|
+
(0, react.useEffect)(() => {
|
|
2374
|
+
if (pane !== "teach" || proseRef.current === null || lesson === null) return;
|
|
2375
|
+
const el = proseRef.current;
|
|
2376
|
+
enhanceRendered(el).catch(() => {});
|
|
2377
|
+
}, [pane, lesson?.html]);
|
|
2378
|
+
(0, react.useEffect)(() => {
|
|
2379
|
+
if (pane !== "mind" && pane !== "cmap" || diagRef.current === null || lesson === null) return;
|
|
2380
|
+
const el = diagRef.current;
|
|
2381
|
+
el.textContent = "";
|
|
2382
|
+
(pane === "mind" ? renderMindmap(el, lesson.markdown) : lesson.concepts.length === 0 ? Promise.reject(/* @__PURE__ */ new Error("no concepts")) : renderLessonConceptMap(el, lesson.title, lesson.concepts.map((c) => ({
|
|
2383
|
+
title: c.title,
|
|
2384
|
+
masteryPct: c.masteryPct
|
|
2385
|
+
})))).catch((err) => {
|
|
2386
|
+
console.error("lks diagram pane failed:", pane, err);
|
|
2387
|
+
el.textContent = pane === "mind" ? tr("bb.fallback.mind") : lesson.concepts.length === 0 ? tr("bb.fallback.cmap.empty") : tr("bb.fallback.cmap");
|
|
2388
|
+
});
|
|
2389
|
+
}, [
|
|
2390
|
+
pane,
|
|
2391
|
+
lesson?.lessonId,
|
|
2392
|
+
lesson?.concepts.length
|
|
2393
|
+
]);
|
|
2394
|
+
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
2395
|
key: c.title,
|
|
1213
2396
|
className: `lks-chip${c.weak ? " weak" : ""}`
|
|
1214
|
-
}, `${c.title} ${c.masteryPct}%${c.weak ? " ⚡" : ""}`))) : null), (0, react.createElement)("div", {
|
|
2397
|
+
}, `${c.title} ${c.masteryPct}%${c.weak ? " ⚡" : ""}`))) : null), (0, react.createElement)("div", { className: "lks-viewtabs" }, (0, react.createElement)("button", {
|
|
2398
|
+
className: `lks-viewtab${pane === "teach" ? " on" : ""}`,
|
|
2399
|
+
onClick: () => {
|
|
2400
|
+
setPane("teach");
|
|
2401
|
+
}
|
|
2402
|
+
}, tr("viewtab.teach")), (0, react.createElement)("button", {
|
|
2403
|
+
className: `lks-viewtab${pane === "mind" ? " on" : ""}`,
|
|
2404
|
+
title: tr("viewtab.mind.title"),
|
|
2405
|
+
onClick: () => {
|
|
2406
|
+
setPane("mind");
|
|
2407
|
+
}
|
|
2408
|
+
}, tr("viewtab.mind")), (0, react.createElement)("button", {
|
|
2409
|
+
className: `lks-viewtab${pane === "cmap" ? " on" : ""}`,
|
|
2410
|
+
title: tr("viewtab.cmap.title"),
|
|
2411
|
+
onClick: () => {
|
|
2412
|
+
setPane("cmap");
|
|
2413
|
+
}
|
|
2414
|
+
}, tr("viewtab.cmap"))), pane === "teach" ? (0, react.createElement)("div", {
|
|
1215
2415
|
className: "lks-prose",
|
|
2416
|
+
ref: proseRef,
|
|
1216
2417
|
dangerouslySetInnerHTML: { __html: lesson.html }
|
|
1217
|
-
})
|
|
2418
|
+
}) : (0, react.createElement)("div", {
|
|
2419
|
+
className: "lks-prose",
|
|
2420
|
+
ref: diagRef
|
|
2421
|
+
}), (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
2422
|
className: "lks-empty",
|
|
1219
2423
|
style: { padding: "16px 0" }
|
|
1220
|
-
}, "
|
|
2424
|
+
}, tr("bb.notes.empty")) : ZONES.filter(([zone]) => lesson.notes.some((n) => n.zone === zone)).map(([zone, labelKey]) => (0, react.createElement)("div", {
|
|
1221
2425
|
key: zone,
|
|
1222
2426
|
className: "lks-zone"
|
|
1223
|
-
}, (0, react.createElement)("h4", null,
|
|
2427
|
+
}, (0, react.createElement)("h4", null, tr(labelKey)), ...lesson.notes.filter((n) => n.zone === zone).map((n) => (0, react.createElement)("div", {
|
|
1224
2428
|
key: n.id,
|
|
1225
2429
|
className: "lks-note"
|
|
1226
2430
|
}, (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" }, "
|
|
2431
|
+
return (0, react.createElement)("div", { className: "lks-col lks-col-bb" }, (0, react.createElement)("div", { className: "lks-colhead" }, tr("col.bb")), body);
|
|
1228
2432
|
}
|
|
1229
2433
|
//#endregion
|
|
1230
2434
|
//#region src/client/starter.tsx
|
|
@@ -1238,8 +2442,6 @@ window.__ModuleLoader__.load({
|
|
|
1238
2442
|
* not reachable from an additive plugin).
|
|
1239
2443
|
* @module dsh-plugin-lookatstudy/client/starter
|
|
1240
2444
|
*/
|
|
1241
|
-
/** The kickoff prompt: starts the tutoring loop and points at the study tab. */
|
|
1242
|
-
const KICKOFF = "开始学习:查看我的学习状态并打开当前焦点课时。如果我还没有课程,推荐我导入示例课程(AI-For-Beginners)并说明怎么开始。回复最后请提醒我点上方「学习」页签进入学习界面。";
|
|
1243
2445
|
/**
|
|
1244
2446
|
* Build the hero starter button bound to the framework services.
|
|
1245
2447
|
* @param ctx - client root context (workspaces + sessions injected).
|
|
@@ -1276,7 +2478,7 @@ window.__ModuleLoader__.load({
|
|
|
1276
2478
|
if (face === void 0) throw new Error("study session is not addressable yet");
|
|
1277
2479
|
const result = await face.prompt([{
|
|
1278
2480
|
type: "text",
|
|
1279
|
-
text:
|
|
2481
|
+
text: tr("prompt.kickoff")
|
|
1280
2482
|
}], "queue");
|
|
1281
2483
|
if (!result.ok) throw new Error(`kickoff prompt rejected: ${result.error.code}: ${result.error.message}`);
|
|
1282
2484
|
ctx.sessions.open(sessionId);
|
|
@@ -1295,41 +2497,259 @@ window.__ModuleLoader__.load({
|
|
|
1295
2497
|
}
|
|
1296
2498
|
}, (0, react.createElement)("button", {
|
|
1297
2499
|
className: "lks-pill on",
|
|
1298
|
-
title: "
|
|
2500
|
+
title: tr("start.title"),
|
|
1299
2501
|
disabled: busy,
|
|
1300
2502
|
onClick: start
|
|
1301
|
-
}, busy ? (0, react.createElement)(IconLoadingOutline16, { className: "lks-spin" }) : (0, react.createElement)(IconThinkOutline16, null), busy ? "
|
|
2503
|
+
}, 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
2504
|
className: "lks-propcard-err",
|
|
1303
2505
|
style: { marginTop: 0 }
|
|
1304
2506
|
}, error) : null);
|
|
1305
2507
|
}
|
|
1306
2508
|
//#endregion
|
|
2509
|
+
//#region src/client/settings.tsx
|
|
2510
|
+
/**
|
|
2511
|
+
* The plugin's settings page: one `settings.section` entry riding the host's
|
|
2512
|
+
* settings shell. Teaching style and study mode write through the same host
|
|
2513
|
+
* routes the study tab uses (POST /api/mode, /api/active — the latter syncs
|
|
2514
|
+
* the tool registry before responding); stats and the state-file path are
|
|
2515
|
+
* read-only projections of the shared poll store. Pure display + the store's
|
|
2516
|
+
* existing write actions; no new routes, no new requests beyond the poll.
|
|
2517
|
+
* @module dsh-plugin-lookatstudy/client/settings
|
|
2518
|
+
*/
|
|
2519
|
+
/** The three souls in pill order (same shape as the study tab's pills). */
|
|
2520
|
+
const MODES = [
|
|
2521
|
+
{
|
|
2522
|
+
id: "direct",
|
|
2523
|
+
labelKey: "soul.direct",
|
|
2524
|
+
hintKey: "soul.direct.hint"
|
|
2525
|
+
},
|
|
2526
|
+
{
|
|
2527
|
+
id: "guide",
|
|
2528
|
+
labelKey: "soul.guide",
|
|
2529
|
+
hintKey: "soul.guide.hint"
|
|
2530
|
+
},
|
|
2531
|
+
{
|
|
2532
|
+
id: "practice",
|
|
2533
|
+
labelKey: "soul.practice",
|
|
2534
|
+
hintKey: "soul.practice.hint"
|
|
2535
|
+
}
|
|
2536
|
+
];
|
|
2537
|
+
/**
|
|
2538
|
+
* The settings section component. Props from the settings shell (`close`) are
|
|
2539
|
+
* unused — every control stays on the page.
|
|
2540
|
+
*/
|
|
2541
|
+
function StudySettingsSection() {
|
|
2542
|
+
const { data, setMode, activate } = useStudy();
|
|
2543
|
+
const [error, setError] = (0, react.useState)(null);
|
|
2544
|
+
const fire = (action) => {
|
|
2545
|
+
action.then(() => {
|
|
2546
|
+
setError(null);
|
|
2547
|
+
}, (err) => {
|
|
2548
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
2549
|
+
});
|
|
2550
|
+
};
|
|
2551
|
+
const progress = data?.progress;
|
|
2552
|
+
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", {
|
|
2553
|
+
key: mode.id,
|
|
2554
|
+
className: `lks-pill${data?.mode === mode.id ? " on" : ""}`,
|
|
2555
|
+
title: tr(mode.hintKey),
|
|
2556
|
+
onClick: () => {
|
|
2557
|
+
fire(setMode(mode.id));
|
|
2558
|
+
}
|
|
2559
|
+
}, 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", {
|
|
2560
|
+
className: `lks-btn ${data?.active === true ? "ghost" : "primary"}`,
|
|
2561
|
+
onClick: () => {
|
|
2562
|
+
if (data !== null) fire(activate(!data.active));
|
|
2563
|
+
}
|
|
2564
|
+
}, 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", {
|
|
2565
|
+
xp: progress.totalXp,
|
|
2566
|
+
level: progress.level,
|
|
2567
|
+
pct: progress.levelPct
|
|
2568
|
+
})), (0, react.createElement)("li", null, tr("settings.stats.today", {
|
|
2569
|
+
xp: progress.todayXp,
|
|
2570
|
+
goal: progress.dailyGoal
|
|
2571
|
+
})), (0, react.createElement)("li", null, tr("settings.stats.streak", {
|
|
2572
|
+
days: progress.streak,
|
|
2573
|
+
best: progress.longestStreak,
|
|
2574
|
+
freeze: progress.freezeCount
|
|
2575
|
+
})))), (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 }));
|
|
2576
|
+
}
|
|
2577
|
+
//#endregion
|
|
2578
|
+
//#region src/client/dock.tsx
|
|
2579
|
+
/**
|
|
2580
|
+
* The composer dock status pill: one `conversation.composer.dock` entry (the
|
|
2581
|
+
* ambient readout band under the composer, next to the token stats) showing
|
|
2582
|
+
* due reviews / streak / level while the study surface is ACTIVE — dormant
|
|
2583
|
+
* installs render nothing at all, the same visibility the tool registry has.
|
|
2584
|
+
* Data rides the shared 3 s poll store (no extra requests). Display-only:
|
|
2585
|
+
* conversation-view tab switching is ui-conversation-private (the starter
|
|
2586
|
+
* button's limitation), so the tooltip points at the Study tab instead.
|
|
2587
|
+
* @module dsh-plugin-lookatstudy/client/dock
|
|
2588
|
+
*/
|
|
2589
|
+
/** Pure projection of the pill's segments (testable without React). */
|
|
2590
|
+
function dockSegments(progress) {
|
|
2591
|
+
return [
|
|
2592
|
+
{
|
|
2593
|
+
key: "due",
|
|
2594
|
+
text: tr("dock.due", { count: progress.dueCount })
|
|
2595
|
+
},
|
|
2596
|
+
{
|
|
2597
|
+
key: "streak",
|
|
2598
|
+
text: tr("dock.streak", { days: progress.streak })
|
|
2599
|
+
},
|
|
2600
|
+
{
|
|
2601
|
+
key: "lv",
|
|
2602
|
+
text: tr("dock.lv", { level: progress.level })
|
|
2603
|
+
}
|
|
2604
|
+
];
|
|
2605
|
+
}
|
|
2606
|
+
/** The dock entry component; renders nothing while data is loading or dormant. */
|
|
2607
|
+
function StudyDockPill() {
|
|
2608
|
+
const { data } = useStudy();
|
|
2609
|
+
if (data === null || data.active !== true) return null;
|
|
2610
|
+
const segments = dockSegments({
|
|
2611
|
+
dueCount: data.dueCount,
|
|
2612
|
+
streak: data.progress.streak,
|
|
2613
|
+
level: data.progress.level
|
|
2614
|
+
});
|
|
2615
|
+
return (0, react.createElement)("span", {
|
|
2616
|
+
className: "lks-root lks-dockpill",
|
|
2617
|
+
title: tr("dock.title", {
|
|
2618
|
+
due: data.dueCount,
|
|
2619
|
+
streak: data.progress.streak,
|
|
2620
|
+
level: data.progress.level
|
|
2621
|
+
})
|
|
2622
|
+
}, ...segments.map((s) => (0, react.createElement)("span", {
|
|
2623
|
+
key: s.key,
|
|
2624
|
+
className: `lks-dockseg lks-dock-${s.key}`
|
|
2625
|
+
}, s.text)));
|
|
2626
|
+
}
|
|
2627
|
+
//#endregion
|
|
2628
|
+
//#region src/client/toolviews.tsx
|
|
2629
|
+
/**
|
|
2630
|
+
* Keyed `tool.call.toolview` entries: learning-aware cards for the
|
|
2631
|
+
* conversation tab's tool rows (our own study tab renders its own chips, but
|
|
2632
|
+
* learning also happens in the plain 对话 view). One generic card body over
|
|
2633
|
+
* the result's `meta` (the tool's presentationMeta lines — replay-safe by
|
|
2634
|
+
* construction) with a per-tool header; `study_record_answer` additionally
|
|
2635
|
+
* parses its args for a live ✓/✗ tone while the call is still running.
|
|
2636
|
+
* @module dsh-plugin-lookatstudy/client/toolviews
|
|
2637
|
+
*/
|
|
2638
|
+
/** Header glyph per tool (missing tools simply use the generic row). */
|
|
2639
|
+
const TOOL_GLYPHS = {
|
|
2640
|
+
study_record_answer: "✍️",
|
|
2641
|
+
study_lesson: "📘",
|
|
2642
|
+
study_due_reviews: "🔁",
|
|
2643
|
+
study_exam_result: "🎯"
|
|
2644
|
+
};
|
|
2645
|
+
/** The ✓/✗ tone from record_answer's args; null for other shapes. Pure. */
|
|
2646
|
+
function answerTone(argsRaw) {
|
|
2647
|
+
if (argsRaw === void 0) return null;
|
|
2648
|
+
try {
|
|
2649
|
+
const args = JSON.parse(argsRaw);
|
|
2650
|
+
return {
|
|
2651
|
+
correct: args.correct === true,
|
|
2652
|
+
concept: typeof args.concept === "string" && args.concept !== "" ? args.concept : tr("chip.unattributed")
|
|
2653
|
+
};
|
|
2654
|
+
} catch {
|
|
2655
|
+
return null;
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
/** Card lines: the result's meta (string[]) when present, else its text content. Pure. */
|
|
2659
|
+
function metaLines(block) {
|
|
2660
|
+
if (Array.isArray(block.meta)) {
|
|
2661
|
+
const lines = block.meta.filter((x) => typeof x === "string");
|
|
2662
|
+
if (lines.length > 0) return lines;
|
|
2663
|
+
}
|
|
2664
|
+
return (block.content ?? []).filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text.split("\n")).flat().filter((l) => l.trim() !== "");
|
|
2665
|
+
}
|
|
2666
|
+
/** Shared card body: header + lines; error results keep their text visible. */
|
|
2667
|
+
function studyToolCard(toolName, block, extra) {
|
|
2668
|
+
const lines = metaLines(block);
|
|
2669
|
+
const glyph = TOOL_GLYPHS[toolName] ?? "🔧";
|
|
2670
|
+
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", {
|
|
2671
|
+
key: String(i),
|
|
2672
|
+
className: "lks-tv-line"
|
|
2673
|
+
}, line))));
|
|
2674
|
+
}
|
|
2675
|
+
/** study_record_answer: live ✓/✗ tone from args + the graded line from meta. */
|
|
2676
|
+
function StudyAnswerView({ toolName, block }) {
|
|
2677
|
+
const tone = answerTone(block.argsRaw);
|
|
2678
|
+
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 })));
|
|
2679
|
+
}
|
|
2680
|
+
/** Generic meta-line card for the read-type study tools. */
|
|
2681
|
+
function StudyLessonView({ toolName, block }) {
|
|
2682
|
+
return studyToolCard(toolName, block);
|
|
2683
|
+
}
|
|
2684
|
+
/** study_due_reviews list card. */
|
|
2685
|
+
function StudyDueView({ toolName, block }) {
|
|
2686
|
+
return studyToolCard(toolName, block);
|
|
2687
|
+
}
|
|
2688
|
+
/** study_exam_result star card. */
|
|
2689
|
+
function StudyExamView({ toolName, block }) {
|
|
2690
|
+
return studyToolCard(toolName, block);
|
|
2691
|
+
}
|
|
2692
|
+
//#endregion
|
|
1307
2693
|
//#region src/client/index.ts
|
|
1308
2694
|
const inject = [
|
|
1309
2695
|
"slots",
|
|
1310
2696
|
"workspaces",
|
|
1311
|
-
"sessions"
|
|
2697
|
+
"sessions",
|
|
2698
|
+
"locale"
|
|
1312
2699
|
];
|
|
1313
2700
|
/**
|
|
1314
2701
|
* Register the study surfaces: styles inject once; the single
|
|
1315
2702
|
* `conversation.view` tab carries the whole plugin; the hero starter button
|
|
1316
2703
|
* (`conversation.input.left`, blank sessions only) automates the onboarding —
|
|
1317
|
-
* study workspace + session + kickoff prompt in one click.
|
|
2704
|
+
* study workspace + session + kickoff prompt in one click. The `lookatstudy`
|
|
2705
|
+
* locale namespace rides the framework locale service (zh/en) and the
|
|
2706
|
+
* client-wide translator is installed before any component renders; both
|
|
2707
|
+
* slot entries declare the namespace so the framework synthesizes their `t`
|
|
2708
|
+
* seat and follows locale switches for the labels.
|
|
1318
2709
|
* @param ctx - client root context.
|
|
1319
2710
|
*/
|
|
1320
2711
|
function apply(ctx) {
|
|
1321
2712
|
ensureStudyStyles();
|
|
2713
|
+
const localeSvc = ctx.locale;
|
|
2714
|
+
ctx.effect(() => registerStudyLocale(localeSvc), "lookatstudy.locale()");
|
|
2715
|
+
setStudyTranslator(studyTranslator(localeSvc));
|
|
1322
2716
|
ctx.slots.inject("conversation.view", () => ctx.slots.register({
|
|
1323
2717
|
name: "conversation.view",
|
|
1324
2718
|
id: "lookatstudy-study",
|
|
1325
2719
|
order: 15,
|
|
1326
|
-
label: "
|
|
2720
|
+
label: () => tr("tab.label"),
|
|
2721
|
+
locale: "lookatstudy"
|
|
1327
2722
|
}, studyView(ctx)));
|
|
1328
2723
|
ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
|
|
1329
2724
|
name: "conversation.input.left",
|
|
1330
2725
|
id: "lookatstudy-start",
|
|
1331
|
-
order: 10
|
|
2726
|
+
order: 10,
|
|
2727
|
+
locale: "lookatstudy"
|
|
1332
2728
|
}, studyStartButton(ctx)));
|
|
2729
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
2730
|
+
name: "settings.section",
|
|
2731
|
+
id: "lookatstudy",
|
|
2732
|
+
order: 30,
|
|
2733
|
+
label: () => tr("settings.nav"),
|
|
2734
|
+
locale: "lookatstudy"
|
|
2735
|
+
}, StudySettingsSection));
|
|
2736
|
+
ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
|
|
2737
|
+
name: "conversation.composer.dock",
|
|
2738
|
+
id: "lookatstudy-status",
|
|
2739
|
+
order: 10,
|
|
2740
|
+
locale: "lookatstudy"
|
|
2741
|
+
}, StudyDockPill));
|
|
2742
|
+
const toolviews = [
|
|
2743
|
+
["study_record_answer", StudyAnswerView],
|
|
2744
|
+
["study_lesson", StudyLessonView],
|
|
2745
|
+
["study_due_reviews", StudyDueView],
|
|
2746
|
+
["study_exam_result", StudyExamView]
|
|
2747
|
+
];
|
|
2748
|
+
for (const [tool, component] of toolviews) ctx.slots.inject("tool.call.toolview", () => ctx.slots.register({
|
|
2749
|
+
name: "tool.call.toolview",
|
|
2750
|
+
key: tool,
|
|
2751
|
+
locale: "lookatstudy"
|
|
2752
|
+
}, component));
|
|
1333
2753
|
}
|
|
1334
2754
|
//#endregion
|
|
1335
2755
|
exports.apply = apply;
|