dsh-plugin-lookatstudy 0.13.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-plugin-lookatstudy
2
2
 
3
- Turn any markdown document, local folder, or GitHub learning repository into a guided course inside [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) — your dsh agent becomes a full AI tutor with the interaction design of [LookatStudy](https://github.com/Kaiji-Z/LookatStudy): per-concept knowledge tracking, mastery-driven progression, spaced repetition, mastery proposals, friction awareness, learner memory, a Cornell notebook, an in-chat proposal card, exam mode with star grades, XP & streak, bilingual lessons, and a rich blackboard (KaTeX math, syntax-highlighted code, mermaid diagrams, mindmap & concept-map views). Learning engine modules are vendored from LookatStudy (MIT).
3
+ Turn any markdown document, local folder, or GitHub learning repository into a guided course inside [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) — your dsh agent becomes a full AI tutor with the interaction design of [LookatStudy](https://github.com/Kaiji-Z/LookatStudy): per-concept knowledge tracking, mastery-driven progression, spaced repetition, mastery proposals, friction awareness, learner memory, a Cornell notebook, an in-chat proposal card, exam mode with star grades, XP & streak, bilingual lessons, read-aloud (Edge TTS with a system-voice fallback), and a rich blackboard (KaTeX math, syntax-highlighted code, mermaid diagrams, mindmap & concept-map views). Learning engine modules are vendored from LookatStudy (MIT).
4
4
 
5
5
  ## Screenshots
6
6
 
@@ -38,7 +38,7 @@ Works with any profile. In the `web` profile the plugin additionally serves the
38
38
  |---|---|
39
39
  | 左 · 课程 | Course picker, progress, due box with one-click review kickoff, lesson tree (gating, mastery bars, ⚡😣 weak spots, clickable focus), one-click demo import when empty |
40
40
  | 中 · 老师 | A read-only mini transcript of the live tutor conversation (assistant replies rendered through the plugin's markdown pipeline, tool calls as chips, streaming included) plus the soul pills (直讲/引导/实战), the focus lesson's starters, and the mastery-proposal banner (接受/再练练). Typing happens in dsh's own composer below the tab — the plugin never ships its own input; every button lands its text in that native composer and submits through the same path as the Send button |
41
- | 右 · 黑板 | The focus lesson's 讲解 (server-sanitized markdown, rendered rich on demand: KaTeX / syntax highlighting / mermaid), a 🧠 mindmap and 🕸 concept-map view of the same lesson, and the Cornell 笔记 three zones |
41
+ | 右 · 黑板 | The focus lesson's 讲解 (server-sanitized markdown, rendered rich on demand: KaTeX / syntax highlighting / mermaid), a 🧠 mindmap and 🕸 concept-map view of the same lesson, the Cornell 笔记 three zones (each note deletable with an armed confirm), and the read-aloud bar — 朗读本课 speaks the lesson sentence-by-sentence through Microsoft's Edge neural voices (host-side synthesis, disk-cached; falls back to the browser's system voice offline) with the current sentence highlighted |
42
42
 
43
43
  The tutor column is a learning surface, not a generic chat: the tutor's quiz options (A–D) render as clickable answer buttons under the latest reply (clicking sends the answer through the native composer), graded answers show as ✓/✗ chips with the tested concept, and every course-tree glyph, tag, and mastery bar carries a hover tooltip explaining its meaning. All content text runs at the dsh chat transcript's own 16 px.
44
44
 
@@ -78,13 +78,13 @@ Misc: `study_set_mode`, `study_delete_course`
78
78
 
79
79
  ## What is intentionally not restored
80
80
 
81
- LookatStudy's Electron-native experiences have no host surface in dsh: the 伴学/voice companion line, persistent text highlighting with DOM anchors, and celebration particles. Everything else — engine, contracts, data models, exam mode with star grades, XP & streak, bilingual translation, image inlining, math/code/diagram rendering — is ported (diagram renderers load from CDN on demand and degrade silently offline).
81
+ LookatStudy's Electron-native experiences have no host surface in dsh: the 伴学 companion creature and its celebration particles, and persistent text highlighting with DOM anchors (the read-aloud bar highlights in its own strip instead). Read-aloud itself IS ported (0.13.0) — Edge neural voices synthesized host-side with the browser's speechSynthesis as fallback. Everything else — engine, contracts, data models, exam mode with star grades, XP & streak, bilingual translation, image inlining, math/code/diagram rendering — is ported (diagram renderers load from CDN on demand and degrade silently offline).
82
82
 
83
83
  ## Development
84
84
 
85
85
  ```sh
86
86
  pnpm exec tsdown # build lib/ (host + client entries, peers external)
87
- pnpm test # 48 node:test cases over the real source (no key needed)
87
+ pnpm test # 218 node:test cases over the real source (no key needed)
88
88
 
89
89
  # iterate against a live dsh (this repo lives beside a deepseek-harness checkout):
90
90
  pnpm dsh web --patch ../dsh-plugin-lookatstudy/cordis.dev.yml # run from the harness checkout
package/lib/client.js CHANGED
@@ -260,6 +260,7 @@ window.__ModuleLoader__.load({
260
260
  .lks-set-row h3{margin:0 0 4px;font-size:15px;font-weight:600}
261
261
  .lks-set-hint{margin:0 0 10px;font-size:13px;color:var(--dsw-alias-label-secondary)}
262
262
  .lks-set-state{margin-left:10px;font-size:13px;color:var(--dsw-alias-label-secondary)}
263
+ .lks-set-select{font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:5px 8px;max-width:100%}
263
264
  .lks-set-stats{margin:0;padding-left:18px;font-size:14px;line-height:1.9}
264
265
  .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}
265
266
 
@@ -754,6 +755,51 @@ window.__ModuleLoader__.load({
754
755
  return bytes.buffer;
755
756
  }
756
757
  };
758
+ /** The voices the dashboard route accepts (same allowlist server-side). */
759
+ const TTS_VOICES = [
760
+ {
761
+ id: "zh-CN-XiaoxiaoNeural",
762
+ labelKey: "voice.xiaoxiao"
763
+ },
764
+ {
765
+ id: "zh-CN-YunxiNeural",
766
+ labelKey: "voice.yunxi"
767
+ },
768
+ {
769
+ id: "zh-CN-YunyangNeural",
770
+ labelKey: "voice.yunyang"
771
+ },
772
+ {
773
+ id: "zh-CN-XiaoyiNeural",
774
+ labelKey: "voice.xiaoyi"
775
+ },
776
+ {
777
+ id: "en-US-AriaNeural",
778
+ labelKey: "voice.aria"
779
+ },
780
+ {
781
+ id: "en-US-GuyNeural",
782
+ labelKey: "voice.guy"
783
+ }
784
+ ];
785
+ const TTS_VOICE_KEY = "dsh-plugin-lookatstudy:tts-voice";
786
+ /** Narrow a stored voice id to the allowlist; anything else falls back to 晓晓. Pure. */
787
+ function normalizeStoredVoice(raw) {
788
+ return TTS_VOICES.some((v) => v.id === raw) ? raw : TTS_VOICES[0].id;
789
+ }
790
+ /** The selected read-aloud voice (browser-local preference; host state untouched). */
791
+ function storedTtsVoice() {
792
+ try {
793
+ return normalizeStoredVoice(typeof localStorage === "undefined" ? null : localStorage.getItem(TTS_VOICE_KEY));
794
+ } catch {
795
+ return TTS_VOICES[0].id;
796
+ }
797
+ }
798
+ function storeTtsVoice(id) {
799
+ try {
800
+ localStorage.setItem(TTS_VOICE_KEY, normalizeStoredVoice(id));
801
+ } catch {}
802
+ }
757
803
  /** The one shared store instance backing every study seat component. */
758
804
  const studyStore = new StudyStore();
759
805
  /** React binding: live snapshot plus the write actions. */
@@ -1664,6 +1710,14 @@ window.__ModuleLoader__.load({
1664
1710
  "settings.mode": "教学风格",
1665
1711
  "settings.mode.hint": "导师的讲解风格;也可在学习页随时切换",
1666
1712
  "settings.studyMode": "学习模式",
1713
+ "settings.voice": "朗读语音",
1714
+ "settings.voice.hint": "「朗读本课」使用的神经网络声音(浏览器本地偏好)",
1715
+ "voice.xiaoxiao": "晓晓 · 中文女声",
1716
+ "voice.yunxi": "云希 · 中文男声",
1717
+ "voice.yunyang": "云扬 · 中文男声(新闻)",
1718
+ "voice.xiaoyi": "晓伊 · 中文女声(轻快)",
1719
+ "voice.aria": "Aria · English female",
1720
+ "voice.guy": "Guy · English male",
1667
1721
  "settings.studyMode.hint": "开启后注册 study_* 工具并载入导师人格;进度与笔记始终保留",
1668
1722
  "settings.on": "状态:已开启",
1669
1723
  "settings.off": "状态:已关闭",
@@ -1792,6 +1846,14 @@ window.__ModuleLoader__.load({
1792
1846
  "settings.mode": "Teaching style",
1793
1847
  "settings.mode.hint": "The tutor's explanation style; switchable anytime in the study tab too",
1794
1848
  "settings.studyMode": "Study mode",
1849
+ "settings.voice": "Read-aloud voice",
1850
+ "settings.voice.hint": "The neural voice used by 朗读本课 (a browser-local preference)",
1851
+ "voice.xiaoxiao": "Xiaoxiao · Chinese female",
1852
+ "voice.yunxi": "Yunxi · Chinese male",
1853
+ "voice.yunyang": "Yunyang · Chinese male (news)",
1854
+ "voice.xiaoyi": "Xiaoyi · Chinese female (lively)",
1855
+ "voice.aria": "Aria · English female",
1856
+ "voice.guy": "Guy · English male",
1795
1857
  "settings.studyMode.hint": "On registers the study_* tools and the tutor persona; progress and notes are always kept",
1796
1858
  "settings.on": "Status: on",
1797
1859
  "settings.off": "Status: off",
@@ -2353,7 +2415,10 @@ window.__ModuleLoader__.load({
2353
2415
  return this.run;
2354
2416
  }
2355
2417
  async speakOn(text) {
2356
- await (this.engine === "system" ? this.fallback : this.primary).speak(text);
2418
+ const impl = this.engine === "system" ? this.fallback : this.primary;
2419
+ const next = this.sentences[this.index + 1];
2420
+ if (next !== void 0) impl.prewarm?.(next);
2421
+ await impl.speak(text);
2357
2422
  while (this.paused && !this.stopped) await sleep(120);
2358
2423
  }
2359
2424
  pause() {
@@ -3113,15 +3178,33 @@ window.__ModuleLoader__.load({
3113
3178
  }
3114
3179
  };
3115
3180
  }
3116
- /** Edge-over-dashboard engine: MP3 from /api/tts played through an <audio>. */
3181
+ /** Edge-over-dashboard engine: MP3 from /api/tts played through an <audio>, with per-session prefetch. */
3117
3182
  function audioSpeechEngine(fetchTts) {
3118
3183
  let audio = null;
3184
+ const prefetch = /* @__PURE__ */ new Map();
3185
+ const load = (text) => {
3186
+ let pending = prefetch.get(text);
3187
+ if (pending === void 0) {
3188
+ pending = fetchTts(text);
3189
+ pending.catch(() => prefetch.delete(text));
3190
+ prefetch.set(text, pending);
3191
+ }
3192
+ return pending;
3193
+ };
3119
3194
  return {
3120
3195
  speak(text) {
3121
- return fetchTts(text).then((buf) => new Promise((resolve, reject) => {
3122
- audio = new Audio(URL.createObjectURL(new Blob([buf], { type: "audio/mpeg" })));
3123
- audio.onended = () => resolve();
3124
- audio.onerror = () => reject(/* @__PURE__ */ new Error("audio playback failed"));
3196
+ return load(text).then((buf) => new Promise((resolve, reject) => {
3197
+ const blobUrl = URL.createObjectURL(new Blob([buf], { type: "audio/mpeg" }));
3198
+ audio = new Audio(blobUrl);
3199
+ const done = () => {
3200
+ URL.revokeObjectURL(blobUrl);
3201
+ resolve();
3202
+ };
3203
+ audio.onended = done;
3204
+ audio.onerror = () => {
3205
+ URL.revokeObjectURL(blobUrl);
3206
+ reject(/* @__PURE__ */ new Error("audio playback failed"));
3207
+ };
3125
3208
  audio.play().catch(reject);
3126
3209
  }));
3127
3210
  },
@@ -3134,6 +3217,9 @@ window.__ModuleLoader__.load({
3134
3217
  cancel() {
3135
3218
  audio?.pause();
3136
3219
  audio = null;
3220
+ },
3221
+ prewarm(text) {
3222
+ load(text);
3137
3223
  }
3138
3224
  };
3139
3225
  }
@@ -3149,7 +3235,9 @@ window.__ModuleLoader__.load({
3149
3235
  const startReading = () => {
3150
3236
  if (lesson === null || lesson.speechText.trim() === "") return;
3151
3237
  readCtl.current?.stop();
3152
- const controller = new ReadAloudController(speechSentencesOf(lesson.speechText).map((s) => speakMathInSentence(s)), audioSpeechEngine((text) => tts(text)), systemSpeechEngine(), (s) => {
3238
+ const sentences = speechSentencesOf(lesson.speechText);
3239
+ const voice = storedTtsVoice();
3240
+ const controller = new ReadAloudController(sentences.map((s) => speakMathInSentence(s)), audioSpeechEngine((text) => tts(text, voice)), systemSpeechEngine(), (s) => {
3153
3241
  setRead(s);
3154
3242
  if (s.degraded && s.engine === "system" && !readError) setReadError(tr("read.engine.system"));
3155
3243
  });
@@ -3401,6 +3489,7 @@ window.__ModuleLoader__.load({
3401
3489
  function StudySettingsSection() {
3402
3490
  const { data, setMode, activate } = useStudy();
3403
3491
  const [error, setError] = (0, react.useState)(null);
3492
+ const [voice, setVoice] = (0, react.useState)(storedTtsVoice);
3404
3493
  const fire = (action) => {
3405
3494
  action.then(() => {
3406
3495
  setError(null);
@@ -3421,7 +3510,17 @@ window.__ModuleLoader__.load({
3421
3510
  onClick: () => {
3422
3511
  if (data !== null) fire(activate(!data.active));
3423
3512
  }
3424
- }, 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", {
3513
+ }, 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")))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.voice")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.voice.hint")), (0, react.createElement)("div", null, (0, react.createElement)("select", {
3514
+ className: "lks-set-select",
3515
+ value: voice,
3516
+ onChange: (e) => {
3517
+ storeTtsVoice(e.target.value);
3518
+ setVoice(e.target.value);
3519
+ }
3520
+ }, ...TTS_VOICES.map((v) => (0, react.createElement)("option", {
3521
+ key: v.id,
3522
+ value: v.id
3523
+ }, tr(v.labelKey)))))), 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", {
3425
3524
  xp: progress.totalXp,
3426
3525
  level: progress.level,
3427
3526
  pct: progress.levelPct