@arcaneorion/dsh-teaching-board
Teaching board view: agent-generated self-contained HTML is projected into a sandboxed iframe as a board, annotated by hand (brush, four colours, eraser, undo), extended in place across several tool calls, and screenshotted back into the conversation as a real user message.
Install
dsh plugin --profile web add @arcaneorion/dsh-teaching-board
Sandbox test results
| Stage | Result |
|---|---|
| 1 install | ✗ |
| 2 list | — |
| 3 mount | — |
| 4 boot | — |
dsh: initialized profile web at /home/node/.dsh/profiles/webFour stages run in an isolated sandbox: install (network on) → list → mount → boot (last three with no network).
README
@arcaneorion/dsh-teaching-board
DSH 会话内的教学平面:把 agent 生成的 self-contained HTML 投影成可讲解的板书界面,
并让使用者直接在板面上圈画、把画面截图发回对话。
单一数据流:面板 = 会话快照的投影,无私有状态、无 RPC、无 wait 闸门。
用户的每一次推进(点选、发图)都是真实用户消息。
安装
dsh plugin --profile web add @arcaneorion/dsh-teaching-board # 然后重启 dsh --profile web 并刷新页面
它是一个 profile 级 bundle:装一次,这个进程里所有会话都拿得到 stage_* 工具与「教学平面」页签。
(dsh plugin add 会把依赖与 bundles 条目一起写进该 profile 的 manifest。)
发布状态:@arcaneorion/dsh-teaching-board已发布。当前工作树为0.6.0:适配 DSH0.2.0-rc.1(会话槽位席位与会话快照契约变更)。
改名前的 @arcaneorion/dsh-stage-panel@0.1.0 仍在 registry 上,对应本仓早期版本——别再用,
装它只会拿到残缺面板(npm deprecate @arcaneorion/dsh-stage-panel "renamed to @arcaneorion/dsh-teaching-board" 可让老名字自己说明去向)。
兼容性(DSH 版本)
当前工作树已适配 DSH0.2.0-rc.1(peer 按0.2.0-rc.1声明)。下面0.1.1-rc.2的记录仅作历史基线。
宿主包 · 声明 · 用途
@deepseek-ai/dsh-tools · >=0.2.0-rc.1 · defineTool 注册 stage_* 四个工具
@deepseek-ai/dsh-client-ui-conversation · 0.2.0-rc.1 · conversation.view 座位(视图 id stage-panel,order 40)
@deepseek-ai/dsh-client-ui-chat · 0.2.0-rc.1 · 会话槽位标准席位 useChat(取 chat.legacy.nodes / runningCalls)
@deepseek-ai/cordis · ^4.0.4 · 插件生命周期
react · ^18.3.1 · client 半 require('react')
0.1.1-rc.2 → 0.2.0-rc.1 的四处契约变更(本插件已按新契约改写)
1. ctx.skills.registerProvider 改为工厂形式:0.1 传 provider 对象,0.2 要求 (control) => provider;旧写法会当场抛错。
2. 会话槽位标准席位 useSession → useChat:0.2 的 conversation.view 不再给 useSession,
改为 useChat(chat => ...),工具行从 chat.legacy.nodes(已定稿节点,node.kind==='tool-result'、node.call.argsRaw)
与 chat.legacy.runningCalls(进行中的 stage_snapshot)读取。
3. 输入框附件 API 更名:inputActions.addImages → addAttachments;空闲判定字段 imageIds → attachmentIds。
4. conversation.createDraftImages 未变,截图回传链路照旧。
换 DSH 版本必须先重新验证、再放宽 peer:座位契约与会话快照结构跨版本会变,
精确钉住的 peer 会在安装时报冲突,好过装上去静默失效。
能力
能力 · 实现位置 · 说明
板书展示 · client · 面板 HTML 在 sandbox="allow-scripts" 的 iframe 里渲染(无同源、无外网)
手写勾画 · iframe 内部(注入 runtime) · 画笔/橡皮、4 色、粗细、撤销、清空;默认不拦截指针事件,切到画笔才接管
内置截图 · iframe 内部(注入 runtime) · 克隆自身文档 → canvas 换位图 → XMLSerializer → <svg><foreignObject> → PNG,零依赖零外网
截图交给 agent · client(父层) · PNG → conversation.createDraftImages → inputActions.addAttachments → 草稿图片
agent 主动截图 · host 工具 + client 监听 · stage_snapshot 工具 → client 从快照看到调用 → 拍图 → 作为用户消息提交
状态条 / 方向选择 · host 工具 · stage_status / stage_choice
自带使用手册 · host(src/skills.js) · 插件把包内 skills/stage-panel/ 注册成 bundled skill:挂上插件就有纪律,任何 preset 都无需另外安装
架构:两条硬约束
1. 面板 iframe 是 sandbox="allow-scripts"(无 allow-same-origin)
→ 父页面永远读不到面板内部 DOM,也截不了它的图。
所以勾画与光栅化都必须在 iframe 内部完成:client 半把一段 runtime 注入进面板 HTML
(injectRuntime),父层只通过 postMessage 下命令、收结果。
好处是笔迹与板书同坐标系:面板内滚动不会让笔迹错位,截图也天然包含笔迹。
2. 静态 bundle 无法注册自定义 Remote,宿主拿不到浏览器里的像素
→ 截图不可能出现在工具结果里。它走产品既有的用户输入通道回来:
client 把 PNG 放进输入草稿,agent 主动请求时再替用户提交——
图片作为一条真实用户消息进入会话,模型下一轮就能看见(与 stage_choice 同机制)。
文件
文件 · 说明
src/index.js · host 半:stage_panel / stage_status / stage_choice / stage_snapshot 四个无状态工具
src/skills.js · host 半:把包内 skills/stage-panel/ 注册成 bundled skill(provider 模式,随 fiber 注销)
skills/stage-panel/ · 行为层:使用手册 SKILL.md + 选型/布局/视觉三份规范 + 五个素材库
src/client.js · client 半:「教学平面」视图(工具栏 + 注入 runtime + 截图交付 + agent 请求监听)
package.json / cordis.patch.yml · bundle 声明(行 id teaching-board),挂载进 web profile
test/ink-anchor.html + test/serve.mjs · 审计测试:笔迹锚定的 5 项断言(改注入 runtime 后必跑)。不进 npm 包(files 未含 test)
PROTOTYPE-*.js · 早期动态原型(lwst-2)存档,仅供历史参考
分层
- 能力层(本 bundle,profile 级):投影 + 勾画 + 截图 + 四个
stage_*工具。 - 行为层(同一 bundle 自带):
skills/stage-panel/—— 使用手册、选型/布局/视觉三份规范、五个素材库;插件apply时以 provider 方式注册进ctx.skills(skill-filesystem只扫固定的本地根目录,扫不到包内文件,所以必须由插件自己发布)。
> 旧布局把行为层放在 preset arcane-stage-panel 的 skills/ 里:教学平面会话拿得到,其余会话停在「有工具、没纪律」。现已合并进插件——一份手册,所有挂载本插件的会话都能读到。
板面模型:一块板 = 一个主题
stage_panel 不是「一次调用画一张图」,而是一块板可以被写很多次——像上课时的黑板:老师写、擦一角、再写,学生上台补几笔,板本身不换。
参数 · 作用
board · 板面 id。同一 id 的连续调用 = 同一块板的演进;换 id 才是换新板(新主题)。省略 = 一次性整块替换,不保留笔迹
op · open(开板/整块重写)/ append(默认,在板尾再写一段)/ set(替换 data-stage-region="名称" 的区域)/ remove(擦掉该区域)
region · set/remove 的目标区域名
折叠而非存储:客户端的板 = 会话日志里同一 board id 的那串调用按顺序折叠出来的(composeBoard)。没有客户端私有状态,板面内容仍然只是快照的函数——和这套设计的单一数据流一致。set/remove 用父页面自己的 DOMParser 定位区域再序列化,不触碰沙箱里的文档。
为什么必须这样:整块重发有三个代价——把学生的笔迹冲掉、把滚动位置顶回顶部、白烧 token(一块 8KB 的板写十次 = 80KB 输出)。append 让「再写一段」的成本与「老师在板上再写一行」相当。
增量而不是重载:面板是沙箱 iframe,父页面碰不到它的 DOM,所以「换 srcdoc」= 整块文档重建:JS 状态、滚动、动画、展开状态全部归零。因此只有三种情况才真的重装——首次挂载、换 board、op:"open"(外加切走页签再切回来这种客户端重挂)。其余 append/set/remove 由父层 postMessage {cmd:'patch'} 交给 runtime 原地打进活着的文档:不重载 → 板内交互状态、滚动位置、动画、笔迹全都不受影响。
一笔带过的限制:增量块用 innerHTML 插入,所以块内的 <script> 不会执行——交互逻辑写在 op:"open" 那一次里;静态排版、MathML、内联 onclick 属性都正常。切走页签再回来会整块重装,那时脚本照常跑。
画笔与点击共存:画笔模式下,pointerdown 会先做一次 elementFromPoint 命中测试,落点是 a[href]/button/input/select/textarea/label/summary/[role=button]/[onclick]/[contenteditable] 就把这次点击转发给控件、不落笔。所以「偶尔画、偶尔点」不需要来回切模式;唯一代价是控件正上方那几个像素画不了线(要先退出画笔)。
保留与边界:
- 笔迹按
boardid 存在客户端,板面更新后由父层重新注入(cmd:'load');滚动位置同理。 - 笔迹锚定在板面内容上(书页感):画布是视口大小的浮层,但笔迹坐标存的是文档坐标,
重绘时按滚动量平移,并记下「这一笔写在哪一段(.stg-block)上」当锚点。
于是滚动、以及 AI 在它上面改写内容导致的重排,笔迹都跟着那段文字一起走 ——
不会像旧版那样钉在屏幕上(旧版存视口坐标,滚动一下圈就脱开,实测漂移 = 滚动距离)。
块被删掉时退回文档坐标。审计测试见 test/ink-anchor.html。
- 笔迹存在每个客户端自己那里:同一会话开了两个浏览器,两边各画各的,不互相同步。
- 第一块(
op:"open")决定整块板的样式:后续片段共享它的 CSS,所以视觉规范要写在开板那一次里。
已知边界
- 截图需要面板处于挂载状态:
conversation.view是「一次只渲染一个」的页签环,
用户切到「对话/轨迹」时面板卸载,stage_snapshot 就拍不到图(页签没挂载)。
(下一步可加一个常驻的离屏截图宿主,挂在对话视图的常驻槽位上。)
- 外链字体/图片不会进截图,也不会渲染(面板规范要求 self-contained)。
- 截图为视口画面(所见即所得),不是整页长图。
- 单图超过约 1.2 MB 时自动退化为 JPEG / 等比缩小(模型单图预算)。
加载/验证
dsh plugin --profile web insta …(truncated)
Rendered from the README published on npm. Formatted only, not edited.
Versions
Published on npm::5,最后更新 2026-09-29