课堂 AI 助手 · dsh-course-recorder
仓库简介(GitHub About 一行版)
dsh(DeepSeek Harness)课堂 AI 助手插件:上课录音 + 本地实时转录,课后经你授权才整理成笔记并自动归入课程目录,作业现场按课程做助教式指导。音频、转录与笔记默认只在本机;可配合 dsh-plugin-wallpaper-engine 使用。
项目名:课堂 AI 助手 插件名:dsh-course-recorder 仓库名:class-help
一个 dsh Host + Client 双半边插件(同时内置一个 runtime skill)。它把「上课用麦克风(可选加系统声音)录音 → 本地 SenseVoice 逐段实时转录 → 我随时暂停/停止 → 只有我明确授权才由 dsh 的模型整理笔记 → 上课/做作业现场按需指导」做成一条闭环。
设计主线是「收集在课堂,推理在谷价时间,输出可验证」:课堂阶段不花模型 token(转录跑本地模型),推理推迟到你说「整理」的那一刻;每条结论都要能追回原话(session_id + 片段号 + 时间戳)。
目录:前置条件 · 一键导入 · 面板 · 21 个工具 · 磁盘布局 · 课程归类 · 配合 wallpaper-engine · 隐私 · 自测 · 常见问题
- 音频、转录、笔记默认只在本机,输出根目录默认
D:\课程。
- 绝不自动整理笔记:
notes_generate / notes_integrate 需要 user_authorized=true 且 dsh 的授权弹窗返回 allowed-once,双闸门缺一不可。
- 录音可随时
class_pause / class_stop(force),已录制内容一律保留。
- 每节课的
session.json 都带 endedAt / endedAtLocal(用户要求的「每次录音结束时间」)。
详见 docs/PROTOCOL.md(Host↔Client 冻结契约)与 SKILL.md(模型侧工作流)。
1. 前置条件
| 依赖 | 说明 |
|---|
| dsh 桌面版 ≥ 0.2.0-rc.1 | 麦克风权限已由 Electron 主进程放行(installMicrophonePermissions) |
官方 Voice Input bundle | 在 设置 → 插件 里启用 @deepseek-ai/dsh-experimental-voice-input-bundle;它提供 ctx.speechToText 与本地 sensevoice-local provider(239 MB int8 模型已在 %USERPROFILE%\.dsh\speech-to-text\sensevoice\ 缓存) |
| Node ≥ 20 | 仅自测用(node --test) |
系统声音(可选):dsh 桌面版渲染会话的 setDisplayMediaRequestHandler 固定返回 {},因此页内 getDisplayMedia() 拿不到音轨,本插件也不假装成功。要录「电脑正在播放的声音」,在 Windows 打开 设置 → 系统 → 声音 → 更多声音设置 → 录制,启用 立体声混音;它随后会作为普通麦克风设备出现,在面板里选它即可。面板同时提供 display 试探源:一旦 dsh 未来支持 loopback,它会自动可用。
2. 安装
在 dsh 里(或用 plugin_manager 工具)把这个包挂进 profile。
2.1 一键导入(脚本,推荐)
仓库自带 scripts/install.ps1,把「挂载目录 + 依赖声明 + 开关」一次做完:
# 在本仓库根目录执行(默认 profile:%USERPROFILE%\.dsh\profiles\desktop)
powershell -ExecutionPolicy Bypass -File .\scripts\install.ps1
它做四件事,每一步都打印出来:
- 把仓库以 junction 挂到
<profile>\node_modules\dsh-course-recorder(加 -Copy 则改为复制文件);
- 在 profile 的
package.json 里写 dependencies["dsh-course-recorder"] = "link:<本仓库绝对路径>";
- 把
dsh-course-recorder 加进 dsh.profile.bundles(已存在则跳过);
- 改前备份
package.json.bak-before-course-recorder-<时间戳>,写完做一次 JSON 校验,然后提示你重启 dsh。
# 指定别的 profile
powershell -ExecutionPolicy Bypass -File .\scripts\install.ps1 -ProfileDir "$env:USERPROFILE\.dsh\profiles\other"
# 复制安装(把插件放进 profile 目录里,而不是链接到本仓库)
powershell -ExecutionPolicy Bypass -File .\scripts\install.ps1 -Copy
# 卸载:摘掉开关、依赖与挂载目录(同样先备份)
powershell -ExecutionPolicy Bypass -File .\scripts\install.ps1 -Uninstall
2.2 用 dsh 自带的插件管理
推荐路径(安装过一次之后都用这条):包已经以 link:<本仓库绝对路径> 的形式挂在 profile 的 dependencies 里,
启用/停用只需要改 dsh.profile.bundles:
plugin_manager set_bundle target="dsh-course-recorder" enabled=true
set_bundle 不会跑 pnpm,因此不会动 profile 的依赖树。
首次安装才需要 install_bundle target="<本仓库绝对路径>",它会在 profile 目录里跑一次 pnpm。注意两个真实存在的坑:
- 它可能删掉第三方插件的 peer 依赖。实测日志
Packages: +1 -29:profile 的 pnpm-workspace.yaml 写着 autoInstallPeers: false,pnpm 会把 react、react-dom、@huanlin/dsh-plugin-better-locale 这类 peer 当成多余项清掉,而且 .modules.yaml 之后不再记录它们,普通 pnpm install 也装不回来。
- 装完必须重启并确认应用能起来,不要连着装好几个插件再一起重启。
安装后 cordis.patch.yml 会在 profile 里插入一行 course-recorder。若要改配置(例如换输出目录),不要手写 profile 的 cordis.patch.yml;直接改本包 cordis.patch.yml 的 config:,或按官方约定在 profile 的 patch 层复制同一 id 并重述所有要保留的键(后层整块替换同 id 的 config)。
⚠️ Client 半边写错会让整个应用起不来
这不是夸张,是实测事故:lib/client.js 曾经写成裸 _ModuleLoader__.load({…}),渲染进程抛
ReferenceError: _ModuleLoader__ is not defined,web boot 随即抛
web boot: 1 entry did not activate,界面弹「应用无法启动或已意外停止」,用户只能点
「禁用第三方插件、备份 profile patch 并重启」——代价是插件列表被清空。
两条硬规则,build-inline.mjs 与 test/client.test.mjs 都在守护它们:
- facade 只在
window 上(window.__ModuleLoader__),没有裸全局。app.asar 里 74 个内置 client 产物全部用 window.__ModuleLoader__.load({...})。
- 包装层必须带守卫:facade 缺失时告警并
return,绝不抛错。宁可面板不出现,也不能让应用起不来。
改代码之后必须做什么
| 改了什么 | 生效方式 |
|---|
Client 半边(src/client/*.js、build-inline.mjs) | node build-inline.mjs 重新生成 lib/client.js,刷新页面(产物 URL 带内容 hash &rev=,会重新拉取) |
Host 半边(src/host/*.js、lib/index.js) | 重启 dsh。Node 的 ESM 缓存按模块 URL 缓存成功的 import,plugin_manager 的重组合只重建条目树、不会重新导入模块,所以即使改了文件,运行中的 Host 仍执行旧的一代模块(官方原文:Replacing installed package versions still requires a restart through Plugin Manager)。 |
包清单(package.json)或 cordis.patch.yml | 同上,重启 dsh |
| 只想启用/停用已有 bundle | plugin_manager set_bundle,会立即重组合(不需要重启,但也不会重新导入已缓存的模块) |
两个容易踩的坑:
plugin_manager 返回的 warnings 里可能带着上一次失败的诊断文本;判断"是不是还在跑旧代码"要看行号是否随文件改动而移动。
- 应用起不来时那个对话框的第三个按钮会禁用第三方插件并备份 profile patch(备份名形如
cordis.patch.yml.bak-<时间戳>)。点过之后 dsh.profile.bundles 会被砍到只剩基础项,需要用 set_bundle 一个个加回来。
第三个坑(2026-10-10 实录):ctx.speechController.transcribe(request, signal) 的第二个参数必须是真的 AbortSignal。
两层语音服务都无条件调用 signal.throwIfAborted()
(dsh-experimental-api-speech-to-text/lib/index.js:196、dsh-experimental-speech-to-text/lib/index.js:179),
漏传不会报"缺参数",而是让每一段都失败在
Cannot read properties of undefined (reading 'throwIfAborted') —— 面板上只看到状态 failed、文本为空,
很像"老师没说话"。第一次真实录音(3 段全部失败)就踩到过这个坑;
src/host/session.js 现在通过 get transcribeSignal() 暴露录音器自己的 controller 信号,test/host.test.mjs
的 stubSpeech 也会像真服务一样检查它。同类教训:桩比真服务宽松,就等于没测。
3. 使用
3.1 面板(Client 半边)
聊天输入框下方出现 课堂录音 面板:
- 选音频源(麦克风 / 立体声混音 / 屏幕共享试探)、开始 / 暂停 / 恢复 / 停止。
- 2 秒轮询一次
/course-recorder/status:录音状态、时长、缓冲毫秒、音频源电平、最近转录片段、错误提示。
- 页内
AudioContext 做 16 kHz 单声道下混与 PCM16 编码,按 2 秒一块 POST /course-recorder/stream;Host 端切段后逐段调用官方 Voice Input bundle 暴露的 ctx.speechController.transcribe(),结果增量落库 + 落 transcript.jsonl。切段规则:目标 20 秒、遇 400 ms 尾静音闭合、25 秒硬上限(真实教室底噪常高于 speechThreshold,静音判定不触发,硬上限才是实际延迟)。
3.2 工具(Host 半边,模型调用)
| 工具 | 作用 |
|---|
class_start(title?, course?, consent: true) | 开始录音,必须 consent=true |
class_pause() / class_resume() | 暂停 / 恢复(暂停期间的音频被丢弃但 seq 不回退) |
class_stop(force?) | 停止;force=true 丢弃未切段缓冲,已落盘内容保留 |
class_status() | 实时监控快照 |
transcript_get_live(session_id?, limit?) | 最近转录片段 |
transcript_get(session_id?, start?, end?, ...) | 按时间/序号取转录 |
notes_generate(session_id, style?, user_authorized: true) | 授权后整理单节课笔记 |
notes_integrate(session_ids, title?, style?, time_window?, user_authorized: true) | 授权后跨多次录音整合 |
notes_get(session_id, format?) / notes_update(session_id, content, ...) | 读 / 手工编辑笔记 |
live_guide(question, session_id?) | 峰价时间现场指导(未整理时标注「基于实时转录,未整理」) |
course_search(query, course?, session_id?, top_k?) | 本地检索(CJK 字符 bigram + TF-IDF,不联网);传 course 就跨该课程全部节次,非法课程名直接拒绝、不会静默回退 |
course_ask(question, course?, session_id?, mode?, top_k?) | 课程问答(mode: "live" / "review");传 course 就是「按课程请教」,跨该课程全部节次检索 |
course_export(session_id?, note_id?, format?, write?) | 导出 Markdown / 纯文本 / JSON |
mic_list_devices() | 说明设备枚举在渲染页完成,并给出系统声音方案 |
session_delete(session_id, confirm: true, keep_notes?) | 一键删除(可只删音频、保留笔记) |
course_audit() | 授权审计 + 整理任务 + 会话清单 |
所有工具统一返回 { ok, action, data, error }。
3.3 磁盘布局(默认 D:\课程)
D:\课程\
index.json # 课程索引镜像(归类后按 storage 现况重建)
未命名课程\ # 课堂中不猜课程:新录音的默认落点
<课程名>\ # 专业课 / 文化课 / 马课 / 其他课程(可带一级子目录)
<YYYYMMDD-HHmmss>-<sessionId>\
session.json # 含 course/courseId/outputRoot/sessionDir + endedAt / endedAtLocal
transcript.jsonl # 每行一个转录片段(含片段号 / 毫秒 / 本地时间 / 文本)
segments\seg-0001.wav # 16k mono PCM16 分段音频(可配置不保留)
notes\draft-<jobId>.md|.json # 未授权时的草稿痕迹
notes\note-<iso>.md|.json # 授权后生成的正式笔记
真正的索引是 dsh 的 storage 域(sessions / segments / postings / notes / authorizations 等表);index.json 只是给人看、给外部工具看的镜像,归类后按现况整份重建。
3.4 课程归类:四个顶层目录
顶层目录名是强约束,不允许自创第五类:
| 目录 | 含义 |
|---|
专业课 | 本专业核心课程 |
文化课 | 语数英物化生史地政等基础学科(可再分,如 文化课/数学) |
马课 | 马原、毛概、思修、近代史、形势与政策、军事理论等 |
其他课程 | 不属于以上三类的兜底 |
归类规则:
- 判据只来自这节课自己的转录(老师讲的主题、章节名、术语、作业),不凭时间或录音时长猜;
- 命中不足(默认少于 2 个词)就归
其他课程,并在笔记开头标「〔分类存疑:疑似 X〕」——宁可标疑问,也不把课塞错门;
- 你在面板上主动指定了课程,就按你指定的走(用户 > 模型);
- 报告固定格式:
归类:未命名课程 → <目标课程> / 依据:<1–2 条,带 session_id + 片段号 + 时间戳>;
- 目标已有同名目录时追加
-2 / -3,绝不覆盖;中途任何一步失败整体回滚;
- 每次归类写一行审计(时间、原课程、新课程、依据、授权方式);
- 用户一句话改类(「这个其实是马课」)→ 立即重归并覆盖上一次归类记录。
4. 与 dsh-plugin-wallpaper-engine 配合使用
本插件可以配合 dsh-plugin-wallpaper-engine 一起使用:两者都以 profile bundle 的形式挂进 dsh,互不依赖——不装它也能正常用。
5. 隐私
- 录音前必须
consent: true,且面板会先弹确认。
- 音频只在本机内存/磁盘与本地 SenseVoice 之间流动;插件不发起任何外部网络请求。
- 整理笔记要用户主动发起 + dsh 授权弹窗,每一次尝试(含被拒)都写
authorizations 审计表。
session_delete 可删整节课(含音频与笔记),或只删音频保留笔记。
- 本仓库不含任何个人数据:没有设备日志、没有 API Key、没有真实录音/转录样本,也没有作者的本地绝对路径(示例路径一律为可配置的默认值)。
6. 开发与自测
src/host/ 宿主半边:纯 Node 模块(无 Cordis import,可直接单测)
index.js apply():注册工具、路由、system prompt 段与 skill
session.js 录音器:切段、落盘、转写、暂停/恢复/停止、1 小时保险、归类移动
routes.js HTTP 路由 + token 围栏 + 回环限制 + manifest
tools.js 21 个工具的实现与授权闸门
notes.js 笔记内容构建、Markdown 渲染、审计日志
context.js 检索与上下文组装(按节、按课程两种范围)
courses.js 课程名归一化与归类判定
search.js 本地检索:CJK 字符 bigram + TF-IDF
segmenter.js WAV 编码与切段规则
store.js dsh storage 域封装(表、复合键)
src/client/ 浏览器半边:采集、传输、面板
capture.js getUserMedia / 混音 / 16 kHz 下混 / PCM16 / 2 秒分块
transport.js /course-recorder/* 调用封装(含 attach 续传)
ui.js 面板组件(React.createElement),玻璃样式与录音列表
core.js 工具函数(时钟、格式化、样式常量)
build-inline.mjs 把 src/client/* 内联成 lib/client.js
lib/ 发布产物:index.js(host re-export)、client.js(构建生成)
test/ 4 个测试文件,共 86 项
docs/PROTOCOL.md Host↔Client 冻结契约
SKILL.md 模型侧工作流(注入给 dsh 的 skill)
node build-inline.mjs # 改了 src/client/* 之后重建产物
node test/host.test.mjs # 41 项
node test/client.test.mjs # 32 项
node test/apply.test.mjs # 9 项
node test/tools-schema.test.mjs # 4 项
node --check lib/index.js; node --check lib/client.js
沙箱提示:node --test <file>(隔离子进程模式)在受限沙箱下会 spawn EPERM;用 --test-isolation=none 或直接 node test/host.test.mjs。
7. 常见问题(都是实测踩过的坑)
Q:面板显示了「○ 空闲」,宿主却还在 recording?
先看 class_status() 的 sources[].lastChunkAt 还在不在前进:不前进就是音频已断。面板显示「● 录音中(宿主)」时点「开始」即可接回同一节(POST /attach),不要另开新会话。
Q:转录全是失败、文本为空,像老师没说话?
大概率是 signal 的坑:两层语音服务都无条件调用 signal.throwIfAborted(),漏传不会报「缺参数」,而是每段都死在 Cannot read properties of undefined (reading 'throwIfAborted')。录音器现在通过 get transcribeSignal() 暴露自己的 controller 信号,测试桩也会像真服务一样检查它。同类教训:桩比真服务宽松,就等于没测。
Q:Client 半边写错会怎样?
会让整个应用起不来:lib/client.js 曾写成裸 _ModuleLoader__.load({…}),渲染进程抛 ReferenceError: _ModuleLoader__ is not defined,界面弹「应用无法启动或已意外停止」,只能点「禁用第三方插件、备份 profile patch 并重启」。两条硬规则由 build-inline.mjs 与 test/client.test.mjs 守护:facade 只在 window 上(没有裸全局),且包装层必须带守卫——facade 缺失时告警并 return,绝不抛错。
Q:为什么默认落在 未命名课程?
课堂中猜课程会把课塞错门,改类还得移动目录。默认落点保证「先录下来」永远成功,课程等你授意转译时按转录内容判定。
Q:音频文件好大,能不留 WAV 吗?
可以:配置项 keepSegmentAudio: false 只留转录;也可以事后 session_delete(..., keep_notes: true) 只删音频。
Q:电脑里的声音怎么录?
Windows 到 设置 → 系统 → 声音 → 更多声音设置 → 录制 启用 立体声混音,在面板里选它即可(原因见第 1 节)。
8. 许可证
MIT,见 LICENSE。