@arcaneorion/dsh-teaching-board 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 arcanexis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # @arcaneorion/dsh-teaching-board
2
+
3
+ DSH 会话内的**教学平面**:把 agent 生成的 self-contained HTML 投影成可讲解的板书界面,
4
+ 并让使用者直接在板面上**圈画**、把画面**截图**发回对话。
5
+
6
+ **单一数据流**:面板 = 会话快照的投影,无私有状态、无 RPC、无 wait 闸门。
7
+ 用户的每一次推进(点选、发图)都是**真实用户消息**。
8
+
9
+ ## 安装
10
+
11
+ ```bash
12
+ dsh plugin --profile web add @arcaneorion/dsh-teaching-board
13
+ # 然后重启 dsh --profile web 并刷新页面
14
+ ```
15
+
16
+ 它是一个 **profile 级 bundle**:装一次,这个进程里所有会话都拿得到 `stage_*` 工具与「教学平面」页签。
17
+ (`dsh plugin add` 会把依赖与 bundles 条目一起写进该 profile 的 manifest。)
18
+
19
+ ## 能力
20
+
21
+ | 能力 | 实现位置 | 说明 |
22
+ |---|---|---|
23
+ | 板书展示 | client | 面板 HTML 在 `sandbox="allow-scripts"` 的 iframe 里渲染(无同源、无外网) |
24
+ | 手写勾画 | **iframe 内部**(注入 runtime) | 画笔/橡皮、4 色、粗细、撤销、清空;默认不拦截指针事件,切到画笔才接管 |
25
+ | 内置截图 | **iframe 内部**(注入 runtime) | 克隆自身文档 → canvas 换位图 → `XMLSerializer` → `<svg><foreignObject>` → PNG,零依赖零外网 |
26
+ | 截图交给 agent | client(父层) | PNG → `conversation.createDraftImages` → `inputActions.addImages` → 草稿图片 |
27
+ | agent 主动截图 | host 工具 + client 监听 | `stage_snapshot` 工具 → client 从快照看到调用 → 拍图 → 作为用户消息提交 |
28
+ | 状态条 / 方向选择 | host 工具 | `stage_status` / `stage_choice` |
29
+
30
+ ## 架构:两条硬约束
31
+
32
+ 1. **面板 iframe 是 `sandbox="allow-scripts"`(无 `allow-same-origin`)**
33
+ → 父页面永远读不到面板内部 DOM,也截不了它的图。
34
+ 所以勾画与光栅化都必须在 iframe 内部完成:client 半把一段 runtime **注入**进面板 HTML
35
+ (`injectRuntime`),父层只通过 `postMessage` 下命令、收结果。
36
+ 好处是笔迹与板书同坐标系:面板内滚动不会让笔迹错位,截图也天然包含笔迹。
37
+
38
+ 2. **静态 bundle 无法注册自定义 Remote,宿主拿不到浏览器里的像素**
39
+ → 截图**不可能**出现在工具结果里。它走产品既有的用户输入通道回来:
40
+ client 把 PNG 放进输入草稿,agent 主动请求时再替用户提交——
41
+ 图片作为一条真实用户消息进入会话,模型下一轮就能看见(与 `stage_choice` 同机制)。
42
+
43
+ ## 文件
44
+
45
+ | 文件 | 说明 |
46
+ |---|---|
47
+ | `src/index.js` | host 半:`stage_panel / stage_status / stage_choice / stage_snapshot` 四个无状态工具 |
48
+ | `src/client.js` | client 半:「教学平面」视图(工具栏 + 注入 runtime + 截图交付 + agent 请求监听) |
49
+ | `package.json` / `cordis.patch.yml` | bundle 声明(行 id `teaching-board`),挂载进 `web` profile |
50
+ | `PROTOTYPE-*.js` | 早期动态原型(`lwst-2`)存档,仅供历史参考 |
51
+
52
+ ## 分层
53
+
54
+ - **能力层(本 bundle,profile 级)**:投影 + 勾画 + 截图 + 工具。
55
+ - **行为层(preset `arcane-stage-panel`)**:教学模式纪律、内容规范(图型/HTML 模式/视觉风格)。
56
+
57
+ ## 板面模型:一块板 = 一个主题
58
+
59
+ `stage_panel` 不是「一次调用画一张图」,而是**一块板可以被写很多次**——像上课时的黑板:老师写、擦一角、再写,学生上台补几笔,板本身不换。
60
+
61
+ | 参数 | 作用 |
62
+ |---|---|
63
+ | `board` | 板面 id。同一 id 的连续调用 = **同一块板的演进**;换 id 才是换新板(新主题)。省略 = 一次性整块替换,不保留笔迹 |
64
+ | `op` | `open`(开板/整块重写)/ `append`(默认,在板尾再写一段)/ `set`(替换 `data-stage-region="名称"` 的区域)/ `remove`(擦掉该区域) |
65
+ | `region` | `set`/`remove` 的目标区域名 |
66
+
67
+ **折叠而非存储**:客户端的板 = 会话日志里同一 `board` id 的那串调用按顺序折叠出来的(`composeBoard`)。没有客户端私有状态,板面内容仍然只是快照的函数——和这套设计的单一数据流一致。`set`/`remove` 用父页面自己的 `DOMParser` 定位区域再序列化,不触碰沙箱里的文档。
68
+
69
+ **为什么必须这样**:整块重发有三个代价——把学生的笔迹冲掉、把滚动位置顶回顶部、白烧 token(一块 8KB 的板写十次 = 80KB 输出)。`append` 让「再写一段」的成本与「老师在板上再写一行」相当。
70
+
71
+ **增量而不是重载**:面板是沙箱 iframe,父页面碰不到它的 DOM,所以「换 `srcdoc`」= 整块文档重建:JS 状态、滚动、动画、展开状态全部归零。因此只有三种情况才真的重装——**首次挂载、换 `board`、`op:"open"`**(外加切走页签再切回来这种客户端重挂)。其余 `append`/`set`/`remove` 由父层 `postMessage {cmd:'patch'}` 交给 runtime **原地打进活着的文档**:不重载 → 板内交互状态、滚动位置、动画、笔迹全都不受影响。
72
+
73
+ **一笔带过的限制**:增量块用 `innerHTML` 插入,所以**块内的 `<script>` 不会执行**——交互逻辑写在 `op:"open"` 那一次里;静态排版、MathML、内联 `onclick` 属性都正常。切走页签再回来会整块重装,那时脚本照常跑。
74
+
75
+ **画笔与点击共存**:画笔模式下,`pointerdown` 会先做一次 `elementFromPoint` 命中测试,落点是 `a[href]/button/input/select/textarea/label/summary/[role=button]/[onclick]/[contenteditable]` 就把这次点击**转发给控件**、不落笔。所以「偶尔画、偶尔点」不需要来回切模式;唯一代价是控件正上方那几个像素画不了线(要先退出画笔)。
76
+
77
+ **保留与边界**:
78
+ - 笔迹按 `board` id 存在客户端,板面更新后由父层重新注入(`cmd:'load'`);滚动位置同理。
79
+ - 笔迹是**视口坐标**:`append` 只往板尾加内容、已有内容不动,所以笔迹对得上;若演进时上半部分重排,旧笔迹会错位——规范是「接着写」时保持板上半部分稳定。
80
+ - 笔迹存在每个客户端自己那里:同一会话开了两个浏览器,两边各画各的,不互相同步。
81
+ - 第一块(`op:"open"`)决定整块板的样式:后续片段共享它的 CSS,所以视觉规范要写在开板那一次里。
82
+
83
+ ## 已知边界
84
+
85
+ - **截图需要面板处于挂载状态**:`conversation.view` 是「一次只渲染一个」的页签环,
86
+ 用户切到「对话/轨迹」时面板卸载,`stage_snapshot` 就拍不到图(页签没挂载)。
87
+ (下一步可加一个常驻的离屏截图宿主,挂在对话视图的常驻槽位上。)
88
+ - 外链字体/图片不会进截图,也不会渲染(面板规范要求 self-contained)。
89
+ - 截图为**视口**画面(所见即所得),不是整页长图。
90
+ - 单图超过约 1.2 MB 时自动退化为 JPEG / 等比缩小(模型单图预算)。
91
+
92
+ ## 加载/验证
93
+
94
+ ```bash
95
+ dsh plugin --profile web install # 补 link symlink(改包源后必须重跑)
96
+ # 重启 dsh --profile web 后:
97
+ # 视图槽位 occupancy 出现 id=teaching-board;工具目录出现 stage_*
98
+ ```
99
+
100
+ > 静态 bundle 改动**需要重启 dsh 进程并刷新页面**,与动态 Cordis 插件的热更新不同。
101
+ > 其它静态化踩坑(boot 解析 / host 需 JS 源 / ModuleLoader handoff / 空配置分支)详见 `../README.md` §4。
@@ -0,0 +1,5 @@
1
+ # 教学平面 bundle row: 板书视图(勾画 / 截图)+ stage_* 工具(能力层,profile 级)。
2
+ # 行为层 = preset arcane-stage-panel(模式纪律与内容规范)。
3
+ - insert:
4
+ - id: teaching-board
5
+ name: '@arcaneorion/dsh-teaching-board'
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@arcaneorion/dsh-teaching-board",
3
+ "version": "0.4.0",
4
+ "type": "module",
5
+ "main": "src/index.js",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./client": "./src/client.js",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "description": "DSH teaching surface: project agent-generated self-contained HTML into a sandboxed in-session board with handwriting annotation and a built-in screenshot that returns to the conversation as a real user message.",
12
+ "license": "MIT",
13
+ "author": "arcanexis",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/ArcaneOrion/dsh-teaching-board.git"
17
+ },
18
+ "keywords": [
19
+ "deepseek",
20
+ "harness",
21
+ "dsh",
22
+ "dsh-plugin",
23
+ "teaching",
24
+ "whiteboard",
25
+ "annotation",
26
+ "screenshot",
27
+ "ui",
28
+ "panel",
29
+ "iframe"
30
+ ],
31
+ "files": [
32
+ "src",
33
+ "cordis.patch.yml",
34
+ "README.md"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "peerDependencies": {
40
+ "@deepseek-ai/dsh-tools": ">=0.1.1-rc.2"
41
+ },
42
+ "dsh": {
43
+ "bundle": {
44
+ "patch": "./cordis.patch.yml"
45
+ },
46
+ "client": {
47
+ "inject": [
48
+ "slots"
49
+ ],
50
+ "platform": "web"
51
+ }
52
+ },
53
+ "homepage": "https://github.com/ArcaneOrion/dsh-teaching-board#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/ArcaneOrion/dsh-teaching-board/issues"
56
+ }
57
+ }
package/src/client.js ADDED
@@ -0,0 +1,760 @@
1
+ /** @arcaneorion/dsh-teaching-board — client 半(静态 bundle)。
2
+ *
3
+ * 定位:会话内「教学平面」——板书展示 + 手写勾画 + 内置截图。
4
+ *
5
+ * 原生设计(单一数据流):视图 = 会话快照的投影(消费最近一次 stage_panel /
6
+ * stage_status / stage_choice / stage_snapshot 工具调用,无轮询、无私有 RPC);
7
+ * 交互 = inputActions 发真实用户消息。
8
+ *
9
+ * 两个硬约束决定了这里的架构:
10
+ * 1) 面板 iframe 是 sandbox="allow-scripts"(无 allow-same-origin)→ 父页面永远读不到
11
+ * 面板内部 DOM,也截不了它的图。所以勾画与光栅化都必须在 iframe 内部完成:
12
+ * 我们把一段 runtime 注入进面板 HTML,父层只通过 postMessage 下命令。
13
+ * 2) 静态 bundle 无法注册自定义 Remote,宿主拿不到浏览器里的像素 → 截图只能经产品
14
+ * 既有的用户输入通道回来(草稿图片 + 提交),而不是塞进工具结果。
15
+ *
16
+ * client-modules 加载协议:bundle 必须通过 window.__ModuleLoader__.load({ id, factory })
17
+ * 注册自己(id = 包名,须与 boot graph row 匹配);factory 接收绑定到模块表的同步
18
+ * require,一次性执行并返回模块导出({ name, inject, apply })。参照 dsh-context。
19
+ */
20
+ window.__ModuleLoader__.load({
21
+ id: '@arcaneorion/dsh-teaching-board',
22
+ factory: (require) => {
23
+ const { createElement, useEffect, useRef, useState } = require('react')
24
+
25
+ const CSS = `
26
+ .stgp-root { display: flex; flex-direction: column; height: 100%; min-height: 0; }
27
+ .stgp-head { flex: none; display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-specific-sidebar-fill); }
28
+ .stgp-title { font-weight: 700; color: var(--dsw-alias-label-primary); }
29
+ .stgp-state { font-size: 12px; color: var(--dsw-alias-label-secondary); }
30
+ .stgp-ctx { font-size: 12px; color: var(--dsw-alias-label-secondary); }
31
+ .stgp-tools { display: flex; align-items: center; gap: 6px; margin-left: auto; flex-wrap: wrap; }
32
+ .stgp-tool { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); border-radius: 6px; padding: 3px 10px; font: inherit; font-size: 12px; cursor: pointer; }
33
+ .stgp-tool:hover { border-color: var(--dsw-alias-brand-primary); }
34
+ .stgp-tool.on { background: var(--dsw-alias-button-primary-fill); border-color: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); }
35
+ .stgp-tool.primary { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
36
+ .stgp-sw { width: 16px; height: 16px; border-radius: 50%; border: 2px solid transparent; box-shadow: 0 0 0 1px var(--dsw-alias-border-l2); cursor: pointer; padding: 0; }
37
+ .stgp-sw.on { box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary); }
38
+ .stgp-size { width: 62px; }
39
+ .stgp-count { font-size: 12px; color: var(--dsw-alias-label-secondary); min-width: 42px; }
40
+ .stgp-body { flex: 1 1 auto; min-height: 0; position: relative; }
41
+ .stgp-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: var(--dsw-alias-bg-base); }
42
+ .stgp-empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--dsw-alias-label-secondary); font-size: 14px; }
43
+ .stgp-toast { position: absolute; left: 50%; transform: translateX(-50%); bottom: 14px; z-index: 5; max-width: 88%;
44
+ background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); border: 1px solid var(--dsw-alias-border-l2);
45
+ border-radius: 999px; padding: 6px 16px; font-size: 12.5px; box-shadow: 0 8px 24px rgba(0,0,0,.16); }
46
+ .stgp-choices { flex: none; display: flex; flex-wrap: nowrap; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--dsw-alias-border-l2); overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 24px), transparent 100%); }
47
+ .stgp-choice { flex: none; white-space: nowrap; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); border-radius: 6px; padding: 6px 14px; font: inherit; cursor: pointer; }
48
+ .stgp-choice:hover { border-color: var(--dsw-alias-brand-primary); }
49
+ `
50
+
51
+ const COLORS = ['#e5484d', '#2f6feb', '#1a7f37', '#111827']
52
+
53
+ /** 注入面板文档的 runtime:勾画层 + 光栅化截图。零依赖、零外网。 */
54
+ const RUNTIME = `
55
+ (function () {
56
+ if (window.__stagePanelInk) return;
57
+ var P = { mode: 'off', color: '#e5484d', size: 3 };
58
+ var strokes = [], cur = null, drawing = false;
59
+ var MAX_BYTES = 1200 * 1024;
60
+
61
+ function send(m) { try { parent.postMessage(m, '*'); } catch (e) {} }
62
+
63
+ var cv = document.createElement('canvas');
64
+ cv.setAttribute('data-stage-ink', '');
65
+ cv.style.cssText = 'position:fixed;left:0;top:0;width:100%;height:100%;z-index:2147483000;pointer-events:none;touch-action:none;';
66
+ (document.body || document.documentElement).appendChild(cv);
67
+ var g = cv.getContext('2d');
68
+
69
+ function dpr() { return window.devicePixelRatio || 1; }
70
+ function fit() {
71
+ var d = dpr();
72
+ cv.width = Math.max(1, Math.round(window.innerWidth * d));
73
+ cv.height = Math.max(1, Math.round(window.innerHeight * d));
74
+ redraw();
75
+ }
76
+ function paintStroke(s) {
77
+ g.globalCompositeOperation = s.erase ? 'destination-out' : 'source-over';
78
+ g.strokeStyle = s.color;
79
+ g.lineWidth = s.erase ? s.size * 5 : s.size;
80
+ g.lineCap = 'round';
81
+ g.lineJoin = 'round';
82
+ g.beginPath();
83
+ for (var i = 0; i < s.pts.length; i++) {
84
+ if (i === 0) g.moveTo(s.pts[i][0], s.pts[i][1]); else g.lineTo(s.pts[i][0], s.pts[i][1]);
85
+ }
86
+ if (s.pts.length === 1) g.lineTo(s.pts[0][0] + 0.1, s.pts[0][1]);
87
+ g.stroke();
88
+ g.globalCompositeOperation = 'source-over';
89
+ }
90
+ function redraw() {
91
+ var d = dpr();
92
+ g.setTransform(1, 0, 0, 1, 0, 0);
93
+ g.clearRect(0, 0, cv.width, cv.height);
94
+ g.setTransform(d, 0, 0, d, 0, 0);
95
+ for (var i = 0; i < strokes.length; i++) paintStroke(strokes[i]);
96
+ if (cur) paintStroke(cur);
97
+ }
98
+ function count() { send({ __stagePanel: true, type: 'ink', strokes: strokes.length, data: strokes }); }
99
+
100
+ var INTERACTIVE = 'a[href],button,input,select,textarea,label,summary,[role="button"],[onclick],[contenteditable="true"]';
101
+
102
+ /** 画笔模式下点到控件:把这次点击转发给控件,而不是画一条线。
103
+ * 这样「偶尔画、偶尔点」不需要来回切模式。 */
104
+ function forwardClick(x, y) {
105
+ var prev = cv.style.pointerEvents;
106
+ cv.style.pointerEvents = 'none';
107
+ var el = null;
108
+ try { el = document.elementFromPoint(x, y); } catch (e) { el = null; }
109
+ cv.style.pointerEvents = prev;
110
+ if (!el || el === cv) return false;
111
+ var hit = null;
112
+ try { hit = el.closest ? el.closest(INTERACTIVE) : null; } catch (e) { hit = null; }
113
+ if (!hit) return false;
114
+ try { hit.click(); } catch (e) {}
115
+ return true;
116
+ }
117
+
118
+ // ── 增量:把新到的 op 原地打进自己的 DOM(不重载、不丢状态) ──────────────
119
+ function regionNode(name) {
120
+ if (typeof name !== 'string' || name === '') return null;
121
+ try { return document.querySelector('[data-stage-region="' + name.replace(/"/g, '') + '"]'); } catch (e) { return null; }
122
+ }
123
+ function appendBlock(html, region) {
124
+ var s = document.createElement('section');
125
+ s.className = 'stg-block';
126
+ if (typeof region === 'string' && region !== '') s.setAttribute('data-stage-region', region);
127
+ s.innerHTML = typeof html === 'string' ? html : '';
128
+ (document.body || document.documentElement).appendChild(s);
129
+ }
130
+ function setRegionBlock(region, html) {
131
+ var n = regionNode(region);
132
+ if (!n) return appendBlock(html, region);
133
+ n.innerHTML = typeof html === 'string' ? html : '';
134
+ }
135
+ function removeRegionBlock(region) {
136
+ var n = regionNode(region);
137
+ if (n && n.parentNode) n.parentNode.removeChild(n);
138
+ }
139
+
140
+ cv.addEventListener('pointerdown', function (e) {
141
+ if (P.mode === 'off') return;
142
+ if (forwardClick(e.clientX, e.clientY)) return;
143
+ drawing = true;
144
+ try { cv.setPointerCapture(e.pointerId); } catch (err) {}
145
+ cur = { color: P.color, size: P.size, erase: P.mode === 'erase', pts: [[e.clientX, e.clientY]] };
146
+ redraw();
147
+ });
148
+ cv.addEventListener('pointermove', function (e) {
149
+ if (!drawing) return;
150
+ cur.pts.push([e.clientX, e.clientY]);
151
+ redraw();
152
+ });
153
+ function end() {
154
+ if (!drawing) return;
155
+ drawing = false;
156
+ strokes.push(cur);
157
+ cur = null;
158
+ redraw();
159
+ count();
160
+ }
161
+ cv.addEventListener('pointerup', end);
162
+ cv.addEventListener('pointercancel', end);
163
+
164
+ /** 克隆自身文档,把每个 canvas 换成它自己的位图,再交给 foreignObject 光栅化。 */
165
+ function rasterize() {
166
+ return new Promise(function (resolve, reject) {
167
+ var w = Math.max(1, Math.round(window.innerWidth));
168
+ var h = Math.max(1, Math.round(window.innerHeight));
169
+ var clone = document.documentElement.cloneNode(true);
170
+ var drop = clone.querySelectorAll('[data-stage-capture-ignore]');
171
+ for (var d = 0; d < drop.length; d++) drop[d].parentNode.removeChild(drop[d]);
172
+ var srcs = document.querySelectorAll('canvas');
173
+ var dsts = clone.querySelectorAll('canvas');
174
+ for (var i = 0; i < srcs.length && i < dsts.length; i++) {
175
+ var st = window.getComputedStyle(srcs[i]);
176
+ var im = document.createElement('img');
177
+ try { im.setAttribute('src', srcs[i].toDataURL('image/png')); } catch (e) { continue; }
178
+ im.setAttribute('style', 'position:fixed;left:' + st.left + ';top:' + st.top +
179
+ ';width:' + st.width + ';height:' + st.height + ';');
180
+ dsts[i].parentNode.replaceChild(im, dsts[i]);
181
+ }
182
+ var xml = new XMLSerializer().serializeToString(clone);
183
+ var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h +
184
+ '" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject x="0" y="0" width="' + w + '" height="' + h +
185
+ '">' + xml + '</foreignObject></svg>';
186
+ var img = new Image();
187
+ img.onload = function () {
188
+ var c = document.createElement('canvas');
189
+ c.width = w;
190
+ c.height = h;
191
+ var cc = c.getContext('2d');
192
+ cc.fillStyle = '#ffffff';
193
+ cc.fillRect(0, 0, w, h);
194
+ cc.drawImage(img, 0, 0, w, h);
195
+ resolve(c);
196
+ };
197
+ img.onerror = function () { reject(new Error('foreignObject 光栅化失败')); };
198
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
199
+ });
200
+ }
201
+
202
+ /** PNG 优先;超过模型单图预算就退到 JPEG,再不行等比缩小。 */
203
+ function encode(c) {
204
+ var out = c.toDataURL('image/png');
205
+ if (out.length * 0.75 <= MAX_BYTES) return out;
206
+ out = c.toDataURL('image/jpeg', 0.85);
207
+ if (out.length * 0.75 <= MAX_BYTES) return out;
208
+ var scale = Math.sqrt(MAX_BYTES / (out.length * 0.75));
209
+ var c2 = document.createElement('canvas');
210
+ c2.width = Math.max(1, Math.round(c.width * scale));
211
+ c2.height = Math.max(1, Math.round(c.height * scale));
212
+ c2.getContext('2d').drawImage(c, 0, 0, c2.width, c2.height);
213
+ return c2.toDataURL('image/jpeg', 0.85);
214
+ }
215
+
216
+ function capture(meta) {
217
+ meta = meta || {};
218
+ rasterize().then(function (c) {
219
+ send({
220
+ __stagePanel: true, type: 'capture', ok: true, dataUrl: encode(c),
221
+ w: c.width, h: c.height, strokes: strokes.length,
222
+ auto: meta.auto === true, requestId: meta.requestId || null,
223
+ });
224
+ }).catch(function (e) {
225
+ send({
226
+ __stagePanel: true, type: 'capture', ok: false, error: String(e && e.message || e),
227
+ auto: meta.auto === true, requestId: meta.requestId || null,
228
+ });
229
+ });
230
+ }
231
+
232
+ window.addEventListener('message', function (e) {
233
+ var d = e.data;
234
+ if (!d || d.__stagePanel !== true || typeof d.cmd !== 'string') return;
235
+ if (d.cmd === 'mode') {
236
+ P.mode = d.value === 'pen' || d.value === 'erase' ? d.value : 'off';
237
+ cv.style.pointerEvents = P.mode === 'off' ? 'none' : 'auto';
238
+ cv.style.cursor = P.mode === 'erase' ? 'cell' : 'crosshair';
239
+ } else if (d.cmd === 'color') {
240
+ P.color = String(d.value || P.color);
241
+ } else if (d.cmd === 'size') {
242
+ P.size = Number(d.value) || P.size;
243
+ } else if (d.cmd === 'undo') {
244
+ strokes.pop();
245
+ redraw();
246
+ count();
247
+ } else if (d.cmd === 'clear') {
248
+ strokes = [];
249
+ redraw();
250
+ count();
251
+ } else if (d.cmd === 'capture') {
252
+ capture({ auto: d.auto === true, requestId: d.requestId || null });
253
+ } else if (d.cmd === 'patch' && Array.isArray(d.ops)) {
254
+ for (var pi = 0; pi < d.ops.length; pi++) {
255
+ var po = d.ops[pi] || {};
256
+ if (po.op === 'append') appendBlock(po.html, po.region);
257
+ else if (po.op === 'set') setRegionBlock(po.region, po.html);
258
+ else if (po.op === 'remove') removeRegionBlock(po.region);
259
+ }
260
+ } else if (d.cmd === 'load') {
261
+ // 同一块板的演进:把父层保存的笔迹重新注入新板。
262
+ strokes = Array.isArray(d.strokes) ? d.strokes : [];
263
+ redraw();
264
+ count();
265
+ if (typeof d.scrollY === 'number' && d.scrollY > 0) {
266
+ try { window.scrollTo(0, d.scrollY); } catch (e) {}
267
+ }
268
+ } else if (d.cmd === 'ping') {
269
+ send({ __stagePanel: true, type: 'ready' });
270
+ }
271
+ });
272
+
273
+ // 把滚动位置报给父层,换板后用来还原(学生正在看的那一段不该跳回顶部)。
274
+ var scrollTimer = null;
275
+ window.addEventListener('scroll', function () {
276
+ if (scrollTimer) return;
277
+ scrollTimer = setTimeout(function () {
278
+ scrollTimer = null;
279
+ send({ __stagePanel: true, type: 'scroll', y: window.scrollY || 0 });
280
+ }, 120);
281
+ });
282
+
283
+ window.addEventListener('resize', fit);
284
+ fit();
285
+ count();
286
+ send({ __stagePanel: true, type: 'ready' });
287
+ window.__stagePanelInk = { capture: capture, count: function () { return strokes.length; } };
288
+ })();
289
+ `
290
+
291
+ /** 把 runtime 注入面板 HTML;没有 </body> 就追加在末尾。 */
292
+ function injectRuntime(html) {
293
+ const tag = '<script data-stage-runtime>' + RUNTIME + '<\/script>'
294
+ if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, tag + '</body>')
295
+ if (/<\/html>/i.test(html)) return html.replace(/<\/html>/i, tag + '</html>')
296
+ return html + tag
297
+ }
298
+
299
+ // ── 板面合成:一块板 = 会话日志里同一 board id 的那串调用折叠出来的 ──────
300
+ // 投影 = 快照的函数,这条设计在这里也没有例外:不存客户端状态,op 序列就是板的全部真相。
301
+ const SHELL_HEAD = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">'
302
+ + '<meta name="viewport" content="width=device-width, initial-scale=1">'
303
+ + '<style>:root{--ink:#1f2937;--muted:#6b7280;--dim:#9ca3af;--line:#e5e7eb;--brand:#2f6feb;--soft:#f7f8fa}'
304
+ + '*{box-sizing:border-box}body{margin:0;background:var(--soft);color:var(--ink);'
305
+ + 'font:15px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}'
306
+ + '.stg-block{max-width:920px;margin:0 auto;padding:18px 24px 30px;background:#fff;'
307
+ + 'border-bottom:1px solid var(--line)}'
308
+ + '.stg-block:first-child{padding-top:24px}'
309
+ + '.stg-block h2{font-size:14px;margin:0 0 10px;color:var(--muted);letter-spacing:.4px}'
310
+ + '.stg-block table{width:100%;border-collapse:collapse;font-size:13.5px}'
311
+ + '.stg-block th,.stg-block td{text-align:left;padding:7px 8px;border-bottom:1px solid var(--line)}'
312
+ + '</style></head><body>'
313
+ const SHELL_TAIL = '</body></html>'
314
+
315
+ function isDocument(html) {
316
+ return /<html[\s>]/i.test(html) || /<!doctype/i.test(html)
317
+ }
318
+
319
+ function wrapBlock(frag, region) {
320
+ const attr = region ? ' data-stage-region="' + String(region).replace(/"/g, '') + '"' : ''
321
+ return '\n<section class="stg-block"' + attr + '>\n' + frag + '\n</section>\n'
322
+ }
323
+
324
+ function ensureDocument(html) {
325
+ if (isDocument(html)) return html
326
+ return SHELL_HEAD + wrapBlock(html, null) + SHELL_TAIL
327
+ }
328
+
329
+ function appendFragment(html, frag, region) {
330
+ const block = wrapBlock(frag, region)
331
+ if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, block + '</body>')
332
+ return html + block
333
+ }
334
+
335
+ /** op=set/remove:用父页面自己的 DOMParser 定位 data-stage-region,改完序列化回字符串。 */
336
+ function patchRegion(html, region, frag) {
337
+ if (typeof region !== 'string' || region === '') return html
338
+ try {
339
+ const doc = new DOMParser().parseFromString(ensureDocument(html), 'text/html')
340
+ const node = doc.querySelector('[data-stage-region="' + region.replace(/"/g, '') + '"]')
341
+ if (!node) return frag === null ? html : appendFragment(html, frag, region)
342
+ if (frag === null) {
343
+ node.parentNode.removeChild(node)
344
+ } else if (frag.trimStart().slice(0, 1) === '<' && node.tagName === 'SECTION') {
345
+ node.innerHTML = frag
346
+ } else {
347
+ node.innerHTML = frag
348
+ }
349
+ return '<!DOCTYPE html>' + doc.documentElement.outerHTML
350
+ } catch {
351
+ return html
352
+ }
353
+ }
354
+
355
+ /** 把一条 op 作用到 HTML 字符串上(首装 / 重载走这条路)。 */
356
+ function applyOp(html, op) {
357
+ if (op.op === 'append') return appendFragment(html, op.html, op.region)
358
+ if (op.op === 'set') return patchRegion(html, op.region, op.html)
359
+ if (op.op === 'remove') return patchRegion(html, op.region, null)
360
+ return html
361
+ }
362
+
363
+ /** 把同一 board id 的 stage_panel 调用按顺序折叠成一块板的 HTML。 */
364
+ function composeBoard(calls) {
365
+ if (!Array.isArray(calls) || calls.length === 0) return null
366
+ const last = calls[calls.length - 1]
367
+ const boardId = typeof last.board === 'string' && last.board !== '' ? last.board : null
368
+ const list = boardId === null
369
+ ? [last]
370
+ : calls.filter((c) => typeof c.board === 'string' && c.board === boardId)
371
+ if (list.length === 0) return null
372
+
373
+ let base = null // 开板那次的文档(或第一条调用)
374
+ const ops = [] // 基准之后的增量:可以原地打进活着的 iframe,不必重载
375
+ let title = null
376
+ let openCount = 0
377
+ for (const call of list) {
378
+ const op = typeof call.op === 'string' && call.op !== '' ? call.op : (base === null ? 'open' : 'append')
379
+ const frag = typeof call.html === 'string' ? call.html : null
380
+ const region = typeof call.region === 'string' && call.region !== '' ? call.region : null
381
+ if (typeof call.title === 'string' && call.title !== '') title = call.title
382
+ if (op === 'open') {
383
+ if (frag !== null) {
384
+ base = frag
385
+ ops.length = 0
386
+ openCount += 1
387
+ }
388
+ continue
389
+ }
390
+ if (base === null) {
391
+ base = frag === null ? '' : frag
392
+ continue
393
+ }
394
+ ops.push({ op, region, html: frag })
395
+ }
396
+ if (base === null) return null
397
+
398
+ const baseHtml = ensureDocument(base)
399
+ let html = baseHtml
400
+ for (const op of ops) html = applyOp(html, op)
401
+ return {
402
+ board: boardId,
403
+ title,
404
+ ops,
405
+ baseHtml,
406
+ html,
407
+ openCount,
408
+ signature: (boardId || 'adhoc') + '|' + openCount + '|' + ops.length,
409
+ }
410
+ }
411
+
412
+ function parseArgs(raw) {
413
+ try {
414
+ const value = JSON.parse(raw)
415
+ return value && typeof value === 'object' ? value : null
416
+ } catch {
417
+ return null
418
+ }
419
+ }
420
+
421
+ /** data:image/...;base64,xxx → File(走产品既有的草稿图片通道)。 */
422
+ function dataUrlToFile(dataUrl, name) {
423
+ const comma = dataUrl.indexOf(',')
424
+ const head = dataUrl.slice(0, comma)
425
+ const type = (head.match(/^data:([^;]+)/) || [])[1] || 'image/png'
426
+ const binary = atob(dataUrl.slice(comma + 1))
427
+ const bytes = new Uint8Array(binary.length)
428
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
429
+ return new File([bytes], name, { type })
430
+ }
431
+
432
+ function apply(ctx) {
433
+ ctx.effect(() => {
434
+ const tag = document.createElement('style')
435
+ tag.dataset.stagePanelStyle = ''
436
+ tag.textContent = CSS
437
+ document.head.appendChild(tag)
438
+ return () => tag.remove()
439
+ }, 'teaching-board: styles')
440
+
441
+ function StagePanelView(props) {
442
+ const { useSession, useInput, inputActions } = props
443
+ const latest = useSession((snapshot) => {
444
+ const panels = []
445
+ let status = null
446
+ let choice = null
447
+ let snapReq = null
448
+ for (const node of snapshot.nodes) {
449
+ if (!node) continue
450
+ const call = node.call || null
451
+ const name = (call && call.name) || node.name || null
452
+ if (name === 'stage_snapshot') {
453
+ // 工具调用先于结果出现;用 callId 去重,保证每个请求只截一次。
454
+ const callId = node.callId || (call && call.callId) || null
455
+ if (callId) {
456
+ const args = parseArgs(node.argsRaw || (call && call.argsRaw) || '')
457
+ snapReq = { callId, note: args && typeof args.note === 'string' ? args.note : '' }
458
+ }
459
+ }
460
+ if (node.kind !== 'tool-result' || !call) continue
461
+ if (call.name === 'stage_panel') {
462
+ const args = parseArgs(call.argsRaw)
463
+ if (args) panels.push(args)
464
+ } else if (call.name === 'stage_status') status = parseArgs(call.argsRaw)
465
+ else if (call.name === 'stage_choice') choice = parseArgs(call.argsRaw)
466
+ }
467
+ return { panels, status, choice, snapReq }
468
+ })
469
+
470
+ const frameRef = useRef(null)
471
+ const handledRef = useRef(new Set())
472
+ const warnedRef = useRef(new Set())
473
+ const toolRef = useRef({ mode: 'off', color: COLORS[0], size: 3 })
474
+ const boardsRef = useRef(new Map()) // boardId -> { strokes, scrollY }
475
+ const boardIdRef = useRef(null)
476
+ // 新 iframe 载入时,runtime 会先报一次空笔迹(初始 count);那一条必须丢掉,
477
+ // 否则它会在 ready 触发的“还笔迹”之前把存下来的笔迹冲掉。
478
+ const awaitingReadyRef = useRef(true)
479
+ // 活着的 iframe 已经吃进去多少条 op(装载时烘进 HTML 的 + 之后 postMessage 打进去的)
480
+ const appliedRef = useRef(0)
481
+ const opsRef = useRef([])
482
+ const docRef = useRef({ key: null, html: null })
483
+ const [doc, setDoc] = useState(docRef.current)
484
+ const [tool, setTool] = useState(toolRef.current)
485
+ const [strokes, setStrokes] = useState(0)
486
+ const [toast, setToast] = useState('')
487
+
488
+ const status = latest.status || null
489
+ const choice = latest.choice || null
490
+ const options = choice && Array.isArray(choice.options) ? choice.options : []
491
+ const composed = composeBoard(latest.panels)
492
+ const boardId = composed ? composed.board : null
493
+ boardIdRef.current = boardId
494
+ const boardTitle = (composed && composed.title) || (status && status.title) || '教学平面'
495
+ opsRef.current = composed === null ? [] : composed.ops
496
+ const signature = composed === null ? 'none' : composed.signature
497
+ const srcDoc = doc.html
498
+
499
+ // 输入框是否空闲。agent 主动截图只在空闲时替用户提交——否则会把人家正在写的
500
+ // 草稿一起发出去。hook 必须在组件顶层调用,结果存进 ref 供消息监听器
501
+ // (那个闭包早于后续渲染)读到最新值。
502
+ const useInputSafe = typeof useInput === 'function' ? useInput : null
503
+ const idleNow = useInputSafe
504
+ ? useInputSafe((s) => s.draft === '' && (Array.isArray(s.imageIds) ? s.imageIds.length === 0 : true))
505
+ : true
506
+ const idleRef = useRef(true)
507
+ idleRef.current = idleNow !== false
508
+
509
+ const send = (text) => {
510
+ try {
511
+ if (inputActions) {
512
+ inputActions.setDraft(text)
513
+ inputActions.submit()
514
+ }
515
+ } catch {
516
+ /* 提交失败仅静默,不阻塞视图 */
517
+ }
518
+ }
519
+
520
+ const post = (msg) => {
521
+ const frame = frameRef.current
522
+ if (frame && frame.contentWindow) {
523
+ try { frame.contentWindow.postMessage({ __stagePanel: true, ...msg }, '*') } catch { /* 面板已卸载 */ }
524
+ }
525
+ }
526
+
527
+ const flash = (text) => {
528
+ setToast(text)
529
+ window.setTimeout(() => setToast((cur) => (cur === text ? '' : cur)), 3600)
530
+ }
531
+
532
+ /** 截图 → 草稿图片;auto=true 时再替用户提交(agent 主动请求的路径)。 */
533
+ const deliver = (dataUrl, auto, note) => {
534
+ const conversation = ctx.get('conversation')
535
+ if (!conversation || typeof conversation.createDraftImages !== 'function') {
536
+ flash('截图成功,但 conversation 服务不可用,无法放入输入框')
537
+ return
538
+ }
539
+ if (!inputActions || typeof inputActions.addImages !== 'function') {
540
+ flash('截图成功,但输入框不可用,无法放入草稿')
541
+ return
542
+ }
543
+ let ids
544
+ try {
545
+ const file = dataUrlToFile(dataUrl, `stage-panel-${Date.now()}.png`)
546
+ const created = conversation.createDraftImages([file])
547
+ ids = (created || []).map((a) => a.id)
548
+ } catch (error) {
549
+ flash('截图已生成,但附件创建失败:' + (error && error.message ? error.message : error))
550
+ return
551
+ }
552
+ if (ids.length === 0) {
553
+ flash('截图已生成,但附件创建失败')
554
+ return
555
+ }
556
+ // 提交阶段可能短暂拒绝(admission/claim 中),退避重试几次再放弃。
557
+ const attempt = (left) => {
558
+ let accepted = false
559
+ try { accepted = inputActions.addImages(ids) } catch { accepted = false }
560
+ if (accepted) {
561
+ if (auto) {
562
+ if (!idleRef.current) {
563
+ // 用户正在写东西:只放入口,不替他按回车。
564
+ flash('agent 请求了这个板面的截图:已放入输入框(你正在输入,没有自动发送)')
565
+ return
566
+ }
567
+ try {
568
+ if (note) inputActions.setDraft(note)
569
+ inputActions.submit()
570
+ flash('已把面板截图发回会话')
571
+ } catch {
572
+ flash('截图已放入输入框,但自动发送失败,请手动回车')
573
+ }
574
+ } else {
575
+ flash('截图已放入输入框 —— 补充一句说明后回车发送')
576
+ }
577
+ return
578
+ }
579
+ if (left <= 1) {
580
+ flash('截图已生成,但输入框当前拒绝图片(可能在忙或超限)')
581
+ return
582
+ }
583
+ window.setTimeout(() => attempt(left - 1), 700)
584
+ }
585
+ attempt(5)
586
+ }
587
+
588
+ useEffect(() => {
589
+ const onMessage = (event) => {
590
+ const d = event.data
591
+ if (!d) return
592
+ if (d.__stagePanel === true) {
593
+ if (d.type === 'capture') {
594
+ if (d.ok === true && typeof d.dataUrl === 'string') {
595
+ deliver(d.dataUrl, d.auto === true, d.note || '')
596
+ } else {
597
+ flash('截图失败:' + (d.error || '未知原因'))
598
+ }
599
+ } else if (d.type === 'ink') {
600
+ if (awaitingReadyRef.current) return // 初始空笔迹:丢掉,等 ready 还笔迹
601
+ setStrokes(Number(d.strokes) || 0)
602
+ const id = boardIdRef.current
603
+ if (id && Array.isArray(d.data)) {
604
+ const rec = boardsRef.current.get(id) || { strokes: [], scrollY: 0 }
605
+ rec.strokes = d.data
606
+ boardsRef.current.set(id, rec)
607
+ }
608
+ } else if (d.type === 'scroll') {
609
+ const id = boardIdRef.current
610
+ if (id) {
611
+ const rec = boardsRef.current.get(id) || { strokes: [], scrollY: 0 }
612
+ rec.scrollY = Number(d.y) || 0
613
+ boardsRef.current.set(id, rec)
614
+ }
615
+ } else if (d.type === 'ready') {
616
+ awaitingReadyRef.current = false
617
+ // iframe 刚载入:把当前工具状态推回去(用 ref 避免闭包过期),
618
+ // 补上装载之后才到的增量,再把这块板之前存下的笔迹与滚动位置还回去。
619
+ const t = toolRef.current
620
+ post({ cmd: 'mode', value: t.mode })
621
+ post({ cmd: 'color', value: t.color })
622
+ post({ cmd: 'size', value: t.size })
623
+ const all = opsRef.current || []
624
+ const tail = all.slice(appliedRef.current)
625
+ appliedRef.current = all.length
626
+ if (tail.length > 0) post({ cmd: 'patch', ops: tail })
627
+ const id = boardIdRef.current
628
+ const rec = id ? boardsRef.current.get(id) : null
629
+ post({ cmd: 'load', strokes: (rec && rec.strokes) || [], scrollY: (rec && rec.scrollY) || 0 })
630
+ }
631
+ return
632
+ }
633
+ if (d.localweb === true && d.type === 'panel_input' && typeof d.text === 'string') {
634
+ send(d.text)
635
+ }
636
+ }
637
+ window.addEventListener('message', onMessage)
638
+ return () => window.removeEventListener('message', onMessage)
639
+ }, [])
640
+
641
+ useEffect(() => {
642
+ if (composed === null) {
643
+ if (docRef.current.key !== null) {
644
+ docRef.current = { key: null, html: null }
645
+ setDoc(docRef.current)
646
+ }
647
+ return
648
+ }
649
+ const key = composed.board + '#' + composed.openCount
650
+ // 同一份文档:不再换 srcdoc(换 = 整块重载),增量走 postMessage。
651
+ if (docRef.current.key === key) return
652
+ const next = { key, html: injectRuntime(composed.html) }
653
+ docRef.current = next
654
+ appliedRef.current = composed.ops.length // 这份文档里已经烘进去了
655
+ awaitingReadyRef.current = true
656
+ setDoc(next)
657
+ }, [signature])
658
+
659
+ // 增量:新到的 op 原地打进活着的 iframe —— 不重载 → 板内状态、滚动、动画都不受影响。
660
+ useEffect(() => {
661
+ if (composed === null) return
662
+ if (docRef.current.key !== composed.board + '#' + composed.openCount) return
663
+ if (awaitingReadyRef.current) return // 装载中:等 ready 统一同步
664
+ const tail = composed.ops.slice(appliedRef.current)
665
+ if (tail.length === 0) return
666
+ appliedRef.current = composed.ops.length
667
+ post({ cmd: 'patch', ops: tail })
668
+ }, [signature])
669
+
670
+ // agent 主动截图:快照里出现未处理的 stage_snapshot 调用就拍一张。
671
+ // 面板没挂载时不标记已处理,等它挂上再拍。
672
+ useEffect(() => {
673
+ const req = latest.snapReq
674
+ if (!req || !req.callId) return
675
+ if (handledRef.current.has(req.callId)) return
676
+ if (srcDoc === null) {
677
+ if (!warnedRef.current.has(req.callId)) {
678
+ warnedRef.current.add(req.callId)
679
+ flash('agent 请求截图,但面板未打开 —— 请切到「面板」页签')
680
+ }
681
+ return
682
+ }
683
+ handledRef.current.add(req.callId)
684
+ post({ cmd: 'capture', auto: true, requestId: req.callId, note: req.note })
685
+ }, [latest.snapReq && latest.snapReq.callId, srcDoc])
686
+
687
+ const setMode = (mode) => {
688
+ const next = tool.mode === mode ? 'off' : mode
689
+ toolRef.current = { ...toolRef.current, mode: next }
690
+ setTool(toolRef.current)
691
+ post({ cmd: 'mode', value: next })
692
+ }
693
+
694
+ return createElement('div', { className: 'stgp-root' },
695
+ createElement('div', { className: 'stgp-head' },
696
+ createElement('span', { className: 'stgp-title' }, (status && status.title) || '教学平面'),
697
+ createElement('span', { className: 'stgp-state' }, (status && status.state) || 'idle'),
698
+ status && Array.isArray(status.context) ? status.context.map((c, i) =>
699
+ createElement('span', { className: 'stgp-ctx', key: i }, `${c.label}: ${c.value}`)) : null,
700
+ createElement('div', { className: 'stgp-tools' },
701
+ createElement('button', { className: 'stgp-tool' + (tool.mode === 'pen' ? ' on' : ''), onClick: () => setMode('pen') }, '画笔'),
702
+ createElement('button', { className: 'stgp-tool' + (tool.mode === 'erase' ? ' on' : ''), onClick: () => setMode('erase') }, '橡皮'),
703
+ COLORS.map((c) => createElement('button', {
704
+ key: c,
705
+ className: 'stgp-sw' + (tool.color === c ? ' on' : ''),
706
+ style: { background: c },
707
+ title: c,
708
+ onClick: () => {
709
+ toolRef.current = { ...toolRef.current, color: c }
710
+ setTool(toolRef.current)
711
+ post({ cmd: 'color', value: c })
712
+ },
713
+ })),
714
+ createElement('input', {
715
+ className: 'stgp-size', type: 'range', min: 1, max: 14, value: tool.size,
716
+ onChange: (e) => {
717
+ const v = Number(e.target.value)
718
+ toolRef.current = { ...toolRef.current, size: v }
719
+ setTool(toolRef.current)
720
+ post({ cmd: 'size', value: v })
721
+ },
722
+ }),
723
+ createElement('button', { className: 'stgp-tool', onClick: () => post({ cmd: 'undo' }) }, '撤销'),
724
+ createElement('button', { className: 'stgp-tool', onClick: () => post({ cmd: 'clear' }) }, '清空'),
725
+ createElement('span', { className: 'stgp-count' }, strokes + ' 笔'),
726
+ createElement('button', { className: 'stgp-tool primary', onClick: () => post({ cmd: 'capture', auto: false }) }, '截图'),
727
+ ),
728
+ ),
729
+ createElement('div', { className: 'stgp-body' },
730
+ srcDoc !== null
731
+ ? createElement('iframe', {
732
+ ref: frameRef,
733
+ className: 'stgp-frame',
734
+ sandbox: 'allow-scripts',
735
+ title: boardTitle,
736
+ srcDoc,
737
+ })
738
+ : createElement('div', { className: 'stgp-empty' }, '暂无板书 —— 让 agent 调用 stage_panel 渲染一个'),
739
+ toast ? createElement('div', { className: 'stgp-toast' }, toast) : null,
740
+ ),
741
+ options.length > 0
742
+ ? createElement('div', { className: 'stgp-choices' },
743
+ options.map((o) => createElement('button', {
744
+ className: 'stgp-choice',
745
+ key: o.id,
746
+ onClick: () => send(`[面板选择] ${o.label}`),
747
+ }, o.label)))
748
+ : null,
749
+ )
750
+ }
751
+
752
+ ctx.slots.inject('conversation.view', () => ctx.slots.register(
753
+ { name: 'conversation.view', id: 'stage-panel', order: 40, label: '教学平面' },
754
+ (props) => createElement(StagePanelView, props),
755
+ ))
756
+ }
757
+
758
+ return { name: 'teaching-board', inject: ['slots'], apply }
759
+ },
760
+ })
package/src/index.js ADDED
@@ -0,0 +1,126 @@
1
+ /** @arcaneorion/dsh-teaching-board — host 半(静态 bundle)。
2
+ *
3
+ * 定位:会话内「教学平面」——把 agent 生成的 self-contained HTML 投影为可讲解、
4
+ * 可勾画、可截图的板书界面。
5
+ *
6
+ * 原生设计(单一数据流):只注册模型工具,无私有状态、无 RPC、无 wait。
7
+ * 「面板」状态 = 会话中最近一次 stage_* 工具调用的参数(client 从会话快照消费);
8
+ * 用户点选/回传 = 真实用户消息(DSH 原生输入流)。
9
+ *
10
+ * 截图为什么不能出现在工具结果里:面板渲染在用户浏览器的沙箱 iframe 内,宿主拿不到
11
+ * 那些像素,而静态 bundle 也无法向客户端注册自定义 Remote 端点。所以截图走产品既有的
12
+ * 用户输入通道回来——client 半捕获后把 PNG 放进输入草稿并提交,图片作为一条真实用户
13
+ * 消息进入会话,模型在下一轮就能看见。这与 stage_choice 的机制完全一致。
14
+ */
15
+ import { defineTool } from '@deepseek-ai/dsh-tools'
16
+
17
+ export const name = 'teaching-board'
18
+ export const inject = ['tools']
19
+
20
+ export function apply(ctx) {
21
+ const textRender = (_args, value) => [{ type: 'text', text: value }]
22
+
23
+ ctx.tools.register(defineTool({
24
+ name: 'stage_panel',
25
+ description:
26
+ '在教学平面(「教学平面」页签,沙箱 iframe)上写板书。'
27
+ + '一块板 = 一个主题/一节课:用同一个 board id 连续调用很多次都算「同一块板在演进」,'
28
+ + '学生的圈画笔迹与滚动位置会保留;换 board id 才是换新板(新板 = 新主题)。'
29
+ + '更新方式用 op 选择:append(默认,在板尾再写一段,最省 token)/ open(开板或整块重写)/ '
30
+ + 'set(替换 HTML 里 data-stage-region="名称" 的那块)/ remove(擦掉某块)。'
31
+ + '所以「再写一段」不要重发整块 HTML——那会丢掉学生笔迹、把滚动顶回去,也白烧 token。'
32
+ + '面板 HTML 必须 self-contained(内联 CSS/JS、data: 资源,不依赖外网);公式用 MathML(浏览器原生,零依赖)。'
33
+ + '面板内纯本地交互(筛选/tab/滑块等)不打扰对话;需要用户在方向上做选择时另用 stage_choice;'
34
+ + '要看学生圈了什么用 stage_snapshot。',
35
+ parameters: {
36
+ html: { type: 'string', description: '本次写入板面的 HTML(op=open/append/set 必需;可为完整文档,也可为片段)' },
37
+ board: { type: 'string', description: '板面 id(如 "stats-se5")。同一 id 的多次调用 = 同一块板的连续演进。省略 = 一次性整块替换,不保留笔迹。' },
38
+ op: { type: 'string', description: 'open=开板/整块替换;append=在板尾追加(默认);set=替换某个 data-stage-region 区域;remove=移除该区域。' },
39
+ region: { type: 'string', description: 'op=set/remove 时的区域名,对应 HTML 里的 data-stage-region="名称"。' },
40
+ title: { type: 'string', description: '板面标题(显示在状态条)' },
41
+ },
42
+ output: { schema: { type: 'string' }, render: textRender },
43
+ execute: async (args) => {
44
+ const op = typeof args.op === 'string' && args.op !== '' ? args.op : 'append'
45
+ const board = typeof args.board === 'string' && args.board !== '' ? args.board : null
46
+ const region = typeof args.region === 'string' && args.region !== '' ? args.region : null
47
+ const html = typeof args.html === 'string' ? args.html : null
48
+ if (html === null && op !== 'remove') {
49
+ return JSON.stringify({ ok: false, error: `op=${op} 需要 html` })
50
+ }
51
+ if ((op === 'set' || op === 'remove') && region === null) {
52
+ return JSON.stringify({ ok: false, error: `op=${op} 需要 region` })
53
+ }
54
+ return JSON.stringify({
55
+ ok: true,
56
+ rendered: true,
57
+ board: board === null ? '(一次性板面)' : board,
58
+ op,
59
+ region,
60
+ title: typeof args.title === 'string' ? args.title : null,
61
+ note: board === null
62
+ ? '未给 board:一次性整块替换,学生笔迹会被清掉。同一主题的演进请带上 board id。'
63
+ : '请结束本轮回复;教学平面按这个 op 更新,学生笔迹保留。',
64
+ })
65
+ },
66
+ }))
67
+
68
+ ctx.tools.register(defineTool({
69
+ name: 'stage_status',
70
+ description: '更新「教学平面」的状态条与上下文信息(当前讲到哪、任务、阶段、等待态等)。',
71
+ parameters: {
72
+ title: { type: 'string', description: '状态条标题(通常是当前这一段教学的主题)' },
73
+ state: { type: 'string', description: '状态:idle / working / waiting_for_user / done' },
74
+ context: { type: 'array', description: '上下文行数组 [{label,value}],例如 [{label:"进度",value:"3/8"}]' },
75
+ },
76
+ output: { schema: { type: 'string' }, render: textRender },
77
+ execute: async (args) => JSON.stringify({
78
+ ok: true,
79
+ status: {
80
+ title: typeof args.title === 'string' ? args.title : null,
81
+ state: typeof args.state === 'string' ? args.state : null,
82
+ context: Array.isArray(args.context) ? args.context.length : 0,
83
+ },
84
+ }),
85
+ }))
86
+
87
+ ctx.tools.register(defineTool({
88
+ name: 'stage_choice',
89
+ description:
90
+ '在教学平面底部发布方向选项(choice 栏),用于「下一步讲什么 / 要不要继续」这类分支。'
91
+ + '发布后结束本轮:用户点选会以用户消息(“[面板选择] <选项>”)到达本会话,无需也不要用任何 wait 工具。',
92
+ parameters: {
93
+ id: { type: 'string', required: true, description: 'choice 组 id' },
94
+ options: { type: 'array', required: true, description: '[{id,label}] 选项数组' },
95
+ },
96
+ output: { schema: { type: 'string' }, render: textRender },
97
+ execute: async (args) => {
98
+ const opts = Array.isArray(args.options)
99
+ ? args.options.filter((o) => o && typeof o.id === 'string').map((o) => ({ id: o.id, label: typeof o.label === 'string' ? o.label : o.id }))
100
+ : []
101
+ return JSON.stringify({ ok: true, choiceId: args.id, optionCount: opts.length, next: '用户点选后将以用户消息到达,无需 wait' })
102
+ },
103
+ }))
104
+
105
+ ctx.tools.register(defineTool({
106
+ name: 'stage_snapshot',
107
+ description:
108
+ '请求把「教学平面」当前的画面截图(含用户在其上的圈画笔迹),并把这张图发回本会话。'
109
+ + '图片不会出现在本工具的结果里:它会作为一条带图片的真实用户消息回来,你在下一轮就能看到它——'
110
+ + '所以调用后请结束本轮,不要在同一轮里假装已经看到图。'
111
+ + '生效前提:用户此刻正停留在「教学平面」页签(该页签没挂载就截不到图,此时会返回失败原因,请让用户切到教学平面页签后重试)。'
112
+ + '典型用途:学生圈出了看不懂的地方让你看、你想核对自己画的板书是否讲清楚了、想把当前画面留档。'
113
+ + '不要连续调用;每轮最多一次。',
114
+ parameters: {
115
+ note: { type: 'string', description: '随图片一起发回的一句话说明(例如「请看我圈的这一步」)。默认“[面板截图]”。' },
116
+ },
117
+ output: { schema: { type: 'string' }, render: textRender },
118
+ execute: async (args) => JSON.stringify({
119
+ ok: true,
120
+ requested: true,
121
+ requestId: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
122
+ note: typeof args.note === 'string' && args.note.trim() !== '' ? args.note : '[面板截图]',
123
+ next: '结束本轮;client 半会在面板挂载时截图并以用户消息发回,你在下一轮看到图片。',
124
+ }),
125
+ }))
126
+ }