@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.
Files changed (109) hide show
  1. package/package.json +1 -1
  2. package/server/api/assets/helpers.js +20 -0
  3. package/server/api/assets/notes.js +50 -0
  4. package/server/api/assets.js +6 -11
  5. package/server/api/board.js +78 -1
  6. package/server/engine/agent/events.js +1 -0
  7. package/server/engine/agent/hooks/user-prompt-submit.js +43 -0
  8. package/server/engine/agent/prompts/nodesign-prelude.md +53 -1
  9. package/server/engine/agent/session-loop.js +11 -0
  10. package/server/engine/mcp/index.js +12 -0
  11. package/server/engine/mcp/tools/arrange-on-board.js +4 -4
  12. package/server/engine/mcp/tools/create-on-board.js +9 -12
  13. package/server/engine/mcp/tools/edit-sketch.js +167 -0
  14. package/server/engine/mcp/tools/look-at-board.js +124 -0
  15. package/server/engine/mcp/tools/read-board.js +121 -34
  16. package/server/engine/mcp/tools/read-user-view.js +59 -0
  17. package/server/engine/mcp/tools/relate-on-board.js +5 -3
  18. package/server/engine/mcp/tools/sketch-on-board.js +337 -0
  19. package/server/engine/mcp/tools/write-on-board.js +130 -0
  20. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +7 -0
  21. package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +7 -0
  22. package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +7 -0
  23. package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +7 -0
  24. package/server/engine/runs/board-tasklist.js +130 -0
  25. package/server/index.js +3 -0
  26. package/server/lib/binding-types.js +12 -0
  27. package/server/lib/board-graph-export.js +191 -0
  28. package/server/lib/board-groups.js +167 -0
  29. package/server/lib/board-hero.js +76 -0
  30. package/server/lib/board-kind-sizes.js +23 -0
  31. package/server/lib/board-relations.js +1 -1
  32. package/server/lib/chalk.js +121 -0
  33. package/server/lib/sketch-layout.js +277 -0
  34. package/server/projects/board-limits.js +12 -0
  35. package/server/projects/board-sanitize.js +252 -0
  36. package/server/projects/board-store.js +72 -205
  37. package/server/projects/ui-config.js +6 -0
  38. package/server/projects/viewpoint-store.js +78 -0
  39. package/server/ws/broker.js +3 -0
  40. package/web/dist/assets/{BrowserWindow-BYCgizHw.js → BrowserWindow-P_vIdh-G.js} +2 -2
  41. package/web/dist/assets/{DeckWindow-pNydEM4L.js → DeckWindow-BpLv5082.js} +3 -3
  42. package/web/dist/assets/{DocxWindow-PPgzh3Gq.js → DocxWindow-DSAU1-tD.js} +4 -4
  43. package/web/dist/assets/MermaidBlock-DR1GikCv.js +313 -0
  44. package/web/dist/assets/SiteWindow-92uBuRRR.js +18 -0
  45. package/web/dist/assets/abnfDiagram-VCTEODGH-uA8Esix4.js +1 -0
  46. package/web/dist/assets/arc-D_khH_xN.js +1 -0
  47. package/web/dist/assets/architectureDiagram-5GKGNRK7-DydJHqOt.js +36 -0
  48. package/web/dist/assets/blockDiagram-NRAW4CY4-B7wBbKiW.js +129 -0
  49. package/web/dist/assets/c4Diagram-UCG6FXSJ-6mrH0x7Y.js +38 -0
  50. package/web/dist/assets/channel-DGkccMO1.js +1 -0
  51. package/web/dist/assets/chunk-2Q5K7J3B-Cuikho5Q.js +1 -0
  52. package/web/dist/assets/chunk-5VM5RSS4-3-KZ5AUz.js +15 -0
  53. package/web/dist/assets/chunk-F27PBJKO-Bkwq2gGU.js +1 -0
  54. package/web/dist/assets/chunk-G27WJ6UU-BLyQzdGE.js +231 -0
  55. package/web/dist/assets/chunk-JWPE2WC7-B7HxxQds.js +1 -0
  56. package/web/dist/assets/chunk-LCL6LL3I-CtXrnUwF.js +206 -0
  57. package/web/dist/assets/chunk-POPQ4Y6H-dxddZlXt.js +1 -0
  58. package/web/dist/assets/chunk-SVP7TREG-DpkmrgHn.js +88 -0
  59. package/web/dist/assets/chunk-XXDRQBXY-BwZ7RbpQ.js +1 -0
  60. package/web/dist/assets/classDiagram-DTDB5LWJ-BGscw3OD.js +1 -0
  61. package/web/dist/assets/classDiagram-v2-JRS7N3AN-BGscw3OD.js +1 -0
  62. package/web/dist/assets/cose-bilkent-JH36ORCC-DGFOsMUt.js +1 -0
  63. package/web/dist/assets/cynefin-OW5HDTMX-DUwn3ehS.js +178 -0
  64. package/web/dist/assets/cynefinDiagram-5FMLGOSQ-DlLAOZ9x.js +62 -0
  65. package/web/dist/assets/cytoscape.esm-BjAbmLdb.js +331 -0
  66. package/web/dist/assets/dagre-3AP2YEHR-CyourLfz.js +4 -0
  67. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  68. package/web/dist/assets/diagram-S7CK7UJ4-BTMbx2np.js +30 -0
  69. package/web/dist/assets/diagram-UQ7AKVKN-UK94_1-P.js +41 -0
  70. package/web/dist/assets/diagram-VSXAHHWV-C7zS-v9v.js +3 -0
  71. package/web/dist/assets/diagram-VX7I27RA-DKJRa6CK.js +24 -0
  72. package/web/dist/assets/diagram-Z3DM3KII-kUbAfGAp.js +24 -0
  73. package/web/dist/assets/ebnfDiagram-PWID7BFC-CVW16_vb.js +1 -0
  74. package/web/dist/assets/erDiagram-SSCWMZ5O-BRQMFQ5y.js +99 -0
  75. package/web/dist/assets/flowDiagram-A5DVABFB-S34LpLtK.js +168 -0
  76. package/web/dist/assets/ganttDiagram-EL5Y4UJY-BFiqk1-G.js +292 -0
  77. package/web/dist/assets/gitGraphDiagram-WWUBYQGX-LZsrdyyv.js +106 -0
  78. package/web/dist/assets/index-GAamh1p4.js +2152 -0
  79. package/web/dist/assets/infoDiagram-RXCK75RN-0DvN6KIp.js +2 -0
  80. package/web/dist/assets/init-Gi6I4Gst.js +1 -0
  81. package/web/dist/assets/ishikawaDiagram-5VMMS53U-CaY3A-Bx.js +70 -0
  82. package/web/dist/assets/journeyDiagram-EYS64GPL-BBeiaCrj.js +139 -0
  83. package/web/dist/assets/kanban-definition-3QL26DDD-ux42DRXQ.js +89 -0
  84. package/web/dist/assets/layout-CnXiF_NB.js +1 -0
  85. package/web/dist/assets/linear-BMW0u6G6.js +1 -0
  86. package/web/dist/assets/mindmap-definition-FBJOCRG2-hUxSpdKn.js +96 -0
  87. package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  88. package/web/dist/assets/pegDiagram-XKGWAZYB-aCaQPH0w.js +1 -0
  89. package/web/dist/assets/{pending-edit-apply-C0I13CbD.js → pending-edit-apply-AcdOQ9Ij.js} +6 -6
  90. package/web/dist/assets/pieDiagram-E7YTZNPT-CmOEZ8zg.js +39 -0
  91. package/web/dist/assets/quadrantDiagram-AXDQQJYC-VW6fAhsV.js +7 -0
  92. package/web/dist/assets/railroadDiagram-O6MQD6OU-CKJ7Ba5K.js +1 -0
  93. package/web/dist/assets/requirementDiagram-EFPCY7ZU-pNGkq9Vw.js +84 -0
  94. package/web/dist/assets/{rotate-cw-B1UQ8Hld.js → rotate-cw-KAfChbGV.js} +1 -1
  95. package/web/dist/assets/sankeyDiagram-P5KCCOFB-BIMmDTfZ.js +40 -0
  96. package/web/dist/assets/sequenceDiagram-WJ2MYXX4-BBW7wzKY.js +162 -0
  97. package/web/dist/assets/sizeCapture-X5ZJPWSS-DdkXmLYR.js +1 -0
  98. package/web/dist/assets/{square-dashed-mouse-pointer-8lrgqmfv.js → square-dashed-mouse-pointer-CDUlhUP7.js} +1 -1
  99. package/web/dist/assets/stateDiagram-HBIQ2CUA-W313Uz16.js +1 -0
  100. package/web/dist/assets/stateDiagram-v2-4QOOHH4V-DDhJBZzf.js +1 -0
  101. package/web/dist/assets/swimlanes-XN3QIQJK-C29TL26K.js +1 -0
  102. package/web/dist/assets/swimlanesDiagram-VK2B7HYN-BfN-Xdmu.js +8 -0
  103. package/web/dist/assets/timeline-definition-24CTP7MA-CF0JMObX.js +120 -0
  104. package/web/dist/assets/vennDiagram-4TSXK5OY-DrDFsaH3.js +34 -0
  105. package/web/dist/assets/wardleyDiagram-VM6X3IG4-CVrnRIS_.js +78 -0
  106. package/web/dist/assets/xychartDiagram-S5SC5T6Z-BvkN1zsT.js +7 -0
  107. package/web/dist/index.html +1 -1
  108. package/web/dist/assets/SiteWindow-DM0LQPCO.js +0 -18
  109. package/web/dist/assets/index-BHTBJlpY.js +0 -2125
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaobuyu/nodesign",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "NoDesign — 面向独立创作者的 agent 创作工作台(本地分发版:npx nodesign)",
@@ -7,12 +7,32 @@
7
7
  * 不会再随下一次拆分复发(谁也不用记得"这个函数该跟着谁走")。
8
8
  */
9
9
 
10
+ import { promises as fs } from 'node:fs';
11
+ import { parseChalk } from '../../lib/chalk.js';
12
+
10
13
  /** 单层路径片段:不含分隔符、不含 `..`、不以点开头、长度可控 */
11
14
  export function safeSegment(s) {
12
15
  return typeof s === 'string' && !!s && s.length <= 200
13
16
  && !s.includes('/') && !s.includes('\\') && !s.includes('..') && !s.startsWith('.');
14
17
  }
15
18
 
19
+ /**
20
+ * 便签/板书正文上卡(2026-08-23 从 assets.js 抽出):frontmatter 带 nd: chalk 的是板书
21
+ * (agent/用户写在画布上的话,同一形态另一张脸),其余按便签头解析;正文 ≤4KB 截断。
22
+ * 读不到就静默(agent 正在写的文件可能半途消失)。
23
+ */
24
+ export async function decorateNoteText(item, absPath) {
25
+ try {
26
+ const raw = await fs.readFile(absPath, 'utf8');
27
+ const parsed = parseChalk(raw);
28
+ const { body, sessionId } = parsed.chalk ? parsed : parseNoteFrontmatter(raw);
29
+ item.text = body.length > 4096 ? body.slice(0, 4096) + '…' : body;
30
+ if (sessionId) item.sessionId = sessionId;
31
+ if (parsed.chalk) item.chalk = parsed.chalk;
32
+ } catch { /* */ }
33
+ return item;
34
+ }
35
+
16
36
  /** 便签 frontmatter:只认最简单的 `---\nsession: xxx\n---` 头,其余原样当正文 */
17
37
  export function parseNoteFrontmatter(raw) {
18
38
  const m = /^---\n([\s\S]{0,500}?)\n---\n?/.exec(raw);
@@ -13,6 +13,7 @@ import express from 'express';
13
13
  import path from 'node:path';
14
14
  import { promises as fs } from 'node:fs';
15
15
  import { safeSegment, parseNoteFrontmatter } from './helpers.js';
16
+ import { patchBoard } from '../../projects/board-store.js';
16
17
 
17
18
  /**
18
19
  * @param {object} deps
@@ -123,6 +124,55 @@ export function mountNotesRoutes({ router, guardProject, getSharedDir, ensurePro
123
124
  } catch (err) { next(err); }
124
125
  });
125
126
 
127
+ /**
128
+ * 板书(notes/板书/<name>.md,2026-08-23):用户侧改 / 删。名字是单段文件名,
129
+ * 目录固定 —— 跟便利贴同一套 safeNoteSegment 闸。
130
+ */
131
+ const resolveChalk = (pid, name) => {
132
+ const base = path.join(getSharedDir(pid), 'notes', '板书');
133
+ const file = path.resolve(base, name);
134
+ return file.startsWith(base + path.sep) ? file : null;
135
+ };
136
+ router.put('/:pid/chalk/:name', express.json(), async (req, res, next) => {
137
+ try {
138
+ if (!guardProject(req, res)) return;
139
+ const { name } = req.params;
140
+ if (!safeNoteSegment(name, { md: true })) return res.status(400).json({ error: 'invalid filename' });
141
+ const text = String(req.body?.text ?? '');
142
+ if (!text.trim()) return res.status(400).json({ error: 'text required' });
143
+ if (text.length > 20_000) return res.status(400).json({ error: 'too long (max 20k chars)' });
144
+ const file = resolveChalk(req.params.pid, name);
145
+ if (!file) return res.status(400).json({ error: 'invalid path' });
146
+ // frontmatter(nd:chalk/anchor/reply_to/tag)由服务端保住:正文只收正文。客户端拼头那条路
147
+ // 在 fetch 失败时会丢头、还会把 4KB 截断的展示副本写回(fable 08-23 P2)。
148
+ // 来的文本若自带头(老客户端拼过来的)剥掉,以磁盘上的头为准;磁盘上没有就保留来的头。
149
+ let head = '';
150
+ try { head = /^---\n[\s\S]{0,800}?\n---\n?/.exec(await fs.readFile(file, 'utf8'))?.[0] || ''; } catch { /* 新文件 */ }
151
+ const incomingHead = /^---\n[\s\S]{0,800}?\n---\n?/.exec(text)?.[0] || '';
152
+ const body = text.slice(incomingHead.length).replace(/^\n+/, '');
153
+ if (!body.trim()) return res.status(400).json({ error: 'text required' });
154
+ await fs.mkdir(path.dirname(file), { recursive: true });
155
+ await fs.writeFile(file, `${head || incomingHead}${head || incomingHead ? '\n' : ''}${body.replace(/\n+$/, '')}\n`, 'utf8');
156
+ res.json({ ok: true, path: `notes/板书/${name}` });
157
+ } catch (err) { next(err); }
158
+ });
159
+ router.delete('/:pid/chalk/:name', async (req, res, next) => {
160
+ try {
161
+ if (!guardProject(req, res)) return;
162
+ const { name } = req.params;
163
+ if (!safeNoteSegment(name, { md: true })) return res.status(400).json({ error: 'invalid filename' });
164
+ const file = resolveChalk(req.params.pid, name);
165
+ if (!file) return res.status(400).json({ error: 'invalid path' });
166
+ try { await fs.unlink(file); } catch (err) {
167
+ if (err.code === 'ENOENT') return res.status(404).json({ error: 'not found' });
168
+ throw err;
169
+ }
170
+ // 座位和线一起清,别留指向已删文件的幽灵条目(read_board 会把它当"摆过的"列出来)
171
+ try { await patchBoard(req.params.pid, { objects: { [`notes/板书/${name}`]: null } }); } catch { /* 清不掉不挡删除 */ }
172
+ res.status(204).end();
173
+ } catch (err) { next(err); }
174
+ });
175
+
126
176
  /** DELETE /:pid/task-notes/:filename — 删便利贴 */
127
177
  router.delete('/:pid/task-notes/:filename', async (req, res, next) => {
128
178
  try {
@@ -28,7 +28,8 @@ import { resolveArtifactFile, isServablePath } from '../lib/artifact-file-path.j
28
28
  import { getProjectCover } from '../lib/cover.js';
29
29
  import { makeDocxPageHandler, makeDocxPdfHandler } from './assets/docx-page.js';
30
30
  import { mountNotesRoutes } from './assets/notes.js';
31
- import { safeSegment, parseNoteFrontmatter } from './assets/helpers.js';
31
+ import { safeSegment, decorateNoteText } from './assets/helpers.js';
32
+ import { CHALK_DIR } from '../lib/chalk.js';
32
33
  import {
33
34
  sendImage, isThumbPath, findOriginalForThumbnail, imageCacheControl,
34
35
  THUMBNAIL_MAX_DIM, THUMBNAIL_QUALITY,
@@ -348,16 +349,8 @@ router.get('/:pid/artifacts', async (req, res, next) => {
348
349
  item.meta = JSON.parse(metaRaw);
349
350
  } catch { /* 无 sidecar(旧图)→ 无 meta */ }
350
351
  }
351
- // 便签直接把正文带给前端渲卡片(≤4KB 截断,完整内容走 artifact-file)。
352
- // frontmatter 里的 session 字段是分区归属(agent 写便签时按约定带上)。
353
- if (kind === 'note' && ext === '.md') {
354
- try {
355
- const raw = await fs.readFile(path.join(dir, e.name), 'utf8');
356
- const { body, sessionId } = parseNoteFrontmatter(raw);
357
- item.text = body.length > 4096 ? body.slice(0, 4096) + '…' : body;
358
- if (sessionId) item.sessionId = sessionId;
359
- } catch { /* */ }
360
- }
352
+ // 便签/板书把正文带给前端渲卡片(≤4KB 截断,完整内容走 artifact-file;见 helpers.decorateNoteText)
353
+ if (kind === 'note' && ext === '.md') await decorateNoteText(item, path.join(dir, e.name));
361
354
  artifacts.push(item);
362
355
  }
363
356
  };
@@ -497,6 +490,8 @@ router.get('/:pid/artifacts', async (req, res, next) => {
497
490
  // 24 条产物里 6 条重复,画布上就是一张卡叠着另一张同名卡。
498
491
  await scanDir(workspaceRoot, 'task-file', '', 0);
499
492
  await scanDir(path.join(workspaceRoot, 'notes'), 'note', 'notes');
493
+ // 板书住 notes/板书/(不进 notes/ 顶层:便利贴注入只列顶层,板书另有自己的注入)
494
+ await scanDir(path.join(workspaceRoot, CHALK_DIR), 'note', CHALK_DIR);
500
495
  for (const rel of folders) {
501
496
  await scanDir(path.join(workspaceRoot, rel), 'task-file', rel, 0);
502
497
  }
@@ -12,7 +12,10 @@
12
12
  import express from 'express';
13
13
  import { validateProjectId, getProject } from '../projects/store.js';
14
14
  import { guardProject } from './_guard.js';
15
- import { readBoard, replaceBoard, patchBoard } from '../projects/board-store.js';
15
+ import { readBoard, replaceBoard, patchBoard, commitStaging, removeByTag } from '../projects/board-store.js';
16
+ import { setViewpoint, getViewpoint } from '../projects/viewpoint-store.js';
17
+ import { exportGraph, exportGraphZip } from '../lib/board-graph-export.js';
18
+ import { getSharedDir } from '../projects/workspace.js';
16
19
 
17
20
  const router = express.Router();
18
21
 
@@ -54,4 +57,78 @@ router.put('/:pid/board', express.json({ limit: '600kb' }), async (req, res, nex
54
57
  }
55
58
  });
56
59
 
60
+ /**
61
+ * 用户视点上报(2026-08-23 黑板):前端节流 POST,服务端只留最近一份。
62
+ * 不广播、不落盘;失败也不该打扰用户(前端 fire-and-forget)。
63
+ */
64
+ router.post('/:pid/viewpoint', express.json({ limit: '16kb' }), async (req, res, next) => {
65
+ try {
66
+ if (!guard(req, res)) return;
67
+ const v = setViewpoint(req.params.pid, req.body?.viewpoint, req.user?.id || null);
68
+ res.json({ ok: !!v });
69
+ } catch (err) { next(err); }
70
+ });
71
+
72
+ /** 读最近一份视点(排障用:看看服务端眼里"用户在看哪";agent 走 read_user_view) */
73
+ router.get('/:pid/viewpoint', async (req, res, next) => {
74
+ try {
75
+ if (!guard(req, res)) return;
76
+ res.json({ viewpoint: getViewpoint(req.params.pid) });
77
+ } catch (err) { next(err); }
78
+ });
79
+
80
+ /** 草稿落定(用户也可以按组落定:黑板上 agent 的草稿半透明,点一下变实) */
81
+ router.post('/:pid/board/commit', express.json({ limit: '16kb' }), async (req, res, next) => {
82
+ try {
83
+ if (!guard(req, res)) return;
84
+ const tag = typeof req.body?.tag === 'string' ? req.body.tag : null;
85
+ const { board, committed } = await commitStaging(req.params.pid, { tag });
86
+ res.json({ ok: true, committed, board });
87
+ } catch (err) { next(err); }
88
+ });
89
+
90
+ /** 黑板擦:按 #tag 整组删(只删画布原生物件与线;产物卡只摘标签) */
91
+ router.post('/:pid/board/erase', express.json({ limit: '16kb' }), async (req, res, next) => {
92
+ try {
93
+ if (!guard(req, res)) return;
94
+ const tag = typeof req.body?.tag === 'string' ? req.body.tag : '';
95
+ if (!tag) return res.status(400).json({ error: 'tag required' });
96
+ const { board, removed } = await removeByTag(req.params.pid, tag);
97
+ res.json({ ok: true, removed, board });
98
+ } catch (err) { next(err); }
99
+ });
100
+
101
+ /**
102
+ * 连接图导出(2026-08-23 黑板):?format=json|mermaid|svg&tag=&layer=
103
+ * 真相是 board.json,这里只派生。download=1 时带附件头。
104
+ */
105
+ router.get('/:pid/board/graph', async (req, res, next) => {
106
+ try {
107
+ if (!guard(req, res)) return;
108
+ const format = String(req.query.format || 'json');
109
+ const tag = typeof req.query.tag === 'string' && req.query.tag ? req.query.tag.slice(0, 40) : null;
110
+ const layer = typeof req.query.layer === 'string' ? req.query.layer.slice(0, 300) : '';
111
+ const board = await readBoard(req.params.pid);
112
+ if (format === 'zip') {
113
+ const projectName = getProject(req.params.pid)?.name || '画布';
114
+ const { zip } = await exportGraphZip(board, { workspaceRoot: getSharedDir(req.params.pid), tag, layer, projectName });
115
+ const buf = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
116
+ res.setHeader('Content-Type', 'application/zip');
117
+ res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(`board${tag ? `-${tag}` : ''}.zip`)}`);
118
+ return res.send(buf);
119
+ }
120
+ const { mime, body } = exportGraph(board, { format, tag, layer });
121
+ res.setHeader('Content-Type', mime);
122
+ if (req.query.download) {
123
+ const ext = format === 'mermaid' ? 'mmd' : format;
124
+ const name = `board${tag ? `-${tag}` : ''}.${ext}`;
125
+ res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(name)}`);
126
+ }
127
+ res.send(body);
128
+ } catch (err) {
129
+ if (err?.status === 400) return res.status(400).json({ error: err.message });
130
+ next(err);
131
+ }
132
+ });
133
+
57
134
  export default router;
@@ -41,6 +41,7 @@
41
41
  * run.rate_limit { info } rate limit 变化
42
42
  * run.image_generated { path, sizeBytes, prompt, assetRole, ... } generate_image 完成(Phase A)
43
43
  * board.updated { objectId, zoneId, summary } pin_to_board 改画布布局(sessionId:null 广播,前端整份重拉 board.json)
44
+ * board.focus { rect, tag, layer } agent 落了一张草图(sessionId:null 广播;前端只在黑板模式跟镜头)
44
45
  * project.active_session { activeSessionId } 项目级会话指针变更(故意不带 sessionId —— 见构造器注释)
45
46
  *
46
47
  * 外层把 EventBus 桥接到:
@@ -26,6 +26,11 @@ import path from 'node:path';
26
26
  import { readUiConfigFile } from '../../../projects/ui-config.js';
27
27
  import { readAssetsSummary } from '../../../projects/assets-summary.js';
28
28
  import { relationsDigest } from '../../../lib/board-relations.js';
29
+ import { getViewpoint, describeViewpoint } from '../../../projects/viewpoint-store.js';
30
+ import { recentChalk, CHALK_DIR } from '../../../lib/chalk.js';
31
+ import { readBoard } from '../../../projects/board-store.js';
32
+ import { estimateSizeOn } from '../../../lib/board-kind-sizes.js';
33
+ import { layerOf } from '../../../lib/canvas-id.js';
29
34
  import {
30
35
  getActiveArtifact, listWorkspaceArtifacts, taskManifest, kindDef,
31
36
  KIND_DECK, KIND_SITE, ENTRY_FILE,
@@ -107,6 +112,25 @@ async function collectSections({ workspaceRoot, sessionId, projectId }) {
107
112
  } catch { /* notes/ 不存在:noop */ }
108
113
 
109
114
  // 画布关系线:用户画的排前面
115
+ // 用户视点(2026-08-23 黑板):一行,只在变化时进注入(renderTurnState 按 hash 判)。
116
+ // 视口矩形按 1/8 视口量化再进 hash,不然相机挪一像素就算"变了"。
117
+ try {
118
+ const vp = getViewpoint(projectId);
119
+ if (vp) {
120
+ const board = await readBoard(projectId);
121
+ const known = new Set(Object.keys(board.zones || {}));
122
+ const rects = Object.entries(board.objects || {})
123
+ .filter(([id, e]) => Number.isFinite(e?.x) && layerOf(id, e, known) === (vp.layer || ''))
124
+ .map(([id, e]) => ({ id, x: e.x, y: e.y, ...estimateSizeOn(board, id, e) }));
125
+ const q = vp.camera ? { ...vp, camera: {
126
+ x: Math.round(vp.camera.x / Math.max(1, vp.camera.w / 8)) * Math.round(vp.camera.w / 8),
127
+ y: Math.round(vp.camera.y / Math.max(1, vp.camera.h / 8)) * Math.round(vp.camera.h / 8),
128
+ w: vp.camera.w, h: vp.camera.h,
129
+ } } : vp;
130
+ const line = describeViewpoint(q, rects);
131
+ if (line) sections.push({ key: 'viewpoint', title: '用户视点', text: `用户此刻在画布上:${line}。他说「这个/这里/这张」多半指选中的 > 开着的窗 > 视口里的东西;要细节调 read_user_view。` });
132
+ }
133
+ } catch { /* 视点读不到就沉默 */ }
110
134
  try {
111
135
  const digest = await relationsDigest(projectId, { limit: 12 });
112
136
  if (digest) {
@@ -137,6 +161,25 @@ async function collectSections({ workspaceRoot, sessionId, projectId }) {
137
161
  } catch { /* 扫不动就不说 */ }
138
162
 
139
163
  // tweaks 开关:文件不存在 = 用户没碰过 = 沉默
164
+ // 最近板书(2026-08-23):你/用户在画布上说过的最近几句 —— 对话在板上,得记得板上说了什么
165
+ try {
166
+ const recent = await recentChalk(workspaceRoot, { limit: 8 });
167
+ if (recent.length) {
168
+ const lines = recent.map(c => ` ${c.path}(${c.by === 'user' ? '用户' : '你'}${c.anchor ? `,关于 ${c.anchor}` : ''}${c.replyTo ? `,回应 ${c.replyTo.replace(`${CHALK_DIR}/`, '')}` : ''})「${c.first}」`);
169
+ sections.push({ key: 'chalk', title: '最近板书', text: `画布上最近的板书(${CHALK_DIR}/,新在前;正文 Read 文件):\n${lines.join('\n')}`, items: recent.map(c => c.path) });
170
+ }
171
+ } catch { /* 板书读不到就沉默 */ }
172
+ // 黑板模式(2026-08-23):用户在画布上专注思考时开。这一节只在开着时注入,关着不提。
173
+ try {
174
+ const cfg = await readUiConfigFile(workspaceRoot);
175
+ if (cfg?.blackboard_mode === true) {
176
+ sections.push({ key: 'blackboard', title: '黑板模式', text:
177
+ '【黑板模式:开】用户此刻在画布上专注思考。这一轮默认这么做:想事情就画成图(sketch_on_board,'
178
+ + '小改动用 edit_sketch 原地改别重画);做完一件东西在它旁边写一条板书(write_on_board near=);'
179
+ + '用户标注了板上的东西就接在那条下面回(reply_to=)。侧栏照常回复,但板上已经写的别大段重复。'
180
+ + '尺寸守规范(0.8 倍一屏可读、正文 md 起、一条板书说一件事);画完 look_at_board 看一眼再收。' });
181
+ }
182
+ } catch { /* 读失败:不注入 */ }
140
183
  try {
141
184
  const cfg = await readUiConfigFile(workspaceRoot);
142
185
  if (cfg) {
@@ -160,13 +160,64 @@ index.html 直接在工作区根上的「根站」仍被识别,但**新站一
160
160
  的以用户为准,别来回拉扯。
161
161
  - `create_on_board` 在画布上落一条**手写便签**(和用户写的同款)。用于值得留在
162
162
  版面上的话(这版为什么这么改、一组东西的题注),带 near+relation 就是一次完整
163
- 的"标注"。不是聊天出口,每回合上限 4 条。
163
+ 的"标注"。不是聊天出口。
164
164
  - `organize_board` 归纳收纳:把散在桌面上的东西(生成图 / 旧稿 / 文件夹)批量搬进
165
165
  文件夹(目标夹没有就建)。**真 mv** —— 和用户拖拽同一套语义,画布身份和关系线
166
166
  自动跟着走,比裸 Bash mv 稳。站点收素材搬进它的 `assets/` 子目录(站点根是产物
167
167
  不是收纳夹,搬不进去)。一轮产出多件同族素材时,收尾顺手归个夹。
168
168
  - 用户「+加入上下文」的物件会作为附件出现在你下一条消息里,那是他指着东西说话。
169
169
 
170
+ ### 画布也是黑板(板书 / 头脑风暴 / 推理板)—— 能写板书就写,能画线就画
171
+ 整块画布就是一张侦探板:产物卡是证物,线是关系,空白处随时能写、能画。侧栏照常回复(那是
172
+ 用户的输入方式,别改),但**表现形式上要积极**:凡是值得留在版面上、贴着东西看的东西,都
173
+ 往板上写、往板上画 —— 板上有而侧栏没有的,是"跟东西贴在一起的上下文"和"读得出关系的版面"。
174
+ - `write_on_board`:**能写就写**。做完一件东西立刻在它旁边写一条(`near=那张卡`:这是什么 /
175
+ 为什么这么做 / 看哪里);给出结论、建议、决定、待办,写一条;比较了几件东西,在它们旁边
176
+ 写判语;用户在板上**标注**了你的板书或手写字(消息里带原文摘录和路径),用 `reply_to=那条
177
+ 路径` 接在下面回,不要只在侧栏答。板书是真文件(`notes/板书/…md`,你能 Read/Grep/Edit),
178
+ 一条 ≤1500 字、一条说一件事;侧栏里提一句"板上写了什么"即可,别大段重复。
179
+ - `relate_on_board` / 草图里的 edges:**能画线就画**。两件东西有关系(改自 / 对照 / 接着 /
180
+ 取材 / 关联 / 板书关于谁),线一定画上 —— 线是用户读版面的主要线索。还在论证中的关系用
181
+ 丝线(yarn)。
182
+ - 用户在**想事情**(对比方案、理流程、摆人物关系、列利弊、排时间线、拆问题)时,画成图
183
+ (sketch_on_board)—— 画出来比一段话清楚,也留得住。纯闲聊、一句话能答完的、长段技术排错、
184
+ 要用户复制的东西,留在侧栏就好。
185
+ - **步骤清单自动上板**:你用 TodoWrite 列的步骤会被镜像成画布上一条「这一轮的步骤」板书
186
+ (✓ 已做 / → 正在 / 待做,原地更新),每一步期间你写出的文件会自动连到这条清单上(「第 N 步」)。
187
+ 所以多步任务**先 TodoWrite 列步骤**(三步以上就列),步骤写成人看得懂的话;不用再手写一份清单。
188
+ - 用户开了「黑板模式」时,上面这些从"积极去做"变成"默认这么做"(每条消息前会注入提示)。
189
+ - `sketch_on_board` **一次落一整张图**:节点(手写 或 `format:'md'` —— 要点/表格/
190
+ KaTeX 公式/```mermaid 围栏)、形状(rect/ellipse/circle/arrow/line/underline,手绘感
191
+ 替你加好;或 path 自由路径)、线(语义×材质)。你只说结构:选模板(column/row/grid/
192
+ mindmap)或在网格上自己摆(1 格=24px,`layout:'free'` + `at`)。整张图落在 `near`
193
+ 的旁边、用户视口里的空地、或桌面内容底下,坐标它算。它带一个 #tag,`read_board {tag}`
194
+ 只读那一组,用户能整组选/整组擦。
195
+ - `edit_sketch` **改已有的图**:按 id 挪(相对另一件东西的右/左/上/下,或网格位移)、改字、
196
+ 加节点、加/改/删线、整组搬家。小改动一律用它,**不要擦掉重画**。位置永远说相对关系,
197
+ 不给绝对坐标。
198
+ - **两阶段**:落下去的是草稿(半透明)。检查完 `finish_sketch {tag}` 变实;忘了也在
199
+ 回合结束自动落定;画坏了 `finish_sketch {tag, erase:true}` 整组擦掉重画。
200
+ - **画完要看**:`look_at_board {tag}` 截一张真实画面(按需调用,不要每步都截)。
201
+ 涂鸦和排布只有看过才算数 —— 画了不看等于没画。
202
+ - **可读性与版式纪律**(板是给人读的,按人的阅读习惯排):
203
+ - 用户在 80%~100% 缩放下看板。正文字号 md 起(sm 只给 ≤40 字题注);一张图一屏装得下
204
+ (工具按他的屏幕算,超了会提醒),硬上限 2600×1700;大了拆两张、用一条线说关系。
205
+ - **先左后右、先上后下**。新东西排在已有内容的**右侧或下方**,不放左侧/上方(除非锚着
206
+ 某件东西)。标题用 `title` 参数横跨顶端,别当普通节点摆进列里。
207
+ - 一张图最多两列;列宽 ≤18 格(432px);每个节点一件事、正文 ≤4 行,写不下就拆节点。
208
+ - 相关的几张图之间留呼吸(40px+),用线说关系;板书贴着它说的东西(near),回应接在
209
+ 被回应那条正下方(reply_to)。
210
+ - 不确定用户此刻看哪、屏幕多大:`read_user_view`。
211
+ - 材质是与语义正交的第二个轴:`ink` 墨线 = 安静的归档线;`pencil` 手绘 = 顺手拉的一笔
212
+ (草图里的缺省);`yarn` 丝线 = 侦探板上带图钉的红线,给**还在论证中**的假设/证据
213
+ 关系。关系定了就用 ink 重画或落定时自然退场;`relate_on_board` 也收 `material`。
214
+ - mermaid 与原生节点的分工:要跟真实产物连线的一律用节点+线;时序图、状态机这类
215
+ 密而规整的图才装 mermaid 盒子。别把三个节点的关系图塞进 mermaid。
216
+ - `read_user_view` 看用户此刻在看哪(视口/缩放/开着的窗/选中)。他说「这个/这里/这张」按
217
+ 选中 > 开着的窗 > 视口里的东西 来解;新图默认落在他视口里的空地,让他不用动镜头。
218
+ 每条用户消息前也会注入一行他的视点(只在变化时报)。
219
+ - 黑板不是弹幕区:一张图说一件事(每张 ≤40 节点),一条板书说一件事。说完在回复里提一句「画布上画了 xxx」。
220
+
170
221
  ## 用户直接改画布时(DirectEdit)
171
222
 
172
223
  用户可以双击改字、选中元素写评论、拖移元素、**在预览上圈一块地方说事**。这些动作攒在
@@ -292,6 +343,7 @@ batch**,一页一个回合太慢;逛到不确定的地方(要不要进、
292
343
  `remove_background` · `web_search` · `expose_tweaks` · `export_handoff` ·
293
344
  `pin_to_board` · `relate_on_board` ·
294
345
  `read_board` · `arrange_on_board` · `create_on_board` · `organize_board` · `deliver_files` ·
346
+ `write_on_board` · `sketch_on_board` · `edit_sketch` · `finish_sketch` · `look_at_board` · `read_user_view` ·
295
347
  `read_document` ·
296
348
  `crystallize_skill` ·
297
349
  `report_issue` · `roll_film` · `paint_still` · `lookup_tags` · `trace_motion` · `explain_style` · `profile_scroll`
@@ -78,6 +78,7 @@ import { autoNameProjectFromSession } from '../../projects/auto-name.js';
78
78
  // getUserById/levelFor 是 main 的每用户内容尺度旋钮(78ceaac);
79
79
  // main 的 listTasks 已随任务层退役,不再引入
80
80
  import { commitTaskWorkspace, commitWorkspace, PROJECTS_DATA_ROOT } from '../../projects/workspace.js';
81
+ import { commitStaging } from '../../projects/board-store.js';
81
82
  import { taskManifest } from '../../lib/artifact-target.js';
82
83
  import { getUserById } from '../../auth/users-store.js';
83
84
  import { can, defaultModerationLevel } from '../../auth/tier.js';
@@ -727,6 +728,16 @@ export async function runSession({
727
728
  await commitWorkspace(projectId, sessionId, `turn ${status}: ${new Date().toISOString()}`, { author: 'agent' })
728
729
  .catch((err) => console.warn('[git] turn commit failed:', err.message));
729
730
 
731
+ // 黑板草稿兜底落定(2026-08-23):agent 这一轮 sketch_on_board 留下的 staging
732
+ // 物件,没调 finish_sketch 也在回合结束时变实 —— 草稿态是"正在画"的信号,
733
+ // 回合都结束了还半透明就是幽灵。取消/出错同样落定:画了就是画了。
734
+ if (projectId) {
735
+ try {
736
+ const { committed } = await commitStaging(projectId);
737
+ if (committed > 0) sharedCtx.emit({ type: 'board.updated', sessionId: null, summary: `黑板草稿落定 ${committed} 件` });
738
+ } catch (err) { console.warn('[board] commitStaging failed:', err.message); }
739
+ }
740
+
730
741
  activeTurnRunId = null;
731
742
  turnStartedAt = 0;
732
743
  markSessionActivity(sessionId); // turn 结束 = 活跃信号;下次 idle 计时重置
@@ -65,6 +65,11 @@ import { makeRelateOnBoardTool } from './tools/relate-on-board.js';
65
65
  import { makeReadBoardTool } from './tools/read-board.js';
66
66
  import { makeArrangeOnBoardTool } from './tools/arrange-on-board.js';
67
67
  import { makeCreateOnBoardTool } from './tools/create-on-board.js';
68
+ import { makeSketchOnBoardTool, makeFinishSketchTool } from './tools/sketch-on-board.js';
69
+ import { makeEditSketchTool } from './tools/edit-sketch.js';
70
+ import { makeWriteOnBoardTool } from './tools/write-on-board.js';
71
+ import { makeLookAtBoardTool } from './tools/look-at-board.js';
72
+ import { makeReadUserViewTool } from './tools/read-user-view.js';
68
73
  import { makeOrganizeBoardTool } from './tools/organize-board.js';
69
74
  import { makeReadDocumentTool } from './tools/read-document.js';
70
75
  import { makeReadTavernJsonTool } from './tools/read-tavern-json.js';
@@ -305,6 +310,13 @@ export function createNodesignMcpServer({ workspaceRoot, sharedRoot, projectId,
305
310
  makeArrangeOnBoardTool({ projectId, ctx }),
306
311
  makeCreateOnBoardTool({ projectId, ctx }),
307
312
  makeOrganizeBoardTool({ projectId, ctx }),
313
+ // 黑板(2026-08-23):一次落一整张草图 / 落定或擦掉 / 看一眼 / 用户在看哪
314
+ makeSketchOnBoardTool({ projectId, ctx }),
315
+ makeFinishSketchTool({ projectId, ctx }),
316
+ makeEditSketchTool({ projectId, ctx }),
317
+ makeWriteOnBoardTool({ projectId, sharedRoot: workspaceRoot || sharedRoot, sessionId, ctx }),
318
+ makeLookAtBoardTool({ projectId, ctx }),
319
+ makeReadUserViewTool({ projectId }),
308
320
 
309
321
  // 注:Phase Image-2 的 request_image_approval 工具已废弃(2026-05-06)。
310
322
  // generate_image 的 CallToolResult 已返 image content block,前端自动渲染;
@@ -12,7 +12,7 @@
12
12
  import { tool } from '@anthropic-ai/claude-agent-sdk';
13
13
  import { z } from 'zod';
14
14
  import { readBoard, patchBoard } from '../../../projects/board-store.js';
15
- import { estimateSize } from '../../../lib/board-kind-sizes.js';
15
+ import { estimateSizeOn } from '../../../lib/board-kind-sizes.js';
16
16
  import { normalizeCanvasId, layerOf } from '../../../lib/canvas-id.js';
17
17
 
18
18
  const GAP_X = 24;
@@ -94,8 +94,8 @@ drag things afterwards — their move wins, do not fight it.`,
94
94
  }
95
95
  }
96
96
 
97
- const aSize = estimateSize(anchor, anchorEntry);
98
- const sSize = estimateSize(subject, subjectEntry);
97
+ const aSize = estimateSizeOn(board, anchor, anchorEntry);
98
+ const sSize = estimateSizeOn(board, subject, subjectEntry);
99
99
  const want = action === 'beside'
100
100
  ? { x: Math.round(anchorEntry.x + aSize.w + GAP_X), y: Math.round(anchorEntry.y) }
101
101
  : { x: Math.round(anchorEntry.x), y: Math.round(anchorEntry.y + aSize.h + GAP_Y) };
@@ -103,7 +103,7 @@ drag things afterwards — their move wins, do not fight it.`,
103
103
  const obstacles = Object.entries(board.objects || {})
104
104
  .filter(([id, e]) => id !== subject && Number.isFinite(e?.x)
105
105
  && layerOf(id, e, known) === anchorLayer)
106
- .map(([id, e]) => ({ ...estimateSize(id, e), x: e.x, y: e.y }));
106
+ .map(([id, e]) => ({ ...estimateSizeOn(board, id, e), x: e.x, y: e.y }));
107
107
  const pos = nudgeDown(want, sSize, obstacles);
108
108
 
109
109
  await patchBoard(projectId, {
@@ -12,16 +12,15 @@
12
12
  * - 尺寸估算抄前端 handleCreateText 同款公式(约 26 全角字/行)
13
13
  * - by:'agent' 出处,用户一眼分得出谁写的
14
14
  *
15
- * 护栏:每回合上限 4 —— 画布是版面不是弹幕区。
15
+ * 护栏:每回合上限 08-23 按用户意思撤了(只记数);画布不是弹幕区这条写在 prelude 里。
16
16
  */
17
17
 
18
18
  import { tool } from '@anthropic-ai/claude-agent-sdk';
19
19
  import { z } from 'zod';
20
20
  import { readBoard, patchBoard, TEXT_FONTS } from '../../../projects/board-store.js';
21
- import { estimateSize } from '../../../lib/board-kind-sizes.js';
21
+ import { estimateSizeOn } from '../../../lib/board-kind-sizes.js';
22
22
  import { normalizeCanvasId, layerOf } from '../../../lib/canvas-id.js';
23
23
 
24
- const MAX_PER_TURN = 4;
25
24
  const SIZE_PX = { sm: 13, md: 16, lg: 22, xl: 30 };
26
25
 
27
26
  let seq = 0;
@@ -44,7 +43,7 @@ export function makeCreateOnBoardTool({ projectId, ctx }) {
44
43
 
45
44
  Use it for remarks worth KEEPING on the board: why a version went this way, what
46
45
  to compare, a caption for a group. Not for conversation (that goes in your reply)
47
- and not for documents (write a .md instead). Hard cap: ${MAX_PER_TURN} notes per turn.
46
+ and not for documents (write a .md instead).
48
47
 
49
48
  - near: anchor it beside a thing (and usually pass relation to draw the line —
50
49
  that is exactly the user's "annotation" gesture: a note + an annotates edge)
@@ -68,11 +67,9 @@ and not for documents (write a .md instead). Hard cap: ${MAX_PER_TURN} notes per
68
67
  }
69
68
  const err = (t) => ({ content: [{ type: 'text', text: t }], isError: true });
70
69
 
71
- const turn = ctx?.counters?.turns ?? -1;
70
+ // 回合闸 08-23 用户拍板先不设:只记数不拦
71
+ const turn = ctx?.runId ?? -1;
72
72
  if (turnStamp.turn !== turn) { turnStamp.turn = turn; turnStamp.count = 0; }
73
- if (turnStamp.count >= MAX_PER_TURN) {
74
- return err(`本回合便签额度用完(${MAX_PER_TURN} 条)—— 画布是版面不是弹幕区,要说的话说给用户听。`);
75
- }
76
73
 
77
74
  const t = String(text).trim();
78
75
  if (!t) return err('空字不落板。');
@@ -89,12 +86,12 @@ and not for documents (write a .md instead). Hard cap: ${MAX_PER_TURN} notes per
89
86
  return err(`锚点 ${rawNear} 还没有座位(read_board 里看不到就锚不上)。`);
90
87
  }
91
88
  zone = layerOf(near, anchorEntry, known);
92
- const aSize = estimateSize(near, anchorEntry);
89
+ const aSize = estimateSizeOn(board, near, anchorEntry);
93
90
  pos = { x: Math.round(anchorEntry.x + aSize.w + 24), y: Math.round(anchorEntry.y) };
94
91
  // 撞卡往下让(同 arrange 的避让)
95
92
  const obstacles = Object.entries(board.objects || {})
96
93
  .filter(([id, e]) => Number.isFinite(e?.x) && layerOf(id, e, known) === zone)
97
- .map(([id, e]) => ({ ...estimateSize(id, e), x: e.x, y: e.y }));
94
+ .map(([id, e]) => ({ ...estimateSizeOn(board, id, e), x: e.x, y: e.y }));
98
95
  for (let g = 0; g < 40; g += 1) {
99
96
  const blocker = obstacles.find(o =>
100
97
  !(pos.x + box.w <= o.x || o.x + o.w <= pos.x || pos.y + box.h <= o.y || o.y + o.h <= pos.y));
@@ -106,7 +103,7 @@ and not for documents (write a .md instead). Hard cap: ${MAX_PER_TURN} notes per
106
103
  let bottom = 0;
107
104
  for (const [id, e] of Object.entries(board.objects || {})) {
108
105
  if (!Number.isFinite(e?.y) || layerOf(id, e, known) !== '') continue;
109
- bottom = Math.max(bottom, e.y + estimateSize(id, e).h);
106
+ bottom = Math.max(bottom, e.y + estimateSizeOn(board, id, e).h);
110
107
  }
111
108
  for (const zz of Object.values(board.zones || {})) {
112
109
  if (Number.isFinite(zz?.y)) bottom = Math.max(bottom, zz.y + 150);
@@ -147,7 +144,7 @@ and not for documents (write a .md instead). Hard cap: ${MAX_PER_TURN} notes per
147
144
  content: [{
148
145
  type: 'text',
149
146
  text: `Note on board at (${pos.x},${pos.y})${relation ? ' with a line' : ''} (id: ${textId}).`
150
- + ` ${MAX_PER_TURN - turnStamp.count} left this turn.`,
147
+ + '',
151
148
  }],
152
149
  };
153
150
  },