@xiaobuyu/nodesign 0.0.2 → 0.0.4
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.en.md +184 -0
- package/README.md +19 -25
- package/package.json +2 -1
- package/server/api/assets/helpers.js +20 -0
- package/server/api/assets/notes.js +50 -0
- package/server/api/assets.js +6 -11
- package/server/api/board.js +78 -1
- package/server/engine/agent/events.js +1 -0
- package/server/engine/agent/hooks/user-prompt-submit.js +43 -0
- package/server/engine/agent/prompts/nodesign-prelude.md +53 -1
- package/server/engine/agent/session-loop.js +11 -0
- package/server/engine/mcp/index.js +12 -0
- package/server/engine/mcp/tools/arrange-on-board.js +4 -4
- package/server/engine/mcp/tools/create-on-board.js +9 -12
- package/server/engine/mcp/tools/edit-sketch.js +167 -0
- package/server/engine/mcp/tools/look-at-board.js +124 -0
- package/server/engine/mcp/tools/read-board.js +121 -34
- package/server/engine/mcp/tools/read-user-view.js +59 -0
- package/server/engine/mcp/tools/relate-on-board.js +5 -3
- package/server/engine/mcp/tools/sketch-on-board.js +337 -0
- package/server/engine/mcp/tools/write-on-board.js +130 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +7 -0
- package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +7 -0
- package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +7 -0
- package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +7 -0
- package/server/engine/runs/board-tasklist.js +130 -0
- package/server/index.js +3 -0
- package/server/lib/binding-types.js +12 -0
- package/server/lib/board-graph-export.js +191 -0
- package/server/lib/board-groups.js +167 -0
- package/server/lib/board-hero.js +76 -0
- package/server/lib/board-kind-sizes.js +23 -0
- package/server/lib/board-relations.js +1 -1
- package/server/lib/chalk.js +121 -0
- package/server/lib/sketch-layout.js +277 -0
- package/server/projects/board-limits.js +12 -0
- package/server/projects/board-sanitize.js +252 -0
- package/server/projects/board-store.js +72 -205
- package/server/projects/ui-config.js +6 -0
- package/server/projects/viewpoint-store.js +78 -0
- package/server/ws/broker.js +3 -0
- package/web/dist/assets/{BrowserWindow-BYCgizHw.js → BrowserWindow-P_vIdh-G.js} +2 -2
- package/web/dist/assets/{DeckWindow-pNydEM4L.js → DeckWindow-BpLv5082.js} +3 -3
- package/web/dist/assets/{DocxWindow-PPgzh3Gq.js → DocxWindow-DSAU1-tD.js} +4 -4
- package/web/dist/assets/MermaidBlock-DR1GikCv.js +313 -0
- package/web/dist/assets/SiteWindow-92uBuRRR.js +18 -0
- package/web/dist/assets/abnfDiagram-VCTEODGH-uA8Esix4.js +1 -0
- package/web/dist/assets/arc-D_khH_xN.js +1 -0
- package/web/dist/assets/architectureDiagram-5GKGNRK7-DydJHqOt.js +36 -0
- package/web/dist/assets/blockDiagram-NRAW4CY4-B7wBbKiW.js +129 -0
- package/web/dist/assets/c4Diagram-UCG6FXSJ-6mrH0x7Y.js +38 -0
- package/web/dist/assets/channel-DGkccMO1.js +1 -0
- package/web/dist/assets/chunk-2Q5K7J3B-Cuikho5Q.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-3-KZ5AUz.js +15 -0
- package/web/dist/assets/chunk-F27PBJKO-Bkwq2gGU.js +1 -0
- package/web/dist/assets/chunk-G27WJ6UU-BLyQzdGE.js +231 -0
- package/web/dist/assets/chunk-JWPE2WC7-B7HxxQds.js +1 -0
- package/web/dist/assets/chunk-LCL6LL3I-CtXrnUwF.js +206 -0
- package/web/dist/assets/chunk-POPQ4Y6H-dxddZlXt.js +1 -0
- package/web/dist/assets/chunk-SVP7TREG-DpkmrgHn.js +88 -0
- package/web/dist/assets/chunk-XXDRQBXY-BwZ7RbpQ.js +1 -0
- package/web/dist/assets/classDiagram-DTDB5LWJ-BGscw3OD.js +1 -0
- package/web/dist/assets/classDiagram-v2-JRS7N3AN-BGscw3OD.js +1 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-DGFOsMUt.js +1 -0
- package/web/dist/assets/cynefin-OW5HDTMX-DUwn3ehS.js +178 -0
- package/web/dist/assets/cynefinDiagram-5FMLGOSQ-DlLAOZ9x.js +62 -0
- package/web/dist/assets/cytoscape.esm-BjAbmLdb.js +331 -0
- package/web/dist/assets/dagre-3AP2YEHR-CyourLfz.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-S7CK7UJ4-BTMbx2np.js +30 -0
- package/web/dist/assets/diagram-UQ7AKVKN-UK94_1-P.js +41 -0
- package/web/dist/assets/diagram-VSXAHHWV-C7zS-v9v.js +3 -0
- package/web/dist/assets/diagram-VX7I27RA-DKJRa6CK.js +24 -0
- package/web/dist/assets/diagram-Z3DM3KII-kUbAfGAp.js +24 -0
- package/web/dist/assets/ebnfDiagram-PWID7BFC-CVW16_vb.js +1 -0
- package/web/dist/assets/erDiagram-SSCWMZ5O-BRQMFQ5y.js +99 -0
- package/web/dist/assets/flowDiagram-A5DVABFB-S34LpLtK.js +168 -0
- package/web/dist/assets/ganttDiagram-EL5Y4UJY-BFiqk1-G.js +292 -0
- package/web/dist/assets/gitGraphDiagram-WWUBYQGX-LZsrdyyv.js +106 -0
- package/web/dist/assets/index-GAamh1p4.js +2152 -0
- package/web/dist/assets/infoDiagram-RXCK75RN-0DvN6KIp.js +2 -0
- package/web/dist/assets/init-Gi6I4Gst.js +1 -0
- package/web/dist/assets/ishikawaDiagram-5VMMS53U-CaY3A-Bx.js +70 -0
- package/web/dist/assets/journeyDiagram-EYS64GPL-BBeiaCrj.js +139 -0
- package/web/dist/assets/kanban-definition-3QL26DDD-ux42DRXQ.js +89 -0
- package/web/dist/assets/layout-CnXiF_NB.js +1 -0
- package/web/dist/assets/linear-BMW0u6G6.js +1 -0
- package/web/dist/assets/mindmap-definition-FBJOCRG2-hUxSpdKn.js +96 -0
- package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/dist/assets/pegDiagram-XKGWAZYB-aCaQPH0w.js +1 -0
- package/web/dist/assets/{pending-edit-apply-C0I13CbD.js → pending-edit-apply-AcdOQ9Ij.js} +6 -6
- package/web/dist/assets/pieDiagram-E7YTZNPT-CmOEZ8zg.js +39 -0
- package/web/dist/assets/quadrantDiagram-AXDQQJYC-VW6fAhsV.js +7 -0
- package/web/dist/assets/railroadDiagram-O6MQD6OU-CKJ7Ba5K.js +1 -0
- package/web/dist/assets/requirementDiagram-EFPCY7ZU-pNGkq9Vw.js +84 -0
- package/web/dist/assets/{rotate-cw-B1UQ8Hld.js → rotate-cw-KAfChbGV.js} +1 -1
- package/web/dist/assets/sankeyDiagram-P5KCCOFB-BIMmDTfZ.js +40 -0
- package/web/dist/assets/sequenceDiagram-WJ2MYXX4-BBW7wzKY.js +162 -0
- package/web/dist/assets/sizeCapture-X5ZJPWSS-DdkXmLYR.js +1 -0
- package/web/dist/assets/{square-dashed-mouse-pointer-8lrgqmfv.js → square-dashed-mouse-pointer-CDUlhUP7.js} +1 -1
- package/web/dist/assets/stateDiagram-HBIQ2CUA-W313Uz16.js +1 -0
- package/web/dist/assets/stateDiagram-v2-4QOOHH4V-DDhJBZzf.js +1 -0
- package/web/dist/assets/swimlanes-XN3QIQJK-C29TL26K.js +1 -0
- package/web/dist/assets/swimlanesDiagram-VK2B7HYN-BfN-Xdmu.js +8 -0
- package/web/dist/assets/timeline-definition-24CTP7MA-CF0JMObX.js +120 -0
- package/web/dist/assets/vennDiagram-4TSXK5OY-DrDFsaH3.js +34 -0
- package/web/dist/assets/wardleyDiagram-VM6X3IG4-CVrnRIS_.js +78 -0
- package/web/dist/assets/xychartDiagram-S5SC5T6Z-BvkN1zsT.js +7 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/SiteWindow-DM0LQPCO.js +0 -18
- package/web/dist/assets/index-BHTBJlpY.js +0 -2125
|
@@ -160,13 +160,64 @@ index.html 直接在工作区根上的「根站」仍被识别,但**新站一
|
|
|
160
160
|
的以用户为准,别来回拉扯。
|
|
161
161
|
- `create_on_board` 在画布上落一条**手写便签**(和用户写的同款)。用于值得留在
|
|
162
162
|
版面上的话(这版为什么这么改、一组东西的题注),带 near+relation 就是一次完整
|
|
163
|
-
的"标注"
|
|
163
|
+
的"标注"。不是聊天出口。
|
|
164
164
|
- `organize_board` 归纳收纳:把散在桌面上的东西(生成图 / 旧稿 / 文件夹)批量搬进
|
|
165
165
|
文件夹(目标夹没有就建)。**真 mv** —— 和用户拖拽同一套语义,画布身份和关系线
|
|
166
166
|
自动跟着走,比裸 Bash mv 稳。站点收素材搬进它的 `assets/` 子目录(站点根是产物
|
|
167
167
|
不是收纳夹,搬不进去)。一轮产出多件同族素材时,收尾顺手归个夹。
|
|
168
168
|
- 用户「+加入上下文」的物件会作为附件出现在你下一条消息里,那是他指着东西说话。
|
|
169
169
|
|
|
170
|
+
### 画布也是黑板(板书 / 头脑风暴 / 推理板)—— 能写板书就写,能画线就画
|
|
171
|
+
整块画布就是一张侦探板:产物卡是证物,线是关系,空白处随时能写、能画。侧栏照常回复(那是
|
|
172
|
+
用户的输入方式,别改),但**表现形式上要积极**:凡是值得留在版面上、贴着东西看的东西,都
|
|
173
|
+
往板上写、往板上画 —— 板上有而侧栏没有的,是"跟东西贴在一起的上下文"和"读得出关系的版面"。
|
|
174
|
+
- `write_on_board`:**能写就写**。做完一件东西立刻在它旁边写一条(`near=那张卡`:这是什么 /
|
|
175
|
+
为什么这么做 / 看哪里);给出结论、建议、决定、待办,写一条;比较了几件东西,在它们旁边
|
|
176
|
+
写判语;用户在板上**标注**了你的板书或手写字(消息里带原文摘录和路径),用 `reply_to=那条
|
|
177
|
+
路径` 接在下面回,不要只在侧栏答。板书是真文件(`notes/板书/…md`,你能 Read/Grep/Edit),
|
|
178
|
+
一条 ≤1500 字、一条说一件事;侧栏里提一句"板上写了什么"即可,别大段重复。
|
|
179
|
+
- `relate_on_board` / 草图里的 edges:**能画线就画**。两件东西有关系(改自 / 对照 / 接着 /
|
|
180
|
+
取材 / 关联 / 板书关于谁),线一定画上 —— 线是用户读版面的主要线索。还在论证中的关系用
|
|
181
|
+
丝线(yarn)。
|
|
182
|
+
- 用户在**想事情**(对比方案、理流程、摆人物关系、列利弊、排时间线、拆问题)时,画成图
|
|
183
|
+
(sketch_on_board)—— 画出来比一段话清楚,也留得住。纯闲聊、一句话能答完的、长段技术排错、
|
|
184
|
+
要用户复制的东西,留在侧栏就好。
|
|
185
|
+
- **步骤清单自动上板**:你用 TodoWrite 列的步骤会被镜像成画布上一条「这一轮的步骤」板书
|
|
186
|
+
(✓ 已做 / → 正在 / 待做,原地更新),每一步期间你写出的文件会自动连到这条清单上(「第 N 步」)。
|
|
187
|
+
所以多步任务**先 TodoWrite 列步骤**(三步以上就列),步骤写成人看得懂的话;不用再手写一份清单。
|
|
188
|
+
- 用户开了「黑板模式」时,上面这些从"积极去做"变成"默认这么做"(每条消息前会注入提示)。
|
|
189
|
+
- `sketch_on_board` **一次落一整张图**:节点(手写 或 `format:'md'` —— 要点/表格/
|
|
190
|
+
KaTeX 公式/```mermaid 围栏)、形状(rect/ellipse/circle/arrow/line/underline,手绘感
|
|
191
|
+
替你加好;或 path 自由路径)、线(语义×材质)。你只说结构:选模板(column/row/grid/
|
|
192
|
+
mindmap)或在网格上自己摆(1 格=24px,`layout:'free'` + `at`)。整张图落在 `near`
|
|
193
|
+
的旁边、用户视口里的空地、或桌面内容底下,坐标它算。它带一个 #tag,`read_board {tag}`
|
|
194
|
+
只读那一组,用户能整组选/整组擦。
|
|
195
|
+
- `edit_sketch` **改已有的图**:按 id 挪(相对另一件东西的右/左/上/下,或网格位移)、改字、
|
|
196
|
+
加节点、加/改/删线、整组搬家。小改动一律用它,**不要擦掉重画**。位置永远说相对关系,
|
|
197
|
+
不给绝对坐标。
|
|
198
|
+
- **两阶段**:落下去的是草稿(半透明)。检查完 `finish_sketch {tag}` 变实;忘了也在
|
|
199
|
+
回合结束自动落定;画坏了 `finish_sketch {tag, erase:true}` 整组擦掉重画。
|
|
200
|
+
- **画完要看**:`look_at_board {tag}` 截一张真实画面(按需调用,不要每步都截)。
|
|
201
|
+
涂鸦和排布只有看过才算数 —— 画了不看等于没画。
|
|
202
|
+
- **可读性与版式纪律**(板是给人读的,按人的阅读习惯排):
|
|
203
|
+
- 用户在 80%~100% 缩放下看板。正文字号 md 起(sm 只给 ≤40 字题注);一张图一屏装得下
|
|
204
|
+
(工具按他的屏幕算,超了会提醒),硬上限 2600×1700;大了拆两张、用一条线说关系。
|
|
205
|
+
- **先左后右、先上后下**。新东西排在已有内容的**右侧或下方**,不放左侧/上方(除非锚着
|
|
206
|
+
某件东西)。标题用 `title` 参数横跨顶端,别当普通节点摆进列里。
|
|
207
|
+
- 一张图最多两列;列宽 ≤18 格(432px);每个节点一件事、正文 ≤4 行,写不下就拆节点。
|
|
208
|
+
- 相关的几张图之间留呼吸(40px+),用线说关系;板书贴着它说的东西(near),回应接在
|
|
209
|
+
被回应那条正下方(reply_to)。
|
|
210
|
+
- 不确定用户此刻看哪、屏幕多大:`read_user_view`。
|
|
211
|
+
- 材质是与语义正交的第二个轴:`ink` 墨线 = 安静的归档线;`pencil` 手绘 = 顺手拉的一笔
|
|
212
|
+
(草图里的缺省);`yarn` 丝线 = 侦探板上带图钉的红线,给**还在论证中**的假设/证据
|
|
213
|
+
关系。关系定了就用 ink 重画或落定时自然退场;`relate_on_board` 也收 `material`。
|
|
214
|
+
- mermaid 与原生节点的分工:要跟真实产物连线的一律用节点+线;时序图、状态机这类
|
|
215
|
+
密而规整的图才装 mermaid 盒子。别把三个节点的关系图塞进 mermaid。
|
|
216
|
+
- `read_user_view` 看用户此刻在看哪(视口/缩放/开着的窗/选中)。他说「这个/这里/这张」按
|
|
217
|
+
选中 > 开着的窗 > 视口里的东西 来解;新图默认落在他视口里的空地,让他不用动镜头。
|
|
218
|
+
每条用户消息前也会注入一行他的视点(只在变化时报)。
|
|
219
|
+
- 黑板不是弹幕区:一张图说一件事(每张 ≤40 节点),一条板书说一件事。说完在回复里提一句「画布上画了 xxx」。
|
|
220
|
+
|
|
170
221
|
## 用户直接改画布时(DirectEdit)
|
|
171
222
|
|
|
172
223
|
用户可以双击改字、选中元素写评论、拖移元素、**在预览上圈一块地方说事**。这些动作攒在
|
|
@@ -292,6 +343,7 @@ batch**,一页一个回合太慢;逛到不确定的地方(要不要进、
|
|
|
292
343
|
`remove_background` · `web_search` · `expose_tweaks` · `export_handoff` ·
|
|
293
344
|
`pin_to_board` · `relate_on_board` ·
|
|
294
345
|
`read_board` · `arrange_on_board` · `create_on_board` · `organize_board` · `deliver_files` ·
|
|
346
|
+
`write_on_board` · `sketch_on_board` · `edit_sketch` · `finish_sketch` · `look_at_board` · `read_user_view` ·
|
|
295
347
|
`read_document` ·
|
|
296
348
|
`crystallize_skill` ·
|
|
297
349
|
`report_issue` · `roll_film` · `paint_still` · `lookup_tags` · `trace_motion` · `explain_style` · `profile_scroll`
|
|
@@ -78,6 +78,7 @@ import { autoNameProjectFromSession } from '../../projects/auto-name.js';
|
|
|
78
78
|
// getUserById/levelFor 是 main 的每用户内容尺度旋钮(78ceaac);
|
|
79
79
|
// main 的 listTasks 已随任务层退役,不再引入
|
|
80
80
|
import { commitTaskWorkspace, commitWorkspace, PROJECTS_DATA_ROOT } from '../../projects/workspace.js';
|
|
81
|
+
import { commitStaging } from '../../projects/board-store.js';
|
|
81
82
|
import { taskManifest } from '../../lib/artifact-target.js';
|
|
82
83
|
import { getUserById } from '../../auth/users-store.js';
|
|
83
84
|
import { can, defaultModerationLevel } from '../../auth/tier.js';
|
|
@@ -727,6 +728,16 @@ export async function runSession({
|
|
|
727
728
|
await commitWorkspace(projectId, sessionId, `turn ${status}: ${new Date().toISOString()}`, { author: 'agent' })
|
|
728
729
|
.catch((err) => console.warn('[git] turn commit failed:', err.message));
|
|
729
730
|
|
|
731
|
+
// 黑板草稿兜底落定(2026-08-23):agent 这一轮 sketch_on_board 留下的 staging
|
|
732
|
+
// 物件,没调 finish_sketch 也在回合结束时变实 —— 草稿态是"正在画"的信号,
|
|
733
|
+
// 回合都结束了还半透明就是幽灵。取消/出错同样落定:画了就是画了。
|
|
734
|
+
if (projectId) {
|
|
735
|
+
try {
|
|
736
|
+
const { committed } = await commitStaging(projectId);
|
|
737
|
+
if (committed > 0) sharedCtx.emit({ type: 'board.updated', sessionId: null, summary: `黑板草稿落定 ${committed} 件` });
|
|
738
|
+
} catch (err) { console.warn('[board] commitStaging failed:', err.message); }
|
|
739
|
+
}
|
|
740
|
+
|
|
730
741
|
activeTurnRunId = null;
|
|
731
742
|
turnStartedAt = 0;
|
|
732
743
|
markSessionActivity(sessionId); // turn 结束 = 活跃信号;下次 idle 计时重置
|
|
@@ -65,6 +65,11 @@ import { makeRelateOnBoardTool } from './tools/relate-on-board.js';
|
|
|
65
65
|
import { makeReadBoardTool } from './tools/read-board.js';
|
|
66
66
|
import { makeArrangeOnBoardTool } from './tools/arrange-on-board.js';
|
|
67
67
|
import { makeCreateOnBoardTool } from './tools/create-on-board.js';
|
|
68
|
+
import { makeSketchOnBoardTool, makeFinishSketchTool } from './tools/sketch-on-board.js';
|
|
69
|
+
import { makeEditSketchTool } from './tools/edit-sketch.js';
|
|
70
|
+
import { makeWriteOnBoardTool } from './tools/write-on-board.js';
|
|
71
|
+
import { makeLookAtBoardTool } from './tools/look-at-board.js';
|
|
72
|
+
import { makeReadUserViewTool } from './tools/read-user-view.js';
|
|
68
73
|
import { makeOrganizeBoardTool } from './tools/organize-board.js';
|
|
69
74
|
import { makeReadDocumentTool } from './tools/read-document.js';
|
|
70
75
|
import { makeReadTavernJsonTool } from './tools/read-tavern-json.js';
|
|
@@ -305,6 +310,13 @@ export function createNodesignMcpServer({ workspaceRoot, sharedRoot, projectId,
|
|
|
305
310
|
makeArrangeOnBoardTool({ projectId, ctx }),
|
|
306
311
|
makeCreateOnBoardTool({ projectId, ctx }),
|
|
307
312
|
makeOrganizeBoardTool({ projectId, ctx }),
|
|
313
|
+
// 黑板(2026-08-23):一次落一整张草图 / 落定或擦掉 / 看一眼 / 用户在看哪
|
|
314
|
+
makeSketchOnBoardTool({ projectId, ctx }),
|
|
315
|
+
makeFinishSketchTool({ projectId, ctx }),
|
|
316
|
+
makeEditSketchTool({ projectId, ctx }),
|
|
317
|
+
makeWriteOnBoardTool({ projectId, sharedRoot: workspaceRoot || sharedRoot, sessionId, ctx }),
|
|
318
|
+
makeLookAtBoardTool({ projectId, ctx }),
|
|
319
|
+
makeReadUserViewTool({ projectId }),
|
|
308
320
|
|
|
309
321
|
// 注:Phase Image-2 的 request_image_approval 工具已废弃(2026-05-06)。
|
|
310
322
|
// generate_image 的 CallToolResult 已返 image content block,前端自动渲染;
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { tool } from '@anthropic-ai/claude-agent-sdk';
|
|
13
13
|
import { z } from 'zod';
|
|
14
14
|
import { readBoard, patchBoard } from '../../../projects/board-store.js';
|
|
15
|
-
import {
|
|
15
|
+
import { estimateSizeOn } from '../../../lib/board-kind-sizes.js';
|
|
16
16
|
import { normalizeCanvasId, layerOf } from '../../../lib/canvas-id.js';
|
|
17
17
|
|
|
18
18
|
const GAP_X = 24;
|
|
@@ -94,8 +94,8 @@ drag things afterwards — their move wins, do not fight it.`,
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
const aSize =
|
|
98
|
-
const sSize =
|
|
97
|
+
const aSize = estimateSizeOn(board, anchor, anchorEntry);
|
|
98
|
+
const sSize = estimateSizeOn(board, subject, subjectEntry);
|
|
99
99
|
const want = action === 'beside'
|
|
100
100
|
? { x: Math.round(anchorEntry.x + aSize.w + GAP_X), y: Math.round(anchorEntry.y) }
|
|
101
101
|
: { x: Math.round(anchorEntry.x), y: Math.round(anchorEntry.y + aSize.h + GAP_Y) };
|
|
@@ -103,7 +103,7 @@ drag things afterwards — their move wins, do not fight it.`,
|
|
|
103
103
|
const obstacles = Object.entries(board.objects || {})
|
|
104
104
|
.filter(([id, e]) => id !== subject && Number.isFinite(e?.x)
|
|
105
105
|
&& layerOf(id, e, known) === anchorLayer)
|
|
106
|
-
.map(([id, e]) => ({ ...
|
|
106
|
+
.map(([id, e]) => ({ ...estimateSizeOn(board, id, e), x: e.x, y: e.y }));
|
|
107
107
|
const pos = nudgeDown(want, sSize, obstacles);
|
|
108
108
|
|
|
109
109
|
await patchBoard(projectId, {
|
|
@@ -12,16 +12,15 @@
|
|
|
12
12
|
* - 尺寸估算抄前端 handleCreateText 同款公式(约 26 全角字/行)
|
|
13
13
|
* - by:'agent' 出处,用户一眼分得出谁写的
|
|
14
14
|
*
|
|
15
|
-
* 护栏:每回合上限
|
|
15
|
+
* 护栏:每回合上限 08-23 按用户意思撤了(只记数);画布不是弹幕区这条写在 prelude 里。
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { tool } from '@anthropic-ai/claude-agent-sdk';
|
|
19
19
|
import { z } from 'zod';
|
|
20
20
|
import { readBoard, patchBoard, TEXT_FONTS } from '../../../projects/board-store.js';
|
|
21
|
-
import {
|
|
21
|
+
import { estimateSizeOn } from '../../../lib/board-kind-sizes.js';
|
|
22
22
|
import { normalizeCanvasId, layerOf } from '../../../lib/canvas-id.js';
|
|
23
23
|
|
|
24
|
-
const MAX_PER_TURN = 4;
|
|
25
24
|
const SIZE_PX = { sm: 13, md: 16, lg: 22, xl: 30 };
|
|
26
25
|
|
|
27
26
|
let seq = 0;
|
|
@@ -44,7 +43,7 @@ export function makeCreateOnBoardTool({ projectId, ctx }) {
|
|
|
44
43
|
|
|
45
44
|
Use it for remarks worth KEEPING on the board: why a version went this way, what
|
|
46
45
|
to compare, a caption for a group. Not for conversation (that goes in your reply)
|
|
47
|
-
and not for documents (write a .md instead).
|
|
46
|
+
and not for documents (write a .md instead).
|
|
48
47
|
|
|
49
48
|
- near: anchor it beside a thing (and usually pass relation to draw the line —
|
|
50
49
|
that is exactly the user's "annotation" gesture: a note + an annotates edge)
|
|
@@ -68,11 +67,9 @@ and not for documents (write a .md instead). Hard cap: ${MAX_PER_TURN} notes per
|
|
|
68
67
|
}
|
|
69
68
|
const err = (t) => ({ content: [{ type: 'text', text: t }], isError: true });
|
|
70
69
|
|
|
71
|
-
|
|
70
|
+
// 回合闸 08-23 用户拍板先不设:只记数不拦
|
|
71
|
+
const turn = ctx?.runId ?? -1;
|
|
72
72
|
if (turnStamp.turn !== turn) { turnStamp.turn = turn; turnStamp.count = 0; }
|
|
73
|
-
if (turnStamp.count >= MAX_PER_TURN) {
|
|
74
|
-
return err(`本回合便签额度用完(${MAX_PER_TURN} 条)—— 画布是版面不是弹幕区,要说的话说给用户听。`);
|
|
75
|
-
}
|
|
76
73
|
|
|
77
74
|
const t = String(text).trim();
|
|
78
75
|
if (!t) return err('空字不落板。');
|
|
@@ -89,12 +86,12 @@ and not for documents (write a .md instead). Hard cap: ${MAX_PER_TURN} notes per
|
|
|
89
86
|
return err(`锚点 ${rawNear} 还没有座位(read_board 里看不到就锚不上)。`);
|
|
90
87
|
}
|
|
91
88
|
zone = layerOf(near, anchorEntry, known);
|
|
92
|
-
const aSize =
|
|
89
|
+
const aSize = estimateSizeOn(board, near, anchorEntry);
|
|
93
90
|
pos = { x: Math.round(anchorEntry.x + aSize.w + 24), y: Math.round(anchorEntry.y) };
|
|
94
91
|
// 撞卡往下让(同 arrange 的避让)
|
|
95
92
|
const obstacles = Object.entries(board.objects || {})
|
|
96
93
|
.filter(([id, e]) => Number.isFinite(e?.x) && layerOf(id, e, known) === zone)
|
|
97
|
-
.map(([id, e]) => ({ ...
|
|
94
|
+
.map(([id, e]) => ({ ...estimateSizeOn(board, id, e), x: e.x, y: e.y }));
|
|
98
95
|
for (let g = 0; g < 40; g += 1) {
|
|
99
96
|
const blocker = obstacles.find(o =>
|
|
100
97
|
!(pos.x + box.w <= o.x || o.x + o.w <= pos.x || pos.y + box.h <= o.y || o.y + o.h <= pos.y));
|
|
@@ -106,7 +103,7 @@ and not for documents (write a .md instead). Hard cap: ${MAX_PER_TURN} notes per
|
|
|
106
103
|
let bottom = 0;
|
|
107
104
|
for (const [id, e] of Object.entries(board.objects || {})) {
|
|
108
105
|
if (!Number.isFinite(e?.y) || layerOf(id, e, known) !== '') continue;
|
|
109
|
-
bottom = Math.max(bottom, e.y +
|
|
106
|
+
bottom = Math.max(bottom, e.y + estimateSizeOn(board, id, e).h);
|
|
110
107
|
}
|
|
111
108
|
for (const zz of Object.values(board.zones || {})) {
|
|
112
109
|
if (Number.isFinite(zz?.y)) bottom = Math.max(bottom, zz.y + 150);
|
|
@@ -147,7 +144,7 @@ and not for documents (write a .md instead). Hard cap: ${MAX_PER_TURN} notes per
|
|
|
147
144
|
content: [{
|
|
148
145
|
type: 'text',
|
|
149
146
|
text: `Note on board at (${pos.x},${pos.y})${relation ? ' with a line' : ''} (id: ${textId}).`
|
|
150
|
-
+
|
|
147
|
+
+ '',
|
|
151
148
|
}],
|
|
152
149
|
};
|
|
153
150
|
},
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp/tools/edit-sketch.js —— edit_sketch(2026-08-23 黑板二期)
|
|
3
|
+
*
|
|
4
|
+
* 改已有的图,不重画。原则抄用户自己的 AI-Annotation-demo 里那条「引用,不吐坐标」:
|
|
5
|
+
* agent 只引用画布 id,位置用**相对关系**表达(在某件东西的右边/下面/上面/左边、
|
|
6
|
+
* 或网格位移),像素由服务端解析 —— 让模型吐绝对坐标必幻觉。
|
|
7
|
+
*
|
|
8
|
+
* 一次调用一批操作(按序执行,一条坏了其余照做并在返回里报):
|
|
9
|
+
* set_text 改字(文字节点:内容/格式/字号/颜色)
|
|
10
|
+
* move 挪一件:to {ref, side, gap?} 或 {dx, dy}(网格单位)
|
|
11
|
+
* move_group 挪一组(同 tag 的全部,保持相对位置):to 同上
|
|
12
|
+
* remove 删一件画布原生物件(text/scribble);产物卡不删(只能摘标签)
|
|
13
|
+
* add_node 加一个文字节点,位置 at {ref, side, gap?},归进 tag
|
|
14
|
+
* add_edge / set_edge / remove_edge 线的增改删(线 id 见 read_board)
|
|
15
|
+
* 改完前端按 board.updated 整份重拉;默认不进 staging(改的是已落定的图)。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { tool } from '@anthropic-ai/claude-agent-sdk';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
import { readBoard, patchBoard, TEXT_FONTS } from '../../../projects/board-store.js';
|
|
21
|
+
import { estimateSizeOn } from '../../../lib/board-kind-sizes.js';
|
|
22
|
+
import { layerOf, normalizeCanvasId } from '../../../lib/canvas-id.js';
|
|
23
|
+
import { BINDING_TYPE_IDS, BINDING_MATERIALS } from '../../../lib/binding-types.js';
|
|
24
|
+
import { UNIT, textBox } from '../../../lib/sketch-layout.js';
|
|
25
|
+
|
|
26
|
+
const MAX_OPS = 40;
|
|
27
|
+
let seq = 0;
|
|
28
|
+
const stamp = () => `${Date.now().toString(36)}${(seq++ % 1000).toString(36)}`;
|
|
29
|
+
|
|
30
|
+
const REL = z.object({
|
|
31
|
+
ref: z.string().min(1).max(300).describe('canvas id to place relative to'),
|
|
32
|
+
side: z.enum(['right', 'left', 'above', 'below']),
|
|
33
|
+
gap: z.number().min(0).max(40).optional().describe('grid units (default 1)'),
|
|
34
|
+
});
|
|
35
|
+
const DELTA = z.object({ dx: z.number().min(-200).max(200), dy: z.number().min(-200).max(200) }).describe('grid units');
|
|
36
|
+
const TO = z.union([REL, DELTA]);
|
|
37
|
+
|
|
38
|
+
const OP = z.discriminatedUnion('op', [
|
|
39
|
+
z.object({ op: z.literal('set_text'), id: z.string().min(1).max(300), text: z.string().min(1).max(4000).optional(), format: z.enum(['plain', 'md']).optional(), size: z.enum(['sm', 'md', 'lg', 'xl']).optional(), color: z.enum(['ink', 'red', 'pencil', 'brass']).optional(), font: z.enum(['pen', 'kai', 'sans', 'serif', 'mono']).optional() }),
|
|
40
|
+
z.object({ op: z.literal('move'), id: z.string().min(1).max(300), to: TO }),
|
|
41
|
+
z.object({ op: z.literal('move_group'), tag: z.string().min(1).max(40), to: TO }),
|
|
42
|
+
z.object({ op: z.literal('remove'), id: z.string().min(1).max(300) }),
|
|
43
|
+
z.object({ op: z.literal('add_node'), id: z.string().regex(/^[A-Za-z0-9_-]{1,24}$/).optional().describe('local handle to reference in later ops of this call'), text: z.string().min(1).max(4000), format: z.enum(['plain', 'md']).optional(), size: z.enum(['sm', 'md', 'lg', 'xl']).optional(), font: z.enum(['pen', 'kai', 'sans', 'serif', 'mono']).optional(), color: z.enum(['ink', 'red', 'pencil', 'brass']).optional(), at: REL, tag: z.string().max(40).optional() }),
|
|
44
|
+
z.object({ op: z.literal('add_edge'), from: z.string().min(1).max(300), to: z.string().min(1).max(300), type: z.enum(BINDING_TYPE_IDS).optional(), material: z.enum(BINDING_MATERIALS).optional(), label: z.string().max(60).optional(), tag: z.string().max(40).optional() }),
|
|
45
|
+
z.object({ op: z.literal('set_edge'), id: z.string().min(1).max(300), label: z.string().max(60).optional(), type: z.enum(BINDING_TYPE_IDS).optional(), material: z.enum(BINDING_MATERIALS).optional() }),
|
|
46
|
+
z.object({ op: z.literal('remove_edge'), id: z.string().min(1).max(300) }),
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
export function makeEditSketchTool({ projectId, ctx }) {
|
|
50
|
+
return tool(
|
|
51
|
+
'edit_sketch',
|
|
52
|
+
`Edit what is already on the board — by id, without redrawing. Positions are RELATIVE
|
|
53
|
+
(to {ref, side, gap} = beside/above/below another canvas id) or grid deltas ({dx,dy},
|
|
54
|
+
1 cell = ${UNIT}px); you never give absolute coordinates. ids come from read_board
|
|
55
|
+
(nodes: text:…/scribble:…, cards: deck:…/site:…/paths; lines: b:…).
|
|
56
|
+
ops (run in order; a failing op is reported, the rest still apply):
|
|
57
|
+
set_text{id,text?,format?,size?,color?,font?} · move{id,to} · move_group{tag,to} ·
|
|
58
|
+
remove{id} · add_node{id?,text,at:{ref,side,gap?},…} · add_edge{from,to,type?,material?,label?} ·
|
|
59
|
+
set_edge{id,label?,type?,material?} · remove_edge{id}
|
|
60
|
+
Use this for "move A under B", "rephrase that note", "add a branch", "delete the
|
|
61
|
+
wrong line". For a brand-new diagram use sketch_on_board.`,
|
|
62
|
+
{
|
|
63
|
+
tag: z.string().max(40).optional().describe('Default tag for add_node/add_edge (the sketch you are editing)'),
|
|
64
|
+
ops: z.array(OP).min(1).max(MAX_OPS),
|
|
65
|
+
},
|
|
66
|
+
async ({ tag: defaultTag, ops }) => {
|
|
67
|
+
const err = (t) => ({ content: [{ type: 'text', text: t }], isError: true });
|
|
68
|
+
if (!projectId) return err('No project bound.');
|
|
69
|
+
const board = await readBoard(projectId);
|
|
70
|
+
const known = new Set(Object.keys(board.zones || {}));
|
|
71
|
+
const objects = {}; const bindings = {}; // 增量 patch
|
|
72
|
+
const live = { ...board.objects }; // 本次调用内的"当前态"(让后一条 op 看见前一条的结果)
|
|
73
|
+
const local = new Map(); // add_node 的本地句柄 → canvas id
|
|
74
|
+
const rid = (raw) => { if (local.has(raw)) return local.get(raw); const c = normalizeCanvasId(raw); return c && live[c] ? c : null; };
|
|
75
|
+
const rectOf = (id) => { const e = live[id]; return e ? { x: e.x, y: e.y, ...estimateSizeOn(board, id, e) } : null; };
|
|
76
|
+
const placeRel = (box, rel) => {
|
|
77
|
+
const r = rectOf(rid(rel.ref));
|
|
78
|
+
if (!r) return null;
|
|
79
|
+
const g = (rel.gap ?? 1) * UNIT;
|
|
80
|
+
if (rel.side === 'right') return { x: r.x + r.w + g, y: r.y };
|
|
81
|
+
if (rel.side === 'left') return { x: r.x - g - box.w, y: r.y };
|
|
82
|
+
if (rel.side === 'below') return { x: r.x, y: r.y + r.h + g };
|
|
83
|
+
return { x: r.x, y: r.y - g - box.h };
|
|
84
|
+
};
|
|
85
|
+
const report = []; let ok = 0;
|
|
86
|
+
const setObj = (id, e) => { live[id] = e; objects[id] = e; };
|
|
87
|
+
|
|
88
|
+
for (let i = 0; i < ops.length; i += 1) {
|
|
89
|
+
const o = ops[i];
|
|
90
|
+
const fail = (why) => report.push(`✗ #${i + 1} ${o.op}: ${why}`);
|
|
91
|
+
try {
|
|
92
|
+
if (o.op === 'set_text') {
|
|
93
|
+
const id = rid(o.id); const e = id && live[id];
|
|
94
|
+
if (!e || e.kind !== 'text') { fail(`${o.id} 不是文字节点`); continue; }
|
|
95
|
+
const data = { ...e.data };
|
|
96
|
+
if (o.text) data.t = o.text;
|
|
97
|
+
if (o.format) { if (o.format === 'md') data.format = 'md'; else delete data.format; }
|
|
98
|
+
if (o.size) data.size = o.size; if (o.color) data.color = o.color; if (o.font && TEXT_FONTS.includes(o.font)) data.font = o.font;
|
|
99
|
+
const box = textBox(data.t, data.size || 'md', { md: data.format === 'md' });
|
|
100
|
+
setObj(id, { ...e, data, w: box.w, h: box.h }); ok += 1;
|
|
101
|
+
} else if (o.op === 'move') {
|
|
102
|
+
const id = rid(o.id); const e = id && live[id];
|
|
103
|
+
if (!e) { fail(`${o.id} 不在板上`); continue; }
|
|
104
|
+
const box = rectOf(id);
|
|
105
|
+
const p = 'ref' in o.to ? placeRel(box, o.to) : { x: e.x + o.to.dx * UNIT, y: e.y + o.to.dy * UNIT };
|
|
106
|
+
if (!p) { fail(`参照 ${o.to.ref} 不在板上`); continue; }
|
|
107
|
+
setObj(id, { ...e, x: Math.round(p.x), y: Math.round(p.y) }); ok += 1;
|
|
108
|
+
} else if (o.op === 'move_group') {
|
|
109
|
+
const members = Object.entries(live).filter(([, e]) => e.tag === o.tag && Number.isFinite(e?.x));
|
|
110
|
+
if (!members.length) { fail(`没有 #${o.tag} 的东西`); continue; }
|
|
111
|
+
const bb = { x: Math.min(...members.map(([, e]) => e.x)), y: Math.min(...members.map(([, e]) => e.y)) };
|
|
112
|
+
const rects = members.map(([id]) => rectOf(id));
|
|
113
|
+
const w = Math.max(...rects.map(r => r.x + r.w)) - bb.x; const h = Math.max(...rects.map(r => r.y + r.h)) - bb.y;
|
|
114
|
+
const p = 'ref' in o.to ? placeRel({ w, h }, o.to) : { x: bb.x + o.to.dx * UNIT, y: bb.y + o.to.dy * UNIT };
|
|
115
|
+
if (!p) { fail(`参照 ${o.to.ref} 不在板上`); continue; }
|
|
116
|
+
const dx = Math.round(p.x - bb.x); const dy = Math.round(p.y - bb.y);
|
|
117
|
+
for (const [id, e] of members) setObj(id, { ...e, x: e.x + dx, y: e.y + dy });
|
|
118
|
+
ok += 1;
|
|
119
|
+
} else if (o.op === 'remove') {
|
|
120
|
+
const id = rid(o.id); const e = id && live[id];
|
|
121
|
+
if (!e) { fail(`${o.id} 不在板上`); continue; }
|
|
122
|
+
if (!e.kind) { fail(`${id} 是产物卡,不能从黑板删;要摘标签用 finish_sketch erase`); continue; }
|
|
123
|
+
delete live[id]; objects[id] = null; ok += 1;
|
|
124
|
+
} else if (o.op === 'add_node') {
|
|
125
|
+
const size = (o.size === 'sm' && o.text.length > 40) ? 'md' : (o.size || 'md');
|
|
126
|
+
const box = textBox(o.text, size, { md: o.format === 'md' });
|
|
127
|
+
const p = placeRel(box, o.at);
|
|
128
|
+
if (!p) { fail(`参照 ${o.at.ref} 不在板上`); continue; }
|
|
129
|
+
const refId = rid(o.at.ref);
|
|
130
|
+
const zone = layerOf(refId, live[refId], known);
|
|
131
|
+
const id = `text:a${stamp()}`;
|
|
132
|
+
const tag = o.tag || defaultTag || live[refId]?.tag || null;
|
|
133
|
+
setObj(id, {
|
|
134
|
+
x: Math.round(p.x), y: Math.round(p.y), z: 1, w: box.w, h: box.h, kind: 'text',
|
|
135
|
+
data: { t: o.text, ...(o.format === 'md' ? { format: 'md' } : {}), font: TEXT_FONTS.includes(o.font) ? o.font : 'pen', size, color: o.color || 'ink' },
|
|
136
|
+
zone, by: 'agent', ...(tag ? { tag } : {}),
|
|
137
|
+
});
|
|
138
|
+
if (o.id) local.set(o.id, id);
|
|
139
|
+
report.push(`+ node ${o.id ? `${o.id}=` : ''}${id}`); ok += 1;
|
|
140
|
+
} else if (o.op === 'add_edge') {
|
|
141
|
+
const from = rid(o.from); const to = rid(o.to);
|
|
142
|
+
if (!from || !to || from === to) { fail(`端点不在板上:${o.from} → ${o.to}`); continue; }
|
|
143
|
+
const id = `b:a${stamp()}`;
|
|
144
|
+
const tag = o.tag || defaultTag || live[from]?.tag || live[to]?.tag || null;
|
|
145
|
+
bindings[id] = { type: o.type || 'link', from, to, by: 'agent', ...(o.material && o.material !== 'ink' ? { material: o.material } : {}), ...(o.label ? { label: o.label } : {}), ...(tag ? { tag } : {}) };
|
|
146
|
+
report.push(`+ edge ${id}`); ok += 1;
|
|
147
|
+
} else if (o.op === 'set_edge') {
|
|
148
|
+
const b = board.bindings?.[o.id];
|
|
149
|
+
if (!b) { fail(`线 ${o.id} 不存在`); continue; }
|
|
150
|
+
const nb = { ...b };
|
|
151
|
+
if (o.label !== undefined) { if (o.label) nb.label = o.label; else delete nb.label; }
|
|
152
|
+
if (o.type) nb.type = o.type;
|
|
153
|
+
if (o.material) { if (o.material === 'ink') delete nb.material; else nb.material = o.material; }
|
|
154
|
+
bindings[o.id] = nb; ok += 1;
|
|
155
|
+
} else if (o.op === 'remove_edge') {
|
|
156
|
+
if (!board.bindings?.[o.id]) { fail(`线 ${o.id} 不存在`); continue; }
|
|
157
|
+
bindings[o.id] = null; ok += 1;
|
|
158
|
+
}
|
|
159
|
+
} catch (e) { fail(String(e?.message || e).slice(0, 120)); }
|
|
160
|
+
}
|
|
161
|
+
if (!ok) return err(`没有一条操作成功:\n${report.join('\n')}`);
|
|
162
|
+
await patchBoard(projectId, { objects, bindings });
|
|
163
|
+
try { ctx?.emit?.({ type: 'board.updated', sessionId: null, summary: `改了黑板(${ok} 处)` }); } catch { /* */ }
|
|
164
|
+
return { content: [{ type: 'text', text: `Applied ${ok}/${ops.length} op(s).${report.length ? `\n${report.join('\n')}` : ''}` }] };
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp/tools/look-at-board.js —— look_at_board(2026-08-23 黑板)
|
|
3
|
+
*
|
|
4
|
+
* agent 的眼睛。在这之前它对画布只有文字座次表;画了涂鸦/草图看不见结果,
|
|
5
|
+
* 08-14 才会说"agent 画矢量笔迹没意义"。有了眼睛,涂鸦就是有回路的事:
|
|
6
|
+
* 画一笔、看一眼、改一笔。
|
|
7
|
+
*
|
|
8
|
+
* 怎么看:用常驻 chromium 打开**用户看的同一张画布页**(?eye=1 眼睛模式,见前端
|
|
9
|
+
* eye-mode.js),等页面把相机摆好打 data-eye-ready,截一张 PNG 回来。不另写服务端
|
|
10
|
+
* 渲染器 —— 同一件东西两个实例会分叉(最贵的一课)。
|
|
11
|
+
*
|
|
12
|
+
* 入口地址:hosted 模式下 SPA 由 nginx 发(node 只出 API),所以要 NODESIGN_WEB_ORIGIN
|
|
13
|
+
* 指到对外入口(exp: https://…:8443);本地分发版 node 自己发 dist,走 127.0.0.1。
|
|
14
|
+
* 两者都没有 → 工具老实说看不了,别静默截一张 API 404 页。
|
|
15
|
+
*
|
|
16
|
+
* 代价:1 vCPU 上一张图几秒钟 + 一阵 CPU;token 按像素算(1400×900 ≈ 1.7k),
|
|
17
|
+
* 所以按需调用,不做每回合自动注入。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { tool } from '@anthropic-ai/claude-agent-sdk';
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
import { getProject } from '../../../projects/store.js';
|
|
23
|
+
import { getUserById } from '../../../auth/users-store.js';
|
|
24
|
+
import { COOKIE_NAME, mintToken, authEnabled } from '../../../auth/session.js';
|
|
25
|
+
import { platform } from '../../../runtime/platform.js';
|
|
26
|
+
import { launchPerceptionBrowser, PERCEPTION_ORIGIN } from './helpers/perception-page.js';
|
|
27
|
+
import { readBoard } from '../../../projects/board-store.js';
|
|
28
|
+
import { estimateSizeOn } from '../../../lib/board-kind-sizes.js';
|
|
29
|
+
import { normalizeCanvasId } from '../../../lib/canvas-id.js';
|
|
30
|
+
|
|
31
|
+
const VIEW = { width: 1400, height: 900 };
|
|
32
|
+
const READY_TIMEOUT_MS = 25_000;
|
|
33
|
+
// 并发闸:一次只开一台 chromium 看板(1 vCPU 机器多会话同时看板 = 多个浏览器进程;fable 08-23 P2)。
|
|
34
|
+
// 排队不拒绝,等前一个看完。
|
|
35
|
+
let gate = Promise.resolve();
|
|
36
|
+
const withGate = (fn) => { const run = gate.then(fn, fn); gate = run.catch(() => {}); return run; };
|
|
37
|
+
|
|
38
|
+
export function webOrigin() {
|
|
39
|
+
const env = String(process.env.NODESIGN_WEB_ORIGIN || '').trim().replace(/\/+$/, '');
|
|
40
|
+
if (env) return env;
|
|
41
|
+
if (platform?.serveWeb) return PERCEPTION_ORIGIN;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function makeLookAtBoardTool({ projectId }) {
|
|
46
|
+
return tool(
|
|
47
|
+
'look_at_board',
|
|
48
|
+
`LOOK at the canvas (a real screenshot of the board as the user sees it). Use it after
|
|
49
|
+
sketch_on_board / create_on_board / relate_on_board to check what you drew, or when the
|
|
50
|
+
user talks about how things look on the board. Pick a region — the whole board shrunk
|
|
51
|
+
into one image is unreadable:
|
|
52
|
+
- tag: frame the group carrying this #tag (a sketch you made)
|
|
53
|
+
- around: frame this canvas id (and some margin)
|
|
54
|
+
- view: explicit world rect {x,y,w,h} (coordinates from read_board)
|
|
55
|
+
- none of the above: everything (overview only)
|
|
56
|
+
Costs a few seconds and ~1.7k tokens; don't call it in a loop.`,
|
|
57
|
+
{
|
|
58
|
+
tag: z.string().max(40).optional(),
|
|
59
|
+
around: z.string().max(300).optional(),
|
|
60
|
+
margin: z.number().min(0).max(2000).optional().describe('World px of margin around `around` (default 160)'),
|
|
61
|
+
view: z.object({ x: z.number(), y: z.number(), w: z.number().min(50), h: z.number().min(50) }).optional(),
|
|
62
|
+
},
|
|
63
|
+
async ({ tag, around, margin, view }) => {
|
|
64
|
+
const err = (t) => ({ content: [{ type: 'text', text: t }], isError: true });
|
|
65
|
+
if (!projectId) return err('No project bound.');
|
|
66
|
+
const origin = webOrigin();
|
|
67
|
+
if (!origin) {
|
|
68
|
+
return err('look_at_board 不可用:这台服务不知道画布页的入口地址(hosted 模式 SPA 由 nginx 发)。'
|
|
69
|
+
+ '管理员在 .env 设 NODESIGN_WEB_ORIGIN=https://<域名>[:端口] 后重启即可。本地分发版自动可用。');
|
|
70
|
+
}
|
|
71
|
+
let box = view || null;
|
|
72
|
+
let what = 'whole board';
|
|
73
|
+
if (!box && around) {
|
|
74
|
+
const board = await readBoard(projectId);
|
|
75
|
+
const id = normalizeCanvasId(around);
|
|
76
|
+
const e = id ? board.objects?.[id] : null;
|
|
77
|
+
if (!e || !Number.isFinite(e.x)) return err(`${around} 还没有座位(read_board 里看不到就框不上)。`);
|
|
78
|
+
const sz = estimateSizeOn(board, id, e); const m = margin ?? 160;
|
|
79
|
+
box = { x: e.x - m, y: e.y - m, w: sz.w + m * 2, h: sz.h + m * 2 };
|
|
80
|
+
what = `around ${id}`;
|
|
81
|
+
}
|
|
82
|
+
if (tag && !box) what = `group #${tag}`;
|
|
83
|
+
if (box) what = `${what === 'whole board' ? 'rect' : what} (${Math.round(box.x)},${Math.round(box.y)}) ${Math.round(box.w)}x${Math.round(box.h)}`;
|
|
84
|
+
|
|
85
|
+
const q = new URLSearchParams({ eye: '1' });
|
|
86
|
+
if (box) q.set('view', [box.x, box.y, box.w, box.h].map(n => Math.round(n)).join(','));
|
|
87
|
+
else if (tag) q.set('tag', tag);
|
|
88
|
+
const url = `${origin}/projects/${encodeURIComponent(projectId)}/work?${q}`;
|
|
89
|
+
|
|
90
|
+
return withGate(async () => {
|
|
91
|
+
let browser = null;
|
|
92
|
+
try {
|
|
93
|
+
browser = await launchPerceptionBrowser();
|
|
94
|
+
const context = await browser.newContext({ viewport: VIEW, deviceScaleFactor: 1 });
|
|
95
|
+
if (authEnabled()) {
|
|
96
|
+
const ownerId = getProject(projectId)?.ownerId;
|
|
97
|
+
const owner = ownerId ? getUserById(ownerId) : null;
|
|
98
|
+
if (!ownerId || !owner || owner.disabled) return err('look_at_board:项目所有者账号不可用(不存在或已停用),拿不到看画布的身份。');
|
|
99
|
+
await context.addCookies([{ name: COOKIE_NAME, value: mintToken(ownerId), url: origin }]);
|
|
100
|
+
}
|
|
101
|
+
const page = await context.newPage();
|
|
102
|
+
const resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20_000 });
|
|
103
|
+
if (resp && resp.status() >= 400) return err(`画布页打不开:HTTP ${resp.status()}(${origin})。NODESIGN_WEB_ORIGIN 指对了吗?`);
|
|
104
|
+
try {
|
|
105
|
+
await page.waitForSelector('html[data-eye-ready="1"]', { timeout: READY_TIMEOUT_MS });
|
|
106
|
+
} catch {
|
|
107
|
+
return err('画布页没在 25s 内就绪(data-eye-ready 没出现)。多半是入口不对或登录被拒;截图放弃。');
|
|
108
|
+
}
|
|
109
|
+
const png = await page.screenshot({ type: 'png', fullPage: false });
|
|
110
|
+
const data = png.toString('base64');
|
|
111
|
+
// 眼睛模式页面会把相机摆到的真实矩形写在 dataset 里(如果前端给了就报)
|
|
112
|
+
const shown = await page.evaluate(() => document.documentElement.dataset.eyeView || null).catch(() => null);
|
|
113
|
+
const text = `Board view: ${what}${shown ? ` — camera showed world rect ${shown}` : ''}. ${VIEW.width}x${VIEW.height} px. `
|
|
114
|
+
+ 'Half-transparent items are still staging. Lines: red with pins = yarn, wobbly = pencil, plain = ink.';
|
|
115
|
+
return { content: [{ type: 'text', text }, { type: 'image', data, mimeType: 'image/png' }] };
|
|
116
|
+
} catch (e) {
|
|
117
|
+
return err(`look_at_board failed: ${String(e?.message || e).slice(0, 200)}`);
|
|
118
|
+
} finally {
|
|
119
|
+
try { await browser?.close(); } catch { /* noop */ }
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
}
|