@arcaneorion/dsh-teaching-board 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -4
- package/cordis.patch.yml +6 -2
- package/package.json +5 -3
- package/skills/stage-panel/SKILL.md +107 -0
- package/skills/stage-panel/references/diagram-selection.md +80 -0
- package/skills/stage-panel/references/html-patterns.md +93 -0
- package/skills/stage-panel/references/visual-style.md +43 -0
- package/skills/stage-panel/submode/code/SKILL.md +17 -0
- package/skills/stage-panel/submode/code/examples/go-hello-channel.json +36 -0
- package/skills/stage-panel/submode/code/examples/go-http-api-basics.json +58 -0
- package/skills/stage-panel/submode/code/examples/py-agent-loop.json +79 -0
- package/skills/stage-panel/submode/code/examples/py-ai-parameters-intro.json +65 -0
- package/skills/stage-panel/submode/code/examples/py-fizzbuzz-intro.json +74 -0
- package/skills/stage-panel/submode/code/examples/py-training-loop.json +114 -0
- package/skills/stage-panel/submode/code/examples/rs-move-basics.json +118 -0
- package/skills/stage-panel/submode/code/references/host-toolchains.md +61 -0
- package/skills/stage-panel/submode/code/schemas/lesson.schema.json +225 -0
- package/skills/stage-panel/submode/code/templates/lab.html +561 -0
- package/skills/stage-panel/submode/game-study/SKILL.md +16 -0
- package/skills/stage-panel/submode/game-study/examples/hft-microstructure.json +60 -0
- package/skills/stage-panel/submode/game-study/examples/securities-law.json +57 -0
- package/skills/stage-panel/submode/game-study/schemas/quest.schema.json +95 -0
- package/skills/stage-panel/submode/game-study/templates/quiz.html +516 -0
- package/skills/stage-panel/submode/learn/SKILL.md +16 -0
- package/skills/stage-panel/submode/learn/examples/epsilon-delta.json +140 -0
- package/skills/stage-panel/submode/learn/examples/template-showcase.json +147 -0
- package/skills/stage-panel/submode/learn/schemas/lesson.schema.json +184 -0
- package/skills/stage-panel/submode/learn/templates/lesson.html +782 -0
- package/skills/stage-panel/submode/ml/SKILL.md +9 -0
- package/skills/stage-panel/submode/ml/examples/gd.json +7 -0
- package/skills/stage-panel/submode/ml/templates/gd.html +217 -0
- package/skills/stage-panel/submode/quant/SKILL.md +9 -0
- package/skills/stage-panel/submode/quant/references/export_backtest_data.py +192 -0
- package/skills/stage-panel/submode/quant/templates/dashboard.html +381 -0
- package/src/client.js +177 -15
- package/src/index.js +11 -0
- package/src/skills.js +88 -0
package/src/client.js
CHANGED
|
@@ -56,12 +56,22 @@ window.__ModuleLoader__.load({
|
|
|
56
56
|
if (window.__stagePanelInk) return;
|
|
57
57
|
var P = { mode: 'off', color: '#e5484d', size: 3 };
|
|
58
58
|
var strokes = [], cur = null, drawing = false;
|
|
59
|
+
var anchorCache = {}; // 一次重绘内,同一块只算一次位移
|
|
60
|
+
var lastSX = -1, lastSY = -1; // 上次重绘时的滚动位置(判断要不要重画)
|
|
59
61
|
var MAX_BYTES = 1200 * 1024;
|
|
60
62
|
|
|
61
63
|
function send(m) { try { parent.postMessage(m, '*'); } catch (e) {} }
|
|
62
64
|
|
|
63
65
|
var cv = document.createElement('canvas');
|
|
64
66
|
cv.setAttribute('data-stage-ink', '');
|
|
67
|
+
/**
|
|
68
|
+
* 画布仍是**视口大小**(内存恒定,不受板面长度影响 —— 铺满整份文档在长板子上
|
|
69
|
+
* 会变成几十 MB 的位图,移动端还有 canvas 面积上限),
|
|
70
|
+
* 但笔迹存的是**文档坐标**,重绘时按滚动量平移 —— 于是它钉在板面内容上。
|
|
71
|
+
*
|
|
72
|
+
* 原实现存 clientX/clientY 且不平移:笔迹其实钉在**屏幕**上,
|
|
73
|
+
* 一滚动(或 AI 增量写板把内容推下去)就与内容错位。
|
|
74
|
+
*/
|
|
65
75
|
cv.style.cssText = 'position:fixed;left:0;top:0;width:100%;height:100%;z-index:2147483000;pointer-events:none;touch-action:none;';
|
|
66
76
|
(document.body || document.documentElement).appendChild(cv);
|
|
67
77
|
var g = cv.getContext('2d');
|
|
@@ -69,11 +79,20 @@ window.__ModuleLoader__.load({
|
|
|
69
79
|
function dpr() { return window.devicePixelRatio || 1; }
|
|
70
80
|
function fit() {
|
|
71
81
|
var d = dpr();
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
var w = Math.max(1, Math.round(window.innerWidth));
|
|
83
|
+
var h = Math.max(1, Math.round(window.innerHeight));
|
|
84
|
+
cv.style.width = w + 'px';
|
|
85
|
+
cv.style.height = h + 'px';
|
|
86
|
+
var nw = Math.max(1, Math.round(w * d));
|
|
87
|
+
var nh = Math.max(1, Math.round(h * d));
|
|
88
|
+
if (nw === cv.width && nh === cv.height) return; // 尺寸没变就别白清一次位图
|
|
89
|
+
cv.width = nw;
|
|
90
|
+
cv.height = nh;
|
|
74
91
|
redraw();
|
|
75
92
|
}
|
|
76
|
-
function paintStroke(s) {
|
|
93
|
+
function paintStroke(s, dx, dy) {
|
|
94
|
+
dx = dx || 0;
|
|
95
|
+
dy = dy || 0;
|
|
77
96
|
g.globalCompositeOperation = s.erase ? 'destination-out' : 'source-over';
|
|
78
97
|
g.strokeStyle = s.color;
|
|
79
98
|
g.lineWidth = s.erase ? s.size * 5 : s.size;
|
|
@@ -81,19 +100,45 @@ window.__ModuleLoader__.load({
|
|
|
81
100
|
g.lineJoin = 'round';
|
|
82
101
|
g.beginPath();
|
|
83
102
|
for (var i = 0; i < s.pts.length; i++) {
|
|
84
|
-
if (i === 0) g.moveTo(s.pts[i][0], s.pts[i][1]
|
|
103
|
+
if (i === 0) g.moveTo(s.pts[i][0] + dx, s.pts[i][1] + dy);
|
|
104
|
+
else g.lineTo(s.pts[i][0] + dx, s.pts[i][1] + dy);
|
|
85
105
|
}
|
|
86
|
-
if (s.pts.length === 1) g.lineTo(s.pts[0][0] + 0.1, s.pts[0][1]);
|
|
106
|
+
if (s.pts.length === 1) g.lineTo(s.pts[0][0] + dx + 0.1, s.pts[0][1] + dy);
|
|
87
107
|
g.stroke();
|
|
88
108
|
g.globalCompositeOperation = 'source-over';
|
|
89
109
|
}
|
|
90
110
|
function redraw() {
|
|
91
111
|
var d = dpr();
|
|
112
|
+
lastSX = window.scrollX || 0;
|
|
113
|
+
lastSY = window.scrollY || 0;
|
|
114
|
+
anchorCache = {}; // 每次重绘重新解析锚点(块可能刚被 AI 改写 / 移动过)
|
|
92
115
|
g.setTransform(1, 0, 0, 1, 0, 0);
|
|
93
116
|
g.clearRect(0, 0, cv.width, cv.height);
|
|
94
117
|
g.setTransform(d, 0, 0, d, 0, 0);
|
|
95
|
-
|
|
96
|
-
|
|
118
|
+
// ★ 视口画布 + 按滚动量平移:笔迹存的是文档坐标,平移之后正好落在板面内容上。
|
|
119
|
+
g.translate(-lastSX, -lastSY);
|
|
120
|
+
for (var i = 0; i < strokes.length; i++) {
|
|
121
|
+
var off = strokeDelta(strokes[i]);
|
|
122
|
+
paintStroke(strokes[i], off[0], off[1]);
|
|
123
|
+
}
|
|
124
|
+
if (cur) {
|
|
125
|
+
var offCur = strokeDelta(cur);
|
|
126
|
+
paintStroke(cur, offCur[0], offCur[1]);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 滚动 = 笔迹要跟着内容走。
|
|
132
|
+
*
|
|
133
|
+
* ⚠️ 必须**同步**重画,不能塞进 requestAnimationFrame:
|
|
134
|
+
* 后台标签页 / 被遮挡的 iframe 里 rAF 会被浏览器暂停,那样笔迹就定格在旧位置
|
|
135
|
+
* (实测:审计测试 C/D 两项就是被这个坑掉的)。浏览器本来就把 scroll 事件
|
|
136
|
+
* 节流到每帧一次,同步重画不会把主线程刷爆。
|
|
137
|
+
*/
|
|
138
|
+
function syncScroll() {
|
|
139
|
+
var x = window.scrollX || 0, y = window.scrollY || 0;
|
|
140
|
+
if (x === lastSX && y === lastSY) return; // 位置没动就别白画一遍
|
|
141
|
+
redraw();
|
|
97
142
|
}
|
|
98
143
|
function count() { send({ __stagePanel: true, type: 'ink', strokes: strokes.length, data: strokes }); }
|
|
99
144
|
|
|
@@ -137,17 +182,89 @@ window.__ModuleLoader__.load({
|
|
|
137
182
|
if (n && n.parentNode) n.parentNode.removeChild(n);
|
|
138
183
|
}
|
|
139
184
|
|
|
185
|
+
/** 视口坐标 → **文档坐标**(笔迹的坐标系:原点 = 文档左上角)。
|
|
186
|
+
画布是视口大小的浮层,指针事件给的是 clientX/clientY,所以必须加上
|
|
187
|
+
当前滚动量换算成文档坐标;原实现直接把 clientX/clientY 存下来,
|
|
188
|
+
笔迹其实钉在屏幕上 —— 一滚动、或 AI 增量写板把内容推下去,就与内容错位。 */
|
|
189
|
+
function toBoard(e) {
|
|
190
|
+
var r = cv.getBoundingClientRect();
|
|
191
|
+
return [
|
|
192
|
+
e.clientX - r.left + (window.scrollX || 0),
|
|
193
|
+
e.clientY - r.top + (window.scrollY || 0),
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 这一笔落在哪一块上(.stg-block),记下它的**文档原点**当锚点。
|
|
199
|
+
*
|
|
200
|
+
* 为什么需要锚点:文档坐标只解决「滚动」,解决不了「重排」——
|
|
201
|
+
* AI 在用户圈过的那段上面又写了一段(set/remove 也会改变高度),
|
|
202
|
+
* 那段文字整体下移,而笔迹的文档坐标没变,于是又脱开了。
|
|
203
|
+
* 锚在「它所在的那一块」上,块移动多少,笔迹就跟着移动多少 —— 这才是写在书上。
|
|
204
|
+
*
|
|
205
|
+
* ⚠️ 不能用 elementFromPoint:画布盖在最上层,内容层根本点不到。
|
|
206
|
+
*/
|
|
207
|
+
function anchorAt(clientX, clientY) {
|
|
208
|
+
var blocks = document.querySelectorAll('.stg-block');
|
|
209
|
+
for (var i = 0; i < blocks.length; i++) {
|
|
210
|
+
var r = blocks[i].getBoundingClientRect();
|
|
211
|
+
if (clientY >= r.top && clientY <= r.bottom && clientX >= r.left && clientX <= r.right) {
|
|
212
|
+
return {
|
|
213
|
+
region: blocks[i].getAttribute('data-stage-region') || null,
|
|
214
|
+
index: i,
|
|
215
|
+
ox: r.left + (window.scrollX || 0),
|
|
216
|
+
oy: r.top + (window.scrollY || 0),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return null; // 落在块外的空白上(比如最后一块下面那片留白):只能靠文档坐标
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 锚点现在跑到哪了(相对画下去时的位置)。块没了就退回绝对坐标。 */
|
|
224
|
+
function anchorDeltaByKey(key) {
|
|
225
|
+
var cached = anchorCache[key];
|
|
226
|
+
if (cached !== undefined) return cached;
|
|
227
|
+
|
|
228
|
+
var a = JSON.parse(key); // [region, index, ox, oy]
|
|
229
|
+
var node = null;
|
|
230
|
+
if (a[0]) {
|
|
231
|
+
try { node = document.querySelector('[data-stage-region="' + String(a[0]).replace(/"/g, '') + '"]'); } catch (e) { node = null; }
|
|
232
|
+
}
|
|
233
|
+
if (!node) {
|
|
234
|
+
var all = document.querySelectorAll('.stg-block');
|
|
235
|
+
node = all[a[1]] || null;
|
|
236
|
+
}
|
|
237
|
+
var out = [0, 0];
|
|
238
|
+
if (node) {
|
|
239
|
+
var r = node.getBoundingClientRect();
|
|
240
|
+
out = [r.left + (window.scrollX || 0) - a[2], r.top + (window.scrollY || 0) - a[3]];
|
|
241
|
+
}
|
|
242
|
+
anchorCache[key] = out;
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function strokeDelta(s) {
|
|
247
|
+
var a = s.anchor;
|
|
248
|
+
if (!a) return [0, 0];
|
|
249
|
+
return anchorDeltaByKey(JSON.stringify([a.region, a.index, Math.round(a.ox), Math.round(a.oy)]));
|
|
250
|
+
}
|
|
251
|
+
|
|
140
252
|
cv.addEventListener('pointerdown', function (e) {
|
|
141
253
|
if (P.mode === 'off') return;
|
|
142
254
|
if (forwardClick(e.clientX, e.clientY)) return;
|
|
143
255
|
drawing = true;
|
|
144
256
|
try { cv.setPointerCapture(e.pointerId); } catch (err) {}
|
|
145
|
-
cur = {
|
|
257
|
+
cur = {
|
|
258
|
+
color: P.color, size: P.size, erase: P.mode === 'erase',
|
|
259
|
+
pts: [toBoard(e)],
|
|
260
|
+
// 记下「这一笔写在哪一段上」—— 那一段以后被推下去,笔迹跟着它走
|
|
261
|
+
anchor: anchorAt(e.clientX, e.clientY),
|
|
262
|
+
};
|
|
146
263
|
redraw();
|
|
147
264
|
});
|
|
148
265
|
cv.addEventListener('pointermove', function (e) {
|
|
149
266
|
if (!drawing) return;
|
|
150
|
-
cur.pts.push(
|
|
267
|
+
cur.pts.push(toBoard(e));
|
|
151
268
|
redraw();
|
|
152
269
|
});
|
|
153
270
|
function end() {
|
|
@@ -166,6 +283,8 @@ window.__ModuleLoader__.load({
|
|
|
166
283
|
return new Promise(function (resolve, reject) {
|
|
167
284
|
var w = Math.max(1, Math.round(window.innerWidth));
|
|
168
285
|
var h = Math.max(1, Math.round(window.innerHeight));
|
|
286
|
+
var sx = Math.max(0, window.scrollX || 0);
|
|
287
|
+
var sy = Math.max(0, window.scrollY || 0);
|
|
169
288
|
var clone = document.documentElement.cloneNode(true);
|
|
170
289
|
var drop = clone.querySelectorAll('[data-stage-capture-ignore]');
|
|
171
290
|
for (var d = 0; d < drop.length; d++) drop[d].parentNode.removeChild(drop[d]);
|
|
@@ -173,15 +292,31 @@ window.__ModuleLoader__.load({
|
|
|
173
292
|
var dsts = clone.querySelectorAll('canvas');
|
|
174
293
|
for (var i = 0; i < srcs.length && i < dsts.length; i++) {
|
|
175
294
|
var st = window.getComputedStyle(srcs[i]);
|
|
295
|
+
var rect = srcs[i].getBoundingClientRect();
|
|
176
296
|
var im = document.createElement('img');
|
|
177
297
|
try { im.setAttribute('src', srcs[i].toDataURL('image/png')); } catch (e) { continue; }
|
|
178
|
-
|
|
179
|
-
|
|
298
|
+
/**
|
|
299
|
+
* ★ img 要放到**文档坐标**上,不能照抄 computed left/top。
|
|
300
|
+
*
|
|
301
|
+
* 画布是 position:fixed(视口锚定)的浮层,它的 left/top 恒为 0px ——
|
|
302
|
+
* 照抄的话,位图会被贴到「文档最顶端」,而它画的却是当前这一屏的笔迹,
|
|
303
|
+
* 于是截图里的圈和内容差着整整一个滚动距离。
|
|
304
|
+
* 位图本身画的是「当前视口 + 滚动量平移」的结果,所以要换算成文档坐标再绝对定位;
|
|
305
|
+
* 再配合下面 SVG 的 viewBox(= 当前视口),拍到的才是「你正在看的那一屏」。
|
|
306
|
+
*/
|
|
307
|
+
var left = Math.round(rect.left + sx);
|
|
308
|
+
var top = Math.round(rect.top + sy);
|
|
309
|
+
im.setAttribute('style', 'position:absolute;left:' + left + 'px;top:' + top + 'px;' +
|
|
310
|
+
'width:' + st.width + ';height:' + st.height + ';z-index:2147483000;');
|
|
180
311
|
dsts[i].parentNode.replaceChild(im, dsts[i]);
|
|
181
312
|
}
|
|
182
313
|
var xml = new XMLSerializer().serializeToString(clone);
|
|
314
|
+
// 文档可能比视口长:foreignObject 装整份文档,viewBox 只框当前视口 ——
|
|
315
|
+
// 拍到的就是你正在看的区域(含笔迹),而不是文档开头。
|
|
316
|
+
var docW = Math.max(w, document.documentElement.scrollWidth || 0);
|
|
317
|
+
var docH = Math.max(h, document.documentElement.scrollHeight || 0);
|
|
183
318
|
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h +
|
|
184
|
-
'" viewBox="
|
|
319
|
+
'" viewBox="' + sx + ' ' + sy + ' ' + w + ' ' + h + '"><foreignObject x="0" y="0" width="' + docW + '" height="' + docH +
|
|
185
320
|
'">' + xml + '</foreignObject></svg>';
|
|
186
321
|
var img = new Image();
|
|
187
322
|
img.onload = function () {
|
|
@@ -257,19 +392,42 @@ window.__ModuleLoader__.load({
|
|
|
257
392
|
else if (po.op === 'set') setRegionBlock(po.region, po.html);
|
|
258
393
|
else if (po.op === 'remove') removeRegionBlock(po.region);
|
|
259
394
|
}
|
|
395
|
+
// 内容长高不影响画布(画布是视口大小、按滚动量平移),不用重新 fit
|
|
260
396
|
} else if (d.cmd === 'load') {
|
|
261
397
|
// 同一块板的演进:把父层保存的笔迹重新注入新板。
|
|
262
398
|
strokes = Array.isArray(d.strokes) ? d.strokes : [];
|
|
263
|
-
redraw();
|
|
264
|
-
count();
|
|
265
399
|
if (typeof d.scrollY === 'number' && d.scrollY > 0) {
|
|
266
400
|
try { window.scrollTo(0, d.scrollY); } catch (e) {}
|
|
267
401
|
}
|
|
402
|
+
redraw(); // 平移量取决于滚动位置,滚动还原之后必须重画一次
|
|
403
|
+
count();
|
|
268
404
|
} else if (d.cmd === 'ping') {
|
|
269
405
|
send({ __stagePanel: true, type: 'ready' });
|
|
270
406
|
}
|
|
271
407
|
});
|
|
272
408
|
|
|
409
|
+
/**
|
|
410
|
+
* 滚动 = 笔迹要跟着内容走(syncScroll 定义在上面,和 redraw 放一起)。
|
|
411
|
+
*
|
|
412
|
+
* ⚠️ 必须**同步**重画,不能塞进 requestAnimationFrame:
|
|
413
|
+
* 后台标签页 / 被遮挡的 iframe 里 rAF 会被浏览器暂停,那样笔迹就定格在旧位置
|
|
414
|
+
* (实测:审计测试 C/D 两项就是被这个坑掉的)。浏览器本来就把 scroll 事件
|
|
415
|
+
* 节流到每帧一次,同步重画不会把主线程刷爆。
|
|
416
|
+
*/
|
|
417
|
+
window.addEventListener('scroll', syncScroll, { passive: true });
|
|
418
|
+
/**
|
|
419
|
+
* 回到前台 / 重新获得焦点时**强制**重画一次。
|
|
420
|
+
*
|
|
421
|
+
* 为什么需要:滚动事件属于「渲染帧」的生命周期 —— 标签页被遮挡、iframe 不在视口里时
|
|
422
|
+
* 浏览器会暂停出帧,scroll 事件也就不派发,位图可能停在旧位置;
|
|
423
|
+
* 用户切回来第一眼就会看到错位的笔迹。(这也正是自动化测试在后台标签页里
|
|
424
|
+
* 会看到「滚动之后笔迹不动」的原因 —— 不是坐标算错了,是事件没来。)
|
|
425
|
+
*/
|
|
426
|
+
function forceRedraw() { lastSX = -1; lastSY = -1; syncScroll(); }
|
|
427
|
+
document.addEventListener('visibilitychange', function () { if (!document.hidden) forceRedraw(); });
|
|
428
|
+
window.addEventListener('focus', forceRedraw);
|
|
429
|
+
window.addEventListener('pageshow', forceRedraw);
|
|
430
|
+
|
|
273
431
|
// 把滚动位置报给父层,换板后用来还原(学生正在看的那一段不该跳回顶部)。
|
|
274
432
|
var scrollTimer = null;
|
|
275
433
|
window.addEventListener('scroll', function () {
|
|
@@ -280,7 +438,11 @@ window.__ModuleLoader__.load({
|
|
|
280
438
|
}, 120);
|
|
281
439
|
});
|
|
282
440
|
|
|
283
|
-
window.addEventListener('resize',
|
|
441
|
+
window.addEventListener('resize', function () {
|
|
442
|
+
fit();
|
|
443
|
+
lastSX = -1; lastSY = -1; // 尺寸/方向变了,强制重画一次
|
|
444
|
+
syncScroll();
|
|
445
|
+
});
|
|
284
446
|
fit();
|
|
285
447
|
count();
|
|
286
448
|
send({ __stagePanel: true, type: 'ready' });
|
package/src/index.js
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
* 定位:会话内「教学平面」——把 agent 生成的 self-contained HTML 投影为可讲解、
|
|
4
4
|
* 可勾画、可截图的板书界面。
|
|
5
5
|
*
|
|
6
|
+
* 两层都在这个包里:
|
|
7
|
+
* 能力层 = 本文件注册的 stage_* 工具 + client 半的「面板」视图;
|
|
8
|
+
* 行为层 = src/skills.js 把包内 skills/stage-panel/ 注册成 bundled skill。
|
|
9
|
+
* 挂上插件就同时拿到两者——不再依赖任何 preset 单独装一份使用手册。
|
|
10
|
+
*
|
|
6
11
|
* 原生设计(单一数据流):只注册模型工具,无私有状态、无 RPC、无 wait。
|
|
7
12
|
* 「面板」状态 = 会话中最近一次 stage_* 工具调用的参数(client 从会话快照消费);
|
|
8
13
|
* 用户点选/回传 = 真实用户消息(DSH 原生输入流)。
|
|
@@ -13,11 +18,17 @@
|
|
|
13
18
|
* 消息进入会话,模型在下一轮就能看见。这与 stage_choice 的机制完全一致。
|
|
14
19
|
*/
|
|
15
20
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
21
|
+
import { createSkillProvider } from './skills.js'
|
|
16
22
|
|
|
17
23
|
export const name = 'teaching-board'
|
|
18
24
|
export const inject = ['tools']
|
|
19
25
|
|
|
20
26
|
export function apply(ctx) {
|
|
27
|
+
// 行为层:把自带的使用手册发布进技能目录。这里用可选读取而不是 inject——
|
|
28
|
+
// 技能注册是增益,不该让白板因为缺 skills 服务而整个不挂载。
|
|
29
|
+
const skills = ctx.get('skills')
|
|
30
|
+
if (skills !== undefined) skills.registerProvider(createSkillProvider)
|
|
31
|
+
|
|
21
32
|
const textRender = (_args, value) => [{ type: 'text', text: value }]
|
|
22
33
|
|
|
23
34
|
ctx.tools.register(defineTool({
|
package/src/skills.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** 教学平面的「行为层」:把本包自带的使用手册注册成一个 bundled skill。
|
|
2
|
+
*
|
|
3
|
+
* 为什么由插件自己注册:纪律要跟着能力走。任何挂载本插件的会话(不论用哪个 preset)
|
|
4
|
+
* 都应该在技能目录里看到 stage-panel。手册若只放在某个 preset 的 skills/ 里,
|
|
5
|
+
* 其余会话就会停在「有工具、没纪律」的状态——白板能开,但不知道该怎么用。
|
|
6
|
+
*
|
|
7
|
+
* 与官方 @deepseek-ai/dsh-skill-badge 同一模式:list() 只给候选,get() 才读正文;
|
|
8
|
+
* resourceBase 指向本包目录,正文里的相对路径(references/…、submode/…)才能解析。
|
|
9
|
+
* provider 由 skills 服务托管,随插件 fiber 一起注销,不需要手动 cleanup。
|
|
10
|
+
*/
|
|
11
|
+
import { readFile } from 'node:fs/promises'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
|
|
14
|
+
const SKILL_NAME = 'stage-panel'
|
|
15
|
+
const PROVIDER_NAME = 'teaching-board'
|
|
16
|
+
const SKILL_DIR = new URL('../skills/stage-panel/', import.meta.url)
|
|
17
|
+
const SKILL_BODY_URL = new URL('SKILL.md', SKILL_DIR)
|
|
18
|
+
const RESOURCE_BASE = { kind: 'directory', path: fileURLToPath(SKILL_DIR) }
|
|
19
|
+
const INVOCATION = { modelInvocable: true, userInvocable: true }
|
|
20
|
+
/** `@deepseek-ai/dsh-skill` 导出的 BUNDLED_SKILL_RANK,随包发布技能的档次。
|
|
21
|
+
* 这里写字面量而不 import:本插件只依赖 dsh-tools,不为了一个排序常量多一条依赖边。
|
|
22
|
+
* rank 只用于同一层内的重名排序(project > runtime > user),跨层由层远近决定。 */
|
|
23
|
+
const BUNDLED_SKILL_RANK = 600
|
|
24
|
+
/** frontmatter 缺 description 时的兜底;正常情况下以 SKILL.md 为准(单一真相)。 */
|
|
25
|
+
const FALLBACK_DESCRIPTION = 'DSH 教学平面(板书面板)使用手册。'
|
|
26
|
+
|
|
27
|
+
/** 拆掉正文首部的 YAML frontmatter:它服务于「被文件系统扫描」那种用法,
|
|
28
|
+
* 由 provider 读取时属于元数据而非正文。只解析需要的一层 key: value。 */
|
|
29
|
+
function splitFrontmatter(raw) {
|
|
30
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw)
|
|
31
|
+
if (match === null) return { meta: {}, content: raw }
|
|
32
|
+
const meta = {}
|
|
33
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
34
|
+
const entry = /^([A-Za-z][\w-]*)\s*:\s*(.*)$/.exec(line.trim())
|
|
35
|
+
if (entry !== null) meta[entry[1]] = entry[2].trim()
|
|
36
|
+
}
|
|
37
|
+
return { meta, content: raw.slice(match[0].length).replace(/^\n+/, '') }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 构造 stage-panel 的 bundled 技能 provider(交给 ctx.skills.registerProvider)。 */
|
|
41
|
+
export function createSkillProvider() {
|
|
42
|
+
let loaded
|
|
43
|
+
const load = () => {
|
|
44
|
+
if (loaded === undefined) {
|
|
45
|
+
loaded = readFile(SKILL_BODY_URL, 'utf8').then((raw) => {
|
|
46
|
+
const { meta, content } = splitFrontmatter(raw)
|
|
47
|
+
return { description: meta.description ?? FALLBACK_DESCRIPTION, content }
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
return loaded
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
name: PROVIDER_NAME,
|
|
55
|
+
async list() {
|
|
56
|
+
let description = FALLBACK_DESCRIPTION
|
|
57
|
+
try {
|
|
58
|
+
description = (await load()).description
|
|
59
|
+
} catch {
|
|
60
|
+
// 手册文件缺失时仍让目录条目可用,正文读取会在 get() 处明确报错。
|
|
61
|
+
}
|
|
62
|
+
return [{
|
|
63
|
+
name: SKILL_NAME,
|
|
64
|
+
description,
|
|
65
|
+
invocation: INVOCATION,
|
|
66
|
+
provider: PROVIDER_NAME,
|
|
67
|
+
source: 'bundled',
|
|
68
|
+
resourceBase: RESOURCE_BASE,
|
|
69
|
+
rank: BUNDLED_SKILL_RANK,
|
|
70
|
+
locator: SKILL_BODY_URL,
|
|
71
|
+
path: fileURLToPath(SKILL_BODY_URL),
|
|
72
|
+
}]
|
|
73
|
+
},
|
|
74
|
+
async get() {
|
|
75
|
+
const { description, content } = await load()
|
|
76
|
+
return {
|
|
77
|
+
name: SKILL_NAME,
|
|
78
|
+
description,
|
|
79
|
+
invocation: INVOCATION,
|
|
80
|
+
provider: PROVIDER_NAME,
|
|
81
|
+
source: 'bundled',
|
|
82
|
+
resourceBase: RESOURCE_BASE,
|
|
83
|
+
content,
|
|
84
|
+
path: fileURLToPath(SKILL_BODY_URL),
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|