dsh-plugin-lookatstudy 0.11.0 → 0.12.0

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