@xiaobuyu/nodesign 0.0.2 → 0.0.3
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/package.json +1 -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
|
@@ -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
|
+
}
|
|
@@ -15,38 +15,53 @@
|
|
|
15
15
|
import { tool } from '@anthropic-ai/claude-agent-sdk';
|
|
16
16
|
import { z } from 'zod';
|
|
17
17
|
import { readBoard } from '../../../projects/board-store.js';
|
|
18
|
-
import {
|
|
18
|
+
import { estimateSizeOn } from '../../../lib/board-kind-sizes.js';
|
|
19
19
|
import { layerOf } from '../../../lib/canvas-id.js';
|
|
20
|
-
import { relationsDigest } from '../../../lib/board-relations.js';
|
|
20
|
+
import { relationsDigest, bindingLine } from '../../../lib/board-relations.js';
|
|
21
|
+
import { groupObjects, asciiMinimap, bboxOfRects, relationOf, columnsOf, viewportRelation } from '../../../lib/board-groups.js';
|
|
22
|
+
import { getViewpoint } from '../../../projects/viewpoint-store.js';
|
|
23
|
+
import { chalkExcerpts, CHALK_DIR } from '../../../lib/chalk.js';
|
|
24
|
+
import { getSharedDir } from '../../../projects/workspace.js';
|
|
21
25
|
|
|
22
26
|
/** 同一"行"的 y 容差:入座算法一行内顶对齐,40 世界像素内视作同行 */
|
|
23
27
|
const ROW_TOLERANCE = 40;
|
|
24
28
|
|
|
25
|
-
function describeEntry(id, entry) {
|
|
26
|
-
const sz =
|
|
29
|
+
function describeEntry(board, id, entry, glyph = null, excerpts = null) {
|
|
30
|
+
const sz = estimateSizeOn(board, id, entry);
|
|
27
31
|
const at = `@(${Math.round(entry.x)},${Math.round(entry.y)}) ${Math.round(sz.w)}x${Math.round(sz.h)}`;
|
|
32
|
+
const g = glyph ? `[${glyph}] ` : '';
|
|
33
|
+
const flags = `${entry.staging ? ' 〔草稿〕' : ''}${entry.tag ? ` #${entry.tag}` : ''}`;
|
|
34
|
+
const ch = excerpts?.get(id);
|
|
35
|
+
if (ch) return `- ${g}[板书·${ch.by === 'user' ? '用户' : '你'}写的] 「${ch.first}」 ${at} (path: ${id})${ch.anchor ? ` 关于 ${ch.anchor}` : ''}${ch.replyTo ? ` 回应 ${ch.replyTo}` : ''}${flags}`;
|
|
28
36
|
if (entry.kind === 'text') {
|
|
29
|
-
const t = String(entry.data?.t || '').replace(/\s+/g, ' ').slice(0, 24);
|
|
30
|
-
|
|
37
|
+
const t = String(entry.data?.t || '').replace(/\s+/g, ' ').slice(0, entry.data?.format === 'md' ? 60 : 24);
|
|
38
|
+
const md = entry.data?.format === 'md' ? 'md' : '手写';
|
|
39
|
+
return `- ${g}[${md}] 「${t}」 ${at} (id: ${id})${entry.by === 'agent' ? ' ·你写的' : ''}${flags}`;
|
|
31
40
|
}
|
|
32
|
-
if (entry.kind === 'scribble') return `- [涂鸦] ${at} (id: ${id})`;
|
|
33
|
-
return `- ${id} ${at}${entry.by === 'agent' ? ' ·你摆的' : ''}`;
|
|
41
|
+
if (entry.kind === 'scribble') return `- ${g}[涂鸦] ${at} (id: ${id})${entry.by === 'agent' ? ' ·你画的' : ''}${flags}`;
|
|
42
|
+
return `- ${g}${id} ${at}${entry.by === 'agent' ? ' ·你摆的' : ''}${flags}`;
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
export function makeReadBoardTool({ projectId }) {
|
|
37
46
|
return tool(
|
|
38
47
|
'read_board',
|
|
39
|
-
`Read the workbench canvas
|
|
48
|
+
`Read the workbench canvas: an ASCII minimap, then GROUPS (things linked by lines or
|
|
49
|
+
sharing a #tag), then loose items row by row, then relation lines.
|
|
40
50
|
|
|
41
|
-
Use this BEFORE arranging
|
|
42
|
-
(
|
|
43
|
-
pixels
|
|
44
|
-
|
|
51
|
+
Use this BEFORE arranging (arrange_on_board), writing notes (create_on_board) or
|
|
52
|
+
sketching (sketch_on_board) — placement without looking is guessing. Coordinates are
|
|
53
|
+
world pixels. Only seated items appear (fresh artifacts get a seat within a second).
|
|
54
|
+
Items marked 〔草稿〕 are still staging (yours from this turn, half-transparent until
|
|
55
|
+
finish_sketch / end of turn). The user's current viewport (if known) is drawn as a box
|
|
56
|
+
on the minimap and listed with what is inside it.`,
|
|
45
57
|
{
|
|
46
58
|
layer: z.string().max(300).optional()
|
|
47
59
|
.describe("Folder path to read ('' or omitted = the root desktop plus a folder list)"),
|
|
60
|
+
tag: z.string().max(40).optional()
|
|
61
|
+
.describe('Only list items/lines carrying this #tag (one group, e.g. a sketch you made)'),
|
|
62
|
+
minimap: z.boolean().optional().describe('Also print an ASCII minimap (off by default — the relative-position summary is usually enough)'),
|
|
48
63
|
},
|
|
49
|
-
async ({ layer }) => {
|
|
64
|
+
async ({ layer, tag, minimap }) => {
|
|
50
65
|
if (!projectId) {
|
|
51
66
|
return { content: [{ type: 'text', text: 'No project bound.' }], isError: true };
|
|
52
67
|
}
|
|
@@ -64,23 +79,79 @@ been seated appear (freshly produced artifacts get their seat within a second).`
|
|
|
64
79
|
}
|
|
65
80
|
|
|
66
81
|
const lines = [];
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
82
|
+
const excerpts = await chalkExcerpts(getSharedDir(projectId), (byLayer.get(want) || []).map(it => it.id));
|
|
83
|
+
const items = (byLayer.get(want) || [])
|
|
84
|
+
.filter(({ entry }) => !tag || entry.tag === tag)
|
|
85
|
+
// 板书条目但文件已经没了 = 幽灵座位,别列给 agent(删文件那条路会清座位,这是兜底)
|
|
86
|
+
.filter(({ id }) => !id.startsWith(`${CHALK_DIR}/`) || excerpts.has(id))
|
|
87
|
+
.sort((a, b) => (a.entry.y - b.entry.y) || (a.entry.x - b.entry.x));
|
|
88
|
+
const entryOf = new Map(items.map(it => [it.id, it.entry]));
|
|
89
|
+
|
|
90
|
+
// 小地图(用户视口画框)
|
|
91
|
+
const vp = getViewpoint(projectId);
|
|
92
|
+
const vpRect = (vp && (vp.layer || '') === want && vp.camera) ? vp.camera : null;
|
|
93
|
+
const rects = items.map(({ id, entry }) => ({ id, x: entry.x, y: entry.y, ...estimateSizeOn(board, id, entry) }));
|
|
94
|
+
const mini = asciiMinimap(rects, { viewport: vpRect });
|
|
95
|
+
const glyphOf = new Map(mini ? mini.legend : []);
|
|
96
|
+
|
|
97
|
+
lines.push(want ? `文件夹「${want}」的座次${tag ? `(只看 #${tag})` : ''}:` : `桌面(根层)的座次${tag ? `(只看 #${tag})` : ''}:`);
|
|
98
|
+
if (!items.length) {
|
|
99
|
+
lines.push('(这一层还没有摆过的东西)');
|
|
100
|
+
} else {
|
|
101
|
+
if (mini && minimap) {
|
|
102
|
+
lines.push(`小地图(一格≈${mini.cell}px,左上=(${mini.bbox.x},${mini.bbox.y}),范围 ${mini.bbox.w}x${mini.bbox.h}${vpRect ? ',┌┐└┘ 框=用户视口' : ''}):`);
|
|
103
|
+
lines.push(mini.grid);
|
|
104
|
+
}
|
|
105
|
+
// 组:连通分量 + tag;≥2 件的才叫组,单件归「散件」按行列
|
|
106
|
+
const groups = groupObjects(items.map(it => it.id), board.bindings || {}, id => entryOf.get(id)?.tag || null);
|
|
107
|
+
const real = groups.filter(g => g.members.length >= 2);
|
|
108
|
+
const loose = groups.filter(g => g.members.length < 2).flatMap(g => g.members);
|
|
109
|
+
// 相对位置总览:每组的包围盒、列数、相对前一组/用户视口在哪(阅读顺序:先左后右、先上后下)
|
|
110
|
+
const rectOf = new Map(rects.map(r => [r.id, r]));
|
|
111
|
+
const boxes = real.map(g => bboxOfRects(g.members.map(id => rectOf.get(id)).filter(Boolean)));
|
|
112
|
+
const whole = bboxOfRects(rects);
|
|
113
|
+
if (whole) lines.push(`这一层内容范围:(${Math.round(whole.x)},${Math.round(whole.y)}) ${Math.round(whole.w)}x${Math.round(whole.h)}${vpRect ? `;用户视口 (${Math.round(vpRect.x)},${Math.round(vpRect.y)}) ${Math.round(vpRect.w)}x${Math.round(vpRect.h)}` : ''}`);
|
|
114
|
+
if (real.length) {
|
|
115
|
+
lines.push('各组位置(新东西默认排在已有内容的右侧或下方,顺着先左后右、先上后下的阅读顺序):');
|
|
116
|
+
real.forEach((g, i) => {
|
|
117
|
+
const b = boxes[i]; if (!b) return;
|
|
118
|
+
const tags = [...g.tags].map(t => `#${t}`).join(' ') || `组 ${i + 1}`;
|
|
119
|
+
const cols = columnsOf(g.members.map(id => rectOf.get(id)).filter(Boolean));
|
|
120
|
+
const bits = [`(${Math.round(b.x)},${Math.round(b.y)}) ${Math.round(b.w)}x${Math.round(b.h)}`, cols.length > 1 ? `${cols.length} 列(${cols.map(c => c.n).join('/')} 件)` : '单列'];
|
|
121
|
+
if (i > 0 && boxes[0]) bits.push(`在 ${[...real[0].tags].map(t => `#${t}`).join(' ') || '组 1'} 的${relationOf(boxes[0], b)}`);
|
|
122
|
+
const vr = viewportRelation(vpRect, b); if (vr) bits.push(vr);
|
|
123
|
+
lines.push(` ${tags}:${bits.join(';')}`);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
real.forEach((g, i) => {
|
|
127
|
+
const tags = [...g.tags].map(t => `#${t}`).join(' ');
|
|
128
|
+
const staging = g.members.every(id => entryOf.get(id)?.staging);
|
|
129
|
+
lines.push('', `组 ${i + 1}${tags ? ` ${tags}` : ''}(${g.members.length} 件 ${g.edges.length} 线${staging ? ',草稿' : ''}):`);
|
|
130
|
+
const sorted = g.members.map(id => ({ id, entry: entryOf.get(id) }))
|
|
131
|
+
.sort((a, b) => (a.entry.y - b.entry.y) || (a.entry.x - b.entry.x));
|
|
132
|
+
for (const { id, entry } of sorted) lines.push(describeEntry(board, id, entry, glyphOf.get(id), excerpts));
|
|
133
|
+
for (const bid of g.edges.slice(0, 12)) lines.push(` ${bindingLine(board.bindings[bid], board)} (line id: ${bid})`);
|
|
134
|
+
if (g.edges.length > 12) lines.push(` …还有 ${g.edges.length - 12} 条线`);
|
|
135
|
+
});
|
|
136
|
+
if (groups.cross?.length) {
|
|
137
|
+
lines.push('', '组间线:');
|
|
138
|
+
for (const bid of groups.cross.slice(0, 12)) lines.push(` ${bindingLine(board.bindings[bid], board)} (line id: ${bid})`);
|
|
139
|
+
}
|
|
140
|
+
if (loose.length) {
|
|
141
|
+
lines.push('', real.length ? '散件:' : '');
|
|
142
|
+
let rowY = null;
|
|
143
|
+
const sorted = loose.map(id => ({ id, entry: entryOf.get(id) }))
|
|
144
|
+
.sort((a, b) => (a.entry.y - b.entry.y) || (a.entry.x - b.entry.x));
|
|
145
|
+
for (const { id, entry } of sorted) {
|
|
146
|
+
if (rowY === null || Math.abs(entry.y - rowY) > ROW_TOLERANCE) {
|
|
147
|
+
rowY = entry.y;
|
|
148
|
+
lines.push(`— 行 y≈${Math.round(rowY)} —`);
|
|
149
|
+
}
|
|
150
|
+
lines.push(describeEntry(board, id, entry, glyphOf.get(id), excerpts));
|
|
76
151
|
}
|
|
77
|
-
lines.push(describeEntry(id, entry));
|
|
78
152
|
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
lines.push(want ? `文件夹「${want}」的座次:` : '桌面(根层)的座次:');
|
|
82
|
-
renderLayer(want);
|
|
83
|
-
if (!want) {
|
|
153
|
+
}
|
|
154
|
+
if (!want && !tag) {
|
|
84
155
|
const folders = Object.keys(board.zones || {}).sort();
|
|
85
156
|
if (folders.length) {
|
|
86
157
|
lines.push('', `文件夹卡:${folders.map(f => {
|
|
@@ -89,12 +160,28 @@ been seated appear (freshly produced artifacts get their seat within a second).`
|
|
|
89
160
|
}).join('、')}`);
|
|
90
161
|
}
|
|
91
162
|
}
|
|
92
|
-
if (board.hero) lines.push('', `★ 显式主角:${board.hero}(arrange_on_board 的 feature/unfeature 管它)`);
|
|
163
|
+
if (board.hero && !tag) lines.push('', `★ 显式主角:${board.hero}(arrange_on_board 的 feature/unfeature 管它)`);
|
|
93
164
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
165
|
+
// 用户视点(有上报才有)
|
|
166
|
+
if (vp && !tag) {
|
|
167
|
+
const inside = vpRect ? rects.filter(r =>
|
|
168
|
+
!(r.x + r.w < vpRect.x || r.x > vpRect.x + vpRect.w || r.y + r.h < vpRect.y || r.y > vpRect.y + vpRect.h))
|
|
169
|
+
.map(r => r.id) : [];
|
|
170
|
+
const bits = [];
|
|
171
|
+
if (vpRect) bits.push(`视口 (${Math.round(vpRect.x)},${Math.round(vpRect.y)}) ${Math.round(vpRect.w)}x${Math.round(vpRect.h)} 缩放 ${vp.zoom ?? '?'}`);
|
|
172
|
+
if (vp.openWindow) bits.push(`开着窗:${vp.openWindow}${vp.openPage ? `(${vp.openPage})` : ''}`);
|
|
173
|
+
if (vp.selected?.length) bits.push(`选中:${vp.selected.slice(0, 8).join('、')}`);
|
|
174
|
+
if (inside.length) bits.push(`视口里有:${inside.slice(0, 12).join('、')}${inside.length > 12 ? ' 等' : ''}`);
|
|
175
|
+
const age = Math.round((Date.now() - (vp.at || 0)) / 1000);
|
|
176
|
+
lines.push('', `用户此刻(${age}s 前上报):${bits.join(';') || '只知道在看这一层'}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!tag) {
|
|
180
|
+
try {
|
|
181
|
+
const digest = await relationsDigest(projectId, { limit: 16 });
|
|
182
|
+
if (digest) lines.push('', '关系线:', digest);
|
|
183
|
+
} catch { /* 关系读不到不挡座次 */ }
|
|
184
|
+
}
|
|
98
185
|
|
|
99
186
|
lines.push('', '(口径:稀疏表只列摆过的;层归属为服务端近似;尺寸为形态估算)');
|
|
100
187
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp/tools/read-user-view.js —— read_user_view(2026-08-23 黑板)
|
|
3
|
+
*
|
|
4
|
+
* 「用户此刻在看哪」:视口世界矩形 + 缩放 + 当前层 + 开着的窗 + 选中集 +
|
|
5
|
+
* 视口里有哪些东西。前端节流上报、服务端只留最近一份(viewpoint-store)。
|
|
6
|
+
* UserPromptSubmit 注入一行摘要(只报变化);细节走这个工具。
|
|
7
|
+
* 指代消解的顺序:选中的 > 开着的窗 > 视口中心附近的东西。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { tool } from '@anthropic-ai/claude-agent-sdk';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { readBoard } from '../../../projects/board-store.js';
|
|
13
|
+
import { estimateSizeOn } from '../../../lib/board-kind-sizes.js';
|
|
14
|
+
import { layerOf } from '../../../lib/canvas-id.js';
|
|
15
|
+
import { getViewpoint, describeViewpoint } from '../../../projects/viewpoint-store.js';
|
|
16
|
+
import { describeEndpoint } from '../../../lib/board-relations.js';
|
|
17
|
+
|
|
18
|
+
export function makeReadUserViewTool({ projectId }) {
|
|
19
|
+
return tool(
|
|
20
|
+
'read_user_view',
|
|
21
|
+
`What the user is looking at RIGHT NOW: viewport (world rect + zoom), which folder
|
|
22
|
+
layer, which artifact window is open, what is selected, and which items are inside the
|
|
23
|
+
viewport (sorted by distance to its centre). Use it to resolve "this / here / that one"
|
|
24
|
+
and to decide where a sketch or note should land so the user actually sees it. Resolve
|
|
25
|
+
pointers in this order: selected > open window > items near the viewport centre.`,
|
|
26
|
+
{ _: z.string().max(1).optional().describe('(no arguments)') },
|
|
27
|
+
async () => {
|
|
28
|
+
if (!projectId) return { content: [{ type: 'text', text: 'No project bound.' }], isError: true };
|
|
29
|
+
const vp = getViewpoint(projectId);
|
|
30
|
+
if (!vp) return { content: [{ type: 'text', text: '还没有用户视点上报(用户没开着画布页,或上报过期 >10 分钟)。' }] };
|
|
31
|
+
const board = await readBoard(projectId);
|
|
32
|
+
const known = new Set(Object.keys(board.zones || {}));
|
|
33
|
+
const layer = vp.layer || '';
|
|
34
|
+
const rects = Object.entries(board.objects || {})
|
|
35
|
+
.filter(([id, e]) => Number.isFinite(e?.x) && layerOf(id, e, known) === layer)
|
|
36
|
+
.map(([id, e]) => ({ id, x: e.x, y: e.y, ...estimateSizeOn(board, id, e) }));
|
|
37
|
+
const lines = [`用户此刻(${Math.round((Date.now() - vp.at) / 1000)}s 前上报):${describeViewpoint(vp, rects) || '未知'}`];
|
|
38
|
+
if (vp.camera) {
|
|
39
|
+
const c = vp.camera; const cx = c.x + c.w / 2; const cy = c.y + c.h / 2;
|
|
40
|
+
const inside = rects
|
|
41
|
+
.filter(r => !(r.x + r.w < c.x || r.x > c.x + c.w || r.y + r.h < c.y || r.y > c.y + c.h))
|
|
42
|
+
.map(r => ({ ...r, d: Math.hypot(r.x + r.w / 2 - cx, r.y + r.h / 2 - cy) }))
|
|
43
|
+
.sort((a, b) => a.d - b.d);
|
|
44
|
+
if (inside.length) {
|
|
45
|
+
lines.push('视口里(离中心由近到远):');
|
|
46
|
+
for (const r of inside.slice(0, 20)) {
|
|
47
|
+
const e = board.objects[r.id];
|
|
48
|
+
const flags = `${e.staging ? ' 〔草稿〕' : ''}${e.tag ? ` #${e.tag}` : ''}`;
|
|
49
|
+
lines.push(`- ${describeEndpoint(r.id, board)} @(${Math.round(r.x)},${Math.round(r.y)}) ${Math.round(r.w)}x${Math.round(r.h)} (id: ${r.id})${flags}`);
|
|
50
|
+
}
|
|
51
|
+
if (inside.length > 20) lines.push(`…还有 ${inside.length - 20} 件`);
|
|
52
|
+
}
|
|
53
|
+
lines.push(`视口中心 = (${Math.round(cx)},${Math.round(cy)});想让用户不用动镜头就看到,就把东西摆进这个矩形。`);
|
|
54
|
+
}
|
|
55
|
+
if (vp.selected?.length) lines.push(`选中:${vp.selected.map(id => describeEndpoint(id, board)).join('、')}`);
|
|
56
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { tool } from '@anthropic-ai/claude-agent-sdk';
|
|
20
20
|
import { z } from 'zod';
|
|
21
21
|
import { patchBoard } from '../../../projects/board-store.js';
|
|
22
|
-
import { BINDING_TYPES, BINDING_TYPE_IDS } from '../../../lib/binding-types.js';
|
|
22
|
+
import { BINDING_TYPES, BINDING_TYPE_IDS, BINDING_MATERIALS } from '../../../lib/binding-types.js';
|
|
23
23
|
|
|
24
24
|
/** 词汇表渲染成工具描述里的一段表,免得两处各写各的 */
|
|
25
25
|
const VOCAB = BINDING_TYPE_IDS
|
|
@@ -63,8 +63,10 @@ where the files live.`,
|
|
|
63
63
|
to: z.string().min(1).max(300).describe('Target canvas id — see forms above'),
|
|
64
64
|
label: z.string().max(60).optional()
|
|
65
65
|
.describe('Optional short words on the line. Omit to use the default for the type.'),
|
|
66
|
+
material: z.enum(BINDING_MATERIALS).optional()
|
|
67
|
+
.describe('How the line is drawn (orthogonal to type): ink = quiet archive line (default); pencil = hand-drawn; yarn = detective red string with pins, for hypotheses/evidence while reasoning. The whole canvas is a detective board — use yarn when the relation is still being argued, ink when it is settled.'),
|
|
66
68
|
},
|
|
67
|
-
async ({ type, from, to, label }) => {
|
|
69
|
+
async ({ type, from, to, label, material }) => {
|
|
68
70
|
if (!projectId) {
|
|
69
71
|
return { content: [{ type: 'text', text: 'No project bound; cannot draw relationships.' }], isError: true };
|
|
70
72
|
}
|
|
@@ -76,7 +78,7 @@ where the files live.`,
|
|
|
76
78
|
}
|
|
77
79
|
const id = nextId();
|
|
78
80
|
// by:'agent' 不只是记录出处 —— 用户画的线 agent 不该擅自删,反过来也一样
|
|
79
|
-
const binding = { type, from, to, by: 'agent', ...(label ? { label } : {}) };
|
|
81
|
+
const binding = { type, from, to, by: 'agent', ...(label ? { label } : {}), ...(material && material !== 'ink' ? { material } : {}) };
|
|
80
82
|
const board = await patchBoard(projectId, { bindings: { [id]: binding } });
|
|
81
83
|
|
|
82
84
|
// 服务端会丢掉非法的线(未知 type / 自环)。**丢了要说**,否则 agent 以为
|