dsh-plugin-lookatstudy 0.10.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/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 CSS = `
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
- center strip reuses the conversation column's width axis
36
- (--dsh-composer-card-max-width inherits from ConversationRoot's root), so
37
- the tutor transcript is exactly as wide as the native composer; the
38
- flanking columns absorb the remaining width and scroll independently. */
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 0 auto;width:min(100%,var(--dsh-composer-card-max-width,780px))}
46
- .lks-col-bb{flex:1 1 0;min-width:230px;overflow-y:auto;padding:2px 6px}
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 variable and
50
- toggles the class, and removes both whenever the shift collapses (narrow
51
- pane, hidden view) so the host's natural centering comes back. */
52
- [data-composer-card].lks-composer-follow{align-self:flex-start;margin-left:var(--lks-composer-shift,0px)}
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 = CSS;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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](url) → 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
- label: "直讲",
561
- hint: "direct 精讲:先讲清楚,再确认懂没懂"
1683
+ labelKey: "soul.direct",
1684
+ hintKey: "soul.direct.hint"
562
1685
  },
563
1686
  {
564
1687
  id: "guide",
565
- label: "引导",
566
- hint: "guide 引导:让你自己往前推一步,导师递台阶"
1688
+ labelKey: "soul.guide",
1689
+ hintKey: "soul.guide.hint"
567
1690
  },
568
1691
  {
569
1692
  id: "practice",
570
- label: "实战",
571
- hint: "practice 实战:在真实世界的乱问题里学"
1693
+ labelKey: "soul.practice",
1694
+ hintKey: "soul.practice.hint"
572
1695
  }
573
1696
  ];
574
- /** Zone labels for the Cornell notebook (understand / record / practice). */
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: "GitHub 仓库链接,如 microsoft/AI-For-Beginners",
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(`导入课程:用 study_import_github 抓取 ${url.trim()}`);
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(`导入课程:用 study_import_github 抓取 ${url.trim()}`);
1748
+ send(tr("prompt.import", { url: url.trim() }));
626
1749
  }
627
- }, "导入")), (0, react.createElement)("div", { className: "lks-import-hint" }, "粘贴 markdown → 说「导入为课程」", (0, react.createElement)("br"), "本地文件夹 → 说「导入 D:/path/to/folder」"));
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 "章节测验:本节全部课时掌握度 ≥50% 后开放";
1759
+ function statusTitle(kind, status, t = tr) {
1760
+ if (kind === "exam") return t("status.exam");
636
1761
  switch (status) {
637
- case "mastered": return "已毕业:掌握度 ≥90%(或你接受了掌握提案)";
638
- case "in_progress": return "学习中:已开课,掌握度从 50% 起步";
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: `✓ 答对 · ${concept}`,
1821
+ label: t("chip.correct", { concept }),
696
1822
  tone: "ok"
697
1823
  } : {
698
- label: `✗ 答错 · ${concept}`,
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 ? "注销 study 工具与导师人格;学习进度保留,可随时重新开启" : "注册 study 工具并载入导师人格,之后普通对话和现在一样",
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 ? "⏻ 退出学习模式" : "▶ 开始学习")), (0, react.createElement)("div", { className: "lks-body" }, (0, react.createElement)("div", { className: "lks-switch" }, ...[
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
- label: "课程"
2005
+ labelKey: "pane.rail"
868
2006
  },
869
2007
  {
870
2008
  id: "tutor",
871
- label: "导师"
2009
+ labelKey: "pane.tutor"
872
2010
  },
873
2011
  {
874
2012
  id: "bb",
875
- label: "黑板"
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.label))), (0, react.createElement)(CourseRail, {
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: `学习「${lesson.title}」:用 study_lesson 打开这一课开始学习。`
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" }, "加载中…") : data.courses.length === 0 ? (0, react.createElement)("div", { className: "lks-empty" }, "暂无课程", (0, react.createElement)("br"), (0, react.createElement)("button", {
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("导入课程:用 study_import_github 抓取 https://github.com/microsoft/AI-For-Beginners");
2096
+ send(tr("prompt.import", { url: "https://github.com/microsoft/AI-For-Beginners" }));
958
2097
  }
959
- }, (0, react.createElement)(IconDownloadOutline16, null), "导入示例课程"), (0, react.createElement)(ImportRow, { send })) : (() => {
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 ? "确认删除?" : "🗑")), (0, react.createElement)("div", { className: "lks-rail-sub" }, `${course.mastered}/${course.total} 已掌握`), (0, react.createElement)("div", {
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 ? "尚无掌握度数据" : `平均掌握度 ${course.avgMasteryPct}%`
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
- }, "📍 回到当前课时") : null, data.dueCount > 0 ? (0, react.createElement)("div", { className: "lks-duebox" }, `🔁 待复习 ${data.dueCount}`, ...data.due.map((d) => (0, react.createElement)("div", {
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" }, `超${d.overdueDays}天`) : null)), (0, react.createElement)("button", {
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
- }, "开始复习")) : null, ...course.sections.flatMap((section) => {
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 ? "折叠本章节" : `展开本章节(${section.lessons.length} 课时)`,
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" }, `${section.lessons.length} 课`), (0, react.createElement)("span", { className: "lks-sechead-c" }, open ? "▾" : "▸")), ...open ? lessons.map((lesson) => {
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 ? "正在打开课时会话…" : statusTitle(lesson.kind, locked ? "locked" : lesson.status),
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(`开始「${section.title}」的章节测验:按本节课时出题,答完逐题判分`);
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: "这课时的复习今天到期(SM-2)"
2223
+ title: tr("rail.due.tag")
1075
2224
  }, "🔁") : null, lesson.weakConcepts > 0 ? (0, react.createElement)("span", {
1076
2225
  className: "lks-tag weak",
1077
- title: `${lesson.weakConcepts} 个薄弱知识点,测验会优先考察`
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: `${lesson.frictionCount} 次卡点记录(你说"不懂"时导师记下的)`
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: `课时掌握度 ${lesson.masteryPct}%(取最薄弱知识点)`
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 ? "收起导入" : "+ 导入课程"), showImport ? (0, react.createElement)(ImportRow, { send }) : null);
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" }, "课程"), body, (0, react.createElement)(ActionError, { error }));
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(`选 ${opt.letter}:${opt.text}`);
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" }, "导师"), (0, react.createElement)("div", {
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" }, "对话会出现在这里", (0, react.createElement)("br"), "在下方输入框和导师说话") : 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" }, `导师提议你已掌握「${proposal.lessonTitle}」:${proposal.rationale}`), (0, react.createElement)("button", {
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(`接受提案 ${proposal.id} —— 确认标记这课为已掌握`);
2335
+ send(tr("prompt.proposal.accept", { id: proposal.id }));
1177
2336
  }
1178
- }, "接受"), (0, react.createElement)("button", {
2337
+ }, tr("proposal.accept")), (0, react.createElement)("button", {
1179
2338
  className: "lks-btn ghost",
1180
2339
  onClick: () => {
1181
- send(`拒绝提案 ${proposal.id} —— 我想再练练`);
2340
+ send(tr("prompt.proposal.decline", { id: proposal.id }));
1182
2341
  }
1183
- }, "再练练")) : null, (0, react.createElement)("div", {
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.hint,
2351
+ title: tr(mode.hintKey),
1193
2352
  onClick: () => {
1194
2353
  fire(setMode(mode.id));
1195
2354
  }
1196
- }, mode.label))), lesson !== null && lesson.starters.length > 0 ? (0, react.createElement)("div", {
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 讲解 plus the Cornell 笔记. */
2367
+ /** Right column: the blackboard — focus-lesson 讲解/脑图/概念图 plus the Cornell 笔记. */
1209
2368
  function BlackboardColumn({ data }) {
1210
2369
  const lesson = data?.lesson ?? null;
1211
- const body = lesson === null ? (0, react.createElement)("div", { className: "lks-empty" }, "黑板还空着", (0, react.createElement)("br"), "在左侧课程树选择一课") : (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 ? "" : ` · 掌握度 ${lesson.masteryPct}%`) + ` · ${lesson.strategy}`), lesson.concepts.length > 0 ? (0, react.createElement)("div", { className: "lks-chips" }, ...lesson.concepts.map((c) => (0, react.createElement)("span", {
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
- }), (0, react.createElement)("div", { className: "lks-bb-notes" }, (0, react.createElement)("div", { className: "lks-sec" }, "笔记"), lesson.notes.length === 0 ? (0, react.createElement)("div", {
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
- }, "这一课还没有笔记") : ZONES.filter(([zone]) => lesson.notes.some((n) => n.zone === zone)).map(([zone, label]) => (0, react.createElement)("div", {
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, label), ...lesson.notes.filter((n) => n.zone === zone).map((n) => (0, react.createElement)("div", {
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" }, "黑板"), body);
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: KICKOFF
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 ? "正在准备学习区…" : data?.active === true ? "进入学习" : "开始学习"), error !== null ? (0, react.createElement)("span", {
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;