dsh-plugin-lookatstudy 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/lib/client.js +10088 -1209
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +1329 -291
- package/package.json +1 -1
package/lib/index.mjs
CHANGED
|
@@ -41,13 +41,721 @@ const Config = z.object({
|
|
|
41
41
|
]).default("auto")
|
|
42
42
|
});
|
|
43
43
|
//#endregion
|
|
44
|
+
//#region src/client/locale.ts
|
|
45
|
+
/** Simplified Chinese dictionary (also the fallback locale). */
|
|
46
|
+
const ZH = {
|
|
47
|
+
"tab.label": "学习",
|
|
48
|
+
"col.rail": "课程",
|
|
49
|
+
"col.tutor": "导师",
|
|
50
|
+
"col.bb": "黑板",
|
|
51
|
+
"pane.rail": "课程",
|
|
52
|
+
"pane.chat": "对话",
|
|
53
|
+
"pane.note": "黑板",
|
|
54
|
+
"viewtab.teach": "讲解",
|
|
55
|
+
"viewtab.cmap": "概念图",
|
|
56
|
+
"viewtab.cmap.title": "本课概念之间的关系图",
|
|
57
|
+
"loading": "加载中…",
|
|
58
|
+
"start.title": "一键准备学习:建立学习工作区、开启会话并让导师就位",
|
|
59
|
+
"start.go": "开始学习",
|
|
60
|
+
"start.enter": "进入学习",
|
|
61
|
+
"start.busy": "正在准备学习区…",
|
|
62
|
+
"soul.direct": "直讲",
|
|
63
|
+
"soul.direct.hint": "精讲:先讲清楚,再确认懂没懂",
|
|
64
|
+
"soul.guide": "引导",
|
|
65
|
+
"soul.guide.hint": "引导:让你自己往前推一步,导师递台阶",
|
|
66
|
+
"soul.practice": "实战",
|
|
67
|
+
"soul.practice.hint": "实战:在真实世界的乱问题里学",
|
|
68
|
+
"zone.understand": "🧠 理解区 — 知识结构",
|
|
69
|
+
"zone.record": "📝 记录区 — 我的话",
|
|
70
|
+
"zone.practice": "✍️ 练习区 — 答题日志",
|
|
71
|
+
"rail.empty.title": "暂无课程",
|
|
72
|
+
"rail.empty.hint": "粘贴 markdown → 说「导入为课程」",
|
|
73
|
+
"rail.empty.hint2": "本地文件夹 → 说「导入 D:/path/to/folder」",
|
|
74
|
+
"rail.empty.placeholder": "GitHub 仓库链接,如 microsoft/AI-For-Beginners",
|
|
75
|
+
"rail.empty.button": "导入",
|
|
76
|
+
"composer.send": "发送",
|
|
77
|
+
"quiz.card.title": "练习",
|
|
78
|
+
"quiz.progress": "{cur}/{total}",
|
|
79
|
+
"quiz.next": "下一题",
|
|
80
|
+
"quiz.finish": "看成绩单",
|
|
81
|
+
"quiz.score": "成绩:{correct}/{total}",
|
|
82
|
+
"quiz.right": "答对了",
|
|
83
|
+
"quiz.wrong": "答错了",
|
|
84
|
+
"quiz.youChose": "你选了",
|
|
85
|
+
"quiz.answer": "正确答案",
|
|
86
|
+
"quiz.hook": "练习卡完成:{correct}/{total} 正确。请简短点评,针对错题讲讲思路。",
|
|
87
|
+
"quiz.action.explain-wrong": "讲讲错题",
|
|
88
|
+
"quiz.action.retry": "再来一组",
|
|
89
|
+
"quiz.action.go-deeper": "深入原理",
|
|
90
|
+
"quiz.action.mark-mastered": "标记我掌握了",
|
|
91
|
+
"quiz.action.markMastered.hint": "会让导师出综合题检验,通过即标记掌握",
|
|
92
|
+
"quiz.action.next-topic": "下一个知识点",
|
|
93
|
+
"artifact.lines": "第 {from}-{to} 行:",
|
|
94
|
+
"artifact.expand": "放大查看",
|
|
95
|
+
"artifact.guess.title": "猜一猜",
|
|
96
|
+
"artifact.guess.pick": "我选「{label}」",
|
|
97
|
+
"artifact.guess.wait": "已记下你的直觉——导师下回合揭晓,看看你想得对不对",
|
|
98
|
+
"artifact.sedimented": "有新的学习产物沉淀进笔记",
|
|
99
|
+
"zone.artifacts": "AI 产物",
|
|
100
|
+
"note.quote.ask": "提问这段",
|
|
101
|
+
"note.quote.save": "加到笔记",
|
|
102
|
+
"note.quote.template": "请讲解这段:「{text}」",
|
|
103
|
+
"note.saved": "已存入记录区笔记",
|
|
104
|
+
"review.rate.title": "复习自评——这课现在记得多牢?",
|
|
105
|
+
"review.again": "再来一次",
|
|
106
|
+
"review.remembered": "记住了",
|
|
107
|
+
"review.mastered": "完全掌握",
|
|
108
|
+
"review.done.good": "已安排下次复习",
|
|
109
|
+
"review.done.again": "明天再来一遍",
|
|
110
|
+
"review.jump": "点开这一课去复习",
|
|
111
|
+
"review.nudge": "有 {n} 课到了复习时间,先回来练一遍?",
|
|
112
|
+
"review.nudge.go": "去复习",
|
|
113
|
+
"rail.search.jump": "打开这一课",
|
|
114
|
+
"proposal.banner": "《{lesson}》可以提前毕业了",
|
|
115
|
+
"proposal.accept": "接受",
|
|
116
|
+
"proposal.decline": "再练练",
|
|
117
|
+
"proposal.accept.msg": "我接受《{lesson}》的掌握度提议,帮我标记为掌握。",
|
|
118
|
+
"proposal.decline.msg": "《{lesson}》我先再练练,暂不接受提前毕业。",
|
|
119
|
+
"tutor.thinking": "导师思考中…",
|
|
120
|
+
"header.xp": "经验值 {xp}",
|
|
121
|
+
"header.streak": "连续学习天数",
|
|
122
|
+
"header.level": "等级",
|
|
123
|
+
"map.node.locked": "锁定 — 先完成前面的课时",
|
|
124
|
+
"map.node.due": "到了复习时间",
|
|
125
|
+
"map.exam.locked": "考试未解锁 — 本章节全部课时掌握度 ≥50% 后开放",
|
|
126
|
+
"map.tab.map": "地图",
|
|
127
|
+
"map.tab.import": "导入",
|
|
128
|
+
"map.world.study": "学习",
|
|
129
|
+
"map.world.practice": "实操",
|
|
130
|
+
"map.empty.practice": "这个课程暂无实操练习内容",
|
|
131
|
+
"map.streaming.notice": "导师正在回复…",
|
|
132
|
+
"thread.streamingBadge": "AI 正在输出",
|
|
133
|
+
"map.search.label": "搜索",
|
|
134
|
+
"map.review.label": "复习",
|
|
135
|
+
"map.overlay.close": "关闭",
|
|
136
|
+
"chat.start": "开始学习",
|
|
137
|
+
"chat.start.hint": "从当前课时的第一个开场动作开始",
|
|
138
|
+
"prompt.start": "你好,我们开始学这一课吧:先给我一个两三句话的导览,再从第一个知识点讲起。",
|
|
139
|
+
"mode.label": "风格:",
|
|
140
|
+
"rail.deleted": "课程已删除",
|
|
141
|
+
"review.random": "随机复习一课",
|
|
142
|
+
"review.random.hint": "随机跳到一门到期课程(交错复习)",
|
|
143
|
+
"scroll.bottom": "回到底部",
|
|
144
|
+
"chat.stop": "停止生成",
|
|
145
|
+
"quiz.submit": "提交",
|
|
146
|
+
"proposal.applied": "《{lesson}》已采纳提前毕业提议",
|
|
147
|
+
"proposal.rejected": "《{lesson}》提议已忽略,继续保持练习",
|
|
148
|
+
"companion.poke": "戳一戳伴学伙伴",
|
|
149
|
+
"companion.poked": "伴学伙伴向你眨了眨眼",
|
|
150
|
+
"companion.form.ember": "小焰",
|
|
151
|
+
"companion.form.frost": "霜绒",
|
|
152
|
+
"companion.form.moss": "苔芽",
|
|
153
|
+
"companion.form.star": "星尘",
|
|
154
|
+
"companion.form.ink": "墨墨",
|
|
155
|
+
"settings.companion": "伴学伙伴",
|
|
156
|
+
"settings.companion.hint": "选择学习面板右下角的伴学生物形态。",
|
|
157
|
+
"quiz.msg.explain-wrong": "我刚才有题答错了,帮我讲讲为什么错、正确的思路是什么。",
|
|
158
|
+
"quiz.msg.retry": "再来一组类似的题巩固一下。",
|
|
159
|
+
"quiz.msg.go-deeper": "这组我答得不错,帮我深入讲讲背后的原理和容易混淆的地方。",
|
|
160
|
+
"quiz.msg.mark-mastered": "这课我觉得掌握了,帮我确认一下——出个综合题检验,通过了就标记为掌握。",
|
|
161
|
+
"quiz.msg.next-topic": "进入下一个知识点。",
|
|
162
|
+
"toast.region": "通知",
|
|
163
|
+
"toast.close": "关闭",
|
|
164
|
+
"composer.busy": "导师正在回复…",
|
|
165
|
+
"rail.empty.demo": "导入示例课程",
|
|
166
|
+
"import.cta": "导入新课程",
|
|
167
|
+
"import.tab.url": "URL",
|
|
168
|
+
"import.tab.md": "MD",
|
|
169
|
+
"import.tab.folder": "文件夹",
|
|
170
|
+
"import.tab.epub": "电子书",
|
|
171
|
+
"import.tab.pack": "课程包",
|
|
172
|
+
"import.url.desc": "GitHub 仓库、arXiv 论文或网页文章的链接",
|
|
173
|
+
"import.btn.url": "导入",
|
|
174
|
+
"import.placeholder.name": "课程名(可选)",
|
|
175
|
+
"import.placeholder.md": "粘贴 Markdown:## 是章节,### 是课时",
|
|
176
|
+
"import.btn.md": "导入为课程",
|
|
177
|
+
"import.folder.desc": "本地文件夹路径(内含 Markdown/电子书/代码的仓库)",
|
|
178
|
+
"import.folder.pathPlaceholder": "D:/path/to/folder",
|
|
179
|
+
"import.btn.folder": "导入文件夹",
|
|
180
|
+
"import.epub.desc": "EPUB 电子书文件路径(按其所在文件夹导入,章节拆课)",
|
|
181
|
+
"import.epub.pathPlaceholder": "D:/books/我的电子书.epub",
|
|
182
|
+
"import.btn.epub": "导入电子书",
|
|
183
|
+
"import.pack.desc": "学习包(.md,study_export 导出的课程)——在本机选择文件",
|
|
184
|
+
"import.btn.pack": "选择课程包文件",
|
|
185
|
+
"import.progress.title": "正在导入课程…",
|
|
186
|
+
"import.progress.starting": "正在启动…",
|
|
187
|
+
"import.progress.note": "导入在导师会话中进行,可继续浏览其他课程",
|
|
188
|
+
"import.progress.cancel": "取消导入",
|
|
189
|
+
"import.progress.elapsed": "已 {s}s",
|
|
190
|
+
"import.step.fetch": "清点内容",
|
|
191
|
+
"import.step.design": "设计课程结构",
|
|
192
|
+
"import.step.apply": "落库建课",
|
|
193
|
+
"import.step.done": "完成",
|
|
194
|
+
"import.cancelled": "已取消导入",
|
|
195
|
+
"import.error": "导入失败:{msg}",
|
|
196
|
+
"import.error.turn": "这一轮结束了,但没有新课程出现——请看对话里的导师反馈",
|
|
197
|
+
"import.success": "导入成功",
|
|
198
|
+
"chat.copy": "复制",
|
|
199
|
+
"chat.copied": "已复制",
|
|
200
|
+
"error.renderFailed": "这部分内容渲染失败(可能格式有问题)。",
|
|
201
|
+
"error.retry": "重试",
|
|
202
|
+
"note.renderFailed": "这段内容渲染失败了。",
|
|
203
|
+
"note.truncated": "(已截断)",
|
|
204
|
+
"note.retryRender": "重试渲染",
|
|
205
|
+
"zoom.in": "放大面板字号",
|
|
206
|
+
"zoom.out": "缩小面板字号",
|
|
207
|
+
"palette.label": "命令面板",
|
|
208
|
+
"palette.placeholder": "搜索课时/课程,或选择动作…",
|
|
209
|
+
"palette.lessons": "课时",
|
|
210
|
+
"palette.courses": "课程",
|
|
211
|
+
"palette.review": "开始复习",
|
|
212
|
+
"palette.study": "开始学习",
|
|
213
|
+
"palette.empty": "没有匹配项",
|
|
214
|
+
"threads.label": "课时线程",
|
|
215
|
+
"meter.label": "上下文用量",
|
|
216
|
+
"meter.estimate": "约 {n} tok(按事件字符估算)",
|
|
217
|
+
"model.unknown": "当前模型",
|
|
218
|
+
"model.switched": "已切换到 {model}",
|
|
219
|
+
"model.switchFailed": "切换失败——请看宿主模型设置",
|
|
220
|
+
"settings.budget": "历史预算",
|
|
221
|
+
"settings.budget.hint": "开启后导师在长会话中主动概括旧轮次(上游 v0.27 历史预算的导师层执行)",
|
|
222
|
+
"settings.budget.on": "开启历史预算",
|
|
223
|
+
"settings.budget.off": "关闭历史预算",
|
|
224
|
+
"attach.sent": "我发了一个文件:{name}(工作区路径 {path})——请读取它再继续。",
|
|
225
|
+
"attach.fail": "附件上传失败",
|
|
226
|
+
"attach.label": "附件(≤20MiB,存入学习工作区)",
|
|
227
|
+
"rail.mastered": "毕业 {mastered}/{total} 课",
|
|
228
|
+
"rail.avg": "平均掌握度 {pct}%",
|
|
229
|
+
"rail.avg.none": "尚无掌握度数据",
|
|
230
|
+
"rail.search": "搜索课时…(多关键词空格分隔)",
|
|
231
|
+
"rail.locate.title": "在课程树中定位当前焦点课时(自动展开所在章节)",
|
|
232
|
+
"rail.locate": "回到当前课时",
|
|
233
|
+
"rail.due": "待复习 {count}",
|
|
234
|
+
"rail.due.over": "超{days}天",
|
|
235
|
+
"rail.due.start": "开始复习",
|
|
236
|
+
"rail.due.none": "暂时没有到期的复习,保持节奏!",
|
|
237
|
+
"chat.reasoning": "思考过程({n} 字)",
|
|
238
|
+
"msg.speak": "朗读",
|
|
239
|
+
"note.pin": "置顶笔记",
|
|
240
|
+
"note.unpin": "取消置顶",
|
|
241
|
+
"note.locate": "回到原文",
|
|
242
|
+
"note.edit": "编辑备注",
|
|
243
|
+
"note.save": "保存",
|
|
244
|
+
"confirm.cancel": "取消",
|
|
245
|
+
"rail.due.tag": "这课时的复习今天到期(SM-2)",
|
|
246
|
+
"rail.delete": "删除本课程",
|
|
247
|
+
"rail.delete.confirm": "确认删除?",
|
|
248
|
+
"rail.delete.title.confirm": "再点一次确认删除(含全部进度与笔记)。反悔前可先在设置页备份状态文件",
|
|
249
|
+
"note.delete": "删除本条笔记",
|
|
250
|
+
"note.delete.confirm": "确认删除?",
|
|
251
|
+
"read.play": "朗读本课",
|
|
252
|
+
"read.stop": "停止朗读",
|
|
253
|
+
"read.pause": "暂停朗读",
|
|
254
|
+
"read.resume": "继续朗读",
|
|
255
|
+
"read.engine.system": "网络合成不可用,已切换为系统语音",
|
|
256
|
+
"read.unavailable": "朗读不可用",
|
|
257
|
+
"rail.section.collapse": "折叠本章节",
|
|
258
|
+
"rail.section.expand": "展开本章节({count} 课时)",
|
|
259
|
+
"rail.section.count": "{count} 课",
|
|
260
|
+
"rail.lesson.opening": "正在打开课时会话…",
|
|
261
|
+
"rail.lesson.openHint": "(点击将打开本课专属会话)",
|
|
262
|
+
"rail.import.toggle": "导入课程",
|
|
263
|
+
"rail.import.close": "收起导入",
|
|
264
|
+
"tag.weak": "{count} 个薄弱知识点,测验会优先考察",
|
|
265
|
+
"tag.friction": "{count} 次卡点记录(你说\"不懂\"时导师记下的)",
|
|
266
|
+
"tag.mastery": "课时掌握度 {pct}%(取最薄弱知识点)",
|
|
267
|
+
"tag.mastery.short": "课时掌握度 = 最薄弱知识点的掌握度",
|
|
268
|
+
"status.exam": "章节测验:本节全部课时掌握度 ≥50% 后开放",
|
|
269
|
+
"status.mastered": "已毕业:掌握度 ≥90%(或你接受了掌握提案)",
|
|
270
|
+
"status.in_progress": "学习中:已开课,掌握度从 50% 起步",
|
|
271
|
+
"status.available": "可开始:已解锁,尚未学习",
|
|
272
|
+
"status.locked": "未解锁:先完成前面的课时",
|
|
273
|
+
"chip.unattributed": "未归因",
|
|
274
|
+
"chip.correct": "✓ 答对 · {concept}",
|
|
275
|
+
"chip.wrong": "✗ 答错 · {concept}",
|
|
276
|
+
"row.thinking": "导师思考中…",
|
|
277
|
+
"row.thinking.title": "导师正在推理,回复马上就来",
|
|
278
|
+
"quiz.title": "点击选项作答,也可以直接打字回答",
|
|
279
|
+
"quiz.answer": "选 {letter}:{text}",
|
|
280
|
+
"tutor.dormant": "学习模式已关闭 — 点右上「▶ 开始学习」开启导师,按钮恢复可用。",
|
|
281
|
+
"tutor.empty": "对话会出现在这里",
|
|
282
|
+
"tutor.empty.hint": "在下方输入框和导师说话",
|
|
283
|
+
"proposal.text": "导师提议你已掌握「{lesson}」:{rationale}",
|
|
284
|
+
"proposal.accept": "接受",
|
|
285
|
+
"proposal.decline": "再练练",
|
|
286
|
+
"bb.empty": "黑板还空着",
|
|
287
|
+
"bb.empty.hint": "在左侧课程树选择一课",
|
|
288
|
+
"bb.mastery": "掌握度 {pct}%",
|
|
289
|
+
"cmap.legend": "琥珀色 = 薄弱知识点(掌握度 <70%)",
|
|
290
|
+
"cmap.node.pct": "掌握度 {pct}%",
|
|
291
|
+
"bb.strategy": "本课学法",
|
|
292
|
+
"bb.fallback.cmap.empty": "概念图需要先由导师定义本课概念",
|
|
293
|
+
"bb.fallback.cmap": "概念图渲染不可用(布局引擎加载失败) — 已回退讲解视图",
|
|
294
|
+
"bb.notes": "笔记",
|
|
295
|
+
"bb.notes.empty": "这一课还没有笔记",
|
|
296
|
+
"tv.exam.stars": "本次 {n}★(历史最佳 {best}★)",
|
|
297
|
+
"tv.exam.fail": "这轮没拿到星——错题就是下一课的地图,从每题的解析看起。",
|
|
298
|
+
"active.off.title": "注册 study 工具并载入导师人格,之后普通对话和现在一样",
|
|
299
|
+
"active.on.title": "注销 study 工具与导师人格;学习进度保留,可随时重新开启",
|
|
300
|
+
"active.off": "▶ 开始学习",
|
|
301
|
+
"active.on": "⏻ 退出学习模式",
|
|
302
|
+
"prompt.kickoff": "开始学习:查看我的学习状态并打开当前焦点课时。如果我还没有课程,推荐我导入示例课程(AI-For-Beginners)并说明怎么开始。回复最后请提醒我点上方「学习」页签进入学习界面。",
|
|
303
|
+
"settings.nav": "学习",
|
|
304
|
+
"settings.mode": "教学风格",
|
|
305
|
+
"settings.mode.hint": "导师的讲解风格;也可在学习页随时切换",
|
|
306
|
+
"settings.studyMode": "学习模式",
|
|
307
|
+
"settings.voice": "朗读语音",
|
|
308
|
+
"settings.voice.hint": "「朗读本课」使用的神经网络声音(浏览器本地偏好)",
|
|
309
|
+
"voice.xiaoxiao": "晓晓 · 中文女声",
|
|
310
|
+
"voice.yunxi": "云希 · 中文男声",
|
|
311
|
+
"voice.yunyang": "云扬 · 中文男声(新闻)",
|
|
312
|
+
"voice.xiaoyi": "晓伊 · 中文女声(轻快)",
|
|
313
|
+
"voice.aria": "Aria · English female",
|
|
314
|
+
"voice.guy": "Guy · English male",
|
|
315
|
+
"settings.studyMode.hint": "开启后注册 study_* 工具并载入导师人格;进度与笔记始终保留",
|
|
316
|
+
"settings.on": "状态:已开启",
|
|
317
|
+
"settings.off": "状态:已关闭",
|
|
318
|
+
"settings.turnOn": "开启",
|
|
319
|
+
"settings.turnOff": "关闭",
|
|
320
|
+
"settings.stats": "学习统计",
|
|
321
|
+
"settings.stats.courses": "课程 {count} 个",
|
|
322
|
+
"settings.stats.xp": "XP {xp} · Lv{level}({pct}%)",
|
|
323
|
+
"settings.stats.today": "今日 {xp}/{goal}",
|
|
324
|
+
"settings.stats.streak": "连续 {days} 天(最长 {best},剩冻结 {freeze})",
|
|
325
|
+
"settings.about": "关于",
|
|
326
|
+
"settings.about.hint": "当前运行的构建版本,点击查看该版更新内容。",
|
|
327
|
+
"settings.stateFile": "状态文件",
|
|
328
|
+
"settings.stateFile.hint": "学习进度存放于本机 JSON,换机可迁移",
|
|
329
|
+
"dock.title": "学习状态:待复习 {due} · 连续 {streak} 天 · Lv{level} — 点上方「学习」页签进入",
|
|
330
|
+
"dock.due": "{count}",
|
|
331
|
+
"dock.streak": "{days}天",
|
|
332
|
+
"dock.lv": "Lv{level}",
|
|
333
|
+
"prompt.import": "导入课程:用 study_import_github 抓取 {url}",
|
|
334
|
+
"prompt.import.url": "导入课程:用 study_import_url 导入这个链接 {url}(GitHub 仓库会自动走仓库导入)。",
|
|
335
|
+
"prompt.import.md": "把下面的内容用 study_import_markdown 导入为课程{title}:\n\n{md}",
|
|
336
|
+
"prompt.import.folder": "导入课程:用 study_import_folder 扫描本地文件夹 {path}{title},按它的设计简报完成课程设计并落库。",
|
|
337
|
+
"prompt.import.epub": "导入课程:用 study_import_folder 扫描电子书所在的文件夹 {path}(其中有电子书 {file},章节会被拆成课时){title},完成设计并落库。",
|
|
338
|
+
"prompt.lesson": "学习「{title}」:用 study_lesson 打开这一课开始学习。",
|
|
339
|
+
"prompt.exam": "开始「{section}」的章节测验:按本节课时出题,答完逐题判分",
|
|
340
|
+
"artifact.zoomOut": "缩小",
|
|
341
|
+
"artifact.zoomIn": "放大",
|
|
342
|
+
"artifact.canvas.fit": "适屏",
|
|
343
|
+
"viewtab.board": "黑板",
|
|
344
|
+
"bb.board.empty": "导师产出的大幅内容(对比表/图表/代码走查)会登上这块黑板——可缩放、可拖动、双击适屏。",
|
|
345
|
+
"exam.generating.title": "正在准备章节测验",
|
|
346
|
+
"exam.generating.stage": "导师正在按本节知识点出题",
|
|
347
|
+
"exam.generating.kcProgress": "{done}/{total} 个知识点已出题",
|
|
348
|
+
"exam.generating.preparing": "正在规划题量…",
|
|
349
|
+
"exam.generating.canLeave": "生成在后台进行——可以切去别的课,回来接着看。",
|
|
350
|
+
"exam.errorTitle": "出题失败",
|
|
351
|
+
"exam.errorEmpty": "没有拿到题库。",
|
|
352
|
+
"exam.failed.retry": "重新生成",
|
|
353
|
+
"exam.ready.title": "章节测验就绪",
|
|
354
|
+
"exam.ready.meta": "{q} 题 · {kc} 个知识点 · 预计 {min} 分钟",
|
|
355
|
+
"exam.start": "开始考试",
|
|
356
|
+
"exam.regenerate": "重新出题",
|
|
357
|
+
"exam.regenerate.confirmMsg": "重新出题会换掉整份题库(历史成绩保留)。",
|
|
358
|
+
"exam.q.progress": "第 {i}/{n} 题",
|
|
359
|
+
"exam.q.next": "下一题",
|
|
360
|
+
"exam.q.submit": "交卷",
|
|
361
|
+
"exam.submitting": "正在批改…",
|
|
362
|
+
"exam.result.score": "{correct}/{total} 题",
|
|
363
|
+
"exam.result.accuracy": "正确率 {pct}%",
|
|
364
|
+
"exam.result.best": "历史最佳 {stars}★",
|
|
365
|
+
"exam.result.attempts": "已考 {n} 次",
|
|
366
|
+
"exam.terminated.banner": "本场考试中途离开——未答的题按错计。",
|
|
367
|
+
"exam.result.kcBreakdown": "按知识点",
|
|
368
|
+
"exam.result.weakKc": "薄弱",
|
|
369
|
+
"exam.result.review": "逐题回顾",
|
|
370
|
+
"exam.result.unanswered": "未作答",
|
|
371
|
+
"exam.result.correctLabel": "答对了",
|
|
372
|
+
"exam.result.yourAnswer": "你的答案",
|
|
373
|
+
"exam.result.correctAnswer": "正确答案",
|
|
374
|
+
"exam.retryExam": "重新考试",
|
|
375
|
+
"exam.leave.title": "离开这场考试?",
|
|
376
|
+
"exam.leave.message": "考试进行中——现在离开,未答的题将按错计分。",
|
|
377
|
+
"exam.leave.confirm": "离开并结算",
|
|
378
|
+
"exam.leave.cancel": "继续答题",
|
|
379
|
+
"prompt.exam.bank": "请为「{section}」的章节测验出题:先用 study_view 读一遍本节课时,再按各课时的知识点用 study_exam_bank_apply 一次性提交整份题库(单选 5-15 题,kcTitle 必须用本节的概念标题,每题附一句话解析)。题库就绪后告诉我可以开考。",
|
|
380
|
+
"prompt.review": "开始今天的复习,从最到期的课时开始",
|
|
381
|
+
"prompt.proposal.accept": "接受提案 {id} —— 确认标记这课为已掌握",
|
|
382
|
+
"prompt.proposal.decline": "拒绝提案 {id} —— 我想再练练"
|
|
383
|
+
};
|
|
384
|
+
/** English dictionary — key set must equal {@link ZH}'s (test-asserted). */
|
|
385
|
+
const EN = {
|
|
386
|
+
"tab.label": "Study",
|
|
387
|
+
"col.rail": "Courses",
|
|
388
|
+
"col.tutor": "Tutor",
|
|
389
|
+
"col.bb": "Blackboard",
|
|
390
|
+
"pane.rail": "Courses",
|
|
391
|
+
"pane.chat": "Chat",
|
|
392
|
+
"pane.note": "Board",
|
|
393
|
+
"viewtab.teach": "Teach",
|
|
394
|
+
"viewtab.cmap": "Concepts",
|
|
395
|
+
"viewtab.cmap.title": "The lesson's concepts relate",
|
|
396
|
+
"loading": "Loading…",
|
|
397
|
+
"start.title": "One click to study: workspace + session + tutor, all set up",
|
|
398
|
+
"start.go": "Start learning",
|
|
399
|
+
"start.enter": "Enter study",
|
|
400
|
+
"start.busy": "Preparing the study area…",
|
|
401
|
+
"soul.direct": "Direct",
|
|
402
|
+
"soul.direct.hint": "Explain first, then check understanding",
|
|
403
|
+
"soul.guide": "Guide",
|
|
404
|
+
"soul.guide.hint": "You push one step forward; the tutor hands you the stairs",
|
|
405
|
+
"soul.practice": "Practice",
|
|
406
|
+
"soul.practice.hint": "Learn inside real, messy problems",
|
|
407
|
+
"zone.understand": "🧠 Understand — knowledge structure",
|
|
408
|
+
"zone.record": "📝 Record — in my own words",
|
|
409
|
+
"zone.practice": "✍️ Practice — answer log",
|
|
410
|
+
"rail.empty.title": "No courses yet",
|
|
411
|
+
"rail.empty.hint": "Paste markdown → say \"import as a course\"",
|
|
412
|
+
"rail.empty.hint2": "Local folder → say \"import D:/path/to/folder\"",
|
|
413
|
+
"rail.empty.placeholder": "GitHub repo link, e.g. microsoft/AI-For-Beginners",
|
|
414
|
+
"rail.empty.button": "Import",
|
|
415
|
+
"composer.send": "Send",
|
|
416
|
+
"quiz.card.title": "Practice",
|
|
417
|
+
"quiz.progress": "{cur}/{total}",
|
|
418
|
+
"quiz.next": "Next",
|
|
419
|
+
"quiz.finish": "See score",
|
|
420
|
+
"quiz.score": "Score: {correct}/{total}",
|
|
421
|
+
"quiz.right": "Correct",
|
|
422
|
+
"quiz.wrong": "Wrong",
|
|
423
|
+
"quiz.youChose": "You chose",
|
|
424
|
+
"quiz.answer": "Answer",
|
|
425
|
+
"quiz.hook": "Practice card finished: {correct}/{total} correct. Please comment briefly and walk through the wrong ones.",
|
|
426
|
+
"quiz.action.explain-wrong": "Explain my wrong answers",
|
|
427
|
+
"quiz.action.retry": "Another set",
|
|
428
|
+
"quiz.action.go-deeper": "Go deeper",
|
|
429
|
+
"quiz.action.mark-mastered": "Mark me as mastered",
|
|
430
|
+
"quiz.action.markMastered.hint": "Asks the tutor to verify with a final question, then marks mastery",
|
|
431
|
+
"quiz.action.next-topic": "Next topic",
|
|
432
|
+
"artifact.lines": "Lines {from}-{to}: ",
|
|
433
|
+
"artifact.expand": "Expand",
|
|
434
|
+
"artifact.guess.title": "Take a guess",
|
|
435
|
+
"artifact.guess.pick": "I pick \"{label}\"",
|
|
436
|
+
"artifact.guess.wait": "Your instinct is noted — the tutor reveals next turn",
|
|
437
|
+
"artifact.sedimented": "A new artifact settled into the notebook",
|
|
438
|
+
"zone.artifacts": "AI artifacts",
|
|
439
|
+
"note.quote.ask": "Ask about this",
|
|
440
|
+
"note.quote.save": "Add note",
|
|
441
|
+
"note.quote.template": "Please explain this passage: \"{text}\"",
|
|
442
|
+
"note.saved": "Saved to the record zone",
|
|
443
|
+
"review.rate.title": "Review self-check — how well do you remember?",
|
|
444
|
+
"review.again": "Again",
|
|
445
|
+
"review.remembered": "Remembered",
|
|
446
|
+
"review.mastered": "Mastered",
|
|
447
|
+
"review.done.good": "Next review scheduled",
|
|
448
|
+
"review.done.again": "Back again tomorrow",
|
|
449
|
+
"review.jump": "Open this lesson to review",
|
|
450
|
+
"review.nudge": "{n} lessons are due for review — come practice first?",
|
|
451
|
+
"review.nudge.go": "Review now",
|
|
452
|
+
"rail.search.jump": "Open this lesson",
|
|
453
|
+
"proposal.banner": "{lesson} is ready to graduate early",
|
|
454
|
+
"proposal.accept": "Accept",
|
|
455
|
+
"proposal.decline": "Keep practicing",
|
|
456
|
+
"proposal.accept.msg": "I accept the mastery proposal for \"{lesson}\" — mark it mastered.",
|
|
457
|
+
"proposal.decline.msg": "I want more practice on \"{lesson}\" before graduating.",
|
|
458
|
+
"tutor.thinking": "The tutor is thinking…",
|
|
459
|
+
"header.xp": "{xp} XP",
|
|
460
|
+
"header.streak": "Day streak",
|
|
461
|
+
"header.level": "Level",
|
|
462
|
+
"map.node.locked": "Locked — finish the previous lessons first",
|
|
463
|
+
"map.node.due": "Due for review",
|
|
464
|
+
"map.exam.locked": "Exam locked — every lesson in this section needs ≥50% mastery",
|
|
465
|
+
"map.tab.map": "Map",
|
|
466
|
+
"map.tab.import": "Import",
|
|
467
|
+
"map.world.study": "Learn",
|
|
468
|
+
"map.world.practice": "Practice",
|
|
469
|
+
"map.empty.practice": "No practice content in this course yet",
|
|
470
|
+
"map.streaming.notice": "The tutor is responding…",
|
|
471
|
+
"thread.streamingBadge": "AI is responding",
|
|
472
|
+
"map.search.label": "Search",
|
|
473
|
+
"map.review.label": "Review",
|
|
474
|
+
"map.overlay.close": "Close",
|
|
475
|
+
"chat.start": "Start learning",
|
|
476
|
+
"chat.start.hint": "Begin with this lesson’s first starter move",
|
|
477
|
+
"prompt.start": "Hi — let’s start this lesson: give me a two-sentence tour, then begin with the first concept.",
|
|
478
|
+
"mode.label": "Style: ",
|
|
479
|
+
"rail.deleted": "Course deleted",
|
|
480
|
+
"review.random": "Random review",
|
|
481
|
+
"review.random.hint": "Jump to a random due lesson (interleaved review)",
|
|
482
|
+
"scroll.bottom": "Back to bottom",
|
|
483
|
+
"chat.stop": "Stop generating",
|
|
484
|
+
"quiz.submit": "Submit",
|
|
485
|
+
"proposal.applied": "Graduation proposal for \"{lesson}\" accepted",
|
|
486
|
+
"proposal.rejected": "Proposal for \"{lesson}\" declined — keep practicing",
|
|
487
|
+
"companion.poke": "Poke the companion",
|
|
488
|
+
"companion.poked": "The companion winks at you",
|
|
489
|
+
"companion.form.ember": "Ember",
|
|
490
|
+
"companion.form.frost": "Frost",
|
|
491
|
+
"companion.form.moss": "Moss",
|
|
492
|
+
"companion.form.star": "Stardust",
|
|
493
|
+
"companion.form.ink": "Ink",
|
|
494
|
+
"settings.companion": "Companion",
|
|
495
|
+
"settings.companion.hint": "Choose the companion creature in the study panel corner.",
|
|
496
|
+
"quiz.msg.explain-wrong": "I got some questions wrong — walk me through why and the right line of thinking.",
|
|
497
|
+
"quiz.msg.retry": "Give me another similar set to consolidate.",
|
|
498
|
+
"quiz.msg.go-deeper": "I did well on this set — go deeper into the principles and common confusions.",
|
|
499
|
+
"quiz.msg.mark-mastered": "I feel I have mastered this lesson — verify me with a final question and mark it mastered if I pass.",
|
|
500
|
+
"quiz.msg.next-topic": "Move on to the next topic.",
|
|
501
|
+
"toast.region": "Notifications",
|
|
502
|
+
"toast.close": "Close",
|
|
503
|
+
"composer.busy": "The tutor is replying…",
|
|
504
|
+
"rail.empty.demo": "Import the demo course",
|
|
505
|
+
"import.cta": "Import a new course",
|
|
506
|
+
"import.tab.url": "URL",
|
|
507
|
+
"import.tab.md": "MD",
|
|
508
|
+
"import.tab.folder": "Folder",
|
|
509
|
+
"import.tab.epub": "eBook",
|
|
510
|
+
"import.tab.pack": "Pack",
|
|
511
|
+
"import.url.desc": "A GitHub repo, arXiv paper, or web-article link",
|
|
512
|
+
"import.btn.url": "Import",
|
|
513
|
+
"import.placeholder.name": "Course title (optional)",
|
|
514
|
+
"import.placeholder.md": "Paste markdown: ## becomes a section, ### a lesson",
|
|
515
|
+
"import.btn.md": "Import as course",
|
|
516
|
+
"import.folder.desc": "A local folder path (a repo of markdown/ebooks/code)",
|
|
517
|
+
"import.folder.pathPlaceholder": "D:/path/to/folder",
|
|
518
|
+
"import.btn.folder": "Import folder",
|
|
519
|
+
"import.epub.desc": "An EPUB file path (imports its folder; chapters split into lessons)",
|
|
520
|
+
"import.epub.pathPlaceholder": "D:/books/my-book.epub",
|
|
521
|
+
"import.btn.epub": "Import eBook",
|
|
522
|
+
"import.pack.desc": "A learning pack (.md exported by study_export) — pick the file",
|
|
523
|
+
"import.btn.pack": "Pick a course-pack file",
|
|
524
|
+
"import.progress.title": "Importing course…",
|
|
525
|
+
"import.progress.starting": "Starting…",
|
|
526
|
+
"import.progress.note": "The import runs in the tutor conversation — keep browsing other courses",
|
|
527
|
+
"import.progress.cancel": "Cancel import",
|
|
528
|
+
"import.progress.elapsed": "{s}s elapsed",
|
|
529
|
+
"import.step.fetch": "Inventory",
|
|
530
|
+
"import.step.design": "Design structure",
|
|
531
|
+
"import.step.apply": "Build course",
|
|
532
|
+
"import.step.done": "Done",
|
|
533
|
+
"import.cancelled": "Import cancelled",
|
|
534
|
+
"import.error": "Import failed: {msg}",
|
|
535
|
+
"import.error.turn": "The turn ended without a new course — check the tutor reply",
|
|
536
|
+
"import.success": "Imported",
|
|
537
|
+
"chat.copy": "Copy",
|
|
538
|
+
"chat.copied": "Copied",
|
|
539
|
+
"error.renderFailed": "This content failed to render (possibly malformed).",
|
|
540
|
+
"error.retry": "Retry",
|
|
541
|
+
"note.renderFailed": "This content failed to render.",
|
|
542
|
+
"note.truncated": "(truncated)",
|
|
543
|
+
"note.retryRender": "Retry render",
|
|
544
|
+
"zoom.in": "Increase panel font size",
|
|
545
|
+
"zoom.out": "Decrease panel font size",
|
|
546
|
+
"palette.label": "Command palette",
|
|
547
|
+
"palette.placeholder": "Search lessons/courses or pick an action…",
|
|
548
|
+
"palette.lessons": "Lesson",
|
|
549
|
+
"palette.courses": "Course",
|
|
550
|
+
"palette.review": "Start review",
|
|
551
|
+
"palette.study": "Start studying",
|
|
552
|
+
"palette.empty": "No matches",
|
|
553
|
+
"threads.label": "Lesson threads",
|
|
554
|
+
"meter.label": "Context usage",
|
|
555
|
+
"meter.estimate": "~{n} tok (char-volume estimate)",
|
|
556
|
+
"model.unknown": "Current model",
|
|
557
|
+
"model.switched": "Switched to {model}",
|
|
558
|
+
"model.switchFailed": "Switch failed — check the host model settings",
|
|
559
|
+
"settings.budget": "History budget",
|
|
560
|
+
"settings.budget.hint": "When on, the tutor summarizes older turns in long threads (upstream v0.27 history-budget, tutor-layer execution)",
|
|
561
|
+
"settings.budget.on": "Enable history budget",
|
|
562
|
+
"settings.budget.off": "Disable history budget",
|
|
563
|
+
"attach.sent": "I attached a file: {name} (workspace path {path}) — read it before continuing.",
|
|
564
|
+
"attach.fail": "Attachment upload failed",
|
|
565
|
+
"attach.label": "Attach (≤20MiB, lands in the study workspace)",
|
|
566
|
+
"rail.mastered": "{mastered}/{total} lessons graduated",
|
|
567
|
+
"rail.avg": "avg mastery {pct}%",
|
|
568
|
+
"rail.avg.none": "no mastery data yet",
|
|
569
|
+
"rail.search": "Search lessons… (space-separated keywords)",
|
|
570
|
+
"rail.locate.title": "Locate the current focus lesson in the tree (auto-expands its section)",
|
|
571
|
+
"rail.locate": "Back to current lesson",
|
|
572
|
+
"rail.due": "{count} due",
|
|
573
|
+
"rail.due.over": "{days}d overdue",
|
|
574
|
+
"rail.due.start": "Start reviewing",
|
|
575
|
+
"rail.due.none": "Nothing due right now — keep the streak!",
|
|
576
|
+
"chat.reasoning": "Thinking ({n} chars)",
|
|
577
|
+
"msg.speak": "Speak",
|
|
578
|
+
"note.pin": "Pin note",
|
|
579
|
+
"note.unpin": "Unpin",
|
|
580
|
+
"note.locate": "Back to source",
|
|
581
|
+
"note.edit": "Edit note",
|
|
582
|
+
"note.save": "Save",
|
|
583
|
+
"confirm.cancel": "Cancel",
|
|
584
|
+
"rail.due.tag": "this lesson's review is due today (SM-2)",
|
|
585
|
+
"rail.delete": "Delete this course",
|
|
586
|
+
"rail.delete.confirm": "Delete?",
|
|
587
|
+
"rail.delete.title.confirm": "Click again to confirm (all progress and notes included). Back up via the state file in Settings first",
|
|
588
|
+
"note.delete": "Delete this note",
|
|
589
|
+
"note.delete.confirm": "Delete?",
|
|
590
|
+
"read.play": "Read aloud",
|
|
591
|
+
"read.stop": "Stop reading",
|
|
592
|
+
"read.pause": "Pause reading",
|
|
593
|
+
"read.resume": "Resume reading",
|
|
594
|
+
"read.engine.system": "Network synthesis unavailable — switched to the system voice",
|
|
595
|
+
"read.unavailable": "Read-aloud unavailable",
|
|
596
|
+
"rail.section.collapse": "Collapse this section",
|
|
597
|
+
"rail.section.expand": "Expand this section ({count} lessons)",
|
|
598
|
+
"rail.section.count": "{count} lessons",
|
|
599
|
+
"rail.lesson.opening": "Opening the lesson session…",
|
|
600
|
+
"rail.lesson.openHint": " (opens this lesson's own session)",
|
|
601
|
+
"rail.import.toggle": "Import a course",
|
|
602
|
+
"rail.import.close": "Collapse import",
|
|
603
|
+
"tag.weak": "{count} weak concepts — quizzes target them first",
|
|
604
|
+
"tag.friction": "{count} friction marks (logged when you said you were stuck)",
|
|
605
|
+
"tag.mastery": "lesson mastery {pct}% (the weakest concept)",
|
|
606
|
+
"tag.mastery.short": "lesson mastery = the weakest concept's mastery",
|
|
607
|
+
"status.exam": "Section exam: opens when every lesson in the section reaches ≥50% mastery",
|
|
608
|
+
"status.mastered": "Graduated: mastery ≥90% (or you accepted a mastery proposal)",
|
|
609
|
+
"status.in_progress": "Learning: opened, mastery starts at 50%",
|
|
610
|
+
"status.available": "Ready: unlocked, not started",
|
|
611
|
+
"status.locked": "Locked: finish the preceding lessons first",
|
|
612
|
+
"chip.unattributed": "unattributed",
|
|
613
|
+
"chip.correct": "✓ correct · {concept}",
|
|
614
|
+
"chip.wrong": "✗ wrong · {concept}",
|
|
615
|
+
"row.thinking": "tutor is thinking…",
|
|
616
|
+
"row.thinking.title": "The tutor is reasoning; the reply is coming",
|
|
617
|
+
"quiz.title": "Click an option to answer, or just type",
|
|
618
|
+
"quiz.answer": "Choose {letter}: {text}",
|
|
619
|
+
"tutor.dormant": "Study mode is off — click \"▶ Start learning\" above to wake the tutor and re-enable these controls.",
|
|
620
|
+
"tutor.empty": "The conversation will appear here",
|
|
621
|
+
"tutor.empty.hint": "Talk to the tutor in the input below",
|
|
622
|
+
"proposal.text": "The tutor proposes you have mastered \"{lesson}\": {rationale}",
|
|
623
|
+
"proposal.accept": "Accept",
|
|
624
|
+
"proposal.decline": "Keep practicing",
|
|
625
|
+
"bb.empty": "The blackboard is empty",
|
|
626
|
+
"bb.empty.hint": "Pick a lesson in the course tree on the left",
|
|
627
|
+
"bb.mastery": "mastery {pct}%",
|
|
628
|
+
"cmap.legend": "Amber = weak concept (mastery < 70%)",
|
|
629
|
+
"cmap.node.pct": "mastery {pct}%",
|
|
630
|
+
"bb.strategy": "Approach",
|
|
631
|
+
"bb.fallback.cmap.empty": "The concept map needs the tutor to define this lesson's concepts first",
|
|
632
|
+
"bb.fallback.cmap": "Concept map unavailable (layout engine failed to load) — fell back to the teach view",
|
|
633
|
+
"bb.notes": "Notes",
|
|
634
|
+
"bb.notes.empty": "No notes for this lesson yet",
|
|
635
|
+
"tv.exam.stars": "This attempt {n}★ (best {best}★)",
|
|
636
|
+
"tv.exam.fail": "No stars this round — the wrong answers are the map for the next one; start with their explanations.",
|
|
637
|
+
"active.off.title": "Register the study tools and load the tutor persona; plain chats stay as they are",
|
|
638
|
+
"active.on.title": "Retire the study tools and tutor persona; progress is kept and you can re-enable anytime",
|
|
639
|
+
"active.off": "▶ Start learning",
|
|
640
|
+
"active.on": "⏻ Exit study mode",
|
|
641
|
+
"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.",
|
|
642
|
+
"settings.nav": "Study",
|
|
643
|
+
"settings.mode": "Teaching style",
|
|
644
|
+
"settings.mode.hint": "The tutor's explanation style; switchable anytime in the study tab too",
|
|
645
|
+
"settings.studyMode": "Study mode",
|
|
646
|
+
"settings.voice": "Read-aloud voice",
|
|
647
|
+
"settings.voice.hint": "The neural voice used by 朗读本课 (a browser-local preference)",
|
|
648
|
+
"voice.xiaoxiao": "Xiaoxiao · Chinese female",
|
|
649
|
+
"voice.yunxi": "Yunxi · Chinese male",
|
|
650
|
+
"voice.yunyang": "Yunyang · Chinese male (news)",
|
|
651
|
+
"voice.xiaoyi": "Xiaoyi · Chinese female (lively)",
|
|
652
|
+
"voice.aria": "Aria · English female",
|
|
653
|
+
"voice.guy": "Guy · English male",
|
|
654
|
+
"settings.studyMode.hint": "On registers the study_* tools and the tutor persona; progress and notes are always kept",
|
|
655
|
+
"settings.on": "Status: on",
|
|
656
|
+
"settings.off": "Status: off",
|
|
657
|
+
"settings.turnOn": "Turn on",
|
|
658
|
+
"settings.turnOff": "Turn off",
|
|
659
|
+
"settings.stats": "Study stats",
|
|
660
|
+
"settings.stats.courses": "{count} courses",
|
|
661
|
+
"settings.stats.xp": "XP {xp} · Lv{level} ({pct}%)",
|
|
662
|
+
"settings.stats.today": "Today {xp}/{goal}",
|
|
663
|
+
"settings.stats.streak": "Streak {days}d (best {best}, {freeze} freeze left)",
|
|
664
|
+
"settings.about": "About",
|
|
665
|
+
"settings.about.hint": "The version of the running build — click to see its release notes.",
|
|
666
|
+
"settings.stateFile": "State file",
|
|
667
|
+
"settings.stateFile.hint": "Your progress lives in a local JSON file — copy it to migrate machines",
|
|
668
|
+
"dock.title": "Study status: {due} due · {streak}d streak · Lv{level} — open the Study tab above to continue",
|
|
669
|
+
"dock.due": "{count}",
|
|
670
|
+
"dock.streak": "{days}d",
|
|
671
|
+
"dock.lv": "Lv{level}",
|
|
672
|
+
"prompt.import": "Import a course: fetch {url} with study_import_github",
|
|
673
|
+
"prompt.import.url": "Import a course: import this link {url} with study_import_url (GitHub repos route to the repository import).",
|
|
674
|
+
"prompt.import.md": "Import the content below as a course with study_import_markdown{title}:\n\n{md}",
|
|
675
|
+
"prompt.import.folder": "Import a course: scan the local folder {path} with study_import_folder{title}, design the course from its brief, and apply it.",
|
|
676
|
+
"prompt.import.epub": "Import a course: scan the folder containing the eBook with study_import_folder ({path}, which contains {file} — chapters become lessons){title}, design and apply.",
|
|
677
|
+
"prompt.lesson": "Study \"{title}\": open this lesson with study_lesson and start teaching.",
|
|
678
|
+
"prompt.exam": "Start the section exam for \"{section}\": quiz me on this section's lessons and grade each answer",
|
|
679
|
+
"artifact.zoomOut": "Zoom out",
|
|
680
|
+
"artifact.zoomIn": "Zoom in",
|
|
681
|
+
"artifact.canvas.fit": "Fit to screen",
|
|
682
|
+
"viewtab.board": "Board",
|
|
683
|
+
"bb.board.empty": "Large tutor outputs (tables, diagrams, code walkthroughs) land on this board — zoom, pan, double-click to fit.",
|
|
684
|
+
"exam.generating.title": "Preparing your exam",
|
|
685
|
+
"exam.generating.stage": "Your tutor is writing questions by concept",
|
|
686
|
+
"exam.generating.kcProgress": "{done}/{total} concepts covered",
|
|
687
|
+
"exam.generating.preparing": "Planning the question set…",
|
|
688
|
+
"exam.generating.canLeave": "Generation runs in the background — you can leave and come back.",
|
|
689
|
+
"exam.errorTitle": "Exam generation failed",
|
|
690
|
+
"exam.errorEmpty": "No question bank arrived.",
|
|
691
|
+
"exam.failed.retry": "Regenerate",
|
|
692
|
+
"exam.ready.title": "Exam ready",
|
|
693
|
+
"exam.ready.meta": "{q} questions · {kc} concepts · ~{min} min",
|
|
694
|
+
"exam.start": "Start exam",
|
|
695
|
+
"exam.regenerate": "New questions",
|
|
696
|
+
"exam.regenerate.confirmMsg": "This replaces the whole bank (past results are kept).",
|
|
697
|
+
"exam.q.progress": "Question {i}/{n}",
|
|
698
|
+
"exam.q.next": "Next",
|
|
699
|
+
"exam.q.submit": "Submit",
|
|
700
|
+
"exam.submitting": "Grading…",
|
|
701
|
+
"exam.result.score": "{correct}/{total} correct",
|
|
702
|
+
"exam.result.accuracy": "{pct}% accuracy",
|
|
703
|
+
"exam.result.best": "Best {stars}★",
|
|
704
|
+
"exam.result.attempts": "{n} attempts",
|
|
705
|
+
"exam.terminated.banner": "You left mid-exam — unanswered questions counted wrong.",
|
|
706
|
+
"exam.result.kcBreakdown": "By concept",
|
|
707
|
+
"exam.result.weakKc": "weak",
|
|
708
|
+
"exam.result.review": "Review",
|
|
709
|
+
"exam.result.unanswered": "Unanswered",
|
|
710
|
+
"exam.result.correctLabel": "Correct",
|
|
711
|
+
"exam.result.yourAnswer": "Your answer",
|
|
712
|
+
"exam.result.correctAnswer": "Correct answer",
|
|
713
|
+
"exam.retryExam": "Retry exam",
|
|
714
|
+
"exam.leave.title": "Leave this exam?",
|
|
715
|
+
"exam.leave.message": "The exam is in progress — leaving now counts unanswered questions as wrong.",
|
|
716
|
+
"exam.leave.confirm": "Leave & settle",
|
|
717
|
+
"exam.leave.cancel": "Keep answering",
|
|
718
|
+
"prompt.exam.bank": "Author the section exam bank for \"{section}\": read this section's lessons with study_view, then submit the full multiple-choice set in one study_exam_bank_apply call (5-15 single-choice questions, kcTitle must be one of the section's concept titles, one-line explanation each). Tell me when the bank is ready.",
|
|
719
|
+
"prompt.review": "Start today's review, from the most overdue lesson first",
|
|
720
|
+
"prompt.proposal.accept": "Accept proposal {id} — confirm marking this lesson as mastered",
|
|
721
|
+
"prompt.proposal.decline": "Reject proposal {id} — I want to practice more"
|
|
722
|
+
};
|
|
723
|
+
/**
|
|
724
|
+
* Pure translator with the service's semantics: own-locale dict → zh
|
|
725
|
+
* fallback → the key itself, `{name}` interpolation left untouched when the
|
|
726
|
+
* param is missing.
|
|
727
|
+
* @param locale - active locale id ('zh'/'en'; unknown ids fall back to zh).
|
|
728
|
+
* @returns a stable-shaped translate function.
|
|
729
|
+
*/
|
|
730
|
+
function makeT(locale) {
|
|
731
|
+
const dict = locale === "en" ? EN : ZH;
|
|
732
|
+
return (key, params) => {
|
|
733
|
+
const template = dict[key] ?? ZH[key] ?? key;
|
|
734
|
+
if (params === void 0) return template;
|
|
735
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match);
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
/** The module's active translator — zh until the view factory installs the live bind. */
|
|
739
|
+
let activeT = makeT("zh");
|
|
740
|
+
/**
|
|
741
|
+
* Translate through the active translator — the import every client module
|
|
742
|
+
* uses for user-visible copy. Pure helpers take an optional explicit `t`
|
|
743
|
+
* defaulting to the current active translator, evaluated per call.
|
|
744
|
+
*/
|
|
745
|
+
function tr(key, params) {
|
|
746
|
+
return activeT(key, params);
|
|
747
|
+
}
|
|
748
|
+
//#endregion
|
|
44
749
|
//#region src/markdown.ts
|
|
45
750
|
/**
|
|
46
751
|
* Server-side markdown → HTML for the study workbench's 讲解 view. Escapes
|
|
47
752
|
* every HTML character first, then renders a pragmatic GFM subset (headings,
|
|
48
753
|
* fenced code, inline code, bold/italic, links, lists, blockquotes, tables,
|
|
49
754
|
* hr, paragraphs). Lesson bodies are imported teaching material, so raw HTML
|
|
50
|
-
* never passes through.
|
|
755
|
+
* never passes through. Fenced code rides the shared CodeBlock card (D8:
|
|
756
|
+
* language chip + copy header — the same structure in the chat stream, the
|
|
757
|
+
* lesson prose, and notebook notes; the copy itself is the client's
|
|
758
|
+
* delegated handler).
|
|
51
759
|
* @module dsh-plugin-lookatstudy/markdown
|
|
52
760
|
*/
|
|
53
761
|
/** Escape all HTML-significant characters. */
|
|
@@ -117,7 +825,7 @@ function renderMarkdown(md) {
|
|
|
117
825
|
i++;
|
|
118
826
|
}
|
|
119
827
|
i++;
|
|
120
|
-
out.push(`<pre><code${lang === "" ? "" : ` class="lang-${lang}"`}>${code.join("\n")}</code></pre>`);
|
|
828
|
+
out.push(`<div class="lks-codeblock" data-testid="md-codeblock"><div class="lks-codeblock-head"><span class="lks-codeblock-lang">${lang === "" ? "code" : lang}</span><button type="button" class="lks-codeblock-copy" data-testid="md-copy" aria-label="${tr("chat.copy")}">${tr("chat.copy")}</button></div><pre><code${lang === "" ? "" : ` class="lang-${lang}"`}>${code.join("\n")}</code></pre></div>`);
|
|
121
829
|
continue;
|
|
122
830
|
}
|
|
123
831
|
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
@@ -1012,44 +1720,195 @@ function recordExamResult(state, lessonId, correct, total) {
|
|
|
1012
1720
|
};
|
|
1013
1721
|
}
|
|
1014
1722
|
/**
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
1019
|
-
* loud — open the lesson first (study_lesson does).
|
|
1020
|
-
* @param state - state to mutate.
|
|
1021
|
-
* @param lessonId - lesson to update.
|
|
1022
|
-
* @param correct - whether the learner answered correctly.
|
|
1023
|
-
* @param concept - concept title the question tested, when attributable.
|
|
1024
|
-
* @param now - current time.
|
|
1025
|
-
* @returns mastery transition, KC attribution, and progression effects.
|
|
1723
|
+
* ── Exam v2 (P12): the bank + attempt lifecycle, upstream exam-service
|
|
1724
|
+
* semantics on state.json instead of SQLite. Generation rides the tutor
|
|
1725
|
+
* (the panel asks, study_exam_bank_apply lands the validated bank); grading
|
|
1726
|
+
* is pure (unanswered = wrong, terminated attempts keep partial credit).
|
|
1026
1727
|
*/
|
|
1027
|
-
|
|
1728
|
+
/** The section's KC-title union (anti-hallucination anchor for bank applies). */
|
|
1729
|
+
function sectionKcTitles(ref) {
|
|
1730
|
+
const titles = /* @__PURE__ */ new Set();
|
|
1731
|
+
for (const lesson of ref.section.lessons) {
|
|
1732
|
+
if (lesson.kind === "exam") continue;
|
|
1733
|
+
for (const concept of lesson.concepts ?? []) titles.add(concept.title);
|
|
1734
|
+
}
|
|
1735
|
+
return titles;
|
|
1736
|
+
}
|
|
1737
|
+
/** Flip the bank to generating (a ready bank stays — regeneration resets it first). */
|
|
1738
|
+
function beginExamGeneration(state, lessonId) {
|
|
1028
1739
|
const ref = findLesson(state, lessonId);
|
|
1029
|
-
if (ref.lesson.
|
|
1030
|
-
if (ref.lesson.status === "
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
});
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1740
|
+
if (ref.lesson.kind !== "exam") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is not an exam node`);
|
|
1741
|
+
if (ref.lesson.examBank?.status === "ready") return;
|
|
1742
|
+
ref.lesson.examBank = {
|
|
1743
|
+
status: "generating",
|
|
1744
|
+
questions: []
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
/** Validate and land the tutor's question bank (anti-hallucination: KC titles must exist). */
|
|
1748
|
+
function applyExamBank(state, lessonId, questions, now) {
|
|
1749
|
+
const ref = findLesson(state, lessonId);
|
|
1750
|
+
if (ref.lesson.kind !== "exam") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is not an exam node`);
|
|
1751
|
+
if (!Array.isArray(questions) || questions.length < 5 || questions.length > 15) throw new Error(`lookatstudy-plugin: exam bank needs ${String(5)}-${String(15)} questions (planExamQuota on the section's KC union), got ${String(Array.isArray(questions) ? questions.length : "non-array")}`);
|
|
1752
|
+
const kcUnion = sectionKcTitles(ref);
|
|
1753
|
+
const bank = questions.map((q, i) => {
|
|
1754
|
+
if (typeof q?.prompt !== "string" || q.prompt.trim() === "") throw new Error(`lookatstudy-plugin: question ${String(i)} has an empty prompt`);
|
|
1755
|
+
if (!Array.isArray(q.options) || q.options.length < 2 || q.options.some((o) => typeof o !== "string" || o.trim() === "")) throw new Error(`lookatstudy-plugin: question ${String(i)} needs 2+ non-empty options`);
|
|
1756
|
+
if (!Number.isInteger(q.answer) || q.answer < 0 || q.answer >= q.options.length) throw new Error(`lookatstudy-plugin: question ${String(i)} answer index ${String(q.answer)} is out of range`);
|
|
1757
|
+
if (q.kcTitle !== void 0 && q.kcTitle !== null && q.kcTitle !== "" && !kcUnion.has(q.kcTitle)) throw new Error(`lookatstudy-plugin: question ${String(i)} kcTitle ${JSON.stringify(q.kcTitle)} is not in the section's concept union ${JSON.stringify([...kcUnion])} — read the section lessons with study_view before authoring`);
|
|
1758
|
+
return {
|
|
1759
|
+
id: `q${String(i)}`,
|
|
1760
|
+
prompt: q.prompt,
|
|
1761
|
+
options: q.options,
|
|
1762
|
+
answer: q.answer,
|
|
1763
|
+
kcTitle: q.kcTitle === void 0 || q.kcTitle === null || q.kcTitle === "" ? null : q.kcTitle,
|
|
1764
|
+
explanation: q.explanation === void 0 || q.explanation === null || q.explanation === "" ? null : q.explanation
|
|
1765
|
+
};
|
|
1766
|
+
});
|
|
1767
|
+
ref.lesson.examBank = {
|
|
1768
|
+
status: "ready",
|
|
1769
|
+
questions: bank,
|
|
1770
|
+
generatedAt: now.toISOString()
|
|
1771
|
+
};
|
|
1772
|
+
return {
|
|
1773
|
+
questionCount: bank.length,
|
|
1774
|
+
kcCount: new Set(bank.map((q) => q.kcTitle).filter((k) => k !== null)).size
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
/** Drop the bank back to idle (regenerate; history snapshots stay self-contained). */
|
|
1778
|
+
function regenerateExamBank(state, lessonId) {
|
|
1779
|
+
const ref = findLesson(state, lessonId);
|
|
1780
|
+
if (ref.lesson.kind !== "exam") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is not an exam node`);
|
|
1781
|
+
ref.lesson.examBank = {
|
|
1782
|
+
status: "idle",
|
|
1783
|
+
questions: []
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
/** Settle every dangling attempt of the lesson (grade dead — unanswered = wrong). */
|
|
1787
|
+
function settleDanglingAttempts(state, lessonId) {
|
|
1788
|
+
const ref = findLesson(state, lessonId);
|
|
1789
|
+
for (const attempt of ref.lesson.examAttemptLog ?? []) if (attempt.finishedAt === null) submitExamAttempt(state, lessonId, attempt.id, true, /* @__PURE__ */ new Date());
|
|
1790
|
+
}
|
|
1791
|
+
/** Open a new attempt against the ready bank (dangling ones are graded dead first). */
|
|
1792
|
+
function startExamAttempt(state, lessonId, now) {
|
|
1793
|
+
const ref = findLesson(state, lessonId);
|
|
1794
|
+
if (ref.lesson.kind !== "exam") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is not an exam node`);
|
|
1795
|
+
if (ref.lesson.examBank?.status !== "ready") throw new Error(`lookatstudy-plugin: exam bank on ${JSON.stringify(lessonId)} is not ready (${String(ref.lesson.examBank?.status ?? "idle")})`);
|
|
1796
|
+
settleDanglingAttempts(state, lessonId);
|
|
1797
|
+
const log = ref.lesson.examAttemptLog ?? [];
|
|
1798
|
+
let n = log.length;
|
|
1799
|
+
while (log.some((a) => a.id === `${lessonId}:a${String(n)}`)) n++;
|
|
1800
|
+
const attempt = {
|
|
1801
|
+
id: `${lessonId}:a${String(n)}`,
|
|
1802
|
+
startedAt: now.toISOString(),
|
|
1803
|
+
finishedAt: null,
|
|
1804
|
+
answers: {},
|
|
1805
|
+
correctCount: null,
|
|
1806
|
+
totalCount: null,
|
|
1807
|
+
stars: null,
|
|
1808
|
+
terminated: false,
|
|
1809
|
+
perQuestion: null
|
|
1810
|
+
};
|
|
1811
|
+
ref.lesson.examAttemptLog = [...log, attempt];
|
|
1812
|
+
return {
|
|
1813
|
+
attemptId: attempt.id,
|
|
1814
|
+
questions: ref.lesson.examBank.questions
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
/** Persist one answer incrementally (open attempts only; original option index as string). */
|
|
1818
|
+
function recordExamAnswer(state, lessonId, attemptId, questionId, answer) {
|
|
1819
|
+
const ref = findLesson(state, lessonId);
|
|
1820
|
+
const attempt = ref.lesson.examAttemptLog?.find((a) => a.id === attemptId);
|
|
1821
|
+
if (attempt === void 0) throw new Error(`lookatstudy-plugin: exam attempt ${JSON.stringify(attemptId)} not found on ${JSON.stringify(lessonId)}`);
|
|
1822
|
+
if (attempt.finishedAt !== null) throw new Error(`lookatstudy-plugin: exam attempt ${JSON.stringify(attemptId)} already finished`);
|
|
1823
|
+
const question = ref.lesson.examBank?.questions.find((q) => q.id === questionId);
|
|
1824
|
+
if (question === void 0) throw new Error(`lookatstudy-plugin: exam question ${JSON.stringify(questionId)} not in the bank`);
|
|
1825
|
+
if (answer !== "" && (!/^\d+$/.test(answer) || Number(answer) >= question.options.length)) throw new Error(`lookatstudy-plugin: exam answer ${JSON.stringify(answer)} is not an option index of ${JSON.stringify(questionId)}`);
|
|
1826
|
+
attempt.answers = {
|
|
1827
|
+
...attempt.answers,
|
|
1828
|
+
[questionId]: answer
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
/** Grade and close an attempt: unanswered = wrong, snapshots self-contained, best-of stars kept. */
|
|
1832
|
+
function submitExamAttempt(state, lessonId, attemptId, terminated, now) {
|
|
1833
|
+
const ref = findLesson(state, lessonId);
|
|
1834
|
+
const attempt = ref.lesson.examAttemptLog?.find((a) => a.id === attemptId);
|
|
1835
|
+
if (attempt === void 0) throw new Error(`lookatstudy-plugin: exam attempt ${JSON.stringify(attemptId)} not found on ${JSON.stringify(lessonId)}`);
|
|
1836
|
+
if (attempt.finishedAt !== null) throw new Error(`lookatstudy-plugin: exam attempt ${JSON.stringify(attemptId)} already finished`);
|
|
1837
|
+
const bank = ref.lesson.examBank;
|
|
1838
|
+
const perQuestion = (bank?.status === "ready" ? bank.questions : []).map((q) => {
|
|
1839
|
+
const user = attempt.answers[q.id] ?? "";
|
|
1840
|
+
return {
|
|
1841
|
+
exerciseId: q.id,
|
|
1842
|
+
kcTitle: q.kcTitle,
|
|
1843
|
+
correct: user === String(q.answer),
|
|
1844
|
+
answered: user !== "",
|
|
1845
|
+
userAnswer: user,
|
|
1846
|
+
correctAnswer: String(q.answer),
|
|
1847
|
+
explanation: q.explanation,
|
|
1848
|
+
prompt: q.prompt,
|
|
1849
|
+
options: q.options
|
|
1850
|
+
};
|
|
1851
|
+
});
|
|
1852
|
+
const correctCount = perQuestion.filter((p) => p.correct).length;
|
|
1853
|
+
const totalCount = perQuestion.length;
|
|
1854
|
+
attempt.finishedAt = now.toISOString();
|
|
1855
|
+
attempt.correctCount = correctCount;
|
|
1856
|
+
attempt.totalCount = totalCount;
|
|
1857
|
+
attempt.stars = totalCount > 0 ? accuracyToStars(correctCount / totalCount) : 0;
|
|
1858
|
+
attempt.terminated = terminated;
|
|
1859
|
+
attempt.perQuestion = perQuestion;
|
|
1860
|
+
const prevBest = ref.lesson.examStars ?? -1;
|
|
1861
|
+
ref.lesson.examStars = Math.max(prevBest, attempt.stars);
|
|
1862
|
+
ref.lesson.examAttempts = (ref.lesson.examAttempts ?? 0) + 1;
|
|
1863
|
+
ref.lesson.lastAnsweredAt = now.toISOString();
|
|
1864
|
+
return {
|
|
1865
|
+
correctCount,
|
|
1866
|
+
totalCount,
|
|
1867
|
+
stars: attempt.stars,
|
|
1868
|
+
bestStars: ref.lesson.examStars,
|
|
1869
|
+
terminated,
|
|
1870
|
+
perQuestion
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* Record one graded answer against a lesson: attribute it to one knowledge
|
|
1875
|
+
* component when named, update BKT (per-KC, aggregated as the weakest),
|
|
1876
|
+
* nudge the SM-2 schedule when one exists, and apply mastery-driven
|
|
1877
|
+
* progression (early unlock at 0.5, graduation at 0.9). Locked lessons fail
|
|
1878
|
+
* loud — open the lesson first (study_lesson does).
|
|
1879
|
+
* @param state - state to mutate.
|
|
1880
|
+
* @param lessonId - lesson to update.
|
|
1881
|
+
* @param correct - whether the learner answered correctly.
|
|
1882
|
+
* @param concept - concept title the question tested, when attributable.
|
|
1883
|
+
* @param now - current time.
|
|
1884
|
+
* @returns mastery transition, KC attribution, and progression effects.
|
|
1885
|
+
*/
|
|
1886
|
+
function recordAnswer(state, lessonId, correct, concept, now) {
|
|
1887
|
+
const ref = findLesson(state, lessonId);
|
|
1888
|
+
if (ref.lesson.status === "locked") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is locked; open it with study_lesson first`);
|
|
1889
|
+
if (ref.lesson.status === "available") ref.lesson.status = "in_progress";
|
|
1890
|
+
const kcIndex = concept === void 0 ? void 0 : ref.lesson.concepts?.findIndex((c) => c.title === concept);
|
|
1891
|
+
if (concept !== void 0 && (ref.lesson.concepts === null || kcIndex === void 0 || kcIndex < 0)) throw new Error(`lookatstudy-plugin: unknown concept ${JSON.stringify(concept)} on lesson ${JSON.stringify(lessonId)} — define concepts with study_define_concepts first`);
|
|
1892
|
+
const prev = ref.lesson.mastery;
|
|
1893
|
+
if (ref.lesson.concepts !== null && kcIndex !== void 0) {
|
|
1894
|
+
const masteries = ref.lesson.conceptMastery ?? {};
|
|
1895
|
+
masteries[kcIndex] = updateMastery(masteries[kcIndex], correct);
|
|
1896
|
+
ref.lesson.conceptMastery = masteries;
|
|
1897
|
+
aggregateMastery(ref.lesson);
|
|
1898
|
+
} else if (ref.lesson.concepts !== null) {
|
|
1899
|
+
const masteries = ref.lesson.conceptMastery ?? {};
|
|
1900
|
+
ref.lesson.concepts.forEach((_, i) => {
|
|
1901
|
+
masteries[i] = updateMastery(masteries[i], correct);
|
|
1902
|
+
});
|
|
1903
|
+
ref.lesson.conceptMastery = masteries;
|
|
1904
|
+
aggregateMastery(ref.lesson);
|
|
1905
|
+
} else ref.lesson.mastery = updateMastery(prev, correct);
|
|
1906
|
+
ref.lesson.attempts += 1;
|
|
1907
|
+
if (correct) ref.lesson.correctCount += 1;
|
|
1908
|
+
ref.lesson.lastAnsweredAt = now.toISOString();
|
|
1909
|
+
if (ref.lesson.sm2 !== null) {
|
|
1910
|
+
const result = computeSm2(ref.lesson.sm2, correct ? 5 : 2, now);
|
|
1911
|
+
ref.lesson.sm2 = {
|
|
1053
1912
|
easeFactor: result.easeFactor,
|
|
1054
1913
|
intervalDays: result.intervalDays,
|
|
1055
1914
|
repetitions: result.repetitions
|
|
@@ -1219,6 +2078,22 @@ function deleteNote(state, lessonId, noteId) {
|
|
|
1219
2078
|
if (index === -1) throw new Error(`lookatstudy-plugin: note ${JSON.stringify(noteId)} not found on lesson ${JSON.stringify(lessonId)}`);
|
|
1220
2079
|
ref.lesson.notes.splice(index, 1);
|
|
1221
2080
|
}
|
|
2081
|
+
/** Edit a note's body text in place (C6 learner comment editing). */
|
|
2082
|
+
function editNote(state, lessonId, noteId, text) {
|
|
2083
|
+
const note = findLesson(state, lessonId).lesson.notes.find((n) => n.id === noteId);
|
|
2084
|
+
if (note === void 0) throw new Error(`lookatstudy-plugin: note ${JSON.stringify(noteId)} not found on lesson ${JSON.stringify(lessonId)}`);
|
|
2085
|
+
const trimmed = text.trim();
|
|
2086
|
+
if (trimmed === "") throw new Error("lookatstudy-plugin: note text cannot be empty");
|
|
2087
|
+
note.text = trimmed;
|
|
2088
|
+
return note;
|
|
2089
|
+
}
|
|
2090
|
+
/** Pin or unpin a note (pinned notes sort first within their zone). */
|
|
2091
|
+
function pinNote(state, lessonId, noteId, pinned) {
|
|
2092
|
+
const note = findLesson(state, lessonId).lesson.notes.find((n) => n.id === noteId);
|
|
2093
|
+
if (note === void 0) throw new Error(`lookatstudy-plugin: note ${JSON.stringify(noteId)} not found on lesson ${JSON.stringify(lessonId)}`);
|
|
2094
|
+
note.pinned = pinned;
|
|
2095
|
+
return note;
|
|
2096
|
+
}
|
|
1222
2097
|
/**
|
|
1223
2098
|
* Propose early mastery graduation for the learner to accept or reject in chat.
|
|
1224
2099
|
* @param state - state to mutate.
|
|
@@ -1606,6 +2481,7 @@ function pluginVersion() {
|
|
|
1606
2481
|
* @returns the page's data contract.
|
|
1607
2482
|
*/
|
|
1608
2483
|
function workbenchState(state, now) {
|
|
2484
|
+
const historyBudget = state.historyBudget === true;
|
|
1609
2485
|
const focusId = state.focus?.lessonId ?? null;
|
|
1610
2486
|
const dueIds = new Set(dueReviews(state, void 0, now).map((d) => d.lessonId));
|
|
1611
2487
|
const courses = state.courses.map((course) => {
|
|
@@ -1642,11 +2518,19 @@ function workbenchState(state, now) {
|
|
|
1642
2518
|
courseTitle: ref.course.title,
|
|
1643
2519
|
sectionTitle: ref.section.title,
|
|
1644
2520
|
title: ref.lesson.title,
|
|
2521
|
+
kind: ref.lesson.kind,
|
|
1645
2522
|
status: ref.lesson.status,
|
|
1646
2523
|
masteryPct: ref.lesson.mastery === null ? null : Math.round(ref.lesson.mastery * 100),
|
|
1647
2524
|
strategy: strategyBand(ref.lesson.mastery),
|
|
1648
2525
|
concepts: conceptViews(ref.lesson) ?? [],
|
|
1649
2526
|
due: dueIds.has(ref.lesson.id),
|
|
2527
|
+
exam: ref.lesson.kind === "exam" ? {
|
|
2528
|
+
status: ref.lesson.examBank?.status ?? "idle",
|
|
2529
|
+
questionCount: ref.lesson.examBank?.status === "ready" ? ref.lesson.examBank.questions.length : 0,
|
|
2530
|
+
kcCount: ref.lesson.examBank?.status === "ready" ? new Set(ref.lesson.examBank.questions.map((q) => q.kcTitle).filter((k) => k !== null)).size : 0,
|
|
2531
|
+
bestStars: ref.lesson.examStars ?? 0,
|
|
2532
|
+
attemptCount: ref.lesson.examAttempts ?? 0
|
|
2533
|
+
} : null,
|
|
1650
2534
|
artifacts: (state.artifacts[ref.lesson.id] ?? []).map((a) => ({
|
|
1651
2535
|
id: a.id,
|
|
1652
2536
|
artifactType: a.artifactType,
|
|
@@ -1663,7 +2547,8 @@ function workbenchState(state, now) {
|
|
|
1663
2547
|
title: n.title,
|
|
1664
2548
|
text: n.text,
|
|
1665
2549
|
source: n.source,
|
|
1666
|
-
quote: n.quote
|
|
2550
|
+
quote: n.quote,
|
|
2551
|
+
pinned: n.pinned === true
|
|
1667
2552
|
})),
|
|
1668
2553
|
html: renderMarkdown(normalizeMathNotation(ref.lesson.translation === void 0 ? ref.lesson.body : renderBilingual(ref.lesson.body, ref.lesson.translation))),
|
|
1669
2554
|
markdown: ref.lesson.body,
|
|
@@ -1676,6 +2561,7 @@ function workbenchState(state, now) {
|
|
|
1676
2561
|
return {
|
|
1677
2562
|
active: state.active,
|
|
1678
2563
|
mode: state.mode,
|
|
2564
|
+
historyBudget,
|
|
1679
2565
|
courses,
|
|
1680
2566
|
focusLessonId: focusId,
|
|
1681
2567
|
lesson,
|
|
@@ -1890,6 +2776,50 @@ function registerDashboard(webServer, deps) {
|
|
|
1890
2776
|
}
|
|
1891
2777
|
return;
|
|
1892
2778
|
}
|
|
2779
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/note/edit") {
|
|
2780
|
+
const body = await readJsonBodySafe(req, res);
|
|
2781
|
+
if (body === void 0) return;
|
|
2782
|
+
if (typeof body.lessonId !== "string" || typeof body.noteId !== "string" || typeof body.text !== "string") {
|
|
2783
|
+
sendJson(res, 400, {
|
|
2784
|
+
ok: false,
|
|
2785
|
+
error: "lessonId, noteId (strings) and text (string) required"
|
|
2786
|
+
});
|
|
2787
|
+
return;
|
|
2788
|
+
}
|
|
2789
|
+
try {
|
|
2790
|
+
editNote(deps.store.get(), body.lessonId, body.noteId, body.text);
|
|
2791
|
+
deps.store.save();
|
|
2792
|
+
sendJson(res, 200, { ok: true });
|
|
2793
|
+
} catch (error) {
|
|
2794
|
+
sendJson(res, 404, {
|
|
2795
|
+
ok: false,
|
|
2796
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2797
|
+
});
|
|
2798
|
+
}
|
|
2799
|
+
return;
|
|
2800
|
+
}
|
|
2801
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/note/pin") {
|
|
2802
|
+
const body = await readJsonBodySafe(req, res);
|
|
2803
|
+
if (body === void 0) return;
|
|
2804
|
+
if (typeof body.lessonId !== "string" || typeof body.noteId !== "string" || typeof body.pinned !== "boolean") {
|
|
2805
|
+
sendJson(res, 400, {
|
|
2806
|
+
ok: false,
|
|
2807
|
+
error: "lessonId, noteId (strings) and pinned (boolean) required"
|
|
2808
|
+
});
|
|
2809
|
+
return;
|
|
2810
|
+
}
|
|
2811
|
+
try {
|
|
2812
|
+
pinNote(deps.store.get(), body.lessonId, body.noteId, body.pinned);
|
|
2813
|
+
deps.store.save();
|
|
2814
|
+
sendJson(res, 200, { ok: true });
|
|
2815
|
+
} catch (error) {
|
|
2816
|
+
sendJson(res, 404, {
|
|
2817
|
+
ok: false,
|
|
2818
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2819
|
+
});
|
|
2820
|
+
}
|
|
2821
|
+
return;
|
|
2822
|
+
}
|
|
1893
2823
|
if (req.method === "POST" && pathname === "/lookatstudy/api/note/user") {
|
|
1894
2824
|
const body = await readJsonBodySafe(req, res);
|
|
1895
2825
|
if (body === void 0) return;
|
|
@@ -1944,288 +2874,300 @@ function registerDashboard(webServer, deps) {
|
|
|
1944
2874
|
}
|
|
1945
2875
|
return;
|
|
1946
2876
|
}
|
|
1947
|
-
if (req.method === "
|
|
1948
|
-
const
|
|
1949
|
-
if (
|
|
1950
|
-
if (typeof body.lessonId !== "string" || typeof body.sessionId !== "string") {
|
|
2877
|
+
if (req.method === "GET" && pathname === "/lookatstudy/api/exam") {
|
|
2878
|
+
const lessonId = new URL(req.url ?? "/", "http://x").searchParams.get("lessonId") ?? "";
|
|
2879
|
+
if (lessonId === "") {
|
|
1951
2880
|
sendJson(res, 400, {
|
|
1952
2881
|
ok: false,
|
|
1953
|
-
error: "lessonId
|
|
2882
|
+
error: "lessonId required"
|
|
1954
2883
|
});
|
|
1955
2884
|
return;
|
|
1956
2885
|
}
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
2886
|
+
try {
|
|
2887
|
+
settleDanglingAttempts(deps.store.get(), lessonId);
|
|
2888
|
+
deps.store.save();
|
|
2889
|
+
const ref = findLesson(deps.store.get(), lessonId);
|
|
2890
|
+
if (ref.lesson.kind !== "exam") throw new Error("not an exam node");
|
|
2891
|
+
const bank = ref.lesson.examBank;
|
|
2892
|
+
const ready = bank?.status === "ready";
|
|
2893
|
+
const log = ref.lesson.examAttemptLog ?? [];
|
|
2894
|
+
const latest = log.length > 0 ? log[log.length - 1] : null;
|
|
2895
|
+
sendJson(res, 200, {
|
|
2896
|
+
ok: true,
|
|
2897
|
+
status: bank?.status ?? "idle",
|
|
2898
|
+
error: bank?.error ?? null,
|
|
2899
|
+
questionCount: ready ? bank.questions.length : 0,
|
|
2900
|
+
kcCount: ready ? new Set(bank.questions.map((q) => q.kcTitle).filter((k) => k !== null)).size : 0,
|
|
2901
|
+
questions: ready ? bank.questions : [],
|
|
2902
|
+
bestStars: ref.lesson.examStars ?? 0,
|
|
2903
|
+
attemptCount: ref.lesson.examAttempts ?? 0,
|
|
2904
|
+
latestAttempt: latest === null ? null : {
|
|
2905
|
+
id: latest.id,
|
|
2906
|
+
finishedAt: latest.finishedAt,
|
|
2907
|
+
correctCount: latest.correctCount,
|
|
2908
|
+
totalCount: latest.totalCount,
|
|
2909
|
+
stars: latest.stars,
|
|
2910
|
+
terminated: latest.terminated,
|
|
2911
|
+
perQuestion: latest.perQuestion ?? []
|
|
2912
|
+
}
|
|
2913
|
+
});
|
|
2914
|
+
} catch (error) {
|
|
2915
|
+
sendJson(res, 404, {
|
|
2916
|
+
ok: false,
|
|
2917
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2918
|
+
});
|
|
2919
|
+
}
|
|
1960
2920
|
return;
|
|
1961
2921
|
}
|
|
1962
|
-
if (req.method === "POST" && pathname === "/lookatstudy/api/
|
|
2922
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/exam/prepare") {
|
|
1963
2923
|
const body = await readJsonBodySafe(req, res);
|
|
1964
2924
|
if (body === void 0) return;
|
|
1965
|
-
|
|
1966
|
-
if (text === "") {
|
|
1967
|
-
sendJson(res, 400, {
|
|
1968
|
-
ok: false,
|
|
1969
|
-
error: "text (non-empty string) required"
|
|
1970
|
-
});
|
|
1971
|
-
return;
|
|
1972
|
-
}
|
|
1973
|
-
if (text.length > 4e3) {
|
|
2925
|
+
if (typeof body.lessonId !== "string") {
|
|
1974
2926
|
sendJson(res, 400, {
|
|
1975
2927
|
ok: false,
|
|
1976
|
-
error: "
|
|
2928
|
+
error: "lessonId (string) required"
|
|
1977
2929
|
});
|
|
1978
2930
|
return;
|
|
1979
2931
|
}
|
|
1980
|
-
const voice = normalizeVoice(typeof body.voice === "string" ? body.voice : void 0);
|
|
1981
|
-
const cacheDir = join(deps.studyAreaPath, "tts-cache");
|
|
1982
2932
|
try {
|
|
2933
|
+
beginExamGeneration(deps.store.get(), body.lessonId);
|
|
2934
|
+
deps.store.save();
|
|
1983
2935
|
sendJson(res, 200, {
|
|
1984
2936
|
ok: true,
|
|
1985
|
-
|
|
1986
|
-
dataBase64: (await cachedTtsMp3(cacheDir, text, voice, deps.tts?.synthesize)).toString("base64")
|
|
2937
|
+
status: "generating"
|
|
1987
2938
|
});
|
|
1988
2939
|
} catch (error) {
|
|
1989
|
-
sendJson(res,
|
|
2940
|
+
sendJson(res, 404, {
|
|
1990
2941
|
ok: false,
|
|
1991
2942
|
error: error instanceof Error ? error.message : String(error)
|
|
1992
2943
|
});
|
|
1993
2944
|
}
|
|
1994
2945
|
return;
|
|
1995
2946
|
}
|
|
1996
|
-
if (req.method === "POST" && pathname === "/lookatstudy/api/
|
|
2947
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/exam/start") {
|
|
1997
2948
|
const body = await readJsonBodySafe(req, res);
|
|
1998
2949
|
if (body === void 0) return;
|
|
1999
|
-
if (
|
|
2950
|
+
if (typeof body.lessonId !== "string") {
|
|
2000
2951
|
sendJson(res, 400, {
|
|
2001
2952
|
ok: false,
|
|
2002
|
-
error: "
|
|
2953
|
+
error: "lessonId (string) required"
|
|
2003
2954
|
});
|
|
2004
|
-
return;
|
|
2005
|
-
}
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
"dock.streak": "{days}天",
|
|
2221
|
-
"dock.lv": "Lv{level}",
|
|
2222
|
-
"prompt.import": "导入课程:用 study_import_github 抓取 {url}",
|
|
2223
|
-
"prompt.lesson": "学习「{title}」:用 study_lesson 打开这一课开始学习。",
|
|
2224
|
-
"prompt.exam": "开始「{section}」的章节测验:按本节课时出题,答完逐题判分",
|
|
2225
|
-
"prompt.review": "开始今天的复习,从最到期的课时开始",
|
|
2226
|
-
"prompt.proposal.accept": "接受提案 {id} —— 确认标记这课为已掌握",
|
|
2227
|
-
"prompt.proposal.decline": "拒绝提案 {id} —— 我想再练练"
|
|
2228
|
-
};
|
|
2955
|
+
return;
|
|
2956
|
+
}
|
|
2957
|
+
try {
|
|
2958
|
+
const r = startExamAttempt(deps.store.get(), body.lessonId, /* @__PURE__ */ new Date());
|
|
2959
|
+
deps.store.save();
|
|
2960
|
+
sendJson(res, 200, {
|
|
2961
|
+
ok: true,
|
|
2962
|
+
attemptId: r.attemptId,
|
|
2963
|
+
questions: r.questions
|
|
2964
|
+
});
|
|
2965
|
+
} catch (error) {
|
|
2966
|
+
sendJson(res, 404, {
|
|
2967
|
+
ok: false,
|
|
2968
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2969
|
+
});
|
|
2970
|
+
}
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/exam/record") {
|
|
2974
|
+
const body = await readJsonBodySafe(req, res);
|
|
2975
|
+
if (body === void 0) return;
|
|
2976
|
+
if (typeof body.lessonId !== "string" || typeof body.attemptId !== "string" || typeof body.questionId !== "string" || typeof body.answer !== "string") {
|
|
2977
|
+
sendJson(res, 400, {
|
|
2978
|
+
ok: false,
|
|
2979
|
+
error: "lessonId, attemptId, questionId, answer (strings) required"
|
|
2980
|
+
});
|
|
2981
|
+
return;
|
|
2982
|
+
}
|
|
2983
|
+
try {
|
|
2984
|
+
recordExamAnswer(deps.store.get(), body.lessonId, body.attemptId, body.questionId, body.answer);
|
|
2985
|
+
deps.store.save();
|
|
2986
|
+
sendJson(res, 200, { ok: true });
|
|
2987
|
+
} catch (error) {
|
|
2988
|
+
sendJson(res, 404, {
|
|
2989
|
+
ok: false,
|
|
2990
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2991
|
+
});
|
|
2992
|
+
}
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2995
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/exam/submit") {
|
|
2996
|
+
const body = await readJsonBodySafe(req, res);
|
|
2997
|
+
if (body === void 0) return;
|
|
2998
|
+
if (typeof body.lessonId !== "string" || typeof body.attemptId !== "string") {
|
|
2999
|
+
sendJson(res, 400, {
|
|
3000
|
+
ok: false,
|
|
3001
|
+
error: "lessonId and attemptId (strings) required"
|
|
3002
|
+
});
|
|
3003
|
+
return;
|
|
3004
|
+
}
|
|
3005
|
+
try {
|
|
3006
|
+
const r = submitExamAttempt(deps.store.get(), body.lessonId, body.attemptId, body.terminated === true, /* @__PURE__ */ new Date());
|
|
3007
|
+
deps.store.save();
|
|
3008
|
+
sendJson(res, 200, {
|
|
3009
|
+
ok: true,
|
|
3010
|
+
...r
|
|
3011
|
+
});
|
|
3012
|
+
} catch (error) {
|
|
3013
|
+
sendJson(res, 404, {
|
|
3014
|
+
ok: false,
|
|
3015
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3016
|
+
});
|
|
3017
|
+
}
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/exam/regenerate") {
|
|
3021
|
+
const body = await readJsonBodySafe(req, res);
|
|
3022
|
+
if (body === void 0) return;
|
|
3023
|
+
if (typeof body.lessonId !== "string") {
|
|
3024
|
+
sendJson(res, 400, {
|
|
3025
|
+
ok: false,
|
|
3026
|
+
error: "lessonId (string) required"
|
|
3027
|
+
});
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
try {
|
|
3031
|
+
regenerateExamBank(deps.store.get(), body.lessonId);
|
|
3032
|
+
deps.store.save();
|
|
3033
|
+
sendJson(res, 200, {
|
|
3034
|
+
ok: true,
|
|
3035
|
+
status: "idle"
|
|
3036
|
+
});
|
|
3037
|
+
} catch (error) {
|
|
3038
|
+
sendJson(res, 404, {
|
|
3039
|
+
ok: false,
|
|
3040
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3041
|
+
});
|
|
3042
|
+
}
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/lesson-session") {
|
|
3046
|
+
const body = await readJsonBodySafe(req, res);
|
|
3047
|
+
if (body === void 0) return;
|
|
3048
|
+
if (typeof body.lessonId !== "string" || typeof body.sessionId !== "string") {
|
|
3049
|
+
sendJson(res, 400, {
|
|
3050
|
+
ok: false,
|
|
3051
|
+
error: "lessonId and sessionId (strings) required"
|
|
3052
|
+
});
|
|
3053
|
+
return;
|
|
3054
|
+
}
|
|
3055
|
+
deps.store.get().lessonSessions[body.lessonId] = body.sessionId;
|
|
3056
|
+
deps.store.save();
|
|
3057
|
+
sendJson(res, 200, { ok: true });
|
|
3058
|
+
return;
|
|
3059
|
+
}
|
|
3060
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/tts") {
|
|
3061
|
+
const body = await readJsonBodySafe(req, res);
|
|
3062
|
+
if (body === void 0) return;
|
|
3063
|
+
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
3064
|
+
if (text === "") {
|
|
3065
|
+
sendJson(res, 400, {
|
|
3066
|
+
ok: false,
|
|
3067
|
+
error: "text (non-empty string) required"
|
|
3068
|
+
});
|
|
3069
|
+
return;
|
|
3070
|
+
}
|
|
3071
|
+
if (text.length > 4e3) {
|
|
3072
|
+
sendJson(res, 400, {
|
|
3073
|
+
ok: false,
|
|
3074
|
+
error: "text exceeds 4000 chars (speak sentence groups, not whole lessons)"
|
|
3075
|
+
});
|
|
3076
|
+
return;
|
|
3077
|
+
}
|
|
3078
|
+
const voice = normalizeVoice(typeof body.voice === "string" ? body.voice : void 0);
|
|
3079
|
+
const cacheDir = join(deps.studyAreaPath, "tts-cache");
|
|
3080
|
+
try {
|
|
3081
|
+
sendJson(res, 200, {
|
|
3082
|
+
ok: true,
|
|
3083
|
+
mime: "audio/mpeg",
|
|
3084
|
+
dataBase64: (await cachedTtsMp3(cacheDir, text, voice, deps.tts?.synthesize)).toString("base64")
|
|
3085
|
+
});
|
|
3086
|
+
} catch (error) {
|
|
3087
|
+
sendJson(res, 502, {
|
|
3088
|
+
ok: false,
|
|
3089
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3090
|
+
});
|
|
3091
|
+
}
|
|
3092
|
+
return;
|
|
3093
|
+
}
|
|
3094
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/mode") {
|
|
3095
|
+
const body = await readJsonBodySafe(req, res);
|
|
3096
|
+
if (body === void 0) return;
|
|
3097
|
+
if (body.mode !== "direct" && body.mode !== "guide" && body.mode !== "practice") {
|
|
3098
|
+
sendJson(res, 400, {
|
|
3099
|
+
ok: false,
|
|
3100
|
+
error: "mode must be direct | guide | practice"
|
|
3101
|
+
});
|
|
3102
|
+
return;
|
|
3103
|
+
}
|
|
3104
|
+
deps.store.get().mode = body.mode;
|
|
3105
|
+
deps.store.save();
|
|
3106
|
+
sendJson(res, 200, {
|
|
3107
|
+
ok: true,
|
|
3108
|
+
mode: body.mode
|
|
3109
|
+
});
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/budget") {
|
|
3113
|
+
const body = await readJsonBodySafe(req, res);
|
|
3114
|
+
if (body === void 0) return;
|
|
3115
|
+
deps.store.get().historyBudget = body.on === true;
|
|
3116
|
+
deps.store.save();
|
|
3117
|
+
sendJson(res, 200, {
|
|
3118
|
+
ok: true,
|
|
3119
|
+
on: body.on === true
|
|
3120
|
+
});
|
|
3121
|
+
return;
|
|
3122
|
+
}
|
|
3123
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/attachment") {
|
|
3124
|
+
const body = await readJsonBodySafe(req, res);
|
|
3125
|
+
if (body === void 0) return;
|
|
3126
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
3127
|
+
const data = typeof body.dataBase64 === "string" ? body.dataBase64 : "";
|
|
3128
|
+
if (name === "" || data === "" || name.length > 200 || /[\/:*?"<>|]/.test(name)) {
|
|
3129
|
+
sendJson(res, 400, {
|
|
3130
|
+
ok: false,
|
|
3131
|
+
error: "name and dataBase64 required (fs-safe name, max 200 chars)"
|
|
3132
|
+
});
|
|
3133
|
+
return;
|
|
3134
|
+
}
|
|
3135
|
+
const buf = Buffer.from(data, "base64");
|
|
3136
|
+
if (buf.length === 0 || buf.length > 20971520) {
|
|
3137
|
+
sendJson(res, 400, {
|
|
3138
|
+
ok: false,
|
|
3139
|
+
error: "attachment must be 1B..20MiB"
|
|
3140
|
+
});
|
|
3141
|
+
return;
|
|
3142
|
+
}
|
|
3143
|
+
try {
|
|
3144
|
+
const dir = join(deps.studyAreaPath, "attachments");
|
|
3145
|
+
mkdirSync(dir, { recursive: true });
|
|
3146
|
+
const safe = `${Date.now().toString(36)}-${name.replaceAll(/[\/:*?"<>|]/g, "_")}`;
|
|
3147
|
+
writeFileSync(join(dir, safe), buf);
|
|
3148
|
+
sendJson(res, 200, {
|
|
3149
|
+
ok: true,
|
|
3150
|
+
path: `attachments/${safe}`,
|
|
3151
|
+
bytes: buf.length
|
|
3152
|
+
});
|
|
3153
|
+
} catch (error) {
|
|
3154
|
+
sendJson(res, 500, {
|
|
3155
|
+
ok: false,
|
|
3156
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3157
|
+
});
|
|
3158
|
+
}
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
sendJson(res, 404, {
|
|
3162
|
+
ok: false,
|
|
3163
|
+
error: "not found"
|
|
3164
|
+
});
|
|
3165
|
+
}
|
|
3166
|
+
});
|
|
3167
|
+
return () => {
|
|
3168
|
+
disposeRoutes();
|
|
3169
|
+
};
|
|
3170
|
+
}
|
|
2229
3171
|
//#endregion
|
|
2230
3172
|
//#region src/commands.ts
|
|
2231
3173
|
/**
|
|
@@ -6091,6 +7033,100 @@ function studyTools(store, deps = {}) {
|
|
|
6091
7033
|
content: textBlocks(result.meta)
|
|
6092
7034
|
})
|
|
6093
7035
|
});
|
|
7036
|
+
const examBankApplyTool = defineTool({
|
|
7037
|
+
name: "study_exam_bank_apply",
|
|
7038
|
+
description: "Author the question bank for a section exam node (exam-v2). The exam page asks for a bank when the learner opens it; call this once with the full multiple-choice set. Question count follows planExamQuota on the section's KC union (clamp 5-15); each kcTitle MUST be one of the section lessons' concept titles (anti-hallucination — unknown KC titles are rejected; read the section with study_view first). On a validation error the tutor fixes the bank and simply calls again. The learner answers in the exam page; grading is automatic (unanswered = wrong, best-of stars kept).",
|
|
7039
|
+
parameters: {
|
|
7040
|
+
lessonId: {
|
|
7041
|
+
type: "string",
|
|
7042
|
+
required: true,
|
|
7043
|
+
description: "The exam lesson node id."
|
|
7044
|
+
},
|
|
7045
|
+
questions: {
|
|
7046
|
+
type: "array",
|
|
7047
|
+
required: true,
|
|
7048
|
+
description: "The full question set (5-15; planExamQuota on the KC union).",
|
|
7049
|
+
items: {
|
|
7050
|
+
type: "object",
|
|
7051
|
+
additionalProperties: false,
|
|
7052
|
+
properties: {
|
|
7053
|
+
prompt: {
|
|
7054
|
+
type: "string",
|
|
7055
|
+
required: true,
|
|
7056
|
+
description: "Question text (self-contained)."
|
|
7057
|
+
},
|
|
7058
|
+
options: {
|
|
7059
|
+
type: "array",
|
|
7060
|
+
required: true,
|
|
7061
|
+
items: { type: "string" },
|
|
7062
|
+
description: "Answer options (2+)."
|
|
7063
|
+
},
|
|
7064
|
+
answer: {
|
|
7065
|
+
type: "integer",
|
|
7066
|
+
required: true,
|
|
7067
|
+
description: "Index of the correct option (0-based, original order — the exam page reshuffles)."
|
|
7068
|
+
},
|
|
7069
|
+
kcTitle: {
|
|
7070
|
+
type: "string",
|
|
7071
|
+
description: "Concept title this question tests; must exist in the section's KC union."
|
|
7072
|
+
},
|
|
7073
|
+
explanation: {
|
|
7074
|
+
type: "string",
|
|
7075
|
+
description: "One-line why (shown in the per-question review)."
|
|
7076
|
+
}
|
|
7077
|
+
}
|
|
7078
|
+
}
|
|
7079
|
+
}
|
|
7080
|
+
},
|
|
7081
|
+
output: {
|
|
7082
|
+
schema: {
|
|
7083
|
+
type: "object",
|
|
7084
|
+
additionalProperties: false,
|
|
7085
|
+
properties: {
|
|
7086
|
+
lessonId: {
|
|
7087
|
+
type: "string",
|
|
7088
|
+
required: true
|
|
7089
|
+
},
|
|
7090
|
+
questionCount: {
|
|
7091
|
+
type: "integer",
|
|
7092
|
+
required: true
|
|
7093
|
+
},
|
|
7094
|
+
kcCount: {
|
|
7095
|
+
type: "integer",
|
|
7096
|
+
required: true
|
|
7097
|
+
},
|
|
7098
|
+
status: {
|
|
7099
|
+
type: "string",
|
|
7100
|
+
required: true
|
|
7101
|
+
}
|
|
7102
|
+
}
|
|
7103
|
+
},
|
|
7104
|
+
render: (_args, value) => [{
|
|
7105
|
+
type: "text",
|
|
7106
|
+
text: `Exam bank ready: ${value.questionCount} questions across ${value.kcCount} knowledge components. The exam page settles into its ready state — tell the learner to hit 开始考试.`
|
|
7107
|
+
}]
|
|
7108
|
+
},
|
|
7109
|
+
execute(args) {
|
|
7110
|
+
return mutate((state) => {
|
|
7111
|
+
const r = applyExamBank(state, args.lessonId, args.questions, /* @__PURE__ */ new Date());
|
|
7112
|
+
return {
|
|
7113
|
+
lessonId: args.lessonId,
|
|
7114
|
+
questionCount: r.questionCount,
|
|
7115
|
+
kcCount: r.kcCount,
|
|
7116
|
+
status: "ready"
|
|
7117
|
+
};
|
|
7118
|
+
});
|
|
7119
|
+
},
|
|
7120
|
+
presentCall: (args) => ({
|
|
7121
|
+
card: "generic",
|
|
7122
|
+
title: `Exam bank: ${Array.isArray(args.questions) ? String(args.questions.length) : "?"} questions`
|
|
7123
|
+
}),
|
|
7124
|
+
presentationMeta: (_args, value) => [`${value.questionCount}Q`],
|
|
7125
|
+
presentResult: (_args, result) => ({
|
|
7126
|
+
card: "generic",
|
|
7127
|
+
content: textBlocks(result.meta)
|
|
7128
|
+
})
|
|
7129
|
+
});
|
|
6094
7130
|
const completeLessonTool = defineTool({
|
|
6095
7131
|
name: "study_complete_lesson",
|
|
6096
7132
|
description: "Mark a lesson mastered manually (graduation at 90% mastery is the automatic path — this is the override). Unlocks the next lesson and schedules the first spaced review for tomorrow. Call only when the learner has genuinely worked through the lesson.",
|
|
@@ -7250,6 +8286,7 @@ function studyTools(store, deps = {}) {
|
|
|
7250
8286
|
lessonContent,
|
|
7251
8287
|
recordAnswerTool,
|
|
7252
8288
|
examResultTool,
|
|
8289
|
+
examBankApplyTool,
|
|
7253
8290
|
completeLessonTool,
|
|
7254
8291
|
dueReviewsTool,
|
|
7255
8292
|
recordReviewTool,
|
|
@@ -7742,6 +8779,7 @@ function snapshotSectionText(state) {
|
|
|
7742
8779
|
if (memory.length > 0) lines.push(`记忆: ${memory.join(" | ")}`);
|
|
7743
8780
|
if (snap.dueCount > 0) lines.push(`今日待复习: ${snap.dueCount} 项`);
|
|
7744
8781
|
if (snap.pendingProposal !== null) lines.push(`待决提案 ${snap.pendingProposal.id}: ${snap.pendingProposal.rationale}(等学习者表态)`);
|
|
8782
|
+
if (state.historyBudget === true) lines.push("历史预算已开启:当会话变长时,主动概括旧轮次的要点并基于概要继续,避免逐字复读历史。");
|
|
7745
8783
|
return lines.join("\n");
|
|
7746
8784
|
}
|
|
7747
8785
|
/**
|