@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.
Files changed (111) hide show
  1. package/README.en.md +184 -0
  2. package/README.md +19 -25
  3. package/package.json +2 -1
  4. package/server/api/assets/helpers.js +20 -0
  5. package/server/api/assets/notes.js +50 -0
  6. package/server/api/assets.js +6 -11
  7. package/server/api/board.js +78 -1
  8. package/server/engine/agent/events.js +1 -0
  9. package/server/engine/agent/hooks/user-prompt-submit.js +43 -0
  10. package/server/engine/agent/prompts/nodesign-prelude.md +53 -1
  11. package/server/engine/agent/session-loop.js +11 -0
  12. package/server/engine/mcp/index.js +12 -0
  13. package/server/engine/mcp/tools/arrange-on-board.js +4 -4
  14. package/server/engine/mcp/tools/create-on-board.js +9 -12
  15. package/server/engine/mcp/tools/edit-sketch.js +167 -0
  16. package/server/engine/mcp/tools/look-at-board.js +124 -0
  17. package/server/engine/mcp/tools/read-board.js +121 -34
  18. package/server/engine/mcp/tools/read-user-view.js +59 -0
  19. package/server/engine/mcp/tools/relate-on-board.js +5 -3
  20. package/server/engine/mcp/tools/sketch-on-board.js +337 -0
  21. package/server/engine/mcp/tools/write-on-board.js +130 -0
  22. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +7 -0
  23. package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +7 -0
  24. package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +7 -0
  25. package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +7 -0
  26. package/server/engine/runs/board-tasklist.js +130 -0
  27. package/server/index.js +3 -0
  28. package/server/lib/binding-types.js +12 -0
  29. package/server/lib/board-graph-export.js +191 -0
  30. package/server/lib/board-groups.js +167 -0
  31. package/server/lib/board-hero.js +76 -0
  32. package/server/lib/board-kind-sizes.js +23 -0
  33. package/server/lib/board-relations.js +1 -1
  34. package/server/lib/chalk.js +121 -0
  35. package/server/lib/sketch-layout.js +277 -0
  36. package/server/projects/board-limits.js +12 -0
  37. package/server/projects/board-sanitize.js +252 -0
  38. package/server/projects/board-store.js +72 -205
  39. package/server/projects/ui-config.js +6 -0
  40. package/server/projects/viewpoint-store.js +78 -0
  41. package/server/ws/broker.js +3 -0
  42. package/web/dist/assets/{BrowserWindow-BYCgizHw.js → BrowserWindow-P_vIdh-G.js} +2 -2
  43. package/web/dist/assets/{DeckWindow-pNydEM4L.js → DeckWindow-BpLv5082.js} +3 -3
  44. package/web/dist/assets/{DocxWindow-PPgzh3Gq.js → DocxWindow-DSAU1-tD.js} +4 -4
  45. package/web/dist/assets/MermaidBlock-DR1GikCv.js +313 -0
  46. package/web/dist/assets/SiteWindow-92uBuRRR.js +18 -0
  47. package/web/dist/assets/abnfDiagram-VCTEODGH-uA8Esix4.js +1 -0
  48. package/web/dist/assets/arc-D_khH_xN.js +1 -0
  49. package/web/dist/assets/architectureDiagram-5GKGNRK7-DydJHqOt.js +36 -0
  50. package/web/dist/assets/blockDiagram-NRAW4CY4-B7wBbKiW.js +129 -0
  51. package/web/dist/assets/c4Diagram-UCG6FXSJ-6mrH0x7Y.js +38 -0
  52. package/web/dist/assets/channel-DGkccMO1.js +1 -0
  53. package/web/dist/assets/chunk-2Q5K7J3B-Cuikho5Q.js +1 -0
  54. package/web/dist/assets/chunk-5VM5RSS4-3-KZ5AUz.js +15 -0
  55. package/web/dist/assets/chunk-F27PBJKO-Bkwq2gGU.js +1 -0
  56. package/web/dist/assets/chunk-G27WJ6UU-BLyQzdGE.js +231 -0
  57. package/web/dist/assets/chunk-JWPE2WC7-B7HxxQds.js +1 -0
  58. package/web/dist/assets/chunk-LCL6LL3I-CtXrnUwF.js +206 -0
  59. package/web/dist/assets/chunk-POPQ4Y6H-dxddZlXt.js +1 -0
  60. package/web/dist/assets/chunk-SVP7TREG-DpkmrgHn.js +88 -0
  61. package/web/dist/assets/chunk-XXDRQBXY-BwZ7RbpQ.js +1 -0
  62. package/web/dist/assets/classDiagram-DTDB5LWJ-BGscw3OD.js +1 -0
  63. package/web/dist/assets/classDiagram-v2-JRS7N3AN-BGscw3OD.js +1 -0
  64. package/web/dist/assets/cose-bilkent-JH36ORCC-DGFOsMUt.js +1 -0
  65. package/web/dist/assets/cynefin-OW5HDTMX-DUwn3ehS.js +178 -0
  66. package/web/dist/assets/cynefinDiagram-5FMLGOSQ-DlLAOZ9x.js +62 -0
  67. package/web/dist/assets/cytoscape.esm-BjAbmLdb.js +331 -0
  68. package/web/dist/assets/dagre-3AP2YEHR-CyourLfz.js +4 -0
  69. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  70. package/web/dist/assets/diagram-S7CK7UJ4-BTMbx2np.js +30 -0
  71. package/web/dist/assets/diagram-UQ7AKVKN-UK94_1-P.js +41 -0
  72. package/web/dist/assets/diagram-VSXAHHWV-C7zS-v9v.js +3 -0
  73. package/web/dist/assets/diagram-VX7I27RA-DKJRa6CK.js +24 -0
  74. package/web/dist/assets/diagram-Z3DM3KII-kUbAfGAp.js +24 -0
  75. package/web/dist/assets/ebnfDiagram-PWID7BFC-CVW16_vb.js +1 -0
  76. package/web/dist/assets/erDiagram-SSCWMZ5O-BRQMFQ5y.js +99 -0
  77. package/web/dist/assets/flowDiagram-A5DVABFB-S34LpLtK.js +168 -0
  78. package/web/dist/assets/ganttDiagram-EL5Y4UJY-BFiqk1-G.js +292 -0
  79. package/web/dist/assets/gitGraphDiagram-WWUBYQGX-LZsrdyyv.js +106 -0
  80. package/web/dist/assets/index-GAamh1p4.js +2152 -0
  81. package/web/dist/assets/infoDiagram-RXCK75RN-0DvN6KIp.js +2 -0
  82. package/web/dist/assets/init-Gi6I4Gst.js +1 -0
  83. package/web/dist/assets/ishikawaDiagram-5VMMS53U-CaY3A-Bx.js +70 -0
  84. package/web/dist/assets/journeyDiagram-EYS64GPL-BBeiaCrj.js +139 -0
  85. package/web/dist/assets/kanban-definition-3QL26DDD-ux42DRXQ.js +89 -0
  86. package/web/dist/assets/layout-CnXiF_NB.js +1 -0
  87. package/web/dist/assets/linear-BMW0u6G6.js +1 -0
  88. package/web/dist/assets/mindmap-definition-FBJOCRG2-hUxSpdKn.js +96 -0
  89. package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  90. package/web/dist/assets/pegDiagram-XKGWAZYB-aCaQPH0w.js +1 -0
  91. package/web/dist/assets/{pending-edit-apply-C0I13CbD.js → pending-edit-apply-AcdOQ9Ij.js} +6 -6
  92. package/web/dist/assets/pieDiagram-E7YTZNPT-CmOEZ8zg.js +39 -0
  93. package/web/dist/assets/quadrantDiagram-AXDQQJYC-VW6fAhsV.js +7 -0
  94. package/web/dist/assets/railroadDiagram-O6MQD6OU-CKJ7Ba5K.js +1 -0
  95. package/web/dist/assets/requirementDiagram-EFPCY7ZU-pNGkq9Vw.js +84 -0
  96. package/web/dist/assets/{rotate-cw-B1UQ8Hld.js → rotate-cw-KAfChbGV.js} +1 -1
  97. package/web/dist/assets/sankeyDiagram-P5KCCOFB-BIMmDTfZ.js +40 -0
  98. package/web/dist/assets/sequenceDiagram-WJ2MYXX4-BBW7wzKY.js +162 -0
  99. package/web/dist/assets/sizeCapture-X5ZJPWSS-DdkXmLYR.js +1 -0
  100. package/web/dist/assets/{square-dashed-mouse-pointer-8lrgqmfv.js → square-dashed-mouse-pointer-CDUlhUP7.js} +1 -1
  101. package/web/dist/assets/stateDiagram-HBIQ2CUA-W313Uz16.js +1 -0
  102. package/web/dist/assets/stateDiagram-v2-4QOOHH4V-DDhJBZzf.js +1 -0
  103. package/web/dist/assets/swimlanes-XN3QIQJK-C29TL26K.js +1 -0
  104. package/web/dist/assets/swimlanesDiagram-VK2B7HYN-BfN-Xdmu.js +8 -0
  105. package/web/dist/assets/timeline-definition-24CTP7MA-CF0JMObX.js +120 -0
  106. package/web/dist/assets/vennDiagram-4TSXK5OY-DrDFsaH3.js +34 -0
  107. package/web/dist/assets/wardleyDiagram-VM6X3IG4-CVrnRIS_.js +78 -0
  108. package/web/dist/assets/xychartDiagram-S5SC5T6Z-BvkN1zsT.js +7 -0
  109. package/web/dist/index.html +1 -1
  110. package/web/dist/assets/SiteWindow-DM0LQPCO.js +0 -18
  111. package/web/dist/assets/index-BHTBJlpY.js +0 -2125
@@ -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 { estimateSize } from '../../../lib/board-kind-sizes.js';
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 = estimateSize(id, entry);
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
- return `- [手写] 「${t}」 ${at} (id: ${id})${entry.by === 'agent' ? ' ·你写的' : ''}`;
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 seating chart: what sits where, row by row, per folder layer.
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 anything (arrange_on_board) or writing notes near things
42
- (create_on_board) — placement without looking is guessing. Coordinates are world
43
- pixels; items listed top-to-bottom, left-to-right per row. Only items that have
44
- been seated appear (freshly produced artifacts get their seat within a second).`,
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 renderLayer = (l) => {
68
- const items = (byLayer.get(l) || []).sort((a, b) =>
69
- (a.entry.y - b.entry.y) || (a.entry.x - b.entry.x));
70
- if (!items.length) { lines.push('(这一层还没有摆过的东西)'); return; }
71
- let rowY = null;
72
- for (const { id, entry } of items) {
73
- if (rowY === null || Math.abs(entry.y - rowY) > ROW_TOLERANCE) {
74
- rowY = entry.y;
75
- lines.push(`— 行 y≈${Math.round(rowY)} —`);
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
- try {
95
- const digest = await relationsDigest(projectId, { limit: 16 });
96
- if (digest) lines.push('', '关系线:', digest);
97
- } catch { /* 关系读不到不挡座次 */ }
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 以为
@@ -0,0 +1,337 @@
1
+ /**
2
+ * mcp/tools/sketch-on-board.js —— sketch_on_board / finish_sketch(2026-08-23 黑板)
3
+ *
4
+ * 让 agent 把画布当黑板:一次调用落一整张示意图(节点 + 形状 + 线),而不是
5
+ * 像 create_on_board 那样一条条写。设计要点(跟用户商定的形状):
6
+ * - **没有几何容器**:一张图 = 一批带同一 #tag 的普通物件和线。read_board 按
7
+ * 连通分量 + tag 分段读,用户可以整组选/整组擦,入座算法照旧绕着走
8
+ * - **两阶段**:落下来的默认是 staging(半透明,"正在画"),finish_sketch 或
9
+ * 回合结束自动落定;落定只清 staging 位,tag 留着当逻辑分组
10
+ * - **结构不是像素**:agent 给模板 + 网格坐标(1 格 = 24px),服务端算像素与落位
11
+ * - **形状是涂鸦**:rect/ellipse/arrow… 在服务端生成带手绘抖动的路径,落成
12
+ * 普通 scribble 对象,前端零改动;自由路径(path)也收,同一白名单
13
+ * - 线的材质缺省 pencil(手绘)—— 草图上的线是人顺手拉的;要丝线/墨线自己说
14
+ *
15
+ * 护栏:每回合 3 张图;每张 ≤ 40 节点 / 30 形状 / 60 线。
16
+ */
17
+
18
+ import { tool } from '@anthropic-ai/claude-agent-sdk';
19
+ import { z } from 'zod';
20
+ import { readBoard, patchBoard, commitStaging, removeByTag, 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, SKETCH_FIT, SKETCH_MAX, textBox, shapePath, layoutNodes, bboxOf, findSpot, fitFor } from '../../../lib/sketch-layout.js';
25
+ import { getViewpoint } from '../../../projects/viewpoint-store.js';
26
+
27
+ const MAX_NODES = 40;
28
+ const MAX_SHAPES = 30;
29
+ const MAX_EDGES = 60;
30
+
31
+ let seq = 0;
32
+ const stamp = () => `${Date.now().toString(36)}${(seq++ % 1000).toString(36)}`;
33
+ const COLORS = ['ink', 'red', 'pencil', 'brass'];
34
+
35
+ const LOCAL_ID = z.string().regex(/^[A-Za-z0-9_-]{1,24}$/, 'local id: letters/digits/_/-');
36
+ const GRID_PT = z.object({ x: z.number().min(-400).max(400), y: z.number().min(-400).max(400) });
37
+
38
+ export function makeSketchOnBoardTool({ projectId, ctx }) {
39
+ const turnStamp = { turn: -1, count: 0 }; // 只记数(返回里不再报余额)
40
+ return tool(
41
+ 'sketch_on_board',
42
+ `Draw a whole sketch on the canvas in ONE call — notes, shapes and lines, laid out
43
+ together: a comparison table, a flow, a mind map, a detective board linking real
44
+ artifacts. The canvas is the blackboard; use it when thinking out loud with the user
45
+ would be clearer as a picture than as prose.
46
+
47
+ How it works
48
+ - You describe STRUCTURE; the server does pixels. Pick a layout template (column /
49
+ row / grid / mindmap) or place nodes yourself on a grid (1 cell = ${UNIT}px, layout
50
+ 'free', at:{x,y}). Whole sketch lands beside \`near\` (a canvas id from read_board)
51
+ or under the desktop's current content.
52
+ - nodes: plain handwriting or format:'md' (Markdown + KaTeX $…$ + \`\`\`mermaid fences).
53
+ Use md for lists/tables/formulas; mermaid only for dense formal diagrams
54
+ (sequence/state) — anything that should connect to real artifacts must be nodes+edges.
55
+ - shapes: rect / ellipse / circle / line / arrow / underline (hand-drawn look is
56
+ applied for you) or path (raw SVG M/L/Q in local px) — all in grid units.
57
+ - edges: between local node ids and/or real canvas ids. type = the relation
58
+ vocabulary (link/annotates/flow/contrast/derives-from/ref); material = ink (quiet
59
+ archive line) / pencil (hand-drawn, default here) / yarn (detective red string + pins
60
+ for hypotheses and evidence).
61
+ - Everything carries one #tag (a group): read_board {tag} reads just it, the user can
62
+ select/erase it as a whole, finish_sketch commits or erases it.
63
+ - Items land as STAGING (half-transparent = you are still drawing). Call finish_sketch
64
+ when done, or they commit automatically when the turn ends. Look at the result with
65
+ look_at_board before you call it done — agent scribbles only make sense with eyes.
66
+ - Readability rule: the user reads the board at 80–100% zoom. Body text stays md/lg
67
+ (sm only for captions ≤40 chars); one sketch ≤ ${SKETCH_FIT.w}x${SKETCH_FIT.h} world px (one screen at 80%),
68
+ hard limit ${SKETCH_MAX.w}x${SKETCH_MAX.h} — split into two tagged sketches instead of one huge one.
69
+ - To change an existing sketch use edit_sketch (move/retext/add/remove by id) — do NOT
70
+ erase and redraw the whole thing for a small change.
71
+ Per sketch: ≤${MAX_NODES} nodes, ${MAX_SHAPES} shapes, ${MAX_EDGES} edges (split big ones).`,
72
+ {
73
+ title: z.string().max(60).optional().describe('Optional heading written at the top of the sketch'),
74
+ tag: z.string().regex(/^[\w一-鿿぀-ヿ-]{1,40}$/).optional()
75
+ .describe('Group tag (letters/digits/_/-/CJK, ≤40). Default: auto "sk-<stamp>"'),
76
+ near: z.string().max(300).optional().describe('Canvas id to place the sketch beside (right side; falls back to below)'),
77
+ layout: z.enum(['auto', 'free', 'column', 'row', 'grid', 'mindmap']).optional()
78
+ .describe('auto = free if any node has at, else column (≤4) / grid'),
79
+ cols: z.number().int().min(1).max(8).optional().describe('grid columns'),
80
+ staging: z.boolean().optional().describe('Default true. false = land committed (solid) immediately'),
81
+ nodes: z.array(z.object({
82
+ id: LOCAL_ID.describe('Local id to reference from edges/shapes'),
83
+ text: z.string().min(1).max(4000),
84
+ format: z.enum(['plain', 'md']).optional(),
85
+ size: z.enum(['sm', 'md', 'lg', 'xl']).optional(),
86
+ font: z.enum(['pen', 'kai', 'sans', 'serif', 'mono']).optional(),
87
+ color: z.enum(['ink', 'red', 'pencil', 'brass']).optional(),
88
+ at: GRID_PT.optional().describe('Grid position (layout free); top-left of the node'),
89
+ w: z.number().min(3).max(22).optional().describe('Width in grid units (md nodes; default by content; ≤22 = 528px — paragraphs grow down, not wide)'),
90
+ })).max(MAX_NODES).optional(),
91
+ shapes: z.array(z.object({
92
+ id: LOCAL_ID.optional(),
93
+ kind: z.enum(['rect', 'ellipse', 'circle', 'line', 'arrow', 'underline', 'path']),
94
+ at: GRID_PT.optional().describe('Grid position (top-left for rect/ellipse; start point for line/arrow). Or use around:'),
95
+ around: LOCAL_ID.optional().describe('rect/ellipse/circle/underline: wrap this node (pad applied) instead of at/w/h'),
96
+ w: z.number().min(0).max(200).optional().describe('grid units'),
97
+ h: z.number().min(0).max(200).optional().describe('grid units'),
98
+ to: GRID_PT.optional().describe('line/arrow end point (grid)'),
99
+ toNode: LOCAL_ID.optional().describe('line/arrow: aim at this node\'s center instead of to'),
100
+ d: z.string().max(8000).optional().describe('path kind: SVG path (M/L/Q/Z only) in local px'),
101
+ color: z.enum(['ink', 'red', 'pencil', 'brass']).optional(),
102
+ width: z.number().min(1).max(12).optional(),
103
+ })).max(MAX_SHAPES).optional(),
104
+ edges: z.array(z.object({
105
+ from: z.string().min(1).max(300).describe('local node id or canvas id'),
106
+ to: z.string().min(1).max(300).describe('local node id or canvas id'),
107
+ type: z.enum(BINDING_TYPE_IDS).optional().describe('default link'),
108
+ material: z.enum(BINDING_MATERIALS).optional().describe('default pencil'),
109
+ label: z.string().max(60).optional(),
110
+ })).max(MAX_EDGES).optional(),
111
+ },
112
+ async (args) => {
113
+ if (!projectId) return { content: [{ type: 'text', text: 'No project bound.' }], isError: true };
114
+ const err = (t) => ({ content: [{ type: 'text', text: t }], isError: true });
115
+ // 回合闸 08-23 用户拍板先不设(「别设置限制吧先」):只记数不拦
116
+ const turn = ctx?.runId ?? -1;
117
+ if (turnStamp.turn !== turn) { turnStamp.turn = turn; turnStamp.count = 0; }
118
+
119
+ const nodesIn = args.nodes || [];
120
+ const shapesIn = args.shapes || [];
121
+ const edgesIn = args.edges || [];
122
+ if (!nodesIn.length && !shapesIn.length) return err('空图不落板:至少给一个节点或形状。');
123
+ const tag = args.tag || `sk-${stamp()}`;
124
+ const staging = args.staging !== false;
125
+
126
+ // ── 节点身位 ──
127
+ const localIds = new Set();
128
+ const nodes = [];
129
+ if (args.title) {
130
+ nodes.push({ key: '__title', text: `## ${args.title}`, format: 'md', size: 'md', font: 'kai', color: 'ink', at: null, w: null });
131
+ }
132
+ for (const n of nodesIn) {
133
+ if (localIds.has(n.id)) return err(`节点 id 重复:${n.id}`);
134
+ localIds.add(n.id);
135
+ // 可读性规范:正文不低于 md(0.8 倍下 12.8 屏幕像素是底线);sm 只给 ≤40 字的题注
136
+ const size = (n.size === 'sm' && n.text.length > 40) ? 'md' : (n.size || 'md');
137
+ nodes.push({ key: n.id, text: n.text, format: n.format || 'plain', size, font: n.font || 'pen', color: n.color || 'ink', at: n.at || null, w: n.w || null });
138
+ }
139
+ for (const n of nodes) {
140
+ const box = textBox(n.text, n.size, { md: n.format === 'md', wUnits: n.w });
141
+ n.w = box.w; n.h = box.h;
142
+ }
143
+ // 标题永远在最上面:free 模式给它 (0,-2),模板模式它就是第一个
144
+ const titleNode = nodes.find(n => n.key === '__title');
145
+ let tpl = args.layout || 'auto';
146
+ if (tpl === 'auto') tpl = nodes.some(n => n.at) ? 'free' : (nodes.length - (titleNode ? 1 : 0) <= 4 ? 'column' : 'grid');
147
+ const layoutInput = titleNode && tpl === 'mindmap' ? nodes.filter(n => n !== titleNode) : nodes;
148
+ const pos = layoutNodes(layoutInput, { template: tpl, cols: args.cols });
149
+ if (titleNode && !pos.has('__title')) {
150
+ const bb = bboxOf([...pos.entries()].map(([k, p]) => ({ x: p.x, y: p.y, ...nodes.find(n => n.key === k) })));
151
+ pos.set('__title', { x: bb.x, y: bb.y - titleNode.h - 12 });
152
+ } else if (titleNode && tpl === 'free' && !titleNode.at) {
153
+ const bb = bboxOf([...pos.entries()].filter(([k]) => k !== '__title').map(([k, p]) => ({ x: p.x, y: p.y, ...nodes.find(n => n.key === k) })));
154
+ pos.set('__title', { x: bb.x, y: bb.y - titleNode.h - 12 });
155
+ }
156
+ const rectOfNode = (key) => { const n = nodes.find(x => x.key === key); const p = pos.get(key); return n && p ? { x: p.x, y: p.y, w: n.w, h: n.h } : null; };
157
+
158
+ // ── 形状(局部像素)──
159
+ const shapes = [];
160
+ for (let i = 0; i < shapesIn.length; i += 1) {
161
+ const s = shapesIn[i];
162
+ const sid = s.id || `s${i + 1}`;
163
+ if (localIds.has(sid) || shapes.some(x => x.key === sid)) return err(`形状 id「${sid}」跟节点/别的形状重名(形状缺省叫 s1,s2…,节点别用这类名)`);
164
+ const seed = `${tag}:${sid}`;
165
+ const color = COLORS.includes(s.color) ? s.color : (s.kind === 'arrow' || s.kind === 'line' ? 'ink2' : 'ink');
166
+ const width = s.width || 2;
167
+ let rect; let d;
168
+ if (s.kind === 'path') {
169
+ if (!s.d || !/^[\dMLQCZ ,.\-eE]+$/.test(s.d)) return err(`形状 ${sid}:path 只收 M/L/Q/Z 与数字`);
170
+ // 自由路径:算它的包围盒,平移到 (at 或 0,0)
171
+ const nums = s.d.match(/-?\d*\.?\d+(?:[eE][-+]?\d+)?/g)?.map(Number) || [];
172
+ const xs = nums.filter((_, k) => k % 2 === 0); const ys = nums.filter((_, k) => k % 2 === 1);
173
+ const w = Math.max(4, Math.max(...xs) - Math.min(0, Math.min(...xs))); const h = Math.max(4, Math.max(...ys) - Math.min(0, Math.min(...ys)));
174
+ rect = { x: (s.at?.x || 0) * UNIT, y: (s.at?.y || 0) * UNIT, w: w + 6, h: h + 6 };
175
+ d = s.d;
176
+ } else if (s.kind === 'line' || s.kind === 'arrow' || s.kind === 'underline') {
177
+ let a; let b;
178
+ if (s.kind === 'underline' && s.around) {
179
+ const r = rectOfNode(s.around); if (!r) return err(`形状 ${sid}:around 指向不存在的节点 ${s.around}`);
180
+ a = { x: r.x + 2, y: r.y + r.h - 2 }; b = { x: r.x + r.w - 2, y: r.y + r.h - 2 };
181
+ } else {
182
+ if (!s.at) return err(`形状 ${sid}:${s.kind} 要 at 起点`);
183
+ a = { x: s.at.x * UNIT, y: s.at.y * UNIT };
184
+ if (s.toNode) {
185
+ const r = rectOfNode(s.toNode); if (!r) return err(`形状 ${sid}:toNode 指向不存在的节点 ${s.toNode}`);
186
+ b = { x: r.x + r.w / 2, y: r.y + r.h / 2 };
187
+ } else if (s.to) b = { x: s.to.x * UNIT, y: s.to.y * UNIT };
188
+ else if (s.kind === 'underline') b = { x: a.x + (s.w || 4) * UNIT, y: a.y };
189
+ else return err(`形状 ${sid}:${s.kind} 要 to 或 toNode`);
190
+ }
191
+ const sp = shapePath(s.kind, { to: { x: b.x - a.x, y: b.y - a.y } }, seed);
192
+ rect = { x: Math.min(a.x, b.x) - 6, y: Math.min(a.y, b.y) - 6, w: sp.w, h: sp.h };
193
+ d = sp.d;
194
+ } else {
195
+ let box;
196
+ if (s.around) {
197
+ const r = rectOfNode(s.around); if (!r) return err(`形状 ${sid}:around 指向不存在的节点 ${s.around}`);
198
+ const pad = s.kind === 'rect' ? 8 : 14;
199
+ box = { x: r.x - pad, y: r.y - pad, w: r.w + pad * 2, h: r.h + pad * 2 };
200
+ if (s.kind === 'circle') { const dmax = Math.max(box.w, box.h); box = { x: box.x + (box.w - dmax) / 2, y: box.y + (box.h - dmax) / 2, w: dmax, h: dmax }; }
201
+ } else {
202
+ if (!s.at || !s.w) return err(`形状 ${sid}:${s.kind} 要 at + w(+h)或 around`);
203
+ box = { x: s.at.x * UNIT, y: s.at.y * UNIT, w: s.w * UNIT, h: (s.h || s.w) * UNIT };
204
+ }
205
+ const sp = shapePath(s.kind, { w: box.w, h: box.h }, seed);
206
+ rect = { x: box.x - 6, y: box.y - 6, w: sp.w, h: sp.h };
207
+ d = sp.d;
208
+ }
209
+ shapes.push({ key: sid, rect, d, color: COLORS.includes(color) ? color : 'ink', width });
210
+ }
211
+
212
+ // ── 先分配 id、解析线 ──
213
+ // 线要先于落位算:一条连到真实产物卡的线会改变**主角判断**(手画线 +0.5 焦点分),
214
+ // 那张卡可能因此放大 1.5 倍 —— 落位必须按"线画上去之后"的尺寸避让
215
+ // (08-23 首跑真踩:草图连了一条丝线到纸本站,纸本当场成主角,身体盖住半张图)。
216
+ const board = await readBoard(projectId);
217
+ const known = new Set(Object.keys(board.zones || {}));
218
+ const idOf = new Map(); // local → canvas id
219
+ for (const n of nodes) idOf.set(n.key, `text:a${stamp()}`);
220
+ for (const sh of shapes) idOf.set(sh.key, `scribble:a${stamp()}`);
221
+ const resolveEnd = (raw) => {
222
+ if (idOf.has(raw)) return idOf.get(raw);
223
+ const cid = normalizeCanvasId(raw);
224
+ if (cid && board.objects?.[cid]) return cid;
225
+ if (cid && board.zones?.[cid] !== undefined) return cid;
226
+ return null;
227
+ };
228
+ const bindings = {};
229
+ const badEdges = [];
230
+ for (const e of edgesIn) {
231
+ const from = resolveEnd(e.from); const to = resolveEnd(e.to);
232
+ if (!from || !to || from === to) { badEdges.push(`${e.from}→${e.to}`); continue; }
233
+ bindings[`b:a${stamp()}`] = {
234
+ type: e.type || 'link', from, to, by: 'agent', material: e.material || 'pencil', tag,
235
+ ...(staging ? { staging: true } : {}), ...(e.label ? { label: e.label } : {}),
236
+ };
237
+ }
238
+ // 落位用"线画上之后"的板来估尺寸(主角可能换人)
239
+ const after = { ...board, bindings: { ...(board.bindings || {}), ...bindings } };
240
+
241
+ // ── 宏观落位 ──
242
+ const local = bboxOf([
243
+ ...nodes.map(n => ({ ...pos.get(n.key), w: n.w, h: n.h })),
244
+ ...shapes.map(sh => sh.rect),
245
+ ]);
246
+ let zone = '';
247
+ let anchor = null;
248
+ if (args.near) {
249
+ const nid = normalizeCanvasId(args.near);
250
+ const e = nid ? board.objects?.[nid] : null;
251
+ if (!e || !Number.isFinite(e.x)) return err(`锚点 ${args.near} 还没有座位(read_board 里看不到就锚不上)。`);
252
+ zone = layerOf(nid, e, known);
253
+ anchor = { x: e.x, y: e.y, ...estimateSizeOn(after, nid, e) };
254
+ }
255
+ const obstacles = [];
256
+ let contentBottom = 0;
257
+ for (const [id, e] of Object.entries(board.objects || {})) {
258
+ if (!Number.isFinite(e?.x) || layerOf(id, e, known) !== zone) continue;
259
+ const r = { x: e.x, y: e.y, ...estimateSizeOn(after, id, e) };
260
+ obstacles.push(r); contentBottom = Math.max(contentBottom, r.y + r.h);
261
+ }
262
+ if (!zone) for (const zz of Object.values(board.zones || {})) if (Number.isFinite(zz?.y)) contentBottom = Math.max(contentBottom, zz.y + 240);
263
+ // 尺寸规范:硬上限直接拒(一张图说一件事),推荐上限以上给提醒
264
+ if (local.w > SKETCH_MAX.w || local.h > SKETCH_MAX.h) {
265
+ return err(`这张图太大了(${Math.round(local.w)}x${Math.round(local.h)} 世界像素,上限 ${SKETCH_MAX.w}x${SKETCH_MAX.h}):用户在 80%~100% 缩放下看不全。拆成两张(各自一个 tag,之间用线连),或减节点/缩 w。`);
266
+ }
267
+ // 用户视口(同一层才算;黑板是主窗口时画在他眼前)+ 按他的屏幕算"一屏"
268
+ const vp = getViewpoint(projectId);
269
+ const vpRect = (vp && (vp.layer || '') === zone && vp.camera) ? vp.camera : null;
270
+ const fit = fitFor(vp);
271
+ const spot = findSpot({ w: local.w + 24, h: local.h + 24, near: anchor, obstacles, contentBottom, viewport: vpRect });
272
+ const ox = spot.x - local.x + 12; const oy = spot.y - local.y + 12;
273
+
274
+ // ── 落盘 ──
275
+ const objects = {};
276
+ const common = { z: 1, zone, by: 'agent', tag, ...(staging ? { staging: true } : {}) };
277
+ for (const n of nodes) {
278
+ const p = pos.get(n.key);
279
+ objects[idOf.get(n.key)] = {
280
+ x: Math.round(p.x + ox), y: Math.round(p.y + oy), w: n.w, h: n.h, kind: 'text',
281
+ data: { t: n.text, ...(n.format === 'md' ? { format: 'md' } : {}), font: TEXT_FONTS.includes(n.font) ? n.font : 'pen', size: n.size, color: n.color },
282
+ ...common,
283
+ };
284
+ }
285
+ for (const sh of shapes) {
286
+ objects[idOf.get(sh.key)] = { x: Math.round(sh.rect.x + ox), y: Math.round(sh.rect.y + oy), w: Math.round(sh.rect.w), h: Math.round(sh.rect.h), kind: 'scribble', data: { d: sh.d, color: sh.color, width: sh.width }, ...common };
287
+ }
288
+ const saved = await patchBoard(projectId, { objects, bindings });
289
+ const landed = Object.keys(objects).filter(id => saved.objects?.[id]).length;
290
+ if (!landed) return err('草图被 board 拒了(内容或字段不合法)。');
291
+ turnStamp.count += 1;
292
+ const world = { x: Math.round(local.x + ox), y: Math.round(local.y + oy), w: Math.round(local.w), h: Math.round(local.h) };
293
+ try {
294
+ ctx?.emit?.({ type: 'board.updated', sessionId: null, summary: `画了一张草图 #${tag}` });
295
+ // 镜头提示(前端只在黑板模式跟随;不是劫持,是"黑板是主窗口"的约定)
296
+ ctx?.emit?.({ type: 'board.focus', sessionId: null, tag, rect: world, layer: zone });
297
+ } catch { /* fail-soft */ }
298
+ const lines = [
299
+ `Sketch #${tag} landed${staging ? ' as STAGING (半透明)' : ''}: ${Object.keys(objects).length - shapes.length} nodes, ${shapes.length} shapes, ${Object.keys(bindings).length} lines; layout ${tpl}; at world (${world.x},${world.y}) ${world.w}x${world.h}${anchor ? ` (${spot.side} of ${args.near})` : ' (below current content)'}.`,
300
+ `ids: ${[...idOf].map(([k, v]) => `${k}=${v}`).join(', ')}`,
301
+ ];
302
+ if (badEdges.length) lines.push(`Skipped ${badEdges.length} edge(s) with unknown endpoints: ${badEdges.slice(0, 6).join(', ')}`);
303
+ if (world.w > fit.w || world.h > fit.h) lines.push(`⚠ Bigger than one screen at 80% zoom${fit.screen ? ` (user's screen ${fit.screen.w}x${fit.screen.h}px → ${fit.w}x${fit.h} world px fits)` : ` (${fit.w}x${fit.h} fits)`} — the user will have to pan. Split into two tagged sketches (side by side or stacked) next time.`);
304
+ if (vp?.zoom && vp.zoom < 0.8) lines.push(`User's zoom is ${vp.zoom} (<0.8): text this size is small on their screen. Keep nodes md/lg and say in one line that there is a sketch on the board.`);
305
+ lines.push(spot.side === 'viewport' ? 'Placed inside the user\'s current viewport.' : `Placed ${spot.side === 'bottom' ? 'below current content' : spot.side + ' of the anchor'}${vpRect ? ' (no room in the user\'s viewport)' : ''}.`);
306
+ lines.push(staging
307
+ ? 'Next: look_at_board {tag} to check it, then finish_sketch {tag} (or it commits at turn end). Mention the sketch to the user in one line.'
308
+ : 'Next: look_at_board {tag} to check it.');
309
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
310
+ },
311
+ );
312
+ }
313
+
314
+ export function makeFinishSketchTool({ projectId, ctx }) {
315
+ return tool(
316
+ 'finish_sketch',
317
+ `Commit a staging sketch (make it solid) — or erase it. Pass the #tag from
318
+ sketch_on_board; omit tag to commit everything still staging. erase:true deletes the
319
+ whole group (its notes/shapes/lines; real artifact cards only lose the tag).`,
320
+ {
321
+ tag: z.string().max(40).optional(),
322
+ erase: z.boolean().optional(),
323
+ },
324
+ async ({ tag, erase }) => {
325
+ if (!projectId) return { content: [{ type: 'text', text: 'No project bound.' }], isError: true };
326
+ if (erase) {
327
+ if (!tag) return { content: [{ type: 'text', text: 'erase needs a tag.' }], isError: true };
328
+ const { removed } = await removeByTag(projectId, tag);
329
+ try { ctx?.emit?.({ type: 'board.updated', sessionId: null, summary: `擦掉了草图 #${tag}` }); } catch { /* */ }
330
+ return { content: [{ type: 'text', text: `Erased #${tag}: ${removed} item(s)/line(s) removed.` }] };
331
+ }
332
+ const { committed } = await commitStaging(projectId, { tag: tag || null });
333
+ if (committed) { try { ctx?.emit?.({ type: 'board.updated', sessionId: null, summary: `草图落定${tag ? ` #${tag}` : ''}` }); } catch { /* */ } }
334
+ return { content: [{ type: 'text', text: committed ? `Committed ${committed} item(s)${tag ? ` of #${tag}` : ''}.` : 'Nothing was staging.' }] };
335
+ },
336
+ );
337
+ }