@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
@@ -0,0 +1,130 @@
1
+ /**
2
+ * mcp/tools/write-on-board.js —— write_on_board(2026-08-23 黑板三期)
3
+ *
4
+ * agent 在画布上**说话**的主通道:一段 md 板书,贴着它说的那件东西(`near`),或者
5
+ * 接在用户/自己的某条板书下面(`reply_to` = 线程)。本体是文件
6
+ * (notes/板书/<stamp>-<slug>.md,见 lib/chalk.js),board.json 只存位置和线。
7
+ *
8
+ * 落位纪律(gutter):
9
+ * - reply_to:落在被回应那条的**正下方**(同列、间距 12),自动连 flow 线(读序)
10
+ * - near:落在锚点右侧、顶对齐,撞了往下让;自动连 annotates 线(这段话关于它)
11
+ * - 都没有:用户视口里的空地 > 桌面内容底下
12
+ * 跟 create_on_board 的分工:那是"记号"(手写一句、画布原生、给人看),这是"话"
13
+ * (md、文件本体、agent 要能读回来、有线程)。黑板模式下回复的主体走这里。
14
+ */
15
+
16
+ import { tool } from '@anthropic-ai/claude-agent-sdk';
17
+ import { z } from 'zod';
18
+ import { readBoard, patchBoard } from '../../../projects/board-store.js';
19
+ import { estimateSizeOn } from '../../../lib/board-kind-sizes.js';
20
+ import { layerOf, normalizeCanvasId } from '../../../lib/canvas-id.js';
21
+ import { textBox, findSpot, SKETCH_FIT } from '../../../lib/sketch-layout.js';
22
+ import { getViewpoint } from '../../../projects/viewpoint-store.js';
23
+ import { renderChalk, chalkFileName, writeChalkFile, CHALK_DIR } from '../../../lib/chalk.js';
24
+
25
+ let seq = 0;
26
+ const stamp = () => `${Date.now().toString(36)}${(seq++ % 1000).toString(36)}`;
27
+
28
+ export function makeWriteOnBoardTool({ projectId, sharedRoot, sessionId, ctx }) {
29
+ const turnStamp = { turn: -1, count: 0 };
30
+ return tool(
31
+ 'write_on_board',
32
+ `Say it ON THE BOARD: write a short Markdown note on the canvas, beside the thing it is
33
+ about (near) or right under a note you are replying to (reply_to). This is your main way
34
+ to talk to the user when they work on the canvas — the sidebar is the log, the board is
35
+ the conversation. The note is a real file (${CHALK_DIR}/…md) you can Read/Grep/Edit later.
36
+
37
+ - near: canvas id the note is about (an artifact card, an image, another note) → lands to
38
+ its right, top-aligned, with an annotates line. Use it right after you finish something:
39
+ "what this is / why this way / what to look at".
40
+ - reply_to: path of a board note (yours or the user's) → lands right under it with a
41
+ flow line (a thread). When the user annotates one of your notes, answer with reply_to.
42
+ - neither → lands in the user's viewport (or below current content).
43
+ - text: Markdown (+KaTeX, lists, tables; ≤1500 chars, keep it one thought — long
44
+ analysis belongs in a .md the user opens). size 'md' default, 'lg' for headlines.
45
+ Keep the chat reply short — a line pointing at the board is enough.`,
46
+ {
47
+ text: z.string().min(1).max(1500),
48
+ near: z.string().max(300).optional(),
49
+ reply_to: z.string().max(300).optional().describe(`path like ${CHALK_DIR}/20260823-070809-xxx.md`),
50
+ tag: z.string().regex(/^[\w一-鿿぀-ヿ-]{1,40}$/).optional(),
51
+ size: z.enum(['md', 'lg']).optional(),
52
+ width: z.number().min(8).max(30).optional().describe('grid units (24px); default by content, max 30'),
53
+ },
54
+ async ({ text, near, reply_to: replyTo, tag, size, width }) => {
55
+ const err = (t) => ({ content: [{ type: 'text', text: t }], isError: true });
56
+ if (!projectId || !sharedRoot) return err('No project bound.');
57
+ // 回合闸 08-23 用户拍板先不设:只记数不拦
58
+ const turn = ctx?.runId ?? -1;
59
+ if (turnStamp.turn !== turn) { turnStamp.turn = turn; turnStamp.count = 0; }
60
+ const body = String(text).trim();
61
+ if (!body) return err('空话不上板。');
62
+
63
+ const board = await readBoard(projectId);
64
+ const known = new Set(Object.keys(board.zones || {}));
65
+ // 默认宽度:散文按 14 格(336px)排,短句按内容;太窄会把一句话折成三行
66
+ const em = (l) => [...l].reduce((n, c) => n + (/[\u3000-\u9fff\uff00-\uffef]/.test(c) ? 1 : 0.62), 0);
67
+ const longest = Math.max(...body.split('\n').map(em));
68
+ const wUnits = width || (longest <= 12 ? null : Math.max(12, Math.min(18, Math.ceil(longest * 16 / 24) + 1)));
69
+ const box = textBox(body, size || 'md', { md: true, wUnits });
70
+ let zone = '';
71
+ let pos = null;
72
+ let anchorId = null; let parentId = null;
73
+ const obstaclesOf = (z) => Object.entries(board.objects || {})
74
+ .filter(([id, e]) => Number.isFinite(e?.x) && layerOf(id, e, known) === z)
75
+ .map(([id, e]) => ({ id, x: e.x, y: e.y, ...estimateSizeOn(board, id, e) }));
76
+ const avoid = (p, obstacles, w, h) => {
77
+ for (let g = 0; g < 40; g += 1) {
78
+ const b = obstacles.find(o => !(p.x + w <= o.x || o.x + o.w <= p.x || p.y + h <= o.y || o.y + o.h <= p.y));
79
+ if (!b) break;
80
+ p.y = b.y + b.h + 12;
81
+ }
82
+ return p;
83
+ };
84
+ if (replyTo) {
85
+ const pid = normalizeCanvasId(replyTo);
86
+ const e = pid ? board.objects?.[pid] : null;
87
+ if (!e || !Number.isFinite(e.x)) return err(`reply_to ${replyTo} 不在板上(read_board 里看不到就接不上)。`);
88
+ parentId = pid; zone = layerOf(pid, e, known);
89
+ const ps = estimateSizeOn(board, pid, e);
90
+ pos = avoid({ x: e.x, y: e.y + ps.h + 12 }, obstaclesOf(zone), box.w, box.h);
91
+ } else if (near) {
92
+ const nid = normalizeCanvasId(near);
93
+ const e = nid ? board.objects?.[nid] : null;
94
+ if (!e || !Number.isFinite(e.x)) return err(`锚点 ${near} 还没有座位(read_board 里看不到就锚不上)。`);
95
+ anchorId = nid; zone = layerOf(nid, e, known);
96
+ const as = estimateSizeOn(board, nid, e);
97
+ pos = avoid({ x: e.x + as.w + 24, y: e.y }, obstaclesOf(zone), box.w, box.h);
98
+ } else {
99
+ const obstacles = obstaclesOf('');
100
+ let bottom = 0; for (const o of obstacles) bottom = Math.max(bottom, o.y + o.h);
101
+ for (const zz of Object.values(board.zones || {})) if (Number.isFinite(zz?.y)) bottom = Math.max(bottom, zz.y + 240);
102
+ const vp = getViewpoint(projectId);
103
+ const vpRect = (vp && !(vp.layer) && vp.camera) ? vp.camera : null;
104
+ const spot = findSpot({ w: box.w + 24, h: box.h + 24, obstacles, contentBottom: bottom, viewport: vpRect });
105
+ pos = { x: spot.x + 12, y: spot.y + 12 };
106
+ }
107
+
108
+ const fileName = chalkFileName(body);
109
+ const content = renderChalk({ body, by: 'agent', anchor: anchorId, replyTo: parentId, tag: tag || null, sessionId: sessionId || null });
110
+ const rel = await writeChalkFile(sharedRoot, fileName, content);
111
+
112
+ const objects = { [rel]: { x: Math.round(pos.x), y: Math.round(pos.y), z: 1, w: box.w, h: box.h, zone, by: 'agent', ...(tag ? { tag } : {}) } };
113
+ const bindings = {};
114
+ if (anchorId) bindings[`b:a${stamp()}`] = { type: 'annotates', from: rel, to: anchorId, by: 'agent', ...(tag ? { tag } : {}) };
115
+ if (parentId) bindings[`b:a${stamp()}`] = { type: 'flow', from: parentId, to: rel, by: 'agent', material: 'pencil', ...(tag ? { tag } : {}) };
116
+ await patchBoard(projectId, { objects, bindings });
117
+ turnStamp.count += 1;
118
+ const rect = { x: Math.round(pos.x), y: Math.round(pos.y), w: box.w, h: box.h };
119
+ try {
120
+ ctx?.emit?.({ type: 'board.updated', sessionId: null, summary: parentId ? '回了一条板书' : '写了一条板书' });
121
+ ctx?.emit?.({ type: 'board.focus', sessionId: null, tag: tag || null, rect, layer: zone, soft: true, chalk: rel });
122
+ } catch { /* */ }
123
+ const big = box.h > SKETCH_FIT.h * 0.6;
124
+ return { content: [{ type: 'text', text:
125
+ `Wrote board note ${rel} at (${rect.x},${rect.y}) ${rect.w}x${rect.h}`
126
+ + (anchorId ? ` beside ${anchorId} (annotates line)` : parentId ? ` under ${parentId} (thread)` : ' in the user\'s view')
127
+ + `.${big ? ' ⚠ It is tall — next time split or shorten.' : ''} Mention it in one line in chat; the user can annotate it to reply.` }] };
128
+ },
129
+ );
130
+ }
@@ -190,3 +190,10 @@ IP、角色、真人、某个具体产品型号、小众品牌 —— 那种情
190
190
  > 工具用法、路径、标记规约、比例硬规则在 prelude;生图 cookbook / tweaks 语法 /
191
191
  > vision-checker 派发模板由 PreToolUse hook 首次调用时注入。
192
192
  > layout 骨架参考按需 Read `<plugin>/patterns/<role>.md`。
193
+
194
+ ## 板书(交稿纪律)
195
+ 做完这一轮的东西,在它旁边写一条板书(`write_on_board near=那张卡`):这版是什么 / 为什么这么做 /
196
+ 看哪里。改了几处就写几条要点,一条说一件事。同一件东西出了新版本,画一条「改自」线
197
+ (`relate_on_board` derives-from);有参考来源画「取材」。用户在你的板书上标注了,用
198
+ `write_on_board reply_to=` 接在那条下面回。多步任务先 TodoWrite 列步骤 —— 步骤清单会自动上板、
199
+ 每步的产物自动连过去。
@@ -160,3 +160,10 @@ screenshot_canvas → 看版式
160
160
  `raw` 就是那个 .docx 本身 —— **它就是交付物**,不是"导出的副产品",这跟 deck /
161
161
  站点反过来。`pdf` 走 LibreOffice,是快速预览用的;要发出去的正式 PDF 让用户在
162
162
  Word 里导(域和字体都对)。
163
+
164
+ ## 板书(交稿纪律)
165
+ 做完这一轮的东西,在它旁边写一条板书(`write_on_board near=那张卡`):这版是什么 / 为什么这么做 /
166
+ 看哪里。改了几处就写几条要点,一条说一件事。同一件东西出了新版本,画一条「改自」线
167
+ (`relate_on_board` derives-from);有参考来源画「取材」。用户在你的板书上标注了,用
168
+ `write_on_board reply_to=` 接在那条下面回。多步任务先 TodoWrite 列步骤 —— 步骤清单会自动上板、
169
+ 每步的产物自动连过去。
@@ -226,3 +226,10 @@ gemini-3.7-flash:$0.75/M 入、$3.75/M 出(促销价,思考 token 计入
226
226
  - 页面里永远不出现任何密钥(不需要:端点靠 cookie)。
227
227
  - 页面不做定时轮询、自动重发 —— 限频闸会拦,但根上就别写。
228
228
  - 演出内容平台不审(chatai 无工具无副作用),尺度是用户和角色设定之间的事。
229
+
230
+ ## 板书(交稿纪律)
231
+ 做完这一轮的东西,在它旁边写一条板书(`write_on_board near=那张卡`):这版是什么 / 为什么这么做 /
232
+ 看哪里。改了几处就写几条要点,一条说一件事。同一件东西出了新版本,画一条「改自」线
233
+ (`relate_on_board` derives-from);有参考来源画「取材」。用户在你的板书上标注了,用
234
+ `write_on_board reply_to=` 接在那条下面回。多步任务先 TodoWrite 列步骤 —— 步骤清单会自动上板、
235
+ 每步的产物自动连过去。
@@ -321,3 +321,10 @@ Read `patterns/page-transitions.md` —— **动手写第一个转场之前先
321
321
  > 目录约定、产物根、`_drafts/`、导出交付在站点技术参考(首次写站点 html 时注入)。
322
322
  > 抠图叠层配方按需 Read 本 skill 目录下的 `patterns/cutout-collage.md`。
323
323
  > 构建道(React / vite / Tailwind / 组件库)配方 Read `patterns/build-lane.md`。
324
+
325
+ ## 板书(交稿纪律)
326
+ 做完这一轮的东西,在它旁边写一条板书(`write_on_board near=那张卡`):这版是什么 / 为什么这么做 /
327
+ 看哪里。改了几处就写几条要点,一条说一件事。同一件东西出了新版本,画一条「改自」线
328
+ (`relate_on_board` derives-from);有参考来源画「取材」。用户在你的板书上标注了,用
329
+ `write_on_board reply_to=` 接在那条下面回。多步任务先 TodoWrite 列步骤 —— 步骤清单会自动上板、
330
+ 每步的产物自动连过去。
@@ -0,0 +1,130 @@
1
+ /**
2
+ * server/engine/runs/board-tasklist.js —— 把 agent 的步骤清单镜像成板书(2026-08-23)
3
+ *
4
+ * 用户定的文化:板书是基础设施。agent 开工时用 TodoWrite 列的步骤不该只活在侧栏的计划
5
+ * 面板里 —— 它是这一轮的骨架,该写在画布上、每一步产出的东西连到对应步骤。
6
+ * 这件事**由 harness 做,不靠 agent 记得**:
7
+ * - run.todo.updated → 一条板书 `notes/板书/<stamp>-步骤.md`(一轮一条、原地更新),
8
+ * Markdown 任务列表:✓ 已做 / → 正在(加粗)/ 待做;落定不走 staging
9
+ * - run.file_changed(有"正在"的步骤时)→ 这条板书到那件产物的 annotates 线,label「第 N 步」,
10
+ * by:'auto'(不算进主角焦点分;同一轮同一件只连一次)
11
+ * - run.done/cancelled/error → 忘掉这一轮的状态(板书留着,它是过程记录)
12
+ * 挂在 project bus 上(broker.getProjectBus 建 bus 时调一次),与 live-turn 折叠器同级。
13
+ * 失败一律吞掉只 warn:镜像挂了不能影响 run。
14
+ */
15
+ import path from 'node:path';
16
+ import { readBoard, patchBoard } from '../../projects/board-store.js';
17
+ import { getSharedDir } from '../../projects/workspace.js';
18
+ import { estimateSizeOn } from '../../lib/board-kind-sizes.js';
19
+ import { layerOf, normalizeCanvasId } from '../../lib/canvas-id.js';
20
+ import { textBox, findSpot } from '../../lib/sketch-layout.js';
21
+ import { getViewpoint } from '../../projects/viewpoint-store.js';
22
+ import { renderChalk, writeChalkFile, CHALK_DIR } from '../../lib/chalk.js';
23
+
24
+ const byRun = new Map(); // runId → { projectId, rel, fileName, items, linked:Set, session }
25
+ const MAX_LINKS_PER_RUN = 24;
26
+
27
+ function renderList(items) {
28
+ const lines = ['### 这一轮的步骤', ''];
29
+ items.forEach((it, i) => {
30
+ const text = String(it.content || it.activeForm || '').trim() || `步骤 ${i + 1}`;
31
+ if (it.status === 'completed') lines.push(`- [x] ${text}`);
32
+ else if (it.status === 'in_progress') lines.push(`- [ ] **→ ${it.activeForm || text}**`);
33
+ else lines.push(`- [ ] ${text}`);
34
+ });
35
+ return lines.join('\n');
36
+ }
37
+
38
+ /** 工作区相对路径 → 画布 id(产物卡的地址):精确 > deck: > 站点/word 根 > 原样 */
39
+ export function canvasIdForRel(board, rel) {
40
+ const r = String(rel || '').replace(/\\/g, '/').replace(/^\.\//, '');
41
+ if (!r) return null;
42
+ const objs = board.objects || {};
43
+ if (objs[r]) return r;
44
+ const norm = normalizeCanvasId(r);
45
+ if (norm && objs[norm]) return norm;
46
+ for (const id of Object.keys(objs)) {
47
+ const m = /^(site|docx):(.+)$/.exec(id);
48
+ if (!m) continue;
49
+ const root = m[2];
50
+ if (r === root || r.startsWith(`${root}/`)) return id;
51
+ }
52
+ // 还没上墙:按形态猜(seated 之后线自然画出来)
53
+ return norm || r;
54
+ }
55
+
56
+ async function upsert(st, items) {
57
+ st.items = items;
58
+ const body = renderList(items);
59
+ const content = renderChalk({ body, by: 'agent', tag: st.tag, sessionId: st.session || null });
60
+ const sharedRoot = getSharedDir(st.projectId);
61
+ if (!st.fileName) st.fileName = `${new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, '').replace('T', '-')}-步骤.md`;
62
+ st.rel = await writeChalkFile(sharedRoot, st.fileName, content, { overwrite: !!st.rel });
63
+ st.fileName = st.rel.split('/').pop(); // 首写可能被加了防撞序号,之后原地覆盖同一个文件
64
+ const board = await readBoard(st.projectId);
65
+ const box = textBox(body, 'md', { md: true, wUnits: 14 });
66
+ const prev = board.objects?.[st.rel];
67
+ if (prev && Number.isFinite(prev.x)) {
68
+ // 已有座位:只改高(行数变了)不碰宽 —— 宽由前端量过真值,回写会跟它来回抖(fable P2)
69
+ await patchBoard(st.projectId, { objects: { [st.rel]: { ...prev, h: box.h } } });
70
+ return;
71
+ }
72
+ // 首次落位:用户视口的空地 > 内容底下(跟 write_on_board 同口径)
73
+ const known = new Set(Object.keys(board.zones || {}));
74
+ const obstacles = Object.entries(board.objects || {})
75
+ .filter(([id, e]) => Number.isFinite(e?.x) && layerOf(id, e, known) === '')
76
+ .map(([id, e]) => ({ x: e.x, y: e.y, ...estimateSizeOn(board, id, e) }));
77
+ let bottom = 0; for (const o of obstacles) bottom = Math.max(bottom, o.y + o.h);
78
+ for (const zz of Object.values(board.zones || {})) if (Number.isFinite(zz?.y)) bottom = Math.max(bottom, zz.y + 240);
79
+ const vp = getViewpoint(st.projectId);
80
+ const vpRect = (vp && !vp.layer && vp.camera) ? vp.camera : null;
81
+ const spot = findSpot({ w: box.w + 24, h: box.h + 24, obstacles, contentBottom: bottom, viewport: vpRect });
82
+ await patchBoard(st.projectId, { objects: { [st.rel]: { x: spot.x + 12, y: spot.y + 12, z: 1, w: box.w, h: box.h, by: 'agent', tag: st.tag } } });
83
+ }
84
+
85
+ export function attachBoardTasklist(bus, projectId) {
86
+ const handle = async (evt) => {
87
+ if (!evt?.runId) return;
88
+ if (evt.type === 'run.todo.updated') {
89
+ const items = Array.isArray(evt.todos) ? evt.todos.filter(t => t && typeof t === 'object') : [];
90
+ if (!items.length) return;
91
+ let st = byRun.get(evt.runId);
92
+ if (!st) {
93
+ st = { projectId, rel: null, fileName: null, items: [], linked: new Set(), session: evt.sessionId || null, tag: `步骤-${String(evt.runId).slice(-6)}` };
94
+ byRun.set(evt.runId, st);
95
+ }
96
+ await upsert(st, items);
97
+ bus.publish({ type: 'board.updated', sessionId: null, runId: evt.runId, summary: '步骤清单上板' });
98
+ return;
99
+ }
100
+ if (evt.type === 'run.file_changed') {
101
+ const st = byRun.get(evt.runId);
102
+ if (!st?.rel || !evt.filePath) return;
103
+ const idx = st.items.findIndex(t => t.status === 'in_progress');
104
+ if (idx < 0) return;
105
+ // 只连工作区内、非隐藏目录的真产物;每轮封顶(否则 /tmp、.nd、.claude 里的写入会灌一堆
106
+ // 指向虚空的 auto 线,MAX_BINDINGS 一到用户自己的线都不收 —— fable 08-23 P2)
107
+ const rel = String(evt.filePath).replace(/\\/g, '/');
108
+ if (rel.startsWith('/') || rel.split('/').some(seg => seg === '..' || seg.startsWith('.') || seg === 'node_modules')) return;
109
+ if (rel.startsWith(`${CHALK_DIR}/`) || rel.startsWith('notes/')) return;
110
+ if (st.linked.size >= MAX_LINKS_PER_RUN) return;
111
+ const board = await readBoard(st.projectId);
112
+ const target = canvasIdForRel(board, evt.filePath);
113
+ if (!target || target === st.rel || target.startsWith(`${CHALK_DIR}/`)) return;
114
+ if (st.linked.has(target)) return;
115
+ st.linked.add(target);
116
+ const id = `b:auto:step:${String(evt.runId).slice(-6)}:${st.linked.size}`;
117
+ await patchBoard(st.projectId, { bindings: { [id]: { type: 'annotates', from: st.rel, to: target, by: 'auto', label: `第 ${idx + 1} 步`, tag: st.tag } } });
118
+ bus.publish({ type: 'board.updated', sessionId: null, runId: evt.runId, summary: `第 ${idx + 1} 步 → ${path.basename(target)}` });
119
+ return;
120
+ }
121
+ if (evt.type === 'run.done' || evt.type === 'run.cancelled' || evt.type === 'run.error') {
122
+ byRun.delete(evt.runId);
123
+ }
124
+ };
125
+ bus.subscribe('*', (evt) => {
126
+ handle(evt).catch(err => console.warn('[board-tasklist]', err?.message || err));
127
+ });
128
+ }
129
+
130
+ export function _resetBoardTasklist() { byRun.clear(); }
package/server/index.js CHANGED
@@ -153,6 +153,9 @@ if (platform.serveWeb) {
153
153
  });
154
154
  }
155
155
 
156
+ // /api 下没路由接住的一律回 JSON 404(express 默认是 HTML,前端 jsonRequest 拿到的是解析错误不是 {error})
157
+ app.use('/api', (req, res) => res.status(404).json({ error: `not found: ${req.method} ${req.originalUrl}`, code: 'NOT_FOUND' }));
158
+
156
159
  // ── 错误兜底 ──
157
160
  app.use((err, _req, res, _next) => {
158
161
  console.error('[server] error:', err);
@@ -55,6 +55,18 @@ export const BINDING_TYPES = {
55
55
  /** 词汇表 id 列表,校验和 parity 断言都用它。 */
56
56
  export const BINDING_TYPE_IDS = Object.keys(BINDING_TYPES);
57
57
 
58
+ /**
59
+ * 材质(2026-08-23 黑板):与语义正交的第二个轴。
60
+ * ink 墨线 —— 版面线的默认材质,安静
61
+ * pencil 手绘 —— 铅笔抖动,像人顺手拉的一笔
62
+ * yarn 丝线 —— 侦探板上的红线 + 图钉,推理态、声量大
63
+ * 前端 board-bindings.js 的 BINDING_MATERIALS 必须与这份 id 一一对应(parity 测试)。
64
+ */
65
+ export const BINDING_MATERIALS = ['ink', 'pencil', 'yarn'];
66
+ export function isBindingMaterial(m) {
67
+ return BINDING_MATERIALS.includes(m);
68
+ }
69
+
58
70
  export function isBindingType(t) {
59
71
  return Object.prototype.hasOwnProperty.call(BINDING_TYPES, t);
60
72
  }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * server/lib/board-graph-export.js —— 连接图导出(2026-08-23 黑板)
3
+ *
4
+ * 真相是 board.json(节点 + 线 + 位置 + 标签),由它派生三种形态:
5
+ * json 结构原样(给程序 / 备份)
6
+ * mermaid flowchart 文本(给别的工具吃;位置丢掉,语义留着)
7
+ * svg 一张能看的图:卡片画成带名字的方框、手写字照排、涂鸦照路径、线照语义
8
+ * —— 这是**导出保真度**的渲染,不是画布本体(画布本体只有前端那一份)
9
+ * 可按 #tag 只导一组。带产物的 zip 另议(zip 要搬文件,是交付层的事)。
10
+ */
11
+ import { BINDING_TYPES } from './binding-types.js';
12
+ import { estimateSizeOn } from './board-kind-sizes.js';
13
+ import { layerOf } from './canvas-id.js';
14
+ import { describeEndpoint } from './board-relations.js';
15
+
16
+ function pick(board, { tag = null, layer = '' } = {}) {
17
+ const known = new Set(Object.keys(board.zones || {}));
18
+ const nodes = [];
19
+ for (const [id, e] of Object.entries(board.objects || {})) {
20
+ if (!Number.isFinite(e?.x)) continue;
21
+ if (layerOf(id, e, known) !== layer) continue;
22
+ if (tag && e.tag !== tag) continue;
23
+ const sz = estimateSizeOn(board, id, e);
24
+ nodes.push({
25
+ id, kind: e.kind || kindOfId(id), x: e.x, y: e.y, w: sz.w, h: sz.h,
26
+ ...(e.kind === 'text' ? { text: e.data?.t || '', format: e.data?.format || 'plain', font: e.data?.font, size: e.data?.size, color: e.data?.color } : {}),
27
+ ...(e.kind === 'scribble' ? { d: e.data?.d || '', color: e.data?.color, width: e.data?.width } : {}),
28
+ ...(e.tag ? { tag: e.tag } : {}), ...(e.staging ? { staging: true } : {}), ...(e.by ? { by: e.by } : {}),
29
+ });
30
+ }
31
+ const ids = new Set(nodes.map(n => n.id));
32
+ const edges = [];
33
+ for (const [id, b] of Object.entries(board.bindings || {})) {
34
+ if (!ids.has(b.from) || !ids.has(b.to)) continue;
35
+ if (tag && b.tag !== tag && !(ids.has(b.from) && ids.has(b.to))) continue;
36
+ edges.push({ id, type: b.type, from: b.from, to: b.to, ...(b.label ? { label: b.label } : {}), material: b.material || 'ink', ...(b.by ? { by: b.by } : {}), ...(b.tag ? { tag: b.tag } : {}) });
37
+ }
38
+ return { nodes, edges };
39
+ }
40
+
41
+ function kindOfId(id) {
42
+ const m = /^(deck|site|docx|doc):/.exec(id);
43
+ if (m) return m[1];
44
+ if (/\.(png|jpe?g|webp|gif|svg|avif)$/i.test(id)) return 'image';
45
+ if (/\.(mp4|webm|mov)$/i.test(id)) return 'video';
46
+ if (/\.(md|txt)$/i.test(id)) return 'note';
47
+ return 'file';
48
+ }
49
+
50
+ export function exportGraph(board, { format = 'json', tag = null, layer = '' } = {}) {
51
+ const g = pick(board, { tag, layer });
52
+ if (format === 'json') return { mime: 'application/json', body: JSON.stringify({ version: 1, tag, layer, ...g }, null, 2) };
53
+ if (format === 'mermaid') return { mime: 'text/plain; charset=utf-8', body: toMermaid(g, board) };
54
+ if (format === 'svg') return { mime: 'image/svg+xml; charset=utf-8', body: toSvg(g, board) };
55
+ throw Object.assign(new Error('unknown format'), { status: 400 });
56
+ }
57
+
58
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
59
+
60
+ function nodeTitle(n, board) {
61
+ if (n.kind === 'text') return String(n.text || '').replace(/^#+\s*/gm, '').replace(/\s+/g, ' ').trim().slice(0, 60);
62
+ if (n.kind === 'scribble') return '(涂鸦)';
63
+ return describeEndpoint(n.id, board).replace(/([a-z]+)$/, '');
64
+ }
65
+
66
+ export function toMermaid(g, board) {
67
+ const key = new Map(g.nodes.map((n, i) => [n.id, `n${i}`]));
68
+ const lines = ['flowchart LR'];
69
+ for (const n of g.nodes) {
70
+ if (n.kind === 'scribble') continue;
71
+ const t = nodeTitle(n, board).replace(/["[\]{}()|]/g, ' ').trim() || n.id;
72
+ const shape = n.kind === 'text' ? `("${t}")` : `["${t}"]`;
73
+ lines.push(` ${key.get(n.id)}${shape}`);
74
+ }
75
+ for (const e of g.edges) {
76
+ const t = BINDING_TYPES[e.type];
77
+ const word = (e.label || t?.label || e.type).replace(/["|]/g, ' ');
78
+ const arrow = t?.directed ? `-- ${word} -->` : `--- ${word} ---`;
79
+ if (!key.has(e.from) || !key.has(e.to)) continue;
80
+ lines.push(` ${key.get(e.from)} ${arrow} ${key.get(e.to)}`);
81
+ }
82
+ return lines.join('\n') + '\n';
83
+ }
84
+
85
+ const PAPER = { paper: '#F0EADB', ink: '#2B2117', ink2: '#5F5142', pencil: '#A39882', red: '#A8362B', brass: '#b08c4f', card: '#FFFEF6' };
86
+ const INK = { ink: PAPER.ink, red: PAPER.red, pencil: PAPER.pencil, brass: PAPER.brass };
87
+ const SIZE_PX = { sm: 13, md: 16, lg: 22, xl: 30 };
88
+
89
+ function edgePoint(a, b) {
90
+ const ac = { x: a.x + a.w / 2, y: a.y + a.h / 2 }; const bc = { x: b.x + b.w / 2, y: b.y + b.h / 2 };
91
+ const dx = bc.x - ac.x; const dy = bc.y - ac.y;
92
+ const pt = (r, c, ddx, ddy) => {
93
+ const tx = ddx === 0 ? Infinity : (r.w / 2) / Math.abs(ddx); const ty = ddy === 0 ? Infinity : (r.h / 2) / Math.abs(ddy);
94
+ const t = Math.min(tx, ty); return { x: c.x + ddx * t, y: c.y + ddy * t };
95
+ };
96
+ return { from: pt(a, ac, dx, dy), to: pt(b, bc, -dx, -dy) };
97
+ }
98
+
99
+ export function toSvg(g, board, { hrefOf = null } = {}) {
100
+ if (!g.nodes.length) return '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60"><text x="10" y="35" font-size="14">(空)</text></svg>';
101
+ const PAD = 40;
102
+ const x0 = Math.min(...g.nodes.map(n => n.x)) - PAD; const y0 = Math.min(...g.nodes.map(n => n.y)) - PAD;
103
+ const x1 = Math.max(...g.nodes.map(n => n.x + n.w)) + PAD; const y1 = Math.max(...g.nodes.map(n => n.y + n.h)) + PAD;
104
+ const W = Math.round(x1 - x0); const H = Math.round(y1 - y0);
105
+ const byId = new Map(g.nodes.map(n => [n.id, n]));
106
+ const out = [];
107
+ out.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="${x0} ${y0} ${W} ${H}" font-family="'Kaiti SC','KaiTi','STKaiti',serif">`);
108
+ out.push(`<rect x="${x0}" y="${y0}" width="${W}" height="${H}" fill="${PAPER.paper}"/>`);
109
+ out.push('<defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="userSpaceOnUse"><path d="M 1 1 L 9 5 L 1 9 z" fill="context-stroke"/></marker><marker id="dot" markerWidth="8" markerHeight="8" refX="4" refY="4" markerUnits="userSpaceOnUse"><circle cx="4" cy="4" r="3" fill="context-stroke"/></marker></defs>');
110
+ // 线在物件下面
111
+ for (const e of g.edges) {
112
+ const a = byId.get(e.from); const b = byId.get(e.to); if (!a || !b) continue;
113
+ const p = edgePoint(a, b);
114
+ const dx = p.to.x - p.from.x; const dy = p.to.y - p.from.y; const dist = Math.hypot(dx, dy) || 1;
115
+ const yarn = e.material === 'yarn';
116
+ const lift = yarn ? 0 : Math.min(dist * 0.14, 46);
117
+ const cx = (p.from.x + p.to.x) / 2 + (yarn ? 0 : (-dy / dist) * lift);
118
+ const cy = (p.from.y + p.to.y) / 2 + (yarn ? Math.min(dist * 0.11, 56) : (dx / dist) * lift);
119
+ const t = BINDING_TYPES[e.type];
120
+ const stroke = yarn ? PAPER.red : (e.type === 'contrast' ? PAPER.brass : e.type === 'ref' ? PAPER.pencil : e.type === 'derives-from' ? PAPER.ink : PAPER.ink2);
121
+ const width = yarn ? 2.6 : e.type === 'derives-from' ? 2.4 : e.type === 'annotates' ? 1.4 : 2;
122
+ const dash = !yarn && e.type === 'annotates' ? ' stroke-dasharray="3 4"' : !yarn && e.type === 'ref' ? ' stroke-dasharray="1 5"' : '';
123
+ const marker = yarn ? '' : (t?.directed ? ' marker-end="url(#arrow)"' : (e.type === 'link' ? ' marker-end="url(#dot)" marker-start="url(#dot)"' : ''));
124
+ out.push(`<path d="M ${p.from.x} ${p.from.y} Q ${cx} ${cy} ${p.to.x} ${p.to.y}" fill="none" stroke="${stroke}" stroke-width="${width}" stroke-linecap="round"${dash}${marker}/>`);
125
+ if (yarn) for (const q of [p.from, p.to]) out.push(`<circle cx="${q.x}" cy="${q.y}" r="4.6" fill="${PAPER.red}" stroke="${PAPER.ink}" stroke-width="0.8"/>`);
126
+ const word = e.label || (e.type !== 'annotates' ? t?.label : null);
127
+ if (word) {
128
+ const mx = 0.25 * p.from.x + 0.5 * cx + 0.25 * p.to.x; const my = 0.25 * p.from.y + 0.5 * cy + 0.25 * p.to.y;
129
+ out.push(`<text x="${mx}" y="${my}" font-size="11" text-anchor="middle" dominant-baseline="middle" fill="${PAPER.ink2}" stroke="${PAPER.paper}" stroke-width="4" paint-order="stroke">${esc(word)}</text>`);
130
+ }
131
+ }
132
+ for (const n of g.nodes) {
133
+ const op = n.staging ? ' opacity="0.55"' : '';
134
+ if (n.kind === 'scribble') {
135
+ out.push(`<g transform="translate(${n.x} ${n.y})"${op}><path d="${esc(n.d)}" fill="none" stroke="${INK[n.color] || PAPER.ink}" stroke-width="${n.width || 2}" stroke-linecap="round" stroke-linejoin="round"/></g>`);
136
+ continue;
137
+ }
138
+ if (n.kind === 'text') {
139
+ const px = SIZE_PX[n.size] || 16; const fill = INK[n.color] || PAPER.ink;
140
+ const lines = String(n.text || '').split('\n').slice(0, 24);
141
+ out.push(`<g transform="translate(${n.x + 6} ${n.y + 4})"${op}>` + lines.map((l, i) => `<text x="0" y="${Math.round((i + 0.85) * px * 1.6)}" font-size="${px}" fill="${fill}">${esc(l.replace(/^#+\s*/, '').replace(/\*\*/g, ''))}</text>`).join('') + '</g>');
142
+ continue;
143
+ }
144
+ const title = nodeTitle(n, board);
145
+ const href = hrefOf ? hrefOf(n) : null;
146
+ out.push(`${href ? `<a href="${esc(href)}">` : ''}<g${op}><rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="10" fill="${PAPER.card}" stroke="rgba(43,33,23,0.18)"/>`
147
+ + `<text x="${n.x + 12}" y="${n.y + 20}" font-size="13" fill="${PAPER.ink}">${esc(title)}</text>`
148
+ + `<text x="${n.x + 12}" y="${n.y + n.h - 10}" font-size="10" fill="${PAPER.pencil}">${esc(n.kind)}</text></g>${href ? '</a>' : ''}`);
149
+ }
150
+ out.push('</svg>');
151
+ return out.join('\n');
152
+ }
153
+
154
+ /* ── 带产物的 zip(2026-08-23)────────────────────────────────────────────
155
+ * 真相仍是 board.json;zip 里:board/graph.json + graph.svg(卡片可点,链到 zip 里的文件)+
156
+ * graph.mmd + index.html(内嵌 svg 的静态重放页)+ 节点指向的产物文件(走 export-collect
157
+ * 同一套寻址:站点整目录、deck 的 html、图片、板书 .md…)。画布原生的手写字/涂鸦没有文件,
158
+ * 它们的内容都在 graph.json/svg 里。
159
+ */
160
+ export async function exportGraphZip(board, { workspaceRoot, tag = null, layer = '', projectName = '画布' } = {}) {
161
+ const [{ default: JSZip }, { collectCards }, fsm] = await Promise.all([
162
+ import('jszip'), import('./export-collect.js'), import('node:fs/promises'),
163
+ ]);
164
+ const g = pick(board, { tag, layer });
165
+ const fileIds = g.nodes.filter(n => n.kind !== 'text' && n.kind !== 'scribble').map(n => n.id);
166
+ const { bundles, skipped } = await collectCards({ workspaceRoot, cardIds: fileIds });
167
+ const zip = new JSZip();
168
+ const relOfNode = new Map();
169
+ for (const b of bundles) {
170
+ for (const f of [...(b.files || []), ...(b.assets || [])]) {
171
+ try { zip.file(f.rel, await fsm.readFile(f.abs)); } catch { /* 读不到就跳,写进 manifest */ }
172
+ }
173
+ }
174
+ // collectCard 不回 cardId:按顺序对齐(collectCards 按 cardIds 顺序 push,跳过的在 skipped)
175
+ const skippedIds = new Set(skipped.map(s => s.cardId));
176
+ let bi = 0;
177
+ for (const id of fileIds) {
178
+ if (skippedIds.has(id)) continue;
179
+ const b = bundles[bi++]; if (!b) break;
180
+ relOfNode.set(id, b.files?.[0]?.rel || b.assets?.[0]?.rel || null);
181
+ }
182
+ const hrefOf = (n) => { const r = relOfNode.get(n.id); return r ? `../${r.split('/').map(encodeURIComponent).join('/')}` : null; };
183
+ const svg = toSvg(g, board, { hrefOf });
184
+ zip.file('board/graph.json', JSON.stringify({ version: 1, tag, layer, ...g, files: Object.fromEntries(relOfNode), skipped }, null, 2));
185
+ zip.file('board/graph.svg', svg);
186
+ zip.file('board/graph.mmd', toMermaid(g, board));
187
+ zip.file('index.html', `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>${esc(projectName)} · 画布</title>
188
+ <style>html,body{margin:0;background:#F0EADB;font-family:'Kaiti SC','KaiTi',serif}header{padding:12px 20px;color:#2B2117;font-size:14px}main{overflow:auto;padding:0 20px 20px}svg{max-width:100%;height:auto;display:block}a:hover rect{stroke:#b08c4f;stroke-width:2}</style></head>
189
+ <body><header>${esc(projectName)} · 画布导出${tag ? ` · #${esc(tag)}` : ''} · ${g.nodes.length} 件 ${g.edges.length} 线 · 点卡片打开文件</header><main>${svg.replace(/\.\.\//g, '')}</main></body></html>`);
190
+ return { zip, skipped, count: { nodes: g.nodes.length, edges: g.edges.length, files: relOfNode.size } };
191
+ }