# 实现笔记 这份是**改这个仓库的人**看的:代码地图、HTTP 接口清单,以及按时间攒下来的改动流水(一条一条记着当时为什么这么改)。 要上手、要加一条 API / 一个工具 / 一个面板子页面、要发版,看 [`DEV.md`](DEV.md)——那是手册; 这份只回答「这块为什么长这样」。视觉规范在 [`design.md`](design.md),不许怎么做在 [`AGENTS.md`](AGENTS.md)。 ## 本地开发与代码地图 > 要动代码,先翻 **[`DEV.md`](DEV.md)**:它是给下一个改这个仓库的人(大概率是个 agent)写的地图—— > 请求链路、数据落盘、加一条 API / 加一个工具 / 加一个面板子页面各要动哪几处、测试 harness 怎么用、 > 本地怎么验收、怎么发版、以及一页踩过的坑。下面这一节只留最小上手。 ```bash git clone <仓库地址> cd dsh-study-coach npm install # 只为拿到 @deepseek-ai/* 的宿主包,用来跑测试 node --test ``` `test/plugin.test.js` 会假装自己是 DSH:造一个 ctx,把 `apply()` 真跑一遍, 再把 `/study` 的 handler 挂到真 HTTP 服务器上打请求。改完东西先跑它。 ### 代码长什么样 一层一层往下,谁都不回头 import 谁: | 文件 | 管什么 | | --- | --- | | `index.js` | 插件入口:定数据根、造 `Store` 与 `Library`、把 router / handler / 工具注册起来 | | `lib/schema.js` | 常量表、`nowIso`、各种空档案的默认形状。**零依赖的叶子**,谁都可以 import | | `lib/store.js` | 盘上那一层:读写 JSON、`Store.snapshot()`。`export * from './schema.js'`,老调用点照旧从 `./store.js` 取常量 | | `lib/map.js` | 地图 / 掌握度 / 错题 / 每级档案 / 总体能力 / 任务视图 | | `lib/analysis.js` | 材料通读的结论、页级归类 | | `lib/notice.js` | 面板顶上那句指引、学生留言 | | `lib/memory.js`、`lib/toolbox.js`、`lib/student.js` | 记忆卡排期、番茄钟与清单、学生画像,都是纯函数,不碰磁盘也不碰 HTTP | | `lib/library.js` | 档案根 → 每个学习目标一个子目录,外加回收站 | | `lib/paths.js` | 页图目录与上传目录只算这一处(`pagesRootOf` / `uploadRootOf`) | | `lib/routes.js` | 所有 `/study/api/*`;不碰 cordis,所以能脱开 DSH 单测 | | `lib/handler.js` | `/study/*` 的静态页、文件、页图、上传 | | `lib/tools.js` | 21 个 `study_*` 工具的定义与 execute | | `lib/review.js` | 今日复盘那张 2048×1180 的海报(服务端现拼 SVG) | | `lib/bridge.js`、`lib/chat.js` | 往 DSH 会话里投递、读会话快照。都是**可选**服务,拿不到就 `available:false` | | `lib/pages.js`、`lib/build-pages.mjs` | 把扫描版 PDF 一页一张渲成 `p0007.png`;拆书走子进程,免得把面板进程僵住 | | `lib/preset.js` | 「学习教练」这个 agent 预设的定义(直接 `agentPresets.register`,不走 patch) | | `lib/panel-server.js` | 插件自己起的那个本地小服务器,绕开 DSH 内嵌浏览器「不许开 DSH 自己」的限制 | | `assets/` | 面板 `panel.*`、做题页 `practice.*`、读卷页 `read.*`、图谱 `graph.*`、六档表 `stages.js`、URL 规则 `urls.js`、小 markdown 渲染器 `md.js`、启动规则 `boot.js` | `assets/` 那一摊的规矩写在 [design.md](design.md) 里,**动 CSS 或新写页面之前先读它**。 两道额外的护栏: - `test/schema.test.js` 请宿主的 `defineTool` + `validateJsonSchemaValue` 把 21 个工具真跑一遍(写进临时档案),schema 跟返回值对不上就会当场失败。这台机器解析不到宿主包时这条会跳过 —— 跑完看一眼 `skipped` 是不是 0。 - `test/preset.test.js` 查预设定义的结构、平台开关、人设文案,以及 `skills/study-coach/SKILL.md` 的 frontmatter 合不合规(`name` 必须 kebab-case)。 本机开发时的 `node_modules/@deepseek-ai` 是个 junction(Windows)/软链,指到 `~/.dsh/profiles/node_modules/@deepseek-ai`,这样不装依赖也能解析到宿主的包。 它是链接不是拷贝,`.gitignore` 已经把 `node_modules/` 排除;干净 clone 之后跑 `npm install`,npm 会自己按 `peerDependencies` 去 npm 上拉这两个包。 发布流程(打标签、发 npm、进市场)写在 [PUBLISHING.md](PUBLISHING.md)。 ## HTTP 接口 读(20 条):`/study/api/state`、`/study/api/summary`、`/study/api/point/:id`、`/study/api/practice`、 `/study/api/archive`、`/study/api/ability`、`/study/api/library`、`/study/api/tasks`、 `/study/api/mistakes`、`/study/api/review`、`/study/api/materials`、`/study/api/material`、 `/study/api/material/tree`、`/study/api/point/pages`、`/study/api/toolbox`、`/study/api/memory`、 `/study/api/student`、`/study/api/chat`、`/study/api/chat/sessions`、`/study/api/panel`。 写(27 条):`/study/api/goal`、`/study/api/materials`、`/study/api/materials/remove`、 `/study/api/materials/import`、`/study/api/materials/build`、`/study/api/tools`、 `/study/api/inbox`、`/study/api/map/module`、`/study/api/map/replace`、`/study/api/map/confirm`、 `/study/api/mastery`、`/study/api/ability`、`/study/api/library`、`/study/api/library/restore`、 `/study/api/task`、`/study/api/task/update`、`/study/api/task/remove`、`/study/api/task/toggle`、 `/study/api/practice/ask`、`/study/api/focus`、`/study/api/todo`、`/study/api/memory`、 `/study/api/student`、`/study/api/chat/send`、`/study/api/panel`、`/study/api/reset`、 `/study/api/material/upload`。 `/study/api/material/upload` 是流式收上传(超过 300 MB 直接拒,让用户改走「粘贴本机路径」), 收完之后一样进 router;真正在 router 之前就拦下的只有 `/study/page?path=…`(把拆出来的页图 发出去,只放行数据根 `pages/` 底下的文件)和 `/study/file?path=…`。 实现见 `lib/routes.js`,它不碰 cordis,所以能脱开 DSH 单测。`/study/api/inbox` 与 `/study/api/practice/ask` 是**异步**的(要把话投进会话),`lib/handler.js` 那边是 `await router(...)`,别改成同步调用。 静态资源只从 `assets/` 出,路径穿越会被挡回 404。 ## 改动流水 ## 补:对话负责写,面板负责看 > **从这里往下都是改动流水**,一条一条记着当时为什么这么改。里面的 > 「工具从 7 个变 9 个」「测试 93 → 101 个」都是**那一次的快照**,不是今天的数字—— > 今天的真值一律以代码为准:工具数看 `lib/tools.js` 末尾 `buildTools` 返回的那个数组, > 测试数看 `node --test` 最后打印的 `tests`。 这是按「方向纠正」改的一版,规矩变了: - **学习目标、材料、知识地图,只有对话那边能写**(`study_goal` / `study_material` / `study_map`)。面板上原来那几个表单已经拆掉 —— 学生自己在面板里填目标是错的,目标得是问出来的。 - 面板上还能做的三件事:点知识点自评六档、勾掉今天做完的任务、确认地图草稿(「看过了,就这样」)。 - 面板顶上那句指引由 `study_guide` 写。**没写就整条不画**(早先垫过一句「本页仅供查看,改动请在对话里提」,每页都挂着:既是废话,也不准——面板上能动手的其实不少)。 - 面板底下有个留言口,学生留的话进 `inbox.json`;下次对话用 `study_inbox` 读,读完标掉。(**这个口后来删了**,面板改成直接开一个对话窗口打 `POST /study/api/chat/send`;`inbox.json` 与 `study_inbox` 还在,见「补:五」。) - 档案文件从 4 个变 6 个:多了 `guide.json`(面板指引)和 `inbox.json`(学生留言)。 - 工具从 7 个变 9 个:多了 `study_guide`、`study_inbox`。`study_report` 的返回里也带上了 `guide` 和 `inboxItems`。 - HTTP 多一条:`POST /study/api/inbox {text}`。 ## 补:材料要先读一遍 - 多了一份 `analysis.json`:每份材料通读之后的结论(整份的教学定位 `role`、跟别份材料怎么配 `pairing`、每章的页码范围 / 例题 / 习题范围与题量 / 难度 / 这一章的教学作用),由 `study_analysis` 写。`chapters` 可以分批喂,同一个 `no` 是字段级合并——这一批没写的字段留着上一批的,所以能一边读一边写。 - `study_report` 和 `study_material` 返回的材料条目多了 `analyzed`(通读过没有)和 `chapterCount`(记了几章);后来又加了 `pagedCount`(其中几章已经拆到具体页码,见文末「教辅拆到页」)。 - 材料删掉时它的分析一起删(`dropAnalysis`),免得以后重新登记同名材料时撞上旧结论。 - 工具从 9 个变 10 个,多的是 `study_analysis`。 - 随包 SKILL.md 第 2 节加了一整段「材料到手别急着排任务,先通读一遍」:怎么抽 PDF 文本(系统 python 有 pypdf)、扫描件怎么看(`read_image`)、厚册子怎么分批或拉子 agent、每章要记哪几样、`coverage` 怎么老实填。画地图和排任务都要从这份结论里取,没读就排作业,页码题号多半是错的。 ## 补:图谱、「打开」按钮、文件服务 - 面板上的知识地图是画出来的**三层折叠树**:大类 → 模块 → 最小单元。默认只摆大类(一科就那么几个),点大类展开模块,点模块展开单元——不是一上来全摊平。 - 最小单元旁边挂着两个小按钮:「看课」开那一节网课,「做题」进那一节的**做题页**。单元本身点一下还是自评,跟下面的列表联动(会滚到那一条)。 - 没挂网课的「看课」按钮是灰的,点了不会干瞪眼——它会替你往 `inbox.json` 留一句(「M1.1 xxx 这一节还没挂网课,帮我配上」),下次对话读到就补路径。 - 画布**能拖着挪**(中间有一版按用户当时那句「拖动可以关掉」把平移整条拆过,后来又催「不是让你改成不可动的吗」;再后来 m21561 又让装回来——所以现在是**有平移**的):按住画布拖就行,**挪过 4px 才算拖**,拖起来光标变抓手、画布跟手;按在右上角那两颗工具按钮上的不算拖。普通滚轮照旧滚页面,**要缩放按住 Ctrl/⌘ 滚轮**(0.4x 到 2.4x)。右上角两个按钮:复位视图、全部收起。**「按住板块挪一下再松手」不算点**——拖过 4px 就让松开时那一发 click 失效,免得拖完画布顺手把板块折叠掉。索引板与工具箱钉在视口上(它们挂在 `` 上、不在视图层里),拖动画布时不动。 - 实现在 `assets/graph.js` + `assets/graph.css`。布局是确定性递归——子树高度求和、父节点摆在这块高度的中间,没有力导向、没有动画循环,所以好测。窗口大小变了会重排(防抖 160ms)。 - 展开状态和视图位置存在模块级的 `state` 里,跨调用保留:自评一下不会把画布弹回原位。 - 面板是**动态** import 它的——图谱模块没了或者写坏了,顶多少画一张图,自评那些按钮不受影响。 - 每个任务多了 `open` 字段(要打开的那份东西:本地绝对路径或 http 链接)。面板上非空就渲染成一个「打开」链接,走 `GET /study/file?path=...`,`target="_blank"`。 - `GET /study/file` 只放行**已登记材料里的文件,或者材料文件夹底下的文件**——不然面板就成了随便读硬盘的后门。不在范围内回 404;**登记过、但盘上已经没了(移动硬盘没插、网盘没挂)回 410**(`registeredTarget()`)——面板的探活正是拿 404 判「服务端还是旧代码」的,这两种「打不开」混在一起,体检卡就会举着红条喊「重启 DSH」(2026-10-02 修的)。 - 点到的如果是**文件夹**(看课入口给的正是一节课的文件夹)就渲染一个目录页:`目录` 标记、文件名一条行、点进去接着开、上面有「上一层」。学生因此能从一节网课里自己挑要播哪个题型,而不是被按在一个 mp4 上。 - 它支持 Range 请求(206 + `content-range`),所以视频能拖进度条;`content-type` 按扩展名给:mp4 / m4v / webm / mov / mkv / mp3 / pdf / txt / md。 ## 补:做题页、折叠式掌握度、重叠 bug - **做题页**:`/study/practice?point=<知识点 id>`。图谱上单元的「做题」按钮、以及别处要练哪一节,都开这页。页里四块:这一节(看课 / 打开配套练习 / 出处)、教辅里对应哪儿(逐章列出页码、习题号、难度,带「翻开」链接)、想练手(「让 AI 出几道新题」写进 `inbox.json`)、我现在到哪一档(六档自评)。 - 章节靠 `GET /study/api/practice` 给:它把所有已分析的章节摊平,再拿知识点的 `source` 去对(章号或章节名),一个都对不上就把整份教辅摆出来——总比空着强。实现在 `lib/routes.js` 的 `chaptersFor()`。 - 做题页是独立页面 `assets/practice.html` + `practice.js` + `practice.css`,六档和配色抽到了 `assets/stages.js`。**这一段当时写的是「只给这页用,面板那份还内联着」——后来收掉了**:面板与图谱都改成从 `assets/stages.js` 取,`test/mirrors.test.js` 钉住前后端那几张表不许再各写一份。`lib/handler.js` 里给 `/study/practice` 单开了分支。 - **掌握度百分比**:`lib/map.js` 里有 `STAGE_SCORE`(没接触过 0 / 见过 0.2 / 能跟做 0.4 / 能独立做 0.6 / 熟练稳定 0.8 / 能讲明白 1)、`progressOf()`(一组知识点算平均)、`progressByGroup()`(按大类、按模块各算一份)。`Store.snapshot()` 多返回 `progress: { overall, groups, modules }`,面板直接用,不用再算。 - 面板下面那份列表改成了**两层折叠**:默认只有大类(后面跟百分比条),点开才见模块(也带百分比),再点开才是最小单元和自评按钮。从图谱上点一个圆点,它所在的大类和模块会自动撑开、滚到那一条。 - **三级图标重叠**是个真 bug,已修:`assets/graph.js` 递归往下传 y 的时候是 `baseY + node.top`,漏掉了父节点自身那半截高度(`node.y - node.subH / 2`),所以两个模块的第二层起点都算成 0。改成 `baseY + node.y - node.subH / 2 + node.top` 之后,单元圆点间距恢复正常。顺手给单元加了 `kg-unit-bg` 底色,看着像一张卡片。 - `POST /study/api/map/module` 原来只认 `body.id`,不收 `group`,大类会丢。现在两种形状都吃(裸模块或 `{ module: {...} }`),并且 `group` 存得住。 ## 补:每级档案、总体能力、任务跳转、目标库、全部 agent 化 这一版按「六点新要求」改的,都落在同一套数据上: - **每级一份掌握档案**:`lib/map.js` 的 `archiveFor(map, mastery, level, key)` 给大类 / 模块 / 单元三级各生成一份账(`progress` / `total` / `touched` / `avgConfidence` / `byStage` / `due` / 逐点状态与证据流水 / 该级证据汇总)。面板上大类的头、模块的头、单元那行后面各挂一个「档案」小按钮,弹层里就是它。工具侧是 `study_archive`,HTTP 侧是 `GET /study/api/archive?level=&key=`。三级算的是同一套东西,所以「函数 60%」跟它底下模块的百分比永远对得上。 - **总体能力**:`abilityReport(state)` 横着把所有大类、所有点、所有基本工具、最近七天的完成节奏摊成一份大盘,外加一句判词(`judgement {text, level, updatedAt}`,由 `study_ability action=set` 写)。面板顶部那张「总体能力」卡就是它:大盘百分比 + 六段构成条 + 各大类 / 卡住的地方 / 该复习了 / 最近七天四块。工具侧 `study_ability`,HTTP 侧 `GET|POST /study/api/ability`。 - **任务能跳过去,也能改能删**:任务多了 `note`;`taskView()` 把一条任务摊成带按钮的形状——命中 `target` 那个单元就自动补上「看这节网课」(`point.video`)、「这一节的讲义」(`point.practice`)、「做题 / 看掌握度」(`/study/practice?point=`);`kind=watch` 的任务改成**看课优先**:「看这节网课」排第一,最后一个按钮是「看完去做练习」(同一页,只是把练习接在看课后面)。面板上任务行里可以**改**(标题 / 类型 / 分钟)和**删**。工具侧 `study_plan` 从 `add|toggle` 扩成 `add|toggle|update|remove`;HTTP 侧多了 `POST /study/api/task/update` 和 `POST /study/api/task/remove`。 - **学习目标库**:`lib/library.js` 的 `Library` 类把「档案根 → 每个目标一个子目录」管起来(`list / create / select / rename / remove`,删掉是移到回收站,最后一个删不掉)。换目标、加一门课、清掉不学的,面板最后一张卡上就能做;工具侧是 `study_library`,HTTP 侧是 `GET|POST /study/api/library`。老的单档案根仍然能用(`library.supported` 是 false,那几个 action 会老实回「这个档案根只装得下一个档案,换不了」)。 - **文件工具**:`study_files`(`list` 列目录 / `stat` 看存在 / `url` 换成 `/study/file` 链接),只能看登记过的材料范围内的路径,好让 agent 在写 `video` / `practice` / `open` 之前先确认文件真在。 - **全部 agent 化**:随包 `skills/study-coach/SKILL.md` 新增第 9 节「面板、档案、目标库、文件」,讲清面板上有什么、每一级档案什么时候翻、总体能力判词什么时候写、目标库怎么切、文件工具怎么用;并把「不许把面板/档案/目标库当摆设」「不给打不开的路径」写进禁令。`study_report` 的返回里 `panelUrl` 现在是必读项。 - **通用性**:`lib/`、`assets/`、`skills/` 里没有作者测试用的学科内容了(举例一律中性化)。测试 fixture 里的线性代数 / 行列式是故意留的中性数据。 - 测试从 89 个加到 **92 个**:新增 `test/panel.test.js`,用最小 DOM stub 把 `assets/panel.js` 真的跑一遍(渲染 + document 事件委托 + 表单提交),断言总体能力卡、三级档案按钮、任务跳转链接、改删任务、目标库、以及草稿地图的定稿按钮都在。`test/schema.test.js` 也扩到覆盖 `study_archive` / `study_ability` / `study_files` / `study_plan update+remove` / `study_library` 全序列。 ## 补:改完代码怎么生效(这条最坑,先看这里) 面板的 js / css 是**每次请求现读磁盘**的,服务端路由是 **DSH 启动时加载**的。所以「改了 `lib/` 没重启 DSH」会变成最难查的一种状态:页面看着是新的,按钮点下去全 404 —— 响应体是 `{"ok":false,"error":{"code":"not-found","message":"unknown study route"}}`。热重载、禁用再启用插件、改 `cordis.patch.yml` 都换不掉已经加载的模块,**只能重启 DSH**。 两个自查 / 自救的小工具: - `node scripts/check-live.mjs`:探一遍正在跑的实例(默认 19387、19388),逐条打印「新 / 旧」。有缺的说明进程里跑的还是旧代码,退出码 1。 - `node scripts/preview.mjs`:不用 DSH,直接把面板起在 19390(`DSH_STUDY_PREVIEW_PORT` 可换)。改前端时省一次重启;DSH 那头还是旧代码、又不想现在重启时,也能拿它临时用。数据目录跟 DSH 那份是同一处,别两边同时写。 面板自己也会防这一手:开机时先探一遍那几条新路由,缺了就顶上挂一张体检卡,写清楚缺哪几样、坏在哪儿、为什么会这样、怎么办(`probeCapabilities()` / `healthReport()` / `healthCard()`);真的点到那些按钮,也是 toast 说一句,而不是默默开出一个 404 页。 ### 体检卡:不是「有没有坏」,是「现在最该动哪儿」 原来顶上那条只是一句「服务端还是旧代码」(`staleCard()`),坏消息和好建议混在一句里,学生看完也不知道先干什么。现在它分三档: | 档 | 意思 | 例子 | | --- | --- | --- | | **挡路的** | 不修的话页面上有一整块是坏的;但他自己修不了,只能重启 DSH | 服务端还是旧代码 | | **该修的** | 不挡路,但拖着迟早出事 | 知识地图还是草稿;学习目标还缺 3 项;2 份材料登记了但没通读;画像里 1 条判断引的证据找不到了;今天排了 90 分钟、比每天能学的多 30 分钟;错题本 3 道挂着「待验证」 | | **顺手能做的** | 现在不做也没什么 | 一份材料都还没登记;5 个单元还没挂练习材料;一条掌握度证据都还没记;今天有 2 张记忆卡到点了 | 几个刻意的选择: - **每条都带一句「为什么」,不只是一句结论。** 「学习目标还缺 3 项」后面必须写清缺的是 `outcome` / `deadline` / `minutesPerDay`,以及「没有分钟数就只能靠猜着排任务」——不然它只是又一个要关掉的提醒。 - **能自己修的都配一颗跳转按钮**,`data-nav` 直接换页(点击处理器读的是 `event.target.closest('[data-nav]')`,所以 `
` 会拼成 `class="card
/pages/<书名的去掉扩展名部分>-/ manifest.json 拆图清单:总页数、dpi、rendered、rendering、书签、时间 p0001.png p0002.png … 每一页一张图,文件名就是物理页码 uploads/ 网页上传进来的原件(up--) ``` 目录名带路径哈希,所以**同一本书换个位置再来一次也认得出来**;`p0007.png` 反过来一眼能看出这是第 7 页。`manifest.json` 里的 `rendering` 是「正在拆」的旗子,跑完才落 `false`——面板靠它显示进度,agent 靠它避免重复起一个拆图进程。 #### 二、六个动作 | 谁 | 动作 | 落到哪 | | --- | --- | --- | | agent | `study_book action=info` | `analysis` 里的 `pageCount` / `pageDir` / `dpi` / `toc` | | agent | `study_book action=build` | 后台进程逐页渲图 + `manifest.json` | | agent | `study_pages` + `read_image` | 把目录页渲出来看,抄成 `toc` | | agent | `study_analysis action=save`(`toc` + `spans`) | `analysis` 里的页级索引 | | agent | `study_book action=pages` | 查某个单元在哪些教辅的哪几页 | | 学生 | 资料页点「拆成页图」/「看页级归类」 | 直接打那两条 HTTP | **为什么 `build` 要起子进程**:`renderBook` 内部是 `execFileSync` 调 python 渲页,164 页要二十来秒、三百页一分多钟。留在面板进程里会把整个 HTTP 服务冻住,所以 `lib/build-pages.mjs` 是个独立的一次性进程(`detached` + `unref`,起完就撒手)。工具返回时就明说「别在这儿等」。 **页码一律是物理页**:`p0003.png` 里印的可能是「4」。教辅的印数和 PDF 物理页常常差 1,页级索引必须记物理页——因为面板的「打开」按钮拼的是 `#page=N`,PDF 阅读器认的是物理页。 #### 三、归类只写区间,不写逐页 ```json { "from": 12, "to": 15, "pointId": "M1.4", "kind": "例题", "note": "含参讨论" } ``` 一本两百页的教辅逐页写 JSON 又长又容易写错,而且**页与页之间的边界本来就是对不齐的**。所以入参只收区间,落盘时 `spansOf` 把相邻、同单元、同 `kind` 的合并成一段,`expandSpans` 反过来算出「哪些页还没归」——那个 `gaps` 就是「这本还没读完」的诚实答案,**排作业前必须看一眼**,不然会给学生指到一段还没归类的页上。 `kind` 只有六种:`讲解 / 例题 / 习题 / 目录 / 答案 / 其他`。面板上缩成一个字(讲 / 例 / 练 / 目 / 答 / 他)。 #### 四、跨两本教辅找同一节 `pagesForPoint(analysis, materials, pointId)` 是这条线的收口:遍历所有登记的材料,把各自命中的区间按**材料登记顺序**排、材料内部**按页码**排。 **刻意不做全局按页数重排**——那样会把同一本书里本来连着的好几段打散(A 书 10—12 页、A 书 40—42 页本来是一整块,插进 B 书的 20 页之后就读不出「这本书要先看完哪段」了)。 做题页那张「按页直达」卡就是这么来的:每本教辅一段,段头写清「哪一本、占哪几页、是什么页」,下面一排页码按钮。**拆出来的页是链接,没拆的是灰的**——不给死链,这是这条线里最容易犯的错(归了类不等于图已经渲出来了)。 #### 五、导入的两条路 - **粘贴本机路径**:原地登记,原件不动。填文件夹会先列清单让你看一眼(`{files, dirs}`,每个文件带 `known` 标记),确认了再 `all: true` 整夹登记,已经登记过的跳掉。**这是给电脑上已经有一堆教辅的人用的。** - **网页选文件上传**:手机、别人那边拿来的走这条。`lib/handler.js` 里是**边收边写盘**(不是先 `Buffer.concat` 再存)——一百兆的书整个进堆会把进程撑爆。超过 300 MB 直接拒,文案让用户改走「粘贴路径」。 上传走的是 `content-type: application/octet-stream` + `x-file-name` 头,**不是 JSON 体**,所以面板里另写了一个 `uploadFile()`,没走通用的 `api()`。 #### 六、页图怎么发出去 `/study/page?path=…` 只放行数据根 `pages/` 底下的文件,别的路径一律 404(`allowedPage()` 里 `resolve` 之后比对前缀)。**原先 `/study/file` 只放行登记过的材料路径**,拆出来的图在那个范围之外——这是「面板取不到页图」的根因,所以另开了一个出口,而不是把 `pages/` 塞进材料清单。 #### 七、测试 `test/book.test.js`(10 条,页级索引的纯函数)、`test/shelf.test.js`(8 条,导入 / 拆图 / 书架 / 按单元找页,靠注入假的 `spawnBuild` 不起真进程)、`test/booktool.test.js`(6 条,三个工具的 `info` / `build` / `pages`)、`test/panel.test.js` 的资料页断言、`test/practice-ui.test.js` 的「按页直达」两条。 写 `Store.update` 的时候踩过一次:`upsertAnalysis` 返回的是**那一份材料的条目**,而 `update('analysis', fn)` 要 fn 返回**整个 analysis**。写成 `store.update('analysis', (a) => upsertAnalysis(a, id, patch))` 会把整份文件写成一个条目——正确写法是 `{ upsertAnalysis(a, id, patch); return a }`。 ## 补:工具栏目 —— 番茄钟和清单 面板上多了一个「工具」页(`/study/toolbox`)。它是**容器不是功能**:页面左边一条二级菜单(`TOOLS` 注册表),右边画选中的那个小工具。以后往里加东西就三步: 1. 写一个 `xxxCard()`,返回一张 `
`; 2. 往 `TOOLS` 数组里加一条 `{ id, label, hint, card: () => xxxCard() }`; 3. 要落盘的话在 `lib/schema.js` 的 `FILES` 里加一份文件、`Store.default()` 里给个初值。 `PAGE_CARDS.toolbox.main` 只有一条 `['tool', '小工具', currentToolCard]`,卡 id 用的是 `tool` 而不是跟二级菜单联动——**卡 id 是折起来之后刻在按钮上的那个身份,跟着二级菜单变会让侧栏的折叠状态当场失忆**。 #### 一、番茄钟只存绝对时刻 `toolbox.json` 的 `focus` 里存的是 `endsAt`(ISO 时刻)、`startedAt`、`phase`,**不存「还剩多少秒」**: ```json { "running": true, "endsAt": "2026-10-01T09:25:00.000Z", "startedAt": "2026-10-01T09:00:00.000Z", "phase": "work", "workMinutes": 25, "breakMinutes": 5, "today": "2026-10-01", "todayMinutes": 107, "todayRounds": 3, "log": [] } ``` 好处是刷新、关页面、出门吃饭、DSH 重启都不影响它算得对不对——剩余时间永远是 `endsAt - now` 现算出来的。所以: - 服务端**没有任何常驻定时器**。每次读(`GET /study/api/toolbox`)和每次写之前都顺手 `settleFocus()`:到点了就结算成一条日志、累加今天的分钟数、把 `phase` 翻到休息,然后 `running` 置回 false。 - 面板上那个 500 ms 的 `syncFocusTicker()` **只是显示刷新,不是真相来源**:它从 `#focus-clock` 的 `data-ends` 现算剩余,只为了数字能动。它只在「工具页 + 正在跑 + 页面可见」时才开,一离开就 `clearInterval`。 - **一轮走完不自动接下一轮**。学生走开半小时回来,不该看到「你正在第 4 个番茄」——他什么都没按过。 - **中途停不算完整番茄**:`stopFocus()` 按实际坐了几分钟写一条 `partial: true` 的日志,分钟数照算,但 `todayRounds` 不加。不这么写的话,「开一下再关」就是刷番茄数的口子。 `requireMinutes` 拦 1—180 之外的整数(含小数、字符串),读的时候也夹一次——档案是 JSON,手改坏、跨版本迁移都可能留下 `workMinutes: 999`。 #### 二、清单跟「今天」不是一回事 `study_todo` 管的是**学生自己想办的事**(背 20 个单词、整理错题),`study_plan` 管的是**教练排的学习计划**。两拨人写的两拨东西,混一张表就会出现「这任务是谁排的」说不清。 排序写死在 `listTodos` 里:没做完的在前,各自按 `at` 倒序;`status` 认 `open` / `today` / `done` 三档。文字必填、最多 120 字、`due` 必须是 `YYYY-MM-DD`,同一条可挂 `pointId`(做题页那条例题错在哪,起番茄钟时能直接挂过去)。 #### 三、测试 `test/toolbox.test.js`(9 条):只存绝对时刻、走完一轮的结算、中途停记半截、同时只能跑一个钟、时长上下限与坏数据夹回、跨天清零、清单校验与排序、`study_focus` / `study_todo` 两个工具、三条路由与中文报错。 #### 四、`fold()` 那个坑 侧栏模式靠 `fold()` 把卡片折成一行名字,它要从卡片工厂返回的 `
` 里**只取 class 属性**: ```js const own = (html.slice(0, at).match(/class="card([^"]*)"/) || ['', ''])[1] ``` 以前写的是「砍到第一个 `>` 再去掉头尾引号」,那套只有 `
` 这种单个属性的卡才成立。工具页那张卡多带了一个 `data-card`,砍出来的就成了 ` focus" data-card="focus`,拼进去变成: ```html
``` `open` 掉进了 `data-card` 里 —— 卡永远折着,点折叠按钮还拿错误的 `dataset.card` 去 toggle。症状是「浏览器模式好好的,一侧栏就展不开」。 ## 补:记忆卡 —— 让要背的东西按艾宾浩斯回来 工具页的第三个格子。管的是「背没背下来」,跟 `mastery`(会不会做题)是两张表:`memory.json` 里只有卡片,挂不挂 `pointId` 都不影响排期。挂了的话,做题页和知识地图能顺着找到相关的卡。 #### 一、阶梯是固定的七级 ```js export const CARD_STEPS = [10, 60, 540, 1440, 2880, 8640, 44640] // 分钟 ``` 10 分 / 1 时 / 9 时 / 1 天 / 2 天 / 6 天 / 31 天,就是艾宾浩斯那条曲线最常用的复现点。`step` 记的是**已经走完几级**,不是等级编号。 四档自评怎么挪: | 自评 | 结果 | | --- | --- | | 忘了 | 退回 0 级,10 分钟后再来(记一次 lapse) | | 模糊 | 退一级,按退到那级的时间回来 | | 记住 | 进一级 | | 秒答 | 进两级;**连着三次秒答直接毕业** | 走到头(`minutes` 为 `null`)或者 `streak >= 3` 就毕业:`dueAt = null`,不再排。 #### 二、新卡是「立刻到期」的 `addCard` 给的 `step` 是 0、`dueAt` 是现在——写下来那一下本来就该看一遍。所以 10 分钟那一级**不是**靠「记住」走出来的,它是「忘了 / 模糊」之后的回来间隔。这条容易搞反:新卡的第一次复习就在当下。 #### 三、复习盒子和筛子互不干涉 `GET /study/api/memory` 一次回三样:全库统计(`stats` / `soon` / `dueTotal`)、**该背的那一队**(`dueItems`,永远是不带筛子的)、以及按 `status` 筛出来的列表(`items`)。 面板上复习盒子只认 `dueItems`,所以换筛子不会把手上这张卡抽走——学生正在背,顺手点一下「已经背下来」看看清单,卡不该没。列表里**只写正面**:翻答案之前不该先被列表剧透。 `listCards` 先数全库、再筛、再排,所以 `due` / `waiting` / `graduated` 是全库计数;面板上「还没到点」那一档的数是 `total - due - graduated` 减出来的(`cardStats` 不给 waiting)。 #### 四、改内容不许动排期 `patchCard` 拿 `normalizeCard` 过一遍之后,**显式把 `step / dueAt / graduatedAt / lapses / streak / reviews / at` 从原卡恢复**。不这么写的话,`normalizeCard` 的默认值会把一张背到第 5 级的卡悄悄打回 0 级——学生只是改了个错别字。 ```js const next = normalizeCard(patch, card, card.id) next.step = card.step; next.dueAt = card.dueAt; /* … */ ``` #### 五、四档自评别只给两颗上底色 一开始给「记住 / 秒答」加了实心底色,截图一看像已经选中了。现在四颗一律不填充,只按记得牢不牢改字色和边框(红 → 琥珀 → 主色 → 绿),像一把尺子。 #### 六、测试 `test/memory.test.js`(7 条):阶梯四档、建卡校验(正反面必填、类型白名单、超 200 字)、复习与 lapse、毕业的两条路(走完七级 / 连三次秒答)、清单排序与筛选与 `limit`、`leftText` 与 `cardStats` 与 `memoryBody`、改内容不重置排期、删除。 面板那条在 `test/panel.test.js`:正面朝上不给答案、翻过来四档都在、自评打的是 `review`、换筛子不抽走手上这张、空正面背面本地拦下不打接口。 ## 补:AI 出题 —— 一份卷子就是一份材料 需求是「资料界面加个 AI 入口,让他出的练习自动归到一类,跟 xx 教辅平级」。做法**不是新开一张练习表**,而是给材料加一个类别 `ai`(现在一共有六种): ``` 材料 = 教辅 | 网课 | 讲义 | 真题 | AI 出题 | 其他 ``` 教练出的卷子正文写成 `ai/` 目录下一份 `.md`,然后照常 `study_material action=add kind=ai`。于是它自动获得材料已有的一切:书架上有位置、分析里有章节与 `spans`、`pagesForPoint` 会把它算进「这一节该练什么」。 #### 一、为什么复用材料,而不是另起一张表 另起一张表意味着「按单元找练习」要在两个地方各查一遍、再合并;而 `pagesForPoint` 早就把「同一节在各份材料里的位置」收在一个函数里了。加一个类别只改一层——**类别白名单**——比新造一条数据线少一半代码。 代价是「页」这个词被撑开了:教辅的一段是页码,AI 卷的一段是**题号**。所以 `spans` 的 `from/to` 对 AI 卷写题号,两个 UI 都按 `material.kind === 'ai'` 切换单位(「第 3 页」→「第 3 题」)。 #### 二、类别现在是要校验的 原先 `POST /study/api/materials` 是 `kind: item.kind || 'other'`——照单全收。写错一个字母(`textbook`)这份材料**不报错**,只是从任何分组里悄悄漏掉。现在工具和路由都按 `MATERIAL_KINDS` 校验,不在表里回 `400 材料类别只能是 …,收到「X」`。 这条改动是被一个老测试抓出来的:`test/store.test.js` 的「材料可以加可以删」当时就用了垃圾类别 `textbook` 并断言 200。**测试里用假数据是可以的,但用「格式合法、值非法」的假数据会把校验缺失一直盖住。** #### 三、书架按类别分组 `assets/panel.js` 的 `shelfCard()` 按 `KIND_ORDER = ['book', 'ai', 'notes', 'past', 'video', 'other']` 分组,每组一个中文小标题(教辅在前,AI 卷紧随其后——它是「学生手上还有什么可做」的一部分)。**只有多于一组时才出小标题**,否则书架顶上会多一行没有信息量的「教辅 1 份」。 行渲染抽成了 `renderShelfRow(s)`,AI 行跟教辅行是两套动作: | | 教辅 | AI 卷 | | --- | --- | --- | | 状态徽标 | 拆完了 16 页 / 未拆 | AI 出的卷 | | 副信息 | N 页 · 有文字层 · N 个单元 | 覆盖 M1.4 · N 份卷 · N 题有索引 | | 动作 | 看页级归类、拆成页图 | 打开这份卷、按 M1.4 做题 | | 展开 | 有页级归类详情 | 没有 | `shelfState()` 对 AI 卷要**短路**:它没有本机原件也没有页图,不短路的话会按「原件不在了」报一条红字——那是给教辅准备的判断。 #### 四、入口在面板,生成在对话 资料页右栏多了一张「让 AI 出题」:三颗类型药丸(随堂小测 / 背诵清单 / 错题变式)+ 单元下拉 + 一句话要求。它**不自己出卷**,只是把这三样拼成 prompt,走已有的 `POST /study/api/practice/ask`(`mode: 'ai'`)投进对话——出题、写文件、登记材料都是对话那头的 agent 干的。 这条边界值得留着:面板读数据、对话写数据,是这一整个插件从第一天起的分工(见上面「对话负责写,面板负责看」)。面板要是自己调一次模型,就得自己处理失败、重试、断流,还得把结果写回去。 prompt 里带了四步收尾指令,最后一步是「告诉他去资料页书架打开、或做题页点 AI 出题」——**生成完不登记等于没生成**,学生看不见。 #### 五、做题页的联动(需求 5) 「资料页和做题页联动」落到两处: - 进去的路:AI 行上每个单元一颗「按 M1.4 做题」→ `/study/practice?point=M1.4`。 - 出来的路:做题页「按页直达」里,同一节的教辅和 AI 卷并排——教辅给 `第 10 页`,AI 卷给 `第 1 题`,点题号直接**打开读卷页**(下一节)。上面那张「材料对应位置」也会列 AI 卷,带一颗 `AI 出题` 标签。 「材料对应位置」这个标题原来叫「教辅对应位置」——加了 AI 卷之后就不准了:学生看到「第 1 章 随堂小测 · M1.4」会以为那也是一本教辅。 #### 六、测试 `test/store.test.js` 的类别校验(`textbook` 被拒且不落盘、`ai` 落盘且 `path` 为空)、`test/tools.test.js` 的 `study_material` 校验、`test/panel.test.js` 的书架分组与 AI 入口(挑类型 / 挑单元 / 递一句话,断言打的是 `/study/api/practice/ask` 而不是别的)、`test/practice-ui.test.js` 的两条(按页直达里的 AI 行用「题」;材料对应位置认出 AI 卷、不写「仅能打开整份 PDF」)。 ## 补:读卷页 —— AI 生成的东西按面板排版摊开 原先的毛病:AI 出一份卷子,正文是 `.md`,面板上点开走的是 `/study/file`——浏览器把 markdown **当纯文本倒出来**,`## 第 3 题`、`**|2A|**` 原样露着,跟这个 web 的版式毫无关系。学生要看的是一份卷子,不是一份源码。 现在 `.md` / `.markdown` / `.txt` 一律走**读卷页** `/study/read?path=…`,其余(PDF、视频、文件夹)照旧 `/study/file`。判据只有一处: - `assets/urls.js` 是这条规则的**唯一实现**。`openPath(target, page)` 决定一个材料路径该往哪儿开:文档 → 读卷页(`page` 变成 `#qN`),其它 → `/study/file` 拼 `#page=N`,`http(s)` 和站内路径原样放行。宿主那半边按 `lib/urls.js`(一行 `export * from '../assets/urls.js'`)引同一份。 - 接线三处:面板的资料卡与 AI 卷按钮(`assets/panel.js`)、做题页的 `openLink()`(`assets/practice.js`)、任务按钮(`lib/map.js` 的 `taskLinks()`)。**再要有第四处,先想清楚为什么不能走 `openPath`。** - `assets/md.js` 是渲染器,不是通用 markdown 实现:标题 → `hN` 并生成侧栏目录;`第 N 题` / `N.` / `N、` / `**N.**` 都认,认出来的题号会钉一个 `id="qN"`,页面顶上自动长出一排「第 N 题」按钮;`## 参考答案`(`参考解答`/`解答`/`解析` 同)整节折进 `
`,**点开之前看不见**;表格、引用、代码块、分隔线都画。不认 LaTeX、HTML、嵌套列表。**先转义再替换**,`` 只会成一行字面量。 - 页面 `assets/read.html` + `read.js` + `read.css`,版式照 `design.md`(版心 900、卡头那条 accent 竖条、`practice.css` 的同款规矩)。页尾留一颗「看原文」通向 `/study/file`,想读源码的时候有路。 - 服务端 `GET /study/api/doc?path=…`(`lib/handler.js`):只在**已登记材料**范围内(复用 `allowedTarget`),非文件 → 400 `not-file`,后缀不在 `.md/.markdown/.txt` → 415 `not-text`,超 `MAX_DOC_BYTES`(512 KB)截断并回 `truncated: true`(读的时候只读到上限,不整份进内存)。 `test/md.test.js`(渲染器 12 条)、`test/read.test.js`(服务端 8 条)、`test/read-ui.test.js`(页面 7 条)钉住它;`scripts/check-live.mjs` 多了 `/study/read`、`/study/api/doc?path=` 两条探针和一次 `read.js` 记号检查,好分辨「新页面配旧路由」这种状态。 写卷子的格式约定在 `skills/study-coach/SKILL.md`「卷子写成什么格式,面板才画得好看」那一节——**那是给 agent 看的**,它决定学生在读卷页上看到什么。 ## 补:学生画像 —— 结论要能追回到哪一次 `mastery` 里每条证据是**流水**(L1):「10 月 1 日,M1.4,quiz,第九组第 3 题做错」。它一条条攒着,谁也不会去读第 40 条。 学生画像(L2)是**结论**:「含参讨论时容易漏掉 A=∅ 那一支」。一句话,读完就知道下次该怎么教。存在 `student.json`,**跟 mastery 分开**:流水一直涨、寿命长;结论要能被推翻改写,寿命短。混在一起的话,「上次的判断是什么」会被新证据淹没。 #### 一、一条判断必须挂证据,挂不上就不许记 ``` 判断 = { id, kind, text(≤120 字), evidence: [{pointId, key}], note, at, updatedAt } ``` `kind` 五选一:习惯 / 强项 / 弱项 / 偏好 / 背景。`evidence` 至少一条,`lib/student.js` 的 `normalizeEvidence()` 会逐条验: - 编一个不存在的 id → `没有任何一条证据的 id 是「e-xxx」。别编 id,去 study_archive 里看真有哪些` - 只给 id、全库对上不止一条 → 让它把 `pointId` 一起写上 - 单元号不在当前地图里 → `地图里没有这个单元:M9.9` - 单元对、但那一条不在这个单元下 → 指出该去 `study_archive` 重看一眼 **这条约束就是这层跟「随口评价」的分界线**,也是它跟向量库的分界线:兑不上要显式报出来,不是算个相似度蒙混过去。 #### 二、证据从匿名行变成有 id 的了 原来证据就是 `{kind, at, note}`——一行流水,匿名无所谓。但 L2 要一条条挂回去,没 id 就只能挂到整个单元,那等于「我记得你这一节学得不好」,说不出是哪一次让你这么想。 所以 `recordEvidence` 现在发 `id: 'e-…'`(`newEvidenceId()`),`study_archive` 的 `evidence[]` 也跟着端出来。**加 id 之前记的老证据**没有 id,档案里按 `evidenceKey()` 的同一套算法推一个:`单元@时刻`,同一毫秒同一单元撞了才补 `#序号`。两边必须同算法,否则今天抄下来的引用明天就兑不上。 #### 三、兑不上的显式记账,不隐藏 判断不会过期,但它引的那次可能没了:地图重画换了单元 id、证据被删。`auditFacts()` 把它们列出来(`地图里没这个单元了` / `这条证据找不到了`),面板照实画: - 卡头写 `3 条判断 · 1 条要修` - 那一格写「集合的表示 **证据没了**」 - 底下补一句「在对话里说一声,让教练核一下」 静默过滤掉是最省事的写法,也是最坏的:学生会慢慢不信这张卡。 #### 四、L3 判词可以引用 L2 `abilityReport` 的 `judgement` 多了 `from`(判断 id 数组),`study_ability action=set` 可以带 `from`。这样面板顶上的「这句判词综合了哪几条」,也是能点回去的。 `setAbility` 里 `from` 不传就留上一次的——只改措辞的时候,不该把「综合了哪几条」冲掉。 #### 五、写和读的分工 写只有两条路,都在对话里:`study_student` 工具,或面板上删(`fact-del` → `POST /study/api/student {action:'remove'}`)。面板**不能加也不能改**——加一条判断要先去 `study_archive` 挑证据,那是 agent 的活。 `study_report` 加了 `studentFacts`(只给 `id / kind / text / note / evidenceCount`,不摊开证据)——**开场读完它就等于认了一遍这个人**,这就是这一层最直接的收益。要看细节再调 `study_student` 或直接读 `/study/api/student`。 #### 六、测试 `test/student.test.js` 7 条:证据 id 与老数据推 key、`addFact` 的四类拒绝、改措辞与改证据互不冲掉、`byKind` 每档都在、证据/单元消失后 `ok:false` 与 `orphans`、`renderFact` 一真一假、路由层(400 文案 + 被拒的不落盘)、工具层(`study_report.studentFacts`、`study_ability.from`)、档案里 `evidence[].id` 跟 `evidenceKey()` 同算法。面板那条在 `test/panel.test.js`(`学生画像:一句话带着当年的那次证据,证据没了要当着面说`)。 --- ## 补:设置界面(面板服务的开关,只在 DSH 设置里) 插件原来只有一个地址:DSH 自己 webServer 上的 `/study`。内嵌浏览器不许开 DSH 自身的 origin,所以还起了**第二个**:插件自己监听 `127.0.0.1:19388`(被占往后试 20 个)的独立端口。第二个能关、能换端口,就得有地方按开关——这个地方**只有一个**:DSH 的设置。 插件自己的网页面板里**不设**这一页。理由:那是「这台机器上这个插件怎么跑」,不是「这个学生学到哪了」,而网页面板整个是给学生看的(改什么都得回对话里跟教练说)。运行参数混进去,学生点错了整个面板就打不开了。 ### 一、设置存在哪 `~/.dsh/study-coach/settings.json`,**不**在学习档案(`profiles//*.json`)里: ```json { "version": 1, "panel": { "autoStart": true, "port": 19388 } } ``` 档案是「这一个学习目标学到哪了」,一个目标一份;这份是「这台机器上这个插件怎么跑」,全局一份。切换学习目标不该把端口改掉,所以 `lib/settings.js` 自己读写、不进 `Store` 的 `FILES` 那张表。 - **读宽容**:文件不在、半截 JSON、字段全乱,一律退回默认值,不抛——盘上那份被人手改坏不该拦住插件启动。 - **写严格**:`validateSettings()` 逐项过关才落盘,不合格抛中文错(`panel.port 要是 1024—65535 之间的整数,收到「80」`)。静默吞掉最坑:存的是 99999,页签上显示的还是旧端口,人以为存上了。 - 默认端口只写在 `lib/panel-server.js` 的 `DEFAULT_PORT` 一处,`lib/settings.js` 从那儿 import。 ### 二、两个地址,别混 | | 谁 | 能不能关 | | --- | --- | --- | | `/study`(同源) | 挂在 DSH 自己的 webServer 上,`ctx.effect(() => ctx.webServer.register({kind:'prefix',path:'/study',handler}))` | 关不掉,也不用关;系统浏览器直接开 | | `http://127.0.0.1:/study`(独立端口) | `createPanelControl()` 起的第二个 HTTP 服务,复用同一个 handler | 能启动 / 停止 / 重启 / 换端口;**这是给 DSH 内嵌浏览器用的** | `autoStart` 为真时插件一加载就起独立端口;关掉之后,要用的时候到设置里的「学习教练」点一下。 ### 三、接口 - `GET /study/api/panel` → `{ ok, supported, running, port, url, preferred, hintUrl, error, settings, note }`。`deps.panel` 缺席(`scripts/preview.mjs` 之外的直接 `createRouter`)回 `supported:false` + 一句为什么。 - `POST /study/api/panel`,两种写法都收,也可以一起给: - `{ action: 'start' | 'stop' | 'restart' }` - `{ panel: { autoStart, port } }` 或扁平的 `{ autoStart, port }`(页签上就俩控件,扁平更好写) - 起 / 重启一律听**设置里那个端口**,不是「这次请求里顺手存的那个」——先改端口再点重启是两次请求,只在当次请求里找端口,重启就会回到旧端口上而界面上写着新端口。改了端口没重启会回一句 `note` 说清现在听的还是旧的。 这条路由只有 DSH 设置页签在用;网页面板不碰它。 ### 四、DSH 里挂的两处(客户端半边) **设置 → 学习教练**:左边自己一栏,跟「通用」「模型」「内置插件」「智能体预设」同层。另外「设置 → 内置插件 → 学习教练」也有一个同名页签,老习惯从那儿找的人也有路。这一半的文件是 `lib/client.js`,**不是普通 ESM 模块**,是宿主在浏览器里执行的一段脚本: > 为什么两处都挂:`settings.section` 才是 DSH 契约里「一个 list 条目就是一个设置页」那个座位,插件自己的页挂在这儿才找得到;`settings.plugins.tab` 是「内置插件」页**里面**的页签,只挂它,人得先想到去点开那一页。两处各挂各的,谁挂不上都不影响另一个。 ```js window.__ModuleLoader__.load({ id: 'dsh-study-coach', factory: (require) => { … } }) ``` 跟 `dsh-talk` / `dsh-client-ui-settings-plugins` 一个形状。三条规矩: 1. `require` 只能取平台种子表里的模块(react / react/jsx-runtime / cordis / 静态 UI 库),外加 `package.json` 里 `dsh.client.inject` 列出的包。**`require` 一个没列进去的包 = 静默失败。** 2. 只导出 `apply` / `inject` / `name`,宿主按这三个接线。`inject` 是 cordis 服务名(`['slots']`),`dsh.client.inject` 是客户端模块包名,两码事。 3. 文案不用走 locale(这一页没有要翻译的东西),`slots.register` 的 `label: () => '学习教练'` 直接给字符串。 注册座位是 **`settings.plugins.tab`**(一个 list 座位,`{ id, order, label }`)。同一个包里也带了 `settings.section`(设置导航里一整块顶级分区)的用法参考,但这个插件用 tab 更贴。 **样式走 `--dsw-alias-*` 那一套**(`--dsw-alias-bg-layer-1`、`--dsw-alias-border-l2`、`--dsw-alias-brand-primary`、`--dsw-alias-label-primary` …),**不要**用插件自己面板的 `--ink` / `--card` / `--accent`——那是另一套配色体系,在 DSH 的页面里根本没有值,混进来就是一片透明。`test/client.test.js` 拿一条正则钉住了这件事。 页签里三块,**「面板服务」排在最前**(启动 / 停止 / 重启;跑着时多一颗「打开网页面板 ↗」,停着时那颗换成「启动并打开 ↗」)、**「网页面板」**(地址 + 今天 / 地图 / 能力 … 每页直达)、**「启动设置」**(首选端口 + 加载时自启)。主按钮**不实心**:品牌色只用来描边 + 左侧一道信号条——因为填充色之上那层文字色不在公开 token 表里,拿 `--dsw-alias-bg-base` 当文字色的按钮会被别的插件(壁纸那类改写别名层的)变成一片空白。fetch 打的是同源的 `/study/api/panel`(`/study` 也挂在 DSH 自己的 webServer 上,所以没有跨域)。 ### 五、测试 - `test/settings.test.js` 7 条:读宽容(不在 / 半截 JSON / 顶层不是对象 / 字段全乱)、写严格(四类非法值 + 抛了不落盘 + 不提 panel 就当没改)、`createPanelControl`(起停重启、重复 start 不换端口、并发 start 合流到一次、首选端口被占往后挪且 `info().preferred` 还是用户写的那个)、路由层(没控制器时的 GET/POST、状态与开关与存端口、被挡住的不落盘)、独立端口那半端得出面板页面和静态资源。 - `test/client.test.js` 12 条:只跟平台要 react、bundle id = 包名、`apply` 之后两个座位都挂上、拿不到 slots 也不炸、跳转入口覆盖各页、样式只用 `--dsw-alias-*`(面板那套 `--ink` / `--card` 一条正则钉死);这一版又加三条:**CSS 里不许出现 `--dsw-alias-bg-base`**(它是背景语义,壁纸那类插件会把它改成 `transparent`,「跳转按钮看不见内容」就是这么来的)、每个别名引用都要带实色兜底、按状态渲染出来的按钮标签与禁用态(跑着时只有一颗 primary 且是「打开网页面板 ↗」,停着时是「启动」,状态没读回来一律禁用)。 - 网页面板那侧没有新增测试——它压根没多出界面。 ### 六、一个真 bug(顺手修的) `server.close()` 只等已有连接自己断。浏览器跟面板一直是 keep-alive,**点「停止」或「重启」就会一直转圈**——而浏览器永远不松手。`lib/panel-server.js` 的 `close()` 里先 `server.closeAllConnections()` 再 `close()`。 ## 补:面板里的对话只认「学习教练」模式的会话 面板的对话页原来列的是 `sessionController.list()` 里的**每一个**非子会话。同一台机器上开着别的会话(比如我干活那个编程会话),学生一点进来就会先看见它,`lib/bridge.js` 挑会话又是「取最近那个」,面板说出去的话有可能掉进跟你学习无关的会话里。现在这条线只认 `agentPreset === 'study-coach'`(`lib/preset.js:59` 的 `PRESET_ID`)。 - **判据只能从会话投影里拿。** `SessionSummary` 上没有 `agentPreset`(也没有 `title`),DSH 自己也是读投影的(asar 里那句注释写得很直白:*Reconstruction reads the `agentPreset` Session projection, never the header*)——`item.projections.values.agentPreset`。所以不必逐会话 `inspect()`,一次 `list()` 就够。 - **判据集中在 `lib/session-preset.js`**:`presetOf(item)`(取不到一律回 `''`;宿主写 `null` 就是「没选预设」)、`hasPresetChannel(items)`、`learningSessions(items) → { items, filtered }`。`lib/chat.js` 和 `lib/bridge.js` 都走它,别在两处各写一遍。 - **老宿主不许被筛成空白。** 整份清单里没有任何 `projections.values` 时 `filtered:false`、原样放行——面板也据此决定说不说「只看学习模式」(`filtered` 才说)。这两件事必须分开:`filtered:false` 是「这次没敢筛」,不是「筛完正好没有」。 - **`history({sessionId})` 也核一遍。** 面板直接带一个别的会话 id 上来时,先自己拉一次清单,不在名单里就回 `ok:false` + 一句「这个会话不是「学习教练」模式的,面板不读它的对话」,**不去读那条日志**。 - **投递宁可不投。** `bridge.pickSession()` 现在返回 `{ sessionId, filtered }`;`remember()` 记下的 id 也不直接信了(学习工具在编程会话里照样调得到,`preferredFromList` 标记的才是从筛选后的清单里挑出来的),一个学习会话都没有时回「没有「学习教练」模式的对话,这句话先存着」——话仍然留在 `inbox.json` 里,不掉。 - **面板那侧**:`chatPicker()` 空清单时不再不吭声,会分两种说法(`filtered === false` → 「没读到会话清单。」;否则 → 「还没有「学习教练」模式的对话 —— **点右边那颗 + 新建 开一个**,这一页就接上了。」),有清单且筛过时行尾挂一句「只看学习模式」(`.chat-only`),会话选择右边永远挂一颗「+ 新建」。 - **面板自己开得出来一个。** 这台机器上原本一个学习教练会话都没有(19 个 `cordis` + 2 个 `standard` + 几个 IM),严格筛完对话页就是空态——所以给了颗「+ 新建」:`POST /study/api/chat/new` → `chat.create()` → `sessionController.create({ agentPreset: PRESET_ID })`,回来把 id 塞进 `ui.chatSession` 并就地重画。**刚开出来的会话先记一笔**:`create()` 回来的那一瞬间投影还没落地,严格筛会把学生自己刚建的那个筛掉——`lib/session-preset.js` 的 `rememberFresh(sessionId)` / `isFresh(sessionId)` 是那张放行表,`learningSessions()` 与 `history()` 都认它;预设号只在 `newSessionRequest()` 里出现一次,路由里不再写一遍。 - **测试**:`test/session-preset.test.js` 7 条(判据、六种投影形态、老宿主放行、非数组不炸、fresh 放行、`newSessionRequest`)、`test/chat-sessions.test.js` 6 条(真 `createChat` + 假 controller:只列学习会话 / 显式塞别的 id 读不到 / 老宿主不拦 / 一个都没有时不读别人日志 / 新建出来读得到 / 建不出来说清楚)、`test/chat.test.js` 3 条(含 `POST /study/api/chat/new` 的端到端)、`test/panel.test.js` 25 条(含「会话选择旁边那颗「+ 新建」」)、`test/bridge.test.js` 13 条。全量 291 条。 - **记账**:`lib/` 改了 → **要重启 DSH 才生效**(`assets/*` 那条刷新即可)。 - 顺带记一笔没修的:`SessionSummary` 上没有 `title`,`lib/chat.js` 的 `summaryView` 读 `item.title` 永远是空串,清单那行就退化成「cwd 尾段 / 前 8 位」。要真标题得另想门路。 ## 补:表情包走图片通道、新消息靠广播通道 用户报的两件事:**「表情包在 web 端加载不出来」**、**「收到的消息要手动刷新才显示」**。都在面板(`assets/panel.js`)这一侧,根子却一个在图库、一个在浏览器。 **表情包为什么「加载不出来」。** 面板把消息当纯文本画,`[表情: 得意闭眼拳头…]` 只会原样显示成一段字。而 DSH 那边的图库在**另一个 origin** 上:`dsh-meme` 用 `webServer.register({ kind:'prefix', path:'/dsh-memes' })` 把图挂在 DSH 自己的端口(19387),`/dsh-memes-api?packId=all` 与 `/dsh-memes//` 都只有那边认;面板那两个 origin(19388 与 DSH 的 `/study` 前缀)上这些路径一律 404。也**没法做代理**——`webServer` 服务只有 `register` / `registerUpgrade` / `registerFallback` / `tapIndex` 那几个方法,**没有 `port` 属性**,插件里拿不到 DSH 的端口号。 所以 `lib/memes.js` 直接**读盘上的图库**(`node:sqlite` 只读打开;`createRequire` 懒加载,老 Node 取不到 sqlite 就认成「没图库」,不炸): - 两个根:用户包 `/.dsh/meme-packs`(注意跟 `DSH_HOME` 不是一回事),内置包扫每个 profile 的 `node_modules/dsh-meme/memes`。一个包 = `index.db` + `manifest.json` + `memes//`。 - 表就是 dsh-meme 自己那张:`memes(path, tag, file_name, file_hash, caption, keywords, mtime, captioned_at)`——`caption` 正好是 `[表情: …]` 里那串描述,`path` 正好是相对包目录的图片路径。 - 认图顺序:描述折成「去空白 / 去标点 / 小写」之后**逐字相等** → 包含(`row.fold.includes(q)`,或者描述里含着一小段、`q.includes(row.fold)`)→ 关键词全含;多个候选取最短那个。折过不到 2 个字不猜。非图片扩展名、以及 `path` 越出包目录的行一律丢。 - 路由:`GET /study/api/meme?q=<描述>` 命中就发字节(`content-type` 按扩展名,`cache-control: public, max-age=86400`);找不到 404(「图库里没有这一张」)、没给 `q` 400、这个进程没接图库 503。另有 `GET /study/api/meme/info`(几个包、多少张)方便排查。索引在内存里缓存 5 分钟(`createMemes({ ttlMs })`)。 - 面板这一侧是 `chatText()`:把 `[表情: 描述]` 换成 `描述`——**`alt` 就是那句描述**,图没命中时浏览器直接显示这行字,不留破图;`.chat-meme` 方角细边、最大 220px(浮窗 160px)。 - 发图片字节走的是 `handler.js` 新加的 `sendRaw(res, raw)`;路由返回 `{ code, raw: { type, body, cache } }` 时 handler 直接写字节,不再 JSON 化。 **「要手动刷新才显示」为什么不是加个轮询就完事。** 面板本来 2.5 秒轮询一次,但**浏览器会把后台标签页里的定时器压到一分钟一次甚至冻住**,切回来也不补一次——学生常常在 DSH 那边说完一句再切回面板,看见的自然是旧的。定时器节流是浏览器行为,改不掉;服务端的推送不受影响。所以加了 `lib/events.js`(唯一一份)与 `GET /study/api/events`: - 接上先发 `event: hello`(之前还写一行注释帧,免得代理压着响应头不放),之后每 2.5 秒一帧 `event: tick`;写操作想立刻刷面板可以 `events.publish('chat')`,会推 `event: change`。 - **没有订阅者就不跑定时器**;`res` 的 `close` / `error` 一律退订(否则那条长连接会一直挂在进程里);定时器 `unref()`,别拖住 `node --test` 退出。`ctx.effect` 里注册 `events.stop()`,插件卸载时把订阅全掐掉。 - 面板 `new EventSource('/study/api/events')`,`hello` / `change` / `tick` 三个事件都去 `refreshChat({ pushed: true })`——**推送那一帧在后台也照刷**(切回来内容就是新的)。轮询定时器反过来只在**前台**挂着;切回前台由 `visibilitychange` 立刻补一次、再重新挂上。没有 `EventSource` 的老浏览器自动退回纯轮询,行为跟以前一样。 - 路由按 HEAD 也算 GET 找(HTTP 语义:只要头不要体),所以 `/study/api/events` 可以直接 HEAD 探活。 - **测试**:`test/memes.test.js` 6 条(折字、两个根、认图顺序、TTL 缓存、图被删、HTTP 段发真字节)、`test/events.test.js` 5 条(头与帧、心跳、退订、真 SSE 流读到 hello 与 tick、没接通道 503)、`test/panel.test.js` 加 1 条(`[表情: …]` 渲成 `` + 推一帧就拉一次 + 离开这一页把长连接收掉)。全量 303 条。 - **记账**:`lib/` 与路由都改了 → **要重启 DSH**(新路由是启动时注册的);`assets/*` 刷新即可。重启前面板里表情包还是文字(`/study/api/meme` 404),消息也还得手动刷。 ## 补:正文走 markdown,数学交给 KaTeX 用户报的:**「web 端数学语言是 markdown 格式显示,你需要让 markdown 在 web 端正常显示」**。面板原来把教练的话当纯文本画,`**判据**`、`- 先配方`、`$x^2+y^2=1$` 全是源码。 **没有引 marked,用的是我们自己那份 `assets/md.js`。** 它本来是给读卷页写的(commit `acfafa7`),全仓只此一份渲染器 —— 读卷页、做题页、面板共用,多写一份就等于以后三处各修一遍。这一批只给它加了数学与一个 `extras` 钩子。 **KaTeX 随包发,不走 CDN、也不依赖别人装的包。** `assets/vendor/katex/`(25 个文件 / 561KB): - `katex.min.js`(katex 0.16.47,MIT);`katex.min.css`(**改写过的**:20 个 `@font-face` 的 `src` 从 woff2/woff/ttf 三个源只留 `url(fonts/X.woff2) format("woff2")`,省掉 20 个 woff、20 个 ttf);`fonts/*.woff2` 20 个。 - **许可分两份**:代码 MIT(`LICENSE`),**字体是 SIL OFL 1.1**(`FONTS-OFL.txt`;npm 包里没有单独的字体许可文件,是从 OFL 官方那份取的全文),`NOTICE.md` 里把两者分开写清。随包发(`package.json` 的 `files` 已含 `assets`)。 - 为什么不从 CDN 拉:这台机器不保证联网,而面板是本地服务 —— 拉了就等于把「能不能看数学」押在网上。为什么不 `import` profile 里那份 `katex`:pnpm 布局下它是别的插件带进来的传递依赖,哪天那个插件没了它就没了。 **机制(`assets/md.js`)**: - 数学认四种写法:行内 `$…$`、`\(…\)`,独立成行 `$$…$$`、`\[…\]`(`INLINE_MATH_RE` 里 `$` 前后不许贴数字,`\$` 不算钱)。 - 有 KaTeX 就 `renderToString(tex, { displayMode, throwOnError: false, strict: 'ignore', trust: false })`;**没装上或排不出来就退回一段等宽 `` 源码,绝不把 `$` 原样吐出来**(装不上也看得懂)。 - `inline()` 的顺序是**安全边界**:① 代码段 → ② 数学 → ③ `extras` → ④ `escapeHtml` → ⑤ 链接 → ⑥ 粗/斜/删除线 → ⑦ 回填插槽(`\u0000N\u0000` 占位)。**数学必须赶在转义之前抠出来**,`\frac` 的反斜杠才是给 KaTeX 的;`extras` 的产物进插槽、不再过转义(它自己负责转义)。 - **反斜杠保护**:markdown 会把 `\`+标点当转义吃掉,而真实消息里 Windows 路径是常态(实测线上 11 条消息里就有 `C:\Users\<你>\.dsh\study-coach\pages\…`)——所以抠完数学才转义,`\.` 不会被吞掉(截图里那条路径原样出来了)。 - `extras` 是给调用方的钩子:面板的 `[表情: 描述]` 走它(`MEME_EXTRA`),`chatText()` 内部就是 `renderMarkdown(text, { extras: [MEME_EXTRA] })` —— 这样「表情包出图」与「markdown 排版」不会互相踩。 **接线**:三个页面外壳(`assets/panel.html` / `read.html` / `practice.html`)各加一行 katex 的 `` 与 `