dsh-drawai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,3777 @@
1
+ /* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
2
+ /**
3
+ * dsh-drawai —— 宿主半边(Host half)· 源文件
4
+ *
5
+ * 这是**源文件**,不要改 lib/index.js —— 那个由 tools/build.mjs 生成。
6
+ *
7
+ * 不需要语法转换:本文件是可直接运行的 ESM。构建器只加一行生成标记,
8
+ * 所以宿主半边改完只要 `npm run build` + 重启 dsh web 就生效
9
+ * (客户端半边有热重载,宿主半边没有 —— 宿主只在启动时加载一次)。
10
+ *
11
+ * 导出形式与 DSH 全部参考包一致:{ name, inject, apply }。
12
+ * 注册的工具:
13
+ * - diagram_read 读回画布文档(节点/边/形状/配色)
14
+ * - diagram_apply 施加结构化编辑 → 分层自动布局 → 原子写回
15
+ */
16
+
17
+ import { defineTool } from '@deepseek-ai/dsh-tools'
18
+ import { readdir } from 'node:fs/promises'
19
+ import { isAbsolute, resolve as resolvePath, join as joinPath } from 'node:path'
20
+ import { applyDocToMxfile, buildMxfile, contentHash, parseMxfile } from './mxfile.js'
21
+ import {
22
+ ARROW_KINDS,
23
+ DASH_KINDS,
24
+ DEFAULT_EDGE_STYLE,
25
+ DEFAULT_FONT,
26
+ DEFAULT_NODE_STYLE,
27
+ DEFAULT_STROKE,
28
+ NODE_SHAPES,
29
+ NODE_SHAPE_STYLE,
30
+ PALETTE,
31
+ SIDES,
32
+ arrowFromStyle,
33
+ avoidFromStyle,
34
+ colorNameFromStyle,
35
+ colorsFromStyle,
36
+ dashFromStyle,
37
+ edgeStyleValueFromStyle,
38
+ formatStyle,
39
+ jettyFromStyle,
40
+ lineKindFromStyle,
41
+ nodeShapeFromStyle,
42
+ normalizeDrawioDoc,
43
+ normalizePoints,
44
+ parseStyle,
45
+ sideFromStyle,
46
+ styleWithArrow,
47
+ styleWithAvoid,
48
+ styleWithColorName,
49
+ styleWithDash,
50
+ styleWithJetty,
51
+ styleWithLineKind,
52
+ styleWithNodeShape,
53
+ styleWithSide,
54
+ styleWithTextColorName,
55
+ textColorNameFromStyle,
56
+ stylePatch,
57
+ styleGet,
58
+ } from './style-kernel.js'
59
+
60
+ /**
61
+ * 列目录走 Node 标准库。
62
+ *
63
+ * 为什么不用 ctx.fs:实测这个 DSH 版本的 ctx.fs **既没有 list 也没有 readRelated**
64
+ * (诊断原话:"ctx.fs 没有 list 方法 / ctx.fs 没有 readRelated 方法")——
65
+ * 我先后猜过这两个 API,都错了。插件本来就跑在宿主进程里,stdlib 一定可用。
66
+ *
67
+ * 沙箱围栏不受影响:**写盘**仍然全部走 ctx.fs.writeText(带策略),
68
+ * 这里只做"列目录"这一件读操作。
69
+ */
70
+ const nodeReaddir = readdir
71
+
72
+ /** 把可能相对的路径归一成绝对路径(宿主工作目录兜底)。 */
73
+ function toAbsolute(p, cwd) {
74
+ const s = String(p === undefined || p === null ? '' : p)
75
+ if (isAbsolute(s)) return s
76
+ return joinPath(cwd === undefined || cwd === null || cwd.length === 0 ? process.cwd() : cwd, s)
77
+ }
78
+
79
+ const DEFAULT_PATH = 'demo.drawio'
80
+ const DEFAULT_W = 170
81
+ /** 缺省高度取**整格**(10px 的倍数):否则节点中心会落在半像素上,连线容易出现"差一像素"的台阶。 */
82
+ const DEFAULT_H = 60
83
+ const GAP_PRIMARY = 90
84
+ const GAP_CROSS = 40
85
+ const LAYOUTS = ['dagre-lr', 'dagre-tb', 'grid', 'none']
86
+ /** `order` 的四种去向(与画布右键的顺序菜单同一套:后面的在上面)。 */
87
+ const ORDER_MODES = ['front', 'back', 'up', 'down']
88
+ /** `duplicate` 的默认位移:两格 —— 与画布上 Ctrl+V 同一套(不然复制出来的正好压在原图上)。 */
89
+ const DUPLICATE_OFFSET = 20
90
+
91
+ /**
92
+ * 工具语言 vs 文档语言 —— 这是本次改造最容易被误读的一条界线。
93
+ *
94
+ * **文档里存的是 drawio 的 style 键**(`dashed=1`、`dashPattern=8 8`、`edgeStyle=orthogonalEdgeStyle`、
95
+ * `jettySize=auto`、`libavoidRouting=1`、`exitX/exitY/entryX/entryY`、`endArrow/startArrow`、
96
+ * `fillColor/strokeColor`、`shape=`/`rounded=`/`arcSize=`…),默认值一律省略,
97
+ * 认不出的键原样保留(开放集合)。这一切由 ../src/style-kernel.js 负责,宿主与客户端共用。
98
+ *
99
+ * **下面这几个枚举只是工具语言**(给模型用的糖):AI 说"改成虚线""双向箭头""黄色"
100
+ * 不该被迫拼 style 串。它们**绝不落盘**——由本文件翻译成上面的键。
101
+ * drawio 自己也是这个分工:面板上给的是名字,文档里只有 fillColor/strokeColor。
102
+ *
103
+ * 旧的立场("存语义、渲染参数属于客户端")已按需求废弃:现在渲染参数就是文档的一部分,
104
+ * 换主题/调间距会改文档 —— 与 drawio 一致。
105
+ */
106
+ const DASHES = DASH_KINDS
107
+ const ARROWS = ARROW_KINDS
108
+
109
+ /** 容忍模型的口语说法:只为降低"op 被拒"的概率,落盘前一律归一成上面那几个枚举值。 */
110
+ function normalizeDash(value) {
111
+ const v = String(value).toLowerCase().trim()
112
+ if (v === 'solid' || v === 'line' || v === 'normal' || v === '实线') return 'solid'
113
+ if (v === 'dashed' || v === 'dash' || v === '虚线') return 'dashed'
114
+ if (v === 'dotted' || v === 'dot' || v === '点线' || v === '点状') return 'dotted'
115
+ return null
116
+ }
117
+
118
+ function normalizeArrow(value) {
119
+ const v = String(value).toLowerCase().trim()
120
+ if (v === 'end' || v === 'forward' || v === 'target' || v === '单向') return 'end'
121
+ if (v === 'both' || v === 'bidirectional' || v === '双向' || v === 'double') return 'both'
122
+ if (v === 'none' || v === 'false' || v === '无') return 'none'
123
+ if (v === 'start' || v === 'backward' || v === 'source' || v === '反向') return 'start'
124
+ return null
125
+ }
126
+
127
+ /**
128
+ * 线型四选一:直线 / 直角折线 / 圆角折线 / 曲线。
129
+ *
130
+ * 模型可能说 "elbow"/"orthogonal"/"折线"(= 直角折线)或 "rounded"/"圆角"/"圆角折线"
131
+ * (= 只在折点处倒角),都归一到内核的 `styleWithLineKind` 认得的那四个值上。
132
+ */
133
+ function normalizeLineKind(value, where) {
134
+ const v = String(value).toLowerCase().trim()
135
+ if (v === 'straight' || v === 'line' || v === 'direct' || v === '直线') return 'straight'
136
+ if (v === 'sharp' || v === 'orthogonal' || v === 'elbow' || v === '折线' || v === '正交' || v === '直角折线') return 'sharp'
137
+ if (v === 'rounded' || v === 'round' || v === '圆角' || v === '圆角折线') return 'rounded'
138
+ if (v === 'curved' || v === 'curve' || v === 'arc' || v === '曲线' || v === '弧线') return 'curved'
139
+ throw new Error(where + ': unknown line "' + value + '"; use straight, sharp, rounded, curved')
140
+ }
141
+
142
+ function messageOf(error) {
143
+ if (error === null || error === undefined) return 'unknown error'
144
+ if (typeof error === 'string') return error
145
+ if (typeof error.message === 'string') return error.message
146
+ return String(error)
147
+ }
148
+
149
+ function numberOr(value, fallback) {
150
+ const n = Number(value)
151
+ return Number.isFinite(n) ? n : fallback
152
+ }
153
+
154
+ function has(value, key) {
155
+ return Object.prototype.hasOwnProperty.call(value, key)
156
+ }
157
+
158
+ /** 单字符显示宽度:CJK / 全角按一倍字宽,其余按 0.55 倍(近似 Helvetica)。 */
159
+ function charWidth(ch, size) {
160
+ const code = ch.codePointAt(0)
161
+ const wide =
162
+ (code >= 0x1100 && code <= 0x115f) ||
163
+ (code >= 0x2e80 && code <= 0xa4cf) ||
164
+ (code >= 0xac00 && code <= 0xd7a3) ||
165
+ (code >= 0xf900 && code <= 0xfaff) ||
166
+ (code >= 0xfe30 && code <= 0xfe6f) ||
167
+ (code >= 0xff00 && code <= 0xff60) ||
168
+ (code >= 0xffe0 && code <= 0xffe6)
169
+ return wide ? size : size * 0.55
170
+ }
171
+
172
+ /**
173
+ * 按标签估算节点宽度并夹在 [130, 300],避免模型手算尺寸。
174
+ *
175
+ * 结果**向上取整到整格(10px)**:与画布的移动/缩放单位一致。宽度不是整格时,
176
+ * 节点中心会落在半像素上,连线就会多出"差一像素"的台阶。
177
+ */
178
+ function estimateWidth(label) {
179
+ const chars = Array.from(String(label))
180
+ let w = 34
181
+ for (let i = 0; i < chars.length; i += 1) w += charWidth(chars[i], 12)
182
+ if (w < 130) return 130
183
+ if (w > 300) return 300
184
+ return Math.ceil(w / 10) * 10
185
+ }
186
+
187
+ /** 读一个"绝对点"参数(addEdge 的自由端点)。缺省 null;给了但形状不对直接报错。 */
188
+ function edgePointFromOp(value, where) {
189
+ if (value === undefined || value === null) return null
190
+ if (typeof value !== 'object' || Array.isArray(value)) throw new Error(where + ' must be {x, y}')
191
+ const x = Number(value.x)
192
+ const y = Number(value.y)
193
+ if (Number.isFinite(x) === false || Number.isFinite(y) === false) throw new Error(where + ' needs finite x and y')
194
+ return { x: x, y: y }
195
+ }
196
+
197
+ /** 扫描 `<prefix><n>` 形式的 id,返回 max+1 —— 多轮迭代不会打乱已有编号。 */
198
+ function nextId(list, prefix) {
199
+ let max = 0
200
+ const re = new RegExp('^' + prefix + '(\\d+)$')
201
+ for (let i = 0; i < list.length; i += 1) {
202
+ const m = re.exec(String(list[i].id))
203
+ if (m !== null) {
204
+ const n = parseInt(m[1], 10)
205
+ if (n > max) max = n
206
+ }
207
+ }
208
+ return prefix + (max + 1)
209
+ }
210
+
211
+ function emptyDoc() {
212
+ // revision 是**文件内容指纹**(载体即真相,没有第二个地方存版本号):
213
+ // 空文档的指纹就是空串,第一次落盘后由写入器算出来。
214
+ //
215
+ // 带一个缺省图层:落盘时写出来的就是 `<mxCell id="1" parent="0" />`(与 drawio 一致,
216
+ // 字节不变),而**内存里的形状与"落盘后再打开"一致** —— 否则新建出来的画布在图层面板里
217
+ // 会显示"没有图层信息",一存一开又有了。
218
+ return {
219
+ version: 2,
220
+ revision: '',
221
+ meta: { layout: 'dagre-tb' },
222
+ nodes: [],
223
+ edges: [],
224
+ layers: [{ id: '1', name: '', visible: true, locked: false }],
225
+ }
226
+ }
227
+
228
+ /**
229
+ * 读入归一化。
230
+ *
231
+ * 文档层只认 v2(drawio style 键);归一化逻辑在 style-kernel 的 `normalizeDrawioDoc`,
232
+ * 两侧半边共用同一份,所以"盘上写什么、两边读到什么"不会分叉。
233
+ */
234
+ function normalizeDoc(raw) {
235
+ const doc = normalizeDrawioDoc(raw)
236
+ return {
237
+ version: doc.version,
238
+ revision: doc.revision,
239
+ meta: doc.meta,
240
+ nodes: doc.nodes,
241
+ edges: doc.edges,
242
+ labels: doc.labels,
243
+ // 图层必须跟着走:丢掉它 = 保存时把单元挂回缺省层(用户的层就没了),
244
+ // 而且 AI 读不到"哪一层是隐藏的"。
245
+ layers: doc.layers,
246
+ // 纸张尺寸同理(丢了就悄悄变回 A4 缺省,谁都不会发现)。
247
+ ...(doc.page !== undefined ? { page: doc.page } : {}),
248
+ }
249
+ }
250
+
251
+ /** 节点位置快照,用于判断一次布局到底动没动端点。 */
252
+ function nodePositions(doc) {
253
+ const map = Object.create(null)
254
+ for (let i = 0; i < doc.nodes.length; i += 1) {
255
+ const n = doc.nodes[i]
256
+ map[n.id] = { x: numberOr(n.x, 0), y: numberOr(n.y, 0), w: numberOr(n.w, 0), h: numberOr(n.h, 0) }
257
+ }
258
+ return map
259
+ }
260
+
261
+ /**
262
+ * 重新布局会让"人摆过的折点"变成过期坐标。
263
+ *
264
+ * 这是 v1 的一个真实缺口:宿主半边当时**完全不知道 `points` 存在**(零读写),
265
+ * 于是 AI 一重排,盘上就留下一堆飘在旧位置上的折点,而没有任何清理或标记。
266
+ * v2 起折点归宿主管:布局真的动了某条边的端点,就清掉它的折点与悬空端自由点 ——
267
+ * 与 drawio 的自动路由在几何变化后重算路径同理。
268
+ *
269
+ * @returns 被清理的边数
270
+ */
271
+ function invalidateStalePoints(doc, before) {
272
+ const now = nodePositions(doc)
273
+ let dropped = 0
274
+ for (let i = 0; i < doc.edges.length; i += 1) {
275
+ const edge = doc.edges[i]
276
+ if (edge.points === undefined && edge.sourcePoint === undefined && edge.targetPoint === undefined) continue
277
+ const moved = function (id) {
278
+ const prev = before[id]
279
+ const cur = now[id]
280
+ if (prev === undefined || cur === undefined) return prev !== cur
281
+ return prev.x !== cur.x || prev.y !== cur.y || prev.w !== cur.w || prev.h !== cur.h
282
+ }
283
+ if (!moved(edge.from) && !moved(edge.to)) continue
284
+ delete edge.points
285
+ delete edge.sourcePoint
286
+ delete edge.targetPoint
287
+ dropped += 1
288
+ }
289
+ return dropped
290
+ }
291
+
292
+ /** 调色板名清单(错误文案用)。 */
293
+ function paletteNames() {
294
+ const names = []
295
+ for (let i = 0; i < PALETTE.length; i += 1) names.push(PALETTE[i].name)
296
+ return names
297
+ }
298
+
299
+ /** 工具语言的 `keys`:值给 null 表示**删键**(回到 drawio 缺省)。 */
300
+ function styleKeysFromOp(keys, where) {
301
+ if (keys === null || typeof keys !== 'object' || Array.isArray(keys)) {
302
+ throw new Error(where + ': "keys" must be an object like { fillColor: "#fff2cc", dashed: null }')
303
+ }
304
+ return keys
305
+ }
306
+
307
+ /**
308
+ * 节点/边的 `style` 这个糖:既收调色板名('yellow'),也收**一段 drawio style 串**
309
+ * (`fillColor=#fff2cc;strokeColor=#d6b656`)。判据是"有没有 '=' 或 ';'",
310
+ * 两者都不像就报错 —— 绝不把垃圾写进文档。
311
+ */
312
+ function styleValueFromOp(style, value, where) {
313
+ if (value.indexOf('=') >= 0 || value.indexOf(';') >= 0) {
314
+ return formatStyle(Object.assign(parseStyle(style), parseStyle(value)))
315
+ }
316
+ const named = styleWithColorName(style, value)
317
+ if (named === style && colorNameFromStyle(style) !== value) {
318
+ throw new Error(
319
+ where + ': unknown style "' + value + '"; use a palette name (' + paletteNames().join(', ') +
320
+ ') or a drawio style string like "fillColor=#fff2cc;strokeColor=#d6b656"',
321
+ )
322
+ }
323
+ return named
324
+ }
325
+
326
+ /** 边上的 `style` 糖:调色板名 → `strokeColor`(drawio 里连线的颜色就是 strokeColor)。 */
327
+ function edgeStyleValueFromOp(style, value, where) {
328
+ if (value.indexOf('=') >= 0 || value.indexOf(';') >= 0) {
329
+ return formatStyle(Object.assign(parseStyle(style), parseStyle(value)))
330
+ }
331
+ for (let i = 0; i < PALETTE.length; i += 1) {
332
+ if (PALETTE[i].name !== value) continue
333
+ return stylePatch(style, { strokeColor: value === 'plain' ? null : PALETTE[i].stroke })
334
+ }
335
+ throw new Error(
336
+ where + ': unknown style "' + value + '"; use a palette name (' + paletteNames().join(', ') +
337
+ ') or a drawio style string like "dashed=1;dashPattern=8 8"',
338
+ )
339
+ }
340
+
341
+ /** 进出侧:'n'/'e'/'s'/'w'(也容忍 north/east/… 与中文),空值 = 清掉约束。 */
342
+ function normalizeSide(value, where) {
343
+ if (value === null || value === undefined || value === '') return null
344
+ const v = String(value).toLowerCase().trim()
345
+ if (v === 'n' || v === 'north' || v === 'top' || v === '上') return 'n'
346
+ if (v === 'e' || v === 'east' || v === 'right' || v === '右') return 'e'
347
+ if (v === 's' || v === 'south' || v === 'bottom' || v === '下') return 's'
348
+ if (v === 'w' || v === 'west' || v === 'left' || v === '左') return 'w'
349
+ throw new Error(where + ': unknown side "' + value + '"; use ' + SIDES.join(', '))
350
+ }
351
+
352
+ /**
353
+ * `fontColor` 糖 → drawio 的 `fontColor` 键值。
354
+ *
355
+ * 三种写法都收(与画布上"文字换色"那一套对齐):
356
+ * · 调色板名(plain/blue/red…)→ 取**文字色**那一支(`styleWithTextColorName`,与独立文字的
357
+ * 配色落点是同一个值,否则同一个名字在菜单里和 AI 手里会是两种颜色);
358
+ * · 任何别的非空字符串(`#b85450`、`rgb(...)`、CSS 名)→ 原样写(drawio 也是原样存);
359
+ * · `null` / 空串 → 删键回缺省(drawio 缺省是黑字)。
360
+ */
361
+ function fontColorValue(raw, where) {
362
+ if (raw === null || raw === undefined) return null
363
+ const text = String(raw).trim()
364
+ if (text.length === 0) return null
365
+ const names = paletteNames()
366
+ if (names.indexOf(text) >= 0) {
367
+ const probe = styleWithTextColorName(DEFAULT_NODE_STYLE, text)
368
+ const hex = styleGet(probe, 'fontColor', null)
369
+ // 'plain' 就是"回缺省字色" = 删键。
370
+ return hex === null ? null : hex
371
+ }
372
+ if (where !== undefined && /^[a-z]+$/i.test(text)) {
373
+ // 纯字母但不在调色板里:多半是把颜色名写错了('gren')。报错比静默写一个 drawio 不认的字面值好。
374
+ throw new Error(where + ': unknown font color "' + text + '"; use a palette name (' + names.join(', ') + ') or a hex value like #b85450')
375
+ }
376
+ return text
377
+ }
378
+
379
+ /**
380
+ * 派生给模型看的**字色**:命中调色板就给名字(AI 好读),认不出的十六进制给原值。
381
+ * 缺省黑字(没写键、或写的就是缺省)返回 undefined —— 与派生 color 的规矩一致(缺省不报)。
382
+ */
383
+ function fontColorFieldOf(style) {
384
+ const name = textColorNameFromStyle(style)
385
+ if (name !== null && name !== 'plain') return name
386
+ const raw = styleGet(style, 'fontColor', null)
387
+ if (raw !== null && raw !== DEFAULT_FONT) return raw
388
+ return undefined
389
+ }
390
+
391
+ /**
392
+ * 节点样式:`shape` / `style` / `keys` 三种糖统一落成 drawio style 键。
393
+ * rect 落成**空串** —— drawio 的 `defaultVertexStyle = {}`,普通矩形就是"没有 shape 键"。
394
+ */
395
+ function nodeStyleFromOp(style, op, where) {
396
+ let out = style === undefined || style === null ? DEFAULT_NODE_STYLE : String(style)
397
+ if (typeof op.shape === 'string') {
398
+ if (!Object.prototype.hasOwnProperty.call(NODE_SHAPE_STYLE, op.shape)) {
399
+ throw new Error(where + ': unknown shape "' + op.shape + '"; use ' + NODE_SHAPES.join(', '))
400
+ }
401
+ out = styleWithNodeShape(out, op.shape)
402
+ }
403
+ if (typeof op.style === 'string' && op.style.length > 0) {
404
+ // 文字元素(独立文字)没有填充与描边:给它调色板名要落到 **fontColor** 上,
405
+ // 否则 AI 说 style:"red"、屏幕上一个像素都不变(而且文件里还多两个没用的键)。
406
+ if (nodeShapeFromStyle(out) === 'text' && op.style.indexOf('=') < 0 && op.style.indexOf(';') < 0) {
407
+ const named = styleWithTextColorName(out, op.style)
408
+ if (named === out && textColorNameFromStyle(out) !== op.style) {
409
+ throw new Error(
410
+ where + ': unknown style "' + op.style + '"; use a palette name (' + paletteNames().join(', ') + ') for the text color',
411
+ )
412
+ }
413
+ out = named
414
+ } else {
415
+ out = styleValueFromOp(out, op.style, where)
416
+ }
417
+ }
418
+ // 字号:`fontSize` 是 drawio 的键,节点标签与独立文字都吃它(null = 删键回缺省)。
419
+ if (has(op, 'fontSize')) out = stylePatch(out, { fontSize: op.fontSize === null ? null : String(op.fontSize) })
420
+ // 字色:`fontColor`(节点标签、独立文字都吃它)。调色板名按"文字换色"那一套取色。
421
+ if (has(op, 'fontColor')) out = stylePatch(out, { fontColor: fontColorValue(op.fontColor, where + ' fontColor') })
422
+ if (has(op, 'keys')) out = stylePatch(out, styleKeysFromOp(op.keys, where))
423
+ return out
424
+ }
425
+
426
+ /**
427
+ * 连线的"曲线"要看得见,就得有一个中点。
428
+ *
429
+ * drawio 的曲线是把**现有走线**抹圆(mxPolyline.paintCurvedLine);两点直连时控制点落在起点上,
430
+ * 于是退化成直线 —— 屏幕上什么都看不出来。所以给这种边补一个**垂直弓形的中点**,
431
+ * 与人在画布上手动拖一个中点出来是同一件事(文件里就是普通的折点,drawio 打开一样)。
432
+ *
433
+ * 取两端落点:连着节点用节点中心,悬空端用自由点。弓高 = 长度的 12%,至少 20px,吸附到半格。
434
+ */
435
+ function arcWaypointFor(doc, edge) {
436
+ const endOf = (which) => {
437
+ const nodeId = which === 'source' ? edge.from : edge.to
438
+ const free = which === 'source' ? edge.sourcePoint : edge.targetPoint
439
+ if (typeof nodeId === 'string') {
440
+ for (let i = 0; i < doc.nodes.length; i += 1) {
441
+ const n = doc.nodes[i]
442
+ if (String(n.id) !== nodeId) continue
443
+ return { x: numberOr(n.x, 0) + numberOr(n.w, DEFAULT_W) / 2, y: numberOr(n.y, 0) + numberOr(n.h, DEFAULT_H) / 2 }
444
+ }
445
+ }
446
+ if (free !== null && free !== undefined && Number.isFinite(Number(free.x)) && Number.isFinite(Number(free.y))) {
447
+ return { x: Number(free.x), y: Number(free.y) }
448
+ }
449
+ return null
450
+ }
451
+ const a = endOf('source')
452
+ const b = endOf('target')
453
+ if (a === null || b === null) return null
454
+ const dx = b.x - a.x
455
+ const dy = b.y - a.y
456
+ const len = Math.sqrt(dx * dx + dy * dy)
457
+ if (!(len > 1)) return null
458
+ const unit = 5 // 与画布一致:折点的最小单位是半格
459
+ const bow = Math.max(20, Math.round((len * 0.12) / unit) * unit)
460
+ return {
461
+ x: Math.round(((a.x + b.x) / 2 - (dy / len) * bow) / unit) * unit,
462
+ y: Math.round(((a.y + b.y) / 2 + (dx / len) * bow) / unit) * unit,
463
+ }
464
+ }
465
+
466
+ /**
467
+ * 边的画法糖:线型 / 箭头 / 颜色 / 进出侧 / 桩点长度 / 路由方式 / 避让,
468
+ * 全部翻成 drawio 的 style 键(`dashed`、`dashPattern`、`endArrow`、`strokeColor`、
469
+ * `exitX/exitY/entryX/entryY`、`jettySize`、`edgeStyle`、`libavoidRouting`)。
470
+ */
471
+ function edgeStyleFromOp(style, op, where) {
472
+ let out = style === undefined || style === null ? DEFAULT_EDGE_STYLE : String(style)
473
+ if (typeof op.dash === 'string') {
474
+ const dash = normalizeDash(op.dash)
475
+ if (dash === null) throw new Error(where + ': unknown dash "' + op.dash + '"; use ' + DASHES.join(', '))
476
+ out = styleWithDash(out, dash)
477
+ }
478
+ if (typeof op.arrow === 'string') {
479
+ const arrow = normalizeArrow(op.arrow)
480
+ if (arrow === null) throw new Error(where + ': unknown arrow "' + op.arrow + '"; use ' + ARROWS.join(', '))
481
+ out = styleWithArrow(out, arrow)
482
+ }
483
+ if (typeof op.color === 'string') out = stylePatch(out, { strokeColor: op.color.length === 0 ? null : op.color })
484
+ if (has(op, 'exit')) out = styleWithSide(out, 'source', normalizeSide(op.exit, where + ' exit'))
485
+ if (has(op, 'entry')) out = styleWithSide(out, 'target', normalizeSide(op.entry, where + ' entry'))
486
+ if (has(op, 'jettySize')) out = styleWithJetty(out, op.jettySize === null ? null : String(op.jettySize))
487
+ // 线型糖:直线 / 折线 / 曲线(drawio 的 Straight / Orthogonal / Curved 三个键组合)。
488
+ // `edgeStyle`(orthogonalEdgeStyle|none)仍然保留 —— 它是键级的写法,line 是它的语义写法。
489
+ if (has(op, 'line')) out = styleWithLineKind(out, normalizeLineKind(op.line, where))
490
+ if (typeof op.edgeStyle === 'string') out = stylePatch(out, { edgeStyle: op.edgeStyle })
491
+ if (has(op, 'avoid')) out = styleWithAvoid(out, op.avoid === true)
492
+ // 字号:连线的文字(边自己的 value 与挂在边上的标签单元)都是 `fontSize`。
493
+ if (has(op, 'fontSize')) out = stylePatch(out, { fontSize: op.fontSize === null ? null : String(op.fontSize) })
494
+ // 字色:连线上的文字(边自己的 value、以及挂在边上的标签单元)都吃 `fontColor`。
495
+ if (has(op, 'fontColor')) out = stylePatch(out, { fontColor: fontColorValue(op.fontColor, where + ' fontColor') })
496
+ if (typeof op.style === 'string' && op.style.length > 0) out = edgeStyleValueFromOp(out, op.style, where)
497
+ if (has(op, 'keys')) out = stylePatch(out, styleKeysFromOp(op.keys, where))
498
+ return out
499
+ }
500
+
501
+ /** 一行摘要:把 style 串翻成人话,给 notes / render 用(只报与缺省不同的部分)。 */
502
+ function styleNote(style) {
503
+ const bits = []
504
+ const dash = dashFromStyle(style)
505
+ if (dash !== 'solid') bits.push(dash)
506
+ const arrow = arrowFromStyle(style)
507
+ if (arrow === 'both') bits.push('both arrows')
508
+ else if (arrow === 'none') bits.push('no arrow')
509
+ else if (arrow === 'start') bits.push('reverse arrow')
510
+ const name = colorNameFromStyle(style)
511
+ if (name !== null && name !== 'plain') bits.push(name)
512
+ const exit = sideFromStyle(style, 'source')
513
+ const entry = sideFromStyle(style, 'target')
514
+ if (exit !== null || entry !== null) bits.push((exit === null ? '?' : exit) + '->' + (entry === null ? '?' : entry))
515
+ const jetty = jettyFromStyle(style)
516
+ if (jetty !== null) bits.push('jetty ' + jetty)
517
+ if (edgeStyleValueFromStyle(style) === 'none') bits.push('straight')
518
+ if (avoidFromStyle(style)) bits.push('avoid')
519
+ return bits.length === 0 ? '' : ' [' + bits.join(', ') + ']'
520
+ }
521
+
522
+ /**
523
+ * 施加 ops。语义校验在这里:任何指向不存在节点的边、任何未知 id,都直接抛错并列出已知节点,
524
+ * 于是失败发生在写盘之前 —— 不会画出半张烂图。
525
+ */
526
+ function applyOps(doc, ops, out) {
527
+ const notes = []
528
+ /**
529
+ * 本次调用里的**别名**(`addNode`/`addEdge` 的 `as`)。
530
+ *
531
+ * 为什么要有:id 省略时是自动分配的(n7、e3…),模型在**同一个 ops 数组**里没法引用
532
+ * "刚建的那个" —— 只能猜下一个编号;画布上已经有 n1..n5 时就猜错,只能等报错再重试。
533
+ * 有了别名,`{op:'addNode', label:'开始', as:'start'}` 之后就能直接 `{op:'addEdge', from:'start'}`。
534
+ */
535
+ const aliases = new Map()
536
+ /**
537
+ * 本次调用真的建了/改了/删了哪些 id。
538
+ *
539
+ * 以前只有 summary 文本("+ node n7 ...")—— 模型要用这个 id 就得去解析自然语言。
540
+ * 现在结构化回给它(`diagram_apply` 的 output schema 里有 created/changed/removed)。
541
+ */
542
+ const created = []
543
+ const changed = []
544
+ const removed = []
545
+ function recordCreated(id, type, label) {
546
+ for (let i = 0; i < created.length; i += 1) if (created[i].id === id) return
547
+ created.push(typeof label === 'string' && label.length > 0 ? { id: id, type: type, label: label } : { id: id, type: type })
548
+ }
549
+ function recordChanged(id) {
550
+ if (changed.indexOf(id) < 0) changed.push(id)
551
+ }
552
+ function findNode(id) {
553
+ for (let i = 0; i < doc.nodes.length; i += 1) if (doc.nodes[i].id === id) return i
554
+ return -1
555
+ }
556
+ function findEdge(id) {
557
+ for (let i = 0; i < doc.edges.length; i += 1) if (doc.edges[i].id === id) return i
558
+ return -1
559
+ }
560
+ /** 别名先解析成真 id(别名指"这次调用里刚建的那个",不指别的东西)。 */
561
+ function resolveId(raw) {
562
+ return typeof raw === 'string' && aliases.has(raw) ? aliases.get(raw) : raw
563
+ }
564
+ function known() {
565
+ const ids = []
566
+ for (let i = 0; i < doc.nodes.length; i += 1) ids.push(doc.nodes[i].id)
567
+ const base = ids.length === 0 ? '(none)' : ids.join(', ')
568
+ if (aliases.size === 0) return base
569
+ const pairs = []
570
+ aliases.forEach((id, name) => pairs.push(name + '→' + id))
571
+ return base + '(本次调用里的别名:' + pairs.join(', ') + ')'
572
+ }
573
+ /** 只涉及连线的 op 报错时列边不列节点(拿节点 id 去重接边时,看节点列表没有帮助)。 */
574
+ function knownEdges() {
575
+ const ids = []
576
+ for (let i = 0; i < doc.edges.length; i += 1) ids.push(doc.edges[i].id)
577
+ return ids.length === 0 ? '(none)' : ids.join(', ')
578
+ }
579
+ /**
580
+ * `ids: [...]` 与 `id` 两种写法归一成一个数组(批量改样式/改标签/移动/删除都要)。
581
+ *
582
+ * 为什么值得有:用户说"把这几个换成绿色"时,AI 看到的是 12 个 id —— 逐个发 op 只是
583
+ * 让模型多写 11 遍同样的参数,多一份写错的机会。
584
+ */
585
+ function idList(op, index, what) { if (Array.isArray(op.ids) && op.ids.length > 0) {
586
+ const ids = []
587
+ for (let k = 0; k < op.ids.length; k += 1) {
588
+ const v = op.ids[k]
589
+ if (typeof v !== 'string' || v.length === 0) throw new Error('ops[' + index + '] ' + what + ': "ids" must be non-empty strings')
590
+ ids.push(resolveId(v))
591
+ }
592
+ if (op.id !== undefined) throw new Error('ops[' + index + '] ' + what + ': give either "id" or "ids", not both')
593
+ return ids
594
+ }
595
+ const one = typeof op.id === 'string' && op.id.length > 0 ? resolveId(op.id) : undefined
596
+ if (one === undefined) throw new Error('ops[' + index + '] ' + what + ' needs string "id" or string array "ids"')
597
+ return [one]
598
+ }
599
+
600
+ function layerList() {
601
+ return Array.isArray(doc.layers) ? doc.layers : []
602
+ }
603
+ /** 报错时把可选项列出来(层名可能为空,那就报 id)。 */
604
+ function layerDescription() {
605
+ const list = layerList()
606
+ if (list.length === 0) return '(没有图层信息)'
607
+ const parts = []
608
+ for (let i = 0; i < list.length; i += 1) {
609
+ const l = list[i]
610
+ const name = typeof l.name === 'string' && l.name.length > 0 ? l.name : '(无名)'
611
+ parts.push(name + '#' + String(l.id) + (l.visible === false ? ' 🚫' : ''))
612
+ }
613
+ return parts.join(', ')
614
+ }
615
+ /**
616
+ * 把"层 id 或层名"解析成层 id。
617
+ *
618
+ * 为什么两种都收:`diagram_read` 回的是层表(名字 + id),用户说"放到'标注'层"时模型手里
619
+ * 只有名字,而文件里挂单元用的是 id。别名(`addLayer … as:"x"`)也在这里解析。
620
+ */
621
+ function resolveLayerRef(raw, where) {
622
+ const ref = typeof raw === 'string' && aliases.has(raw) ? aliases.get(raw) : raw
623
+ const list = layerList()
624
+ if (list.length === 0) throw new Error(where + ': 这份文档没有图层信息(layers 为空)')
625
+ for (let i = 0; i < list.length; i += 1) if (String(list[i].id) === ref) return String(list[i].id)
626
+ for (let i = 0; i < list.length; i += 1) {
627
+ const name = typeof list[i].name === 'string' ? list[i].name : ''
628
+ if (name.length > 0 && name === ref) return String(list[i].id)
629
+ }
630
+ throw new Error(where + ': unknown layer "' + ref + '". Layers: ' + layerDescription())
631
+ }
632
+ /** 别名注册(addNode/addEdge/addLayer/duplicate 共用):撞已有 id、已有层名、已用别名都直接报错。 */
633
+ function registerAlias(name, id, where) {
634
+ if (typeof name !== 'string' || name.length === 0) return
635
+ if (aliases.has(name)) throw new Error(where + ': alias "' + name + '" is already used by ' + aliases.get(name))
636
+ if (findNode(name) >= 0 || findEdge(name) >= 0) throw new Error(where + ': alias "' + name + '" collides with an existing id')
637
+ const list = layerList()
638
+ for (let i = 0; i < list.length; i += 1) {
639
+ const layerName = typeof list[i].name === 'string' ? list[i].name : ''
640
+ if (String(list[i].id) === name || (layerName.length > 0 && layerName === name)) {
641
+ throw new Error(where + ': alias "' + name + '" collides with a layer')
642
+ }
643
+ }
644
+ aliases.set(name, id)
645
+ }
646
+
647
+ for (let i = 0; i < ops.length; i += 1) {
648
+ const op = ops[i]
649
+ if (op === null || typeof op !== 'object' || Array.isArray(op)) {
650
+ throw new Error('ops[' + i + '] must be an object like { op: "addNode", label: "..." }')
651
+ }
652
+ const kind = op.op
653
+
654
+ if (kind === 'addNode') {
655
+ const label = typeof op.label === 'string' && op.label.length > 0 ? op.label : undefined
656
+ if (label === undefined) throw new Error('ops[' + i + '] addNode needs a non-empty string "label"')
657
+ let id = typeof op.id === 'string' && op.id.length > 0 ? op.id : undefined
658
+ if (id !== undefined && findNode(id) >= 0) {
659
+ throw new Error('ops[' + i + '] addNode: node "' + id + '" already exists. Use setLabel to change it, or omit "id" to auto-allocate one.')
660
+ }
661
+ if (id === undefined) id = nextId(doc.nodes, 'n')
662
+ // 别名:给这个名字之后就能在同一个 ops 数组里引用它(见 applyOps 开头的注释)。
663
+ registerAlias(op.as, id, 'ops[' + i + '] addNode')
664
+ const node = {
665
+ id: id,
666
+ label: label,
667
+ // 形状/配色是糖,落盘只有 style 键;不给就是 drawio 缺省(空串 = 普通矩形 + 缺省配色)。
668
+ style: nodeStyleFromOp(DEFAULT_NODE_STYLE, op, 'ops[' + i + '] addNode'),
669
+ w: numberOr(op.w, estimateWidth(label)),
670
+ h: numberOr(op.h, DEFAULT_H),
671
+ }
672
+ if (typeof op.layer === 'string' && op.layer.length > 0) node.layer = resolveLayerRef(op.layer, 'ops[' + i + '] addNode layer')
673
+ // 关键:只有显式给了坐标才写入。缺省写 (0,0) 会让 placeMissing 以为"坐标齐全"而不补位,
674
+ // 新节点就会堆在原点压住别人。
675
+ if (has(op, 'x')) node.x = numberOr(op.x, 0)
676
+ if (has(op, 'y')) node.y = numberOr(op.y, 0)
677
+ doc.nodes.push(node)
678
+ recordCreated(id, 'node', label)
679
+ notes.push('+ node ' + id + ' "' + label + '"' + (has(op, 'x') || has(op, 'y') ? ' at explicit coords' : ' (position pending)'))
680
+ continue
681
+ }
682
+
683
+ if (kind === 'addEdge') {
684
+ const from = typeof op.from === 'string' ? resolveId(op.from) : undefined
685
+ const to = typeof op.to === 'string' ? resolveId(op.to) : undefined
686
+ // **独立线**:drawio 允许一条边的两端都是自由点(几何里的 sourcePoint/targetPoint),
687
+ // 于是"先画一条线、再决定接哪儿"是合法形态 —— 线可以完全独立于节点存在。
688
+ // 糖名用 fromPoint/toPoint(跟 from/to 对齐),模型名 sourcePoint/targetPoint 也认。
689
+ const fromPoint = edgePointFromOp(op.fromPoint !== undefined ? op.fromPoint : op.sourcePoint, 'ops[' + i + '] addEdge fromPoint')
690
+ const toPoint = edgePointFromOp(op.toPoint !== undefined ? op.toPoint : op.targetPoint, 'ops[' + i + '] addEdge toPoint')
691
+ if (from === undefined && fromPoint === null) throw new Error('ops[' + i + '] addEdge needs "from" (node id) or "fromPoint" (absolute point)')
692
+ if (to === undefined && toPoint === null) throw new Error('ops[' + i + '] addEdge needs "to" (node id) or "toPoint" (absolute point)')
693
+ if (from !== undefined && findNode(from) < 0) throw new Error('ops[' + i + '] addEdge: unknown "from" node "' + from + '". Known nodes: ' + known())
694
+ if (to !== undefined && findNode(to) < 0) throw new Error('ops[' + i + '] addEdge: unknown "to" node "' + to + '". Known nodes: ' + known())
695
+ let id = typeof op.id === 'string' && op.id.length > 0 ? op.id : undefined
696
+ if (id !== undefined && findEdge(id) >= 0) throw new Error('ops[' + i + '] addEdge: edge "' + id + '" already exists')
697
+ if (id === undefined) id = nextId(doc.edges, 'e')
698
+ if (typeof op.as === 'string' && op.as.length > 0) {
699
+ registerAlias(op.as, id, 'ops[' + i + '] addEdge')
700
+ }
701
+ const edge = { id: id }
702
+ // 新单元进哪一层:不给就由写回按缺省层处理(宿主看不到客户端的"当前层",
703
+ // 所以 AI 想让它进某一层必须显式说 —— 层 id 或层名都收)。
704
+ if (typeof op.layer === 'string' && op.layer.length > 0) edge.layer = resolveLayerRef(op.layer, 'ops[' + i + '] addEdge layer')
705
+ if (from !== undefined) edge.from = from
706
+ if (to !== undefined) edge.to = to
707
+ // 自由点只在那一端**没有真实顶点**时生效(drawio 语义,与内核的 edgeFreePoint 一致)。
708
+ if (from === undefined && fromPoint !== null) edge.sourcePoint = fromPoint
709
+ if (to === undefined && toPoint !== null) edge.targetPoint = toPoint
710
+ if (typeof op.label === 'string' && op.label.length > 0) edge.label = op.label
711
+ // 建边时就能带上画法:AI 想表达"这是一条异步/可选依赖"时,
712
+ // 不该被迫先 addEdge 再补一次 setStyle(两次写盘、两次往返)。
713
+ edge.style = edgeStyleFromOp(DEFAULT_EDGE_STYLE, op, 'ops[' + i + '] addEdge')
714
+ // 线型是"语义 + 几何"两件事:直线不带折点(新边本来就没有);曲线要看得见就得有个中点
715
+ // (见 arcWaypointFor —— 两点直连的曲线在 drawio 里会退化成一条直线)。
716
+ if (has(op, 'line') && normalizeLineKind(op.line, 'ops[' + i + '] addEdge') === 'curved') {
717
+ const bow = arcWaypointFor(doc, edge)
718
+ if (bow !== null) edge.points = [bow]
719
+ }
720
+ doc.edges.push(edge)
721
+ recordCreated(id, 'edge', typeof edge.label === 'string' ? edge.label : undefined)
722
+ const head = from !== undefined ? from : '(free ' + fromPoint.x + ',' + fromPoint.y + ')'
723
+ const tail = to !== undefined ? to : '(free ' + toPoint.x + ',' + toPoint.y + ')'
724
+ notes.push('+ edge ' + id + ' ' + head + ' -> ' + tail + styleNote(edge.style))
725
+ continue
726
+ }
727
+
728
+ if (kind === 'duplicate') {
729
+ // 复制一组单元(Ctrl+C/V 的工具版):**新 id、整体平移**,而折点、端点约束、标签位置、
730
+ // 自定义数据全都照旧带过去。
731
+ //
732
+ // 以前 AI 想"再来一份"只能重新 addNode + addEdge —— 折点、端点约束、label 位置全丢,
733
+ // 而且新边还得自己重连一遍。
734
+ const ids = idList(op, i, 'duplicate')
735
+ // 与画布上的 Ctrl+V 同一套位移:两格(2 × 10px),不然复制出来的会正好压在原图上。
736
+ const dx = Number.isFinite(Number(op.dx)) ? Number(op.dx) : DUPLICATE_OFFSET
737
+ const dy = Number.isFinite(Number(op.dy)) ? Number(op.dy) : DUPLICATE_OFFSET
738
+ // 默认把"两端都在被复制节点之间"的边一起复制 —— 用户说"复制这个子图"时指的就是它;
739
+ // withEdges:false 只要节点。
740
+ const withEdges = op.withEdges !== false
741
+ const layer = typeof op.layer === 'string' && op.layer.length > 0 ? resolveLayerRef(op.layer, 'ops[' + i + '] duplicate layer') : undefined
742
+ const srcNodes = []
743
+ const srcEdges = []
744
+ for (let k = 0; k < ids.length; k += 1) {
745
+ const id = ids[k]
746
+ const ni = findNode(id)
747
+ if (ni >= 0) {
748
+ srcNodes.push(doc.nodes[ni])
749
+ continue
750
+ }
751
+ const ei = findEdge(id)
752
+ if (ei >= 0) {
753
+ srcEdges.push(doc.edges[ei])
754
+ continue
755
+ }
756
+ throw new Error('ops[' + i + '] duplicate: unknown id "' + id + '". Known nodes: ' + known())
757
+ }
758
+ if (typeof op.as === 'string' && op.as.length > 0 && srcNodes.length !== 1) {
759
+ throw new Error('ops[' + i + '] duplicate: "as" 只在复制**单个节点**时可用(一组没有唯一的新 id 可指)')
760
+ }
761
+ if (srcNodes.length === 0) {
762
+ // 只复制连线:接在节点上的边不行 —— 新边会继续指着**原来的**节点,那不是"复制"。
763
+ for (let k = 0; k < srcEdges.length; k += 1) {
764
+ const e = srcEdges[k]
765
+ if (typeof e.from === 'string' && typeof e.to === 'string') {
766
+ throw new Error(
767
+ 'ops[' + i + '] duplicate: edge "' + e.id + '" 两端都接在节点上 —— 把它的两端节点一起写进 ids(否则新边还指着原来的节点)',
768
+ )
769
+ }
770
+ }
771
+ }
772
+ const remap = {}
773
+ for (let k = 0; k < srcNodes.length; k += 1) {
774
+ const src = srcNodes[k]
775
+ const id = nextId(doc.nodes, 'n')
776
+ remap[String(src.id)] = id
777
+ // 整份浅拷 + 改写 id 与新位置:label/style/w/h/data/layer 都在里面。
778
+ const copy = Object.assign({}, src, {
779
+ id: id,
780
+ x: Math.round(numberOr(src.x, 0) + dx),
781
+ y: Math.round(numberOr(src.y, 0) + dy),
782
+ })
783
+ if (layer !== undefined) copy.layer = layer
784
+ doc.nodes.push(copy)
785
+ recordCreated(id, 'node', typeof copy.label === 'string' ? copy.label : undefined)
786
+ notes.push('+ node ' + id + '(复制自 ' + src.id + ')')
787
+ }
788
+ const wantEdges = srcEdges.slice()
789
+ if (withEdges && srcNodes.length > 0) {
790
+ for (let k = 0; k < doc.edges.length; k += 1) {
791
+ const e = doc.edges[k]
792
+ // 两端都在新节点里就带上(自环 from === to 也自然算在内)
793
+ if (remap[String(e.from)] === undefined || remap[String(e.to)] === undefined) continue
794
+ let already = false
795
+ for (let j = 0; j < wantEdges.length; j += 1) if (wantEdges[j].id === e.id) already = true
796
+ if (already === false) wantEdges.push(e)
797
+ }
798
+ }
799
+ for (let k = 0; k < wantEdges.length; k += 1) {
800
+ const src = wantEdges[k]
801
+ const from = src.from === undefined ? undefined : remap[String(src.from)]
802
+ const to = src.to === undefined ? undefined : remap[String(src.to)]
803
+ if (src.from !== undefined && from === undefined) {
804
+ throw new Error('ops[' + i + '] duplicate: edge "' + src.id + '" 的起点 ' + src.from + ' 不在被复制的节点里(把两端一起复制)')
805
+ }
806
+ if (src.to !== undefined && to === undefined) {
807
+ throw new Error('ops[' + i + '] duplicate: edge "' + src.id + '" 的终点 ' + src.to + ' 不在被复制的节点里(把两端一起复制)')
808
+ }
809
+ const id = nextId(doc.edges, 'e')
810
+ const copy = Object.assign({}, src, { id: id })
811
+ if (from !== undefined) copy.from = from
812
+ if (to !== undefined) copy.to = to
813
+ if (layer !== undefined) copy.layer = layer
814
+ // 折点与自由端点**深拷**并跟着位移:共用数组是这类复制最经典的坑
815
+ //(改一份另一份跟着变,客户端 pasteInto 里也专门盯着它)。
816
+ if (Array.isArray(src.points)) copy.points = src.points.map((p) => ({ x: Math.round(p.x + dx), y: Math.round(p.y + dy) }))
817
+ if (src.sourcePoint !== undefined && src.sourcePoint !== null) {
818
+ copy.sourcePoint = { x: Math.round(src.sourcePoint.x + dx), y: Math.round(src.sourcePoint.y + dy) }
819
+ }
820
+ if (src.targetPoint !== undefined && src.targetPoint !== null) {
821
+ copy.targetPoint = { x: Math.round(src.targetPoint.x + dx), y: Math.round(src.targetPoint.y + dy) }
822
+ }
823
+ doc.edges.push(copy)
824
+ recordCreated(id, 'edge', typeof copy.label === 'string' ? copy.label : undefined)
825
+ notes.push('+ edge ' + id + '(复制自 ' + src.id + ')')
826
+ }
827
+ if (typeof op.as === 'string' && op.as.length > 0 && srcNodes.length === 1) {
828
+ registerAlias(op.as, remap[String(srcNodes[0].id)], 'ops[' + i + '] duplicate')
829
+ }
830
+ continue
831
+ }
832
+
833
+ if (kind === 'move') {
834
+ const ids = idList(op, i, 'move')
835
+ const hasX = Number.isFinite(Number(op.x))
836
+ const hasY = Number.isFinite(Number(op.y))
837
+ const dx = Number.isFinite(Number(op.dx)) ? Number(op.dx) : 0
838
+ const dy = Number.isFinite(Number(op.dy)) ? Number(op.dy) : 0
839
+ if (hasX === false && hasY === false && dx === 0 && dy === 0) {
840
+ throw new Error('ops[' + i + '] move needs "x"/"y" (absolute) or a non-zero "dx"/"dy"')
841
+ }
842
+ // 批量移动只认相对位移:给一组 id + 一个绝对 x/y 等于"把它们全叠到同一个点上",
843
+ // 几乎不可能是本意(那是 N 个节点压成一摞),所以直接拒绝而不是猜。
844
+ if (ids.length > 1 && (hasX || hasY)) {
845
+ throw new Error('ops[' + i + '] move: with "ids" use "dx"/"dy" (one absolute x/y would stack them all on the same point)')
846
+ }
847
+ for (let k = 0; k < ids.length; k += 1) {
848
+ const id = ids[k]
849
+ // 节点:绝对坐标(给 x/y)或相对位移(给 dx/dy)都行。
850
+ // 以前只能"删掉重画"来改位置 —— 那会丢 id、丢边上的端点约束与折点,是实打实的损失。
851
+ const ni = findNode(id)
852
+ if (ni >= 0) {
853
+ const n = doc.nodes[ni]
854
+ n.x = Math.round(hasX ? Number(op.x) : numberOr(n.x, 0) + dx)
855
+ n.y = Math.round(hasY ? Number(op.y) : numberOr(n.y, 0) + dy)
856
+ recordChanged(id)
857
+ notes.push('~ node ' + id + ' @' + n.x + ',' + n.y)
858
+ continue
859
+ }
860
+ // 连线:整体平移它**自己的**几何(折点 + 悬空端的自由点),
861
+ // 与画布上"拖整组"完全同一套语义(端点接在节点上时由节点决定,不受影响)。
862
+ const ei = findEdge(id)
863
+ if (ei < 0) throw new Error('ops[' + i + '] move: unknown node or edge "' + id + '". Known nodes: ' + known())
864
+ if (hasX || hasY) throw new Error('ops[' + i + '] move: an edge can only be moved by "dx"/"dy" (its geometry is relative)')
865
+ const edge = doc.edges[ei]
866
+ let touched = 0
867
+ if (Array.isArray(edge.points)) {
868
+ edge.points = edge.points.map((p) => ({ x: Math.round(p.x + dx), y: Math.round(p.y + dy) }))
869
+ touched += 1
870
+ }
871
+ if (edge.sourcePoint !== undefined && edge.sourcePoint !== null) {
872
+ edge.sourcePoint = { x: Math.round(edge.sourcePoint.x + dx), y: Math.round(edge.sourcePoint.y + dy) }
873
+ touched += 1
874
+ }
875
+ if (edge.targetPoint !== undefined && edge.targetPoint !== null) {
876
+ edge.targetPoint = { x: Math.round(edge.targetPoint.x + dx), y: Math.round(edge.targetPoint.y + dy) }
877
+ touched += 1
878
+ }
879
+ recordChanged(id)
880
+ notes.push('~ edge ' + id + ' moved by ' + dx + ',' + dy + (touched === 0 ? '(它两端都接在节点上、也没有折点,等于没有可平移的几何)' : ''))
881
+ }
882
+ continue
883
+ }
884
+
885
+ if (kind === 'order') {
886
+ // 顺序(z-order):模型数组里的先后 = 画布上的覆盖顺序 = 文件里单元的先后。
887
+ // 与画布右键的「置顶/上移/下移/置底」完全同一套语义(见客户端 reorderItem)。
888
+ //
889
+ // 为什么 AI 需要它:**所有边都画在所有节点下面**(画布目前的层叠限制),
890
+ // "让这条边压在那几个节点上面"只能靠图层,而图层 ops 还没有 —— 至少先把
891
+ // 同一类之间的顺序(谁压谁)交给 AI。
892
+ const ids = idList(op, i, 'order')
893
+ const to = typeof op.to === 'string' ? op.to : undefined
894
+ if (ORDER_MODES.indexOf(to) < 0) {
895
+ throw new Error('ops[' + i + '] order needs "to": ' + ORDER_MODES.join('|') + '(后面的在上面)')
896
+ }
897
+ if (ids.length > 1 && to !== 'front' && to !== 'back') {
898
+ throw new Error('ops[' + i + '] order: 一组只能用 front/back(up/down 是"挪一层",一组里谁先谁后说不清)')
899
+ }
900
+ // 节点与连线是**两条**序列(文件里它们可能交错,但模型与画布都按各自的序列层叠)。
901
+ let target = null
902
+ for (let k = 0; k < ids.length; k += 1) {
903
+ const id = ids[k]
904
+ const ni = findNode(id)
905
+ const ei = findEdge(id)
906
+ if (ni < 0 && ei < 0) throw new Error('ops[' + i + '] order: unknown id "' + id + '". Known nodes: ' + known())
907
+ const which = ni >= 0 ? 'node' : 'edge'
908
+ if (target === null) target = which
909
+ else if (target !== which) throw new Error('ops[' + i + '] order: 一次只能排同一类单元(节点和连线是两条序列)')
910
+ }
911
+ const list = target === 'node' ? doc.nodes : doc.edges
912
+ if (to === 'up' || to === 'down') {
913
+ const id = ids[0]
914
+ let at = -1
915
+ for (let k = 0; k < list.length; k += 1) {
916
+ if (list[k].id === id) {
917
+ at = k
918
+ break
919
+ }
920
+ }
921
+ const item = list.splice(at, 1)[0]
922
+ // 与客户端同一套:at 是"拔出来之前"的位置,插回时按它 +1 / -1 并夹在范围内。
923
+ if (to === 'up') list.splice(Math.min(at + 1, list.length), 0, item)
924
+ else list.splice(Math.max(at - 1, 0), 0, item)
925
+ } else {
926
+ // 一组:先按它们在序列里的原顺序取出来,再整体放到头/尾 —— 组内相对顺序不变
927
+ //("把这三条边置顶"不该顺手把它们互换个位置)。
928
+ const picked = []
929
+ for (let k = list.length - 1; k >= 0; k -= 1) if (ids.indexOf(list[k].id) >= 0) picked.unshift(list.splice(k, 1)[0])
930
+ if (to === 'front') for (let k = 0; k < picked.length; k += 1) list.push(picked[k])
931
+ else for (let k = picked.length - 1; k >= 0; k -= 1) list.unshift(picked[k])
932
+ for (let k = 0; k < ids.length; k += 1) recordChanged(ids[k])
933
+ notes.push('~ order: ' + ids.join(', ') + ' → ' + to + '(' + (target === 'node' ? '节点' : '连线') + '共 ' + list.length + ' 个)')
934
+ continue
935
+ }
936
+ recordChanged(ids[0])
937
+ notes.push('~ order: ' + ids[0] + ' → ' + to)
938
+ continue
939
+ }
940
+
941
+ if (kind === 'setEdge') {
942
+ // 重接一条边:只改 source/target(或某一端的自由点),**其它一切照旧** ——
943
+ // id、标签、标签位置、折点、端点约束(exitX/entryX)都留着。
944
+ // 以前只能 remove + addEdge 重画,那会把这些全丢掉。
945
+ const ids = idList(op, i, 'setEdge')
946
+ if (ids.length !== 1) throw new Error('ops[' + i + '] setEdge: 一次改一条边,给单个 "id"')
947
+ const id = ids[0]
948
+ const ei = findEdge(id)
949
+ if (ei < 0) throw new Error('ops[' + i + '] setEdge: unknown edge "' + id + '". Known edges: ' + knownEdges())
950
+ const edge = doc.edges[ei]
951
+ let touched = 0
952
+ // 两端各处理一次:`from`(节点 id)/`fromPoint`(绝对点)二选一,`to`/`toPoint` 同理。
953
+ for (let end = 0; end < 2; end += 1) {
954
+ const isSource = end === 0
955
+ const nodeKey = isSource ? 'from' : 'to'
956
+ const pointKey = isSource ? 'fromPoint' : 'toPoint'
957
+ const pointAlias = isSource ? 'sourcePoint' : 'targetPoint'
958
+ const freeKey = isSource ? 'sourcePoint' : 'targetPoint'
959
+ const rawPoint = op[pointKey] !== undefined ? op[pointKey] : op[pointAlias]
960
+ const nodeDeclared = has(op, nodeKey)
961
+ const rawNode = op[nodeKey]
962
+ // 明确写 `from: null` / `to: null` = "这一端不接节点"(模型表达"脱开"的自然写法)。
963
+ const hasNode = nodeDeclared && rawNode !== null
964
+ const hasPoint = rawPoint !== undefined && rawPoint !== null
965
+ if (nodeDeclared === true && rawNode === null && hasPoint === false) {
966
+ throw new Error(
967
+ 'ops[' + i + '] setEdge: ' + nodeKey + ' 给 null 表示"这一端不接节点",那就要一起给 ' + pointKey + '(自由端得有坐标)',
968
+ )
969
+ }
970
+ if (hasNode === false && hasPoint === false) continue
971
+ if (hasNode === true && hasPoint === true) {
972
+ throw new Error('ops[' + i + '] setEdge: ' + nodeKey + ' 与 ' + pointKey + ' 只能给一个(接节点还是接一个自由点)')
973
+ }
974
+ if (hasNode === true) {
975
+ const ref = rawNode
976
+ if (typeof ref !== 'string' || ref.length === 0) {
977
+ throw new Error('ops[' + i + '] setEdge: ' + nodeKey + ' 要么是节点 id,要么给 null + ' + pointKey + '(脱开节点需要有坐标)')
978
+ }
979
+ const nodeId = resolveId(ref)
980
+ if (findNode(nodeId) < 0) throw new Error('ops[' + i + '] setEdge: unknown "' + nodeKey + '" node "' + nodeId + '". Known nodes: ' + known())
981
+ if (isSource) edge.from = nodeId
982
+ else edge.to = nodeId
983
+ // 那一端接上节点之后,旧的自由点必须删掉:drawio 的规矩是"有真实顶点时忽略自由点",
984
+ // 留着不会显示,但会在文件里留一条过期几何,下次换个工具打开就是脏数据。
985
+ delete edge[freeKey]
986
+ touched += 1
987
+ continue
988
+ }
989
+ const point = edgePointFromOp(rawPoint, 'ops[' + i + '] setEdge ' + pointKey)
990
+ if (point === null) throw new Error('ops[' + i + '] setEdge: ' + pointKey + ' 需要 {x, y}')
991
+ delete edge[nodeKey]
992
+ edge[freeKey] = point
993
+ touched += 1
994
+ }
995
+ if (touched === 0) throw new Error('ops[' + i + '] setEdge needs "from"/"to" (node id) or "fromPoint"/"toPoint" (absolute point)')
996
+ const head = typeof edge.from === 'string' ? edge.from : '(free ' + edge.sourcePoint.x + ',' + edge.sourcePoint.y + ')'
997
+ const tail = typeof edge.to === 'string' ? edge.to : '(free ' + edge.targetPoint.x + ',' + edge.targetPoint.y + ')'
998
+ const kept = []
999
+ if (Array.isArray(edge.points) && edge.points.length > 0) kept.push(edge.points.length + ' 个折点')
1000
+ if (typeof edge.label === 'string' && edge.label.length > 0) kept.push('标签')
1001
+ recordChanged(id)
1002
+ notes.push('~ edge ' + id + ' 改接为 ' + head + ' -> ' + tail + (kept.length > 0 ? '(' + kept.join('、') + '照旧)' : ''))
1003
+ continue
1004
+ }
1005
+
1006
+ if (kind === 'setLabelPos') {
1007
+ // 边上的文字在**画布上的位置**。drawio 存法:边几何的 x = 沿边比例(-1..1,0 = 弧长中点)、
1008
+ // y = 垂直偏移 px、`<mxPoint as="offset">` = 取整剩下的零头(见 mxfile 的 relativeLabelPos)。
1009
+ //
1010
+ // 客户端能按住线上的字拖,AI 却既看不见位置、也改不了 —— "文字压住线"这种小毛病
1011
+ // 原来只能让用户自己动手。现在:read 回 x/y,改给 setLabelPos。
1012
+ const id = typeof op.id === 'string' && op.id.length > 0 ? resolveId(op.id) : undefined
1013
+ if (id === undefined) throw new Error('ops[' + i + '] setLabelPos needs string "id"')
1014
+ const ei = findEdge(id)
1015
+ if (ei < 0) throw new Error('ops[' + i + '] setLabelPos: unknown edge "' + id + '". Known edges: ' + knownEdges())
1016
+ const edge = doc.edges[ei]
1017
+ // center:放回弧长中点(= 删掉位置,与画布右键「标签居中」同一件事)。
1018
+ if (op.center === true) {
1019
+ delete edge.labelX
1020
+ delete edge.labelY
1021
+ delete edge.labelOffsetX
1022
+ delete edge.labelOffsetY
1023
+ recordChanged(id)
1024
+ notes.push('~ edge ' + id + ' 标签放回弧长中点')
1025
+ continue
1026
+ }
1027
+ const hasX = Number.isFinite(Number(op.x))
1028
+ const hasY = Number.isFinite(Number(op.y))
1029
+ const dx = Number.isFinite(Number(op.dx)) ? Number(op.dx) : 0
1030
+ const dy = Number.isFinite(Number(op.dy)) ? Number(op.dy) : 0
1031
+ const hasOffX = Number.isFinite(Number(op.offsetX))
1032
+ const hasOffY = Number.isFinite(Number(op.offsetY))
1033
+ if (hasX === false && hasY === false && dx === 0 && dy === 0 && hasOffX === false && hasOffY === false) {
1034
+ throw new Error('ops[' + i + '] setLabelPos needs "x"/"y" (absolute, relative to the path) or "dx"/"dy" (nudge) or center:true')
1035
+ }
1036
+ const curX = Number.isFinite(Number(edge.labelX)) ? Number(edge.labelX) : 0
1037
+ const curY = Number.isFinite(Number(edge.labelY)) ? Number(edge.labelY) : 0
1038
+ const nextX = hasX ? Number(op.x) : curX + dx
1039
+ if (nextX < -1 || nextX > 1) {
1040
+ throw new Error('ops[' + i + '] setLabelPos: "x" 是**沿边比例**(-1..1,0 = 中点),要按像素挪动请用 dy')
1041
+ }
1042
+ // x 收四位小数(与客户端拖动时同一套存法);y 取整。
1043
+ edge.labelX = Math.round(nextX * 10000) / 10000
1044
+ edge.labelY = Math.round(hasY ? Number(op.y) : curY + dy)
1045
+ // 零头只在显式给的时候写:它是"客户端拖到某个像素后取整剩下的那点偏移",
1046
+ // 我们按 x/y 设定位置时留着它会把落点带偏(真实位置 = x/y + offset)。
1047
+ if (hasOffX || hasOffY) {
1048
+ const offX = hasOffX ? Math.round(Number(op.offsetX)) : numberOr(edge.labelOffsetX, 0)
1049
+ const offY = hasOffY ? Math.round(Number(op.offsetY)) : numberOr(edge.labelOffsetY, 0)
1050
+ if (offX === 0) delete edge.labelOffsetX
1051
+ else edge.labelOffsetX = offX
1052
+ if (offY === 0) delete edge.labelOffsetY
1053
+ else edge.labelOffsetY = offY
1054
+ } else {
1055
+ delete edge.labelOffsetX
1056
+ delete edge.labelOffsetY
1057
+ }
1058
+ recordChanged(id)
1059
+ notes.push('~ edge ' + id + ' 标签位置 x=' + edge.labelX + '(沿边比例)y=' + edge.labelY + 'px')
1060
+ continue
1061
+ }
1062
+
1063
+ if (kind === 'addLayer') {
1064
+ // 新建一层(加在最上面)。有了它 + setLayer,"把这一组放到新图层"才做得到 ——
1065
+ // 以前 AI 看得到层表却一层都加不了。
1066
+ const list = layerList()
1067
+ const used = {}
1068
+ for (let k = 0; k < list.length; k += 1) used[String(list[k].id)] = true
1069
+ let id = undefined
1070
+ if (typeof op.id === 'string' && op.id.length > 0) {
1071
+ if (used[op.id] === true) throw new Error('ops[' + i + '] addLayer: layer id "' + op.id + '" already exists')
1072
+ id = op.id
1073
+ } else {
1074
+ // 与 drawio 一致地挑一个数字串 id(最小的空缺),避开已用的。
1075
+ let n = 1
1076
+ while (used[String(n)] === true) n += 1
1077
+ id = String(n)
1078
+ }
1079
+ const name = typeof op.name === 'string' ? op.name : ''
1080
+ doc.layers = list.concat([{ id: id, name: name, visible: op.visible !== false, locked: op.locked === true }])
1081
+ registerAlias(op.as, id, 'ops[' + i + '] addLayer')
1082
+ recordCreated(id, 'layer', name)
1083
+ notes.push('+ layer ' + id + (name.length > 0 ? ' "' + name + '"' : '') + '(加在最上面;新单元要显式写 layer 才进这一层)')
1084
+ continue
1085
+ }
1086
+
1087
+ if (kind === 'setLayer') {
1088
+ // 把已有单元**移到**另一层(层 id 或层名)。客户端 v2 的"把选中移到别的层",
1089
+ // AI 侧就是这一条 —— 隐藏/锁定别的层与它配合才能"只看一部分"。
1090
+ const ids = idList(op, i, 'setLayer')
1091
+ const ref = typeof op.layer === 'string' && op.layer.length > 0 ? op.layer : undefined
1092
+ if (ref === undefined) throw new Error('ops[' + i + '] setLayer needs string "layer"(层 id 或层名)')
1093
+ const layerId = resolveLayerRef(ref, 'ops[' + i + '] setLayer')
1094
+ for (let k = 0; k < ids.length; k += 1) {
1095
+ const id = ids[k]
1096
+ const ni = findNode(id)
1097
+ if (ni >= 0) {
1098
+ doc.nodes[ni].layer = layerId
1099
+ recordChanged(id)
1100
+ continue
1101
+ }
1102
+ const ei = findEdge(id)
1103
+ if (ei >= 0) {
1104
+ doc.edges[ei].layer = layerId
1105
+ recordChanged(id)
1106
+ continue
1107
+ }
1108
+ throw new Error('ops[' + i + '] setLayer: unknown node or edge "' + id + '". Known nodes: ' + known())
1109
+ }
1110
+ notes.push('~ ' + ids.length + ' 个单元移到图层 ' + layerId)
1111
+ continue
1112
+ }
1113
+
1114
+ if (kind === 'setLayerProps') {
1115
+ // 层的名字 / 显示 / 锁定 —— 纯文档状态(隐藏是写进文件的显示状态,不是删除)。
1116
+ const ref = typeof op.layer === 'string' && op.layer.length > 0 ? op.layer : undefined
1117
+ if (ref === undefined) throw new Error('ops[' + i + '] setLayerProps needs string "layer"(层 id 或层名)')
1118
+ const layerId = resolveLayerRef(ref, 'ops[' + i + '] setLayerProps')
1119
+ const list = layerList()
1120
+ let target = null
1121
+ for (let k = 0; k < list.length; k += 1) if (String(list[k].id) === layerId) target = list[k]
1122
+ if (target === null) throw new Error('ops[' + i + '] setLayerProps: 找不到层 ' + layerId)
1123
+ const changedKeys = []
1124
+ if (has(op, 'name')) {
1125
+ target.name = typeof op.name === 'string' ? op.name : ''
1126
+ changedKeys.push('name="' + target.name + '"')
1127
+ }
1128
+ if (has(op, 'visible')) {
1129
+ target.visible = op.visible !== false
1130
+ changedKeys.push(target.visible === false ? '隐藏' : '显示')
1131
+ }
1132
+ if (has(op, 'locked')) {
1133
+ target.locked = op.locked === true
1134
+ changedKeys.push(target.locked === true ? '锁定' : '解锁')
1135
+ }
1136
+ if (changedKeys.length === 0) throw new Error('ops[' + i + '] setLayerProps needs at least one of "name"/"visible"/"locked"')
1137
+ notes.push('~ layer ' + layerId + ' ' + changedKeys.join(' '))
1138
+ continue
1139
+ }
1140
+
1141
+ if (kind === 'export') {
1142
+ // 导出这张画布(svg 落成文件、png 走浏览器下载)。
1143
+ //
1144
+ // 为什么是"请求"而不是当场生成:渲染器在浏览器那一半(正交路由、避让、圆角、断线),
1145
+ // 宿主这边只有文档。所以这里只登记意图,真正的渲染由画布在下一次轮询时完成。
1146
+ const format = typeof op.format === 'string' ? op.format.toLowerCase() : 'svg'
1147
+ if (format !== 'svg' && format !== 'png') {
1148
+ throw new Error('ops[' + i + '] export: "format" 只能是 svg 或 png(默认 svg)')
1149
+ }
1150
+ let name = typeof op.name === 'string' ? op.name.trim() : ''
1151
+ if (name.length > 0) {
1152
+ name = name.replace(/\.(svg|png)$/i, '')
1153
+ // 只收**基名**:导出永远落在 .drawio 旁边,不收路径(想放别处请先让用户另存到那儿)。
1154
+ if (/[\\/:*?"<>|\u0000-\u001f]/.test(name)) {
1155
+ throw new Error('ops[' + i + '] export: "name" 只能是文件基名(不含路径分隔符与非法字符)')
1156
+ }
1157
+ }
1158
+ if (out !== undefined && out !== null) out.export = { format: format, name: name }
1159
+ notes.push('⤓ 已请求导出 ' + format.toUpperCase() + (name.length > 0 ? '(' + name + ')' : '') + ':画布下一次刷新时渲染')
1160
+ continue
1161
+ }
1162
+
1163
+ if (kind === 'highlight') {
1164
+ // **只提示、不改文档**:把几个 id 记成"下次客户端来取时选中它们",
1165
+ // 于是 AI 能说"你看这几个节点"而不用让用户自己找。
1166
+ const ids = Array.isArray(op.ids) ? op.ids.filter((v) => typeof v === 'string' && v.length > 0) : []
1167
+ if (ids.length === 0) throw new Error('ops[' + i + '] highlight needs non-empty string array "ids"')
1168
+ if (out !== undefined && out !== null) out.highlight = ids.slice()
1169
+ notes.push('👁 已请求高亮 ' + ids.length + ' 项(客户端下次刷新时会选中它们)')
1170
+ continue
1171
+ }
1172
+
1173
+ if (kind === 'setLabel') {
1174
+ const label = typeof op.label === 'string' ? op.label : undefined
1175
+ if (label === undefined) throw new Error('ops[' + i + '] setLabel needs string "label"')
1176
+ const ids = idList(op, i, 'setLabel')
1177
+ for (let k = 0; k < ids.length; k += 1) {
1178
+ const id = ids[k]
1179
+ const ni = findNode(id)
1180
+ if (ni >= 0) {
1181
+ doc.nodes[ni].label = label
1182
+ recordChanged(id)
1183
+ notes.push('~ node ' + id + ' label = "' + label + '"')
1184
+ continue
1185
+ }
1186
+ const ei = findEdge(id)
1187
+ if (ei >= 0) {
1188
+ doc.edges[ei].label = label
1189
+ recordChanged(id)
1190
+ notes.push('~ edge ' + id + ' label = "' + label + '"')
1191
+ continue
1192
+ }
1193
+ throw new Error('ops[' + i + '] setLabel: unknown id "' + id + '". Known nodes: ' + known())
1194
+ }
1195
+ continue
1196
+ }
1197
+
1198
+ if (kind === 'setStyle') {
1199
+ const ids = idList(op, i, 'setStyle')
1200
+ for (let k = 0; k < ids.length; k += 1) {
1201
+ const id = ids[k]
1202
+ const ni = findNode(id)
1203
+ if (ni < 0) {
1204
+ // 连线也归 setStyle 管 —— 节点的"画法"是 shape/style,连线的"画法"是线型/箭头/颜色。
1205
+ // 分成两个 op 只是多一条要记的规则,对模型和人都没有好处。
1206
+ const ei = findEdge(id)
1207
+ if (ei < 0) throw new Error('ops[' + i + '] setStyle: unknown node or edge "' + id + '". Known nodes: ' + known())
1208
+ const edge = doc.edges[ei]
1209
+ const where = 'ops[' + i + '] setStyle'
1210
+ // 键级合并:只改点到的键,其余(含认不出的键)原样留着 —— 开放集合的代价与好处都在这。
1211
+ const base = edge.style === undefined ? DEFAULT_EDGE_STYLE : edge.style
1212
+ const next = edgeStyleFromOp(base, op, where)
1213
+ if (next !== base) edge.style = next
1214
+ // 显式清折点(右键"自动路由"与 AI 都能用同一件事)。
1215
+ //
1216
+ // **自由端点不是折点**:一端没有真实顶点时,那个自由点就是这条线的落点。无条件删它,
1217
+ // "两端都自由"的独立线就一个落点都不剩 —— 写回会把它当成"两端都没有落点的边"整条跳过
1218
+ //(实测:画好的一条尾巴就这么没了,notes 里那句"已略过"没人会去看)。
1219
+ // 所以只在那一端**有真实顶点**时才删(那种情况下 drawio 本来就忽略自由点)。
1220
+ if (op.clearPoints === true) {
1221
+ delete edge.points
1222
+ if (typeof edge.from === 'string') delete edge.sourcePoint
1223
+ if (typeof edge.to === 'string') delete edge.targetPoint
1224
+ }
1225
+ // 线型带来的几何后果(与画布右键菜单同一套语义,见客户端的 applyLineKind):
1226
+ // 直线 = 无折点:留着折点就不是直线了;
1227
+ // 曲线 = 把现有折线抹圆,而**两点直连的边**在 drawio 里曲线会退化成直线
1228
+ // (mxPolyline.paintCurvedLine 两点时控制点落在起点上),所以补一个弓形中点。
1229
+ if (has(op, 'line')) {
1230
+ const kind = normalizeLineKind(op.line, where)
1231
+ if (kind === 'straight') delete edge.points
1232
+ else if (kind === 'curved' && (Array.isArray(edge.points) === false || edge.points.length === 0)) {
1233
+ const bow = arcWaypointFor(doc, edge)
1234
+ if (bow !== null) edge.points = [bow]
1235
+ }
1236
+ }
1237
+ recordChanged(id)
1238
+ notes.push('~ edge ' + id + ' style' + styleNote(edge.style === undefined ? base : edge.style))
1239
+ continue
1240
+ }
1241
+ const node = doc.nodes[ni]
1242
+ const nodeBase = node.style === undefined ? DEFAULT_NODE_STYLE : node.style
1243
+ const nextStyle = nodeStyleFromOp(nodeBase, op, 'ops[' + i + '] setStyle')
1244
+ if (nextStyle !== nodeBase) node.style = nextStyle
1245
+ if (has(op, 'w')) node.w = numberOr(op.w, node.w)
1246
+ if (has(op, 'h')) node.h = numberOr(op.h, node.h)
1247
+ recordChanged(id)
1248
+ notes.push('~ node ' + id + ' [' + nodeShapeFromStyle(nextStyle) + ']' + (nextStyle.length === 0 ? ' (默认样式)' : ''))
1249
+ }
1250
+ continue
1251
+ }
1252
+
1253
+ if (kind === 'remove') {
1254
+ const ids = idList(op, i, 'remove')
1255
+ for (let k = 0; k < ids.length; k += 1) {
1256
+ const id = ids[k]
1257
+ const ni = findNode(id)
1258
+ if (ni >= 0) {
1259
+ doc.nodes.splice(ni, 1)
1260
+ const kept = []
1261
+ let dropped = 0
1262
+ for (let j = 0; j < doc.edges.length; j += 1) {
1263
+ const e = doc.edges[j]
1264
+ if (e.from === id || e.to === id) dropped += 1
1265
+ else kept.push(e)
1266
+ }
1267
+ doc.edges = kept
1268
+ if (removed.indexOf(id) < 0) removed.push(id)
1269
+ notes.push('- node ' + id + (dropped > 0 ? ' (and ' + dropped + ' edge(s))' : ''))
1270
+ continue
1271
+ }
1272
+ const ei = findEdge(id)
1273
+ if (ei >= 0) {
1274
+ doc.edges.splice(ei, 1)
1275
+ if (removed.indexOf(id) < 0) removed.push(id)
1276
+ notes.push('- edge ' + id)
1277
+ continue
1278
+ }
1279
+ throw new Error('ops[' + i + '] remove: unknown id "' + id + '". Known nodes: ' + known())
1280
+ }
1281
+ continue
1282
+ }
1283
+
1284
+ throw new Error(
1285
+ 'ops[' + i + '] unknown op ' + JSON.stringify(kind) +
1286
+ '. Supported: addNode, addEdge, setLabel, setLabelPos, setStyle, setEdge, order, duplicate, addLayer, setLayer, setLayerProps, move, remove, highlight',
1287
+ )
1288
+ }
1289
+ // 结构化返回"这次到底动了什么":模型要用新建的 id(或确认改到了谁)时不必解析 summary 文本。
1290
+ if (out !== undefined && out !== null) {
1291
+ out.created = created
1292
+ out.changed = changed
1293
+ out.removed = removed
1294
+ }
1295
+ return notes
1296
+ }
1297
+
1298
+ /** 分层自动布局:DFS 去回边 → 最长路径分层 → 重心排序减交叉 → 逐层居中定位
1299
+ * (树/森林再补一遍"父节点居中于孩子",见末尾那一段)。 */
1300
+ function autoLayout(doc, mode) {
1301
+ const nodes = doc.nodes
1302
+ if (nodes.length === 0) return
1303
+
1304
+ if (mode === 'grid') {
1305
+ const cols = Math.max(1, Math.round(Math.sqrt(nodes.length)))
1306
+ const rows = Math.ceil(nodes.length / cols)
1307
+ const colW = []
1308
+ for (let c = 0; c < cols; c += 1) colW.push(0)
1309
+ const rowH = []
1310
+ for (let r = 0; r < rows; r += 1) rowH.push(0)
1311
+ for (let i = 0; i < nodes.length; i += 1) {
1312
+ const c = i % cols
1313
+ const r = Math.floor(i / cols)
1314
+ const w = numberOr(nodes[i].w, DEFAULT_W)
1315
+ const h = numberOr(nodes[i].h, DEFAULT_H)
1316
+ if (w > colW[c]) colW[c] = w
1317
+ if (h > rowH[r]) rowH[r] = h
1318
+ }
1319
+ const colX = []
1320
+ let px = 0
1321
+ for (let c = 0; c < cols; c += 1) {
1322
+ colX.push(px)
1323
+ px += colW[c] + GAP_CROSS
1324
+ }
1325
+ const rowY = []
1326
+ let py = 0
1327
+ for (let r = 0; r < rows; r += 1) {
1328
+ rowY.push(py)
1329
+ py += rowH[r] + GAP_CROSS
1330
+ }
1331
+ for (let i = 0; i < nodes.length; i += 1) {
1332
+ nodes[i].x = colX[i % cols]
1333
+ nodes[i].y = rowY[Math.floor(i / cols)]
1334
+ }
1335
+ return
1336
+ }
1337
+
1338
+ const index = {}
1339
+ for (let i = 0; i < nodes.length; i += 1) index[nodes[i].id] = i
1340
+
1341
+ const outgoing = {}
1342
+ for (let i = 0; i < doc.edges.length; i += 1) {
1343
+ const e = doc.edges[i]
1344
+ if (index[e.from] === undefined || index[e.to] === undefined) continue
1345
+ if (e.from === e.to) continue
1346
+ const key = typeof e.id === 'string' ? e.id : '#' + i
1347
+ if (outgoing[e.from] === undefined) outgoing[e.from] = []
1348
+ outgoing[e.from].push({ to: e.to, key: key })
1349
+ }
1350
+
1351
+ // 去掉回边,保证分层在含环图上收敛(否则层号会一轮轮涨到爆)。
1352
+ const color = {}
1353
+ const isBack = {}
1354
+ function visit(id) {
1355
+ color[id] = 1
1356
+ const list = outgoing[id] === undefined ? [] : outgoing[id]
1357
+ for (let i = 0; i < list.length; i += 1) {
1358
+ const target = list[i].to
1359
+ if (color[target] === 1) {
1360
+ isBack[list[i].key] = true
1361
+ continue
1362
+ }
1363
+ if (color[target] === undefined) visit(target)
1364
+ }
1365
+ color[id] = 2
1366
+ }
1367
+ for (let i = 0; i < nodes.length; i += 1) if (color[nodes[i].id] === undefined) visit(nodes[i].id)
1368
+
1369
+ const usedEdges = []
1370
+ for (let i = 0; i < doc.edges.length; i += 1) {
1371
+ const e = doc.edges[i]
1372
+ if (index[e.from] === undefined || index[e.to] === undefined) continue
1373
+ if (e.from === e.to) continue
1374
+ const key = typeof e.id === 'string' ? e.id : '#' + i
1375
+ if (isBack[key] === true) continue
1376
+ usedEdges.push(e)
1377
+ }
1378
+
1379
+ const layer = {}
1380
+ for (let i = 0; i < nodes.length; i += 1) layer[nodes[i].id] = 0
1381
+ for (let iter = 0; iter < nodes.length; iter += 1) {
1382
+ let changed = false
1383
+ for (let i = 0; i < usedEdges.length; i += 1) {
1384
+ const e = usedEdges[i]
1385
+ if (layer[e.to] < layer[e.from] + 1) {
1386
+ layer[e.to] = layer[e.from] + 1
1387
+ changed = true
1388
+ }
1389
+ }
1390
+ if (!changed) break
1391
+ }
1392
+
1393
+ let top = 0
1394
+ for (let i = 0; i < nodes.length; i += 1) if (layer[nodes[i].id] > top) top = layer[nodes[i].id]
1395
+ const buckets = []
1396
+ for (let L = 0; L <= top; L += 1) buckets.push([])
1397
+ for (let i = 0; i < nodes.length; i += 1) buckets[layer[nodes[i].id]].push(nodes[i])
1398
+
1399
+ const pos = {}
1400
+ for (let L = 0; L <= top; L += 1) for (let i = 0; i < buckets[L].length; i += 1) pos[buckets[L][i].id] = i
1401
+ const preds = {}
1402
+ for (let i = 0; i < usedEdges.length; i += 1) {
1403
+ const e = usedEdges[i]
1404
+ if (preds[e.to] === undefined) preds[e.to] = []
1405
+ preds[e.to].push(e.from)
1406
+ }
1407
+ for (let sweep = 0; sweep < 4; sweep += 1) {
1408
+ for (let L = 1; L <= top; L += 1) {
1409
+ const row = buckets[L]
1410
+ if (row.length < 2) continue
1411
+ const score = {}
1412
+ for (let i = 0; i < row.length; i += 1) {
1413
+ const id = row[i].id
1414
+ const list = preds[id]
1415
+ let sum = 0
1416
+ let count = 0
1417
+ if (list !== undefined) {
1418
+ for (let k = 0; k < list.length; k += 1) {
1419
+ const p = pos[list[k]]
1420
+ if (p !== undefined) {
1421
+ sum += p
1422
+ count += 1
1423
+ }
1424
+ }
1425
+ }
1426
+ score[id] = count > 0 ? sum / count : pos[id]
1427
+ }
1428
+ row.sort(function (a, b) {
1429
+ const d = score[a.id] - score[b.id]
1430
+ if (d !== 0) return d
1431
+ return pos[a.id] - pos[b.id]
1432
+ })
1433
+ for (let i = 0; i < row.length; i += 1) pos[row[i].id] = i
1434
+ }
1435
+ }
1436
+
1437
+ const horizontal = mode !== 'dagre-tb'
1438
+ const thick = []
1439
+ const cross = []
1440
+ for (let L = 0; L <= top; L += 1) {
1441
+ let t = 0
1442
+ let c = 0
1443
+ for (let i = 0; i < buckets[L].length; i += 1) {
1444
+ const n = buckets[L][i]
1445
+ const w = numberOr(n.w, DEFAULT_W)
1446
+ const h = numberOr(n.h, DEFAULT_H)
1447
+ const primary = horizontal ? w : h
1448
+ const secondary = horizontal ? h : w
1449
+ if (primary > t) t = primary
1450
+ c += secondary + (i > 0 ? GAP_CROSS : 0)
1451
+ }
1452
+ thick.push(t)
1453
+ cross.push(c)
1454
+ }
1455
+ let maxCross = 0
1456
+ for (let L = 0; L <= top; L += 1) if (cross[L] > maxCross) maxCross = cross[L]
1457
+
1458
+ let along = 0
1459
+ for (let L = 0; L <= top; L += 1) {
1460
+ let offset = (maxCross - cross[L]) / 2
1461
+ for (let i = 0; i < buckets[L].length; i += 1) {
1462
+ const n = buckets[L][i]
1463
+ const w = numberOr(n.w, DEFAULT_W)
1464
+ const h = numberOr(n.h, DEFAULT_H)
1465
+ if (horizontal) {
1466
+ n.x = Math.round(along)
1467
+ n.y = Math.round(offset)
1468
+ } else {
1469
+ n.y = Math.round(along)
1470
+ n.x = Math.round(offset)
1471
+ }
1472
+ offset += (horizontal ? h : w) + GAP_CROSS
1473
+ }
1474
+ along += thick[L] + GAP_PRIMARY
1475
+ }
1476
+
1477
+ // ── 树/森林:改用"父节点居中于孩子"的摆法 ────────────────────────────────────
1478
+ //
1479
+ // 上面的逐层居中是"整层一起居中",于是**父节点会正好压在某个孩子头上**:
1480
+ // 实测一棵完整的二叉树(A;B/C;D/E/F/G)排出来是
1481
+ // A(180) B(130) C(230) D(30) E(130) F(230) G(330)
1482
+ // —— B 落在 E 的正上方、D 甩在左边;而教科书(也是人的直觉)是父节点在两个孩子**中间**。
1483
+ //
1484
+ // 这一个 pass 只对**每个节点至多一个父**的图生效:二叉树、组织架构图、决策树都是这种。
1485
+ // 多父的 DAG(依赖图那种)保持原样 —— 那里"居中于父"本来就没有唯一答案。
1486
+ //
1487
+ // 两步,都只动**交叉轴**(tb 是 x、lr 是 y):
1488
+ // 1. 自下而上:有孩子的节点摆到"最左孩子中心 … 最右孩子中心"的中点;
1489
+ // 2. 每一层从左往右:谁离左邻居太近就往右让(只让本节点,不搬子树)
1490
+ // —— 对称的树(绝大多数)根本不会触发,只有真挤在一起时才牺牲一点居中换不重叠。
1491
+ const parentOf = {}
1492
+ const childrenOf = {}
1493
+ let forest = true
1494
+ for (let i = 0; i < usedEdges.length; i += 1) {
1495
+ const e = usedEdges[i]
1496
+ if (parentOf[e.to] !== undefined && parentOf[e.to] !== e.from) {
1497
+ forest = false
1498
+ break
1499
+ }
1500
+ parentOf[e.to] = e.from
1501
+ if (childrenOf[e.from] === undefined) childrenOf[e.from] = []
1502
+ childrenOf[e.from].push(e.to)
1503
+ }
1504
+ if (forest) {
1505
+ const byId = {}
1506
+ for (let i = 0; i < nodes.length; i += 1) byId[nodes[i].id] = nodes[i]
1507
+ const sizeOf = (n) => (horizontal ? numberOr(n.h, DEFAULT_H) : numberOr(n.w, DEFAULT_W))
1508
+ const crossOf = (n) => (horizontal ? n.y : n.x)
1509
+ const setCross = (n, value) => {
1510
+ if (horizontal) n.y = Math.round(value)
1511
+ else n.x = Math.round(value)
1512
+ }
1513
+ for (let L = top - 1; L >= 0; L -= 1) {
1514
+ const row = buckets[L]
1515
+ for (let i = 0; i < row.length; i += 1) {
1516
+ const kids = childrenOf[row[i].id]
1517
+ if (kids === undefined || kids.length === 0) continue
1518
+ let lo = Infinity
1519
+ let hi = -Infinity
1520
+ for (let k = 0; k < kids.length; k += 1) {
1521
+ const kid = byId[kids[k]]
1522
+ if (kid === undefined) continue
1523
+ const c = crossOf(kid)
1524
+ if (c < lo) lo = c
1525
+ if (c > hi) hi = c
1526
+ }
1527
+ if (lo <= hi) setCross(row[i], (lo + hi) / 2)
1528
+ }
1529
+ for (let i = 1; i < row.length; i += 1) {
1530
+ const prev = row[i - 1]
1531
+ const cur = row[i]
1532
+ const minGap = sizeOf(prev) / 2 + GAP_CROSS + sizeOf(cur) / 2
1533
+ const gap = crossOf(cur) - crossOf(prev)
1534
+ if (gap < minGap) setCross(cur, crossOf(prev) + minGap)
1535
+ }
1536
+ }
1537
+ }
1538
+ }
1539
+
1540
+ /** `layout:'none'` 时的补位:只补缺失的那一轴,不覆盖已给的坐标。 */
1541
+ function placeMissing(doc) {
1542
+ let maxY = 0
1543
+ for (let i = 0; i < doc.nodes.length; i += 1) {
1544
+ const n = doc.nodes[i]
1545
+ if (Number.isFinite(Number(n.y))) {
1546
+ const bottom = Number(n.y) + numberOr(n.h, DEFAULT_H)
1547
+ if (bottom > maxY) maxY = bottom
1548
+ }
1549
+ }
1550
+ let slot = 0
1551
+ for (let i = 0; i < doc.nodes.length; i += 1) {
1552
+ const n = doc.nodes[i]
1553
+ const hasX = Number.isFinite(Number(n.x))
1554
+ const hasY = Number.isFinite(Number(n.y))
1555
+ if (hasX && hasY) continue
1556
+ if (!hasX) n.x = slot * (DEFAULT_W + GAP_CROSS)
1557
+ if (!hasY) n.y = maxY + 80
1558
+ slot += 1
1559
+ }
1560
+ }
1561
+
1562
+ /** 画布写回端点:人工编辑唯一的落盘通道。 */
1563
+ export const SAVE_PATH = '/drawai/api/save'
1564
+ /** 「打开」菜单最多下钻几个子目录 —— 不做全盘递归,避免撞上 node_modules 这类大目录。 */
1565
+ const MAX_SCAN_DIRS = 12
1566
+ /** 自定义请求头 —— 跨站表单设不了它,跨域 fetch 会被预检拦住(CSRF 围栏)。 */
1567
+ export const SAVE_HEADER = 'x-drawai-save'
1568
+ /** 请求体上限,防止一次误发把内存打满。 */
1569
+ const SAVE_MAX_BYTES = 4 * 1024 * 1024
1570
+
1571
+ function sendJson(res, status, payload) {
1572
+ const body = JSON.stringify(payload)
1573
+ res.writeHead(status, {
1574
+ 'content-type': 'application/json; charset=utf-8',
1575
+ 'cache-control': 'no-store',
1576
+ })
1577
+ res.end(body)
1578
+ }
1579
+
1580
+ function readBody(req, limitBytes) {
1581
+ return new Promise((resolve, reject) => {
1582
+ let size = 0
1583
+ const chunks = []
1584
+ req.on('data', (chunk) => {
1585
+ size += chunk.length
1586
+ if (size > limitBytes) {
1587
+ reject(new Error('request body exceeds ' + limitBytes + ' bytes'))
1588
+ req.destroy()
1589
+ return
1590
+ }
1591
+ chunks.push(chunk)
1592
+ })
1593
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
1594
+ req.on('error', reject)
1595
+ })
1596
+ }
1597
+
1598
+ export const name = 'drawai'
1599
+ export const inject = ['tools', 'fs', 'sessions', 'sandboxPolicy', 'webServer']
1600
+
1601
+ /**
1602
+ * 插件**自带**的使用说明,以"内嵌技能"注册给 DSH 的 skill 注册表
1603
+ * (`ctx.skills.register()`,见 apply() 末尾)。
1604
+ *
1605
+ * 为什么要有它:工具名/描述/schema 本来就随 `lib/index.js` 一起装到任何机器上,模型一定看得到;
1606
+ * 但 README、源码、tools/ 都不会跟着插件走(`package.json.files` 只有 lib 与 cordis.patch.yml)。
1607
+ * 换了电脑、只装了插件时,模型能依仗的就只剩那段工具描述。技能是 DSH 为此准备的**正规通道**:
1608
+ * 它出现在会话的技能目录里,模型需要时按需取全文 —— 于是"怎么在这张画布上操作"这件事
1609
+ * 跟着插件走,而不是跟着这份仓库走。
1610
+ *
1611
+ * 写成模板串但**不用反引号**(markdown 里要写示例,反引号会破坏模板串):代码块用四空格缩进。
1612
+ */
1613
+ const SKILL_BODY = `# DrawAI 画布:怎么读、怎么改
1614
+
1615
+ ## 一句话
1616
+ 工作区里任何一个 .drawio 文件**就是**一张 DrawAI 画布 —— 它是 drawio 自己的 mxfile 格式。
1617
+ 用户在 DSH 右栏画布面板里看到的、以及用 drawio 桌面版打开的,是同一份文件;
1618
+ 没有"导入/导出"这一步,你直接读写这个文件就是读写那张画布。
1619
+
1620
+ ## 两个工具怎么配合
1621
+ 1. **diagram_read(path?, layer?, ids?)** —— 先读。返回节点(id/label/shape/style/**x·y·w·h 坐标尺寸**)、
1622
+ 边(id/from/to/label/style/dash/arrow/折点/悬空端的自由点/标签位置 labelX·labelY)、
1623
+ revision(**文件内容指纹**,不是版本号)、page(**纸张尺寸**:文件里写了才有)、
1624
+ layers(**图层表**:顺序 = 叠放顺序,含 name/visible/locked)、每个单元的 layer(它在哪一层)、
1625
+ notes(画布表示不了但会原样保留的东西:多页、分组、图片、HTML 标签)、
1626
+ **selection(用户此刻选中的单元 id)**、highlight(AI 自己请求的高亮,见下)、
1627
+ canRevert / revertSteps(AI 的改动还能退回几步,见下)、export(导出状态)。
1628
+ fontColor(设过才有)是**字色**的派生名:命中调色板给名字(red/grey…),否则给十六进制。
1629
+ visible:false 的层在画布上不画,但里面的单元**照旧读得到** —— 隐藏是显示状态,不是删除;
1630
+ 想让人看见就提醒用户去「图层」菜单打开。
1631
+ **大图先过滤**:layer(层 id 或层名)只看一层、ids 只看几个单元 —— 过滤时返回体会带
1632
+ filtered 与 totalNodes/totalEdges(整张有多少),层表照旧整份回。别一上来就把几百个单元读完。
1633
+ 2. **diagram_apply(path, ops, layout?, expectRevision?)** —— 再改。给一组结构化编辑,宿主**无损写回**:只改我们拥有的单元,
1634
+ 文件其余部分(别的页、未知单元、自定义属性)逐字节保留。打开后原样保存 = 文件一个字节都不变。
1635
+
1636
+ 不传 path 时,优先改**用户当前打开的那张画布**(客户端上报的聚焦路径),最后才退回 demo.drawio。
1637
+ 用户说"这张图"时通常不用传 path。改完可以再 read 一次自查。
1638
+
1639
+ **当前是哪一张,有两个来源**:用户在画布面板里切换标签页时,宿主会把"当前画布 = <路径>"
1640
+ **注入到会话里**(你会看到一条 plugin 来源的上下文);不放心时也不带 path 调一次
1641
+ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一个记录)。
1642
+
1643
+ **"这几个"是哪几个**:用户在画布上选中的单元会随 diagram_read 一起回来(selection)。
1644
+ 用户说"把这几个挪一下/换个颜色"时就用那些 id —— 别靠坐标猜。选中的是连线也一样能用
1645
+ (setStyle/move/remove 都收边 id)。没人选时它是空数组。
1646
+
1647
+ **读到的 revision 别扔**:下次改这张图时把它当 expectRevision 传进来。对不上(用户在 drawio 里
1648
+ 改过、或另一个会话动过)调用会**直接失败、一个字节都不写** —— 重新 read 一次拿新指纹再改。
1649
+ 不传就是不检查(并发编辑时可能覆盖别人的改动,所以"读过再改"的流程里最好都传)。
1650
+
1651
+ ## ops 速查
1652
+ {op:"addNode", label:"必填", as?, shape?, style?, keys?, w?, h?, x?, y?, fontColor?}
1653
+ {op:"addEdge", from?, to?, fromPoint?, toPoint?, as?, label?, style?, dash?, arrow?, color?, exit?, entry?, jettySize?, edgeStyle?, line?, avoid?, fontSize?, fontColor?, keys?}
1654
+ {op:"setLabel", id|ids, label}
1655
+ {op:"setLabelPos", id, x?, y?, dx?, dy?, center?}
1656
+ {op:"setStyle", id|ids, shape?, style?, keys?, w?, h?, dash?, arrow?, color?, exit?, entry?, jettySize?, edgeStyle?, line?, avoid?, fontSize?, fontColor?, clearPoints?}
1657
+ {op:"setEdge", id, from?, to?, fromPoint?, toPoint?}
1658
+ {op:"order", id|ids, to:"front"|"back"|"up"|"down"}
1659
+ {op:"duplicate", id|ids, dx?, dy?, withEdges?, layer?, as?}
1660
+ {op:"addLayer", name?, id?, visible?, as?}
1661
+ {op:"setLayer", id|ids, layer}
1662
+ {op:"setLayerProps", layer, name?, visible?, locked?}
1663
+ {op:"export", format?:"svg"|"png", name?}
1664
+ {op:"move", id|ids, dx?, dy?, x?, y?}
1665
+ {op:"remove", id|ids}
1666
+ {op:"highlight", ids:["n1","n2"]}
1667
+
1668
+ - **改接一条边用 setEdge,别 remove + addEdge 重画**:setEdge 只换 source/target(from/to 给节点 id,
1669
+ 或 fromPoint/toPoint 给绝对点 = 脱开节点变成自由端),边的 **id、标签、标签位置、折点、
1670
+ 端点约束(exitX/entryX)全都留着** —— 重画会把这些丢掉,还会让别的引用它的地方失效。
1671
+ - **边上的文字压住线**:diagram_read 回 labelX/labelY(拖过才有;x = 沿边比例 -1..1,0 = 弧长中点、
1672
+ y = 垂直偏移 px)。{op:"setLabelPos", id, center:true} 放回中点(= 画布右键「标签居中」);
1673
+ {op:"setLabelPos", id, dy:12} 往上/下挪 12px(dx 是比例,一般别用)。
1674
+ - **顺序(谁压谁)用 order**:to 是 front(到最上面)/back/up/down,与画布右键的顺序菜单同一套。
1675
+ 它改的是**文件里单元的先后**(drawio 打开也是这个层叠)。注意"所有边都画在所有节点下面"
1676
+ 是画布目前的层叠限制,所以"让边压住节点"要放到更上面的**图层**里,不是 order 能解决的。
1677
+ - **再来一份用 duplicate**:新 id + 整体平移(默认两格 20px,与画布 Ctrl+V 一样),
1678
+ 折点、端点约束、标签位置、自定义数据都带过去。默认会把"两端都在被复制节点之间"的边一起复制
1679
+ (withEdges:false 只要节点);只列一条接在节点上的边会被拒 —— 把两端节点一起写进 ids。
1680
+ - **图层**:addLayer 新建一层(加在最上面,返回它的 id)、addNode/addEdge/duplicate 可以带 layer
1681
+ (层 id 或层名)、setLayer 把已有单元移到别的层、setLayerProps 改层的名字/显示/锁定。
1682
+ 隐藏一层是**文档状态**(写进文件,drawio 打开也是隐藏的),不是删除 —— 隐藏层里的单元照旧读得到。
1683
+ - **导出**:{op:"export", format:"svg"} 会让画布渲染一张 SVG 落在 .drawio 旁边(同名的 .svg,
1684
+ 可以用 name 指定基名);format:"png" 则是**浏览器下载**一张 PNG(用户下载目录里)。
1685
+ 渲染在画布那一半做,所以这一步是"请求":返回值/下一次 read 里的 export 状态
1686
+ pending → done(带文件路径)/ downloaded / failed。看到 pending 说明画布还没刷新 ——
1687
+ 别反复请求,等下一次 read 再看(或提醒用户打开那张画布)。
1688
+
1689
+ - **改一组用 ids**:{op:"setStyle", ids:["n1","n2","n5"], style:"green"} —— setStyle / setLabel /
1690
+ move / remove 都收 ids,不要为了"把这几个改成绿色"发 12 个 op。批量 move 只收 dx/dy
1691
+ (给一个绝对 x/y 等于把它们叠成一摞,宿主会直接拒绝)。
1692
+ - **引用刚建的那个用 as**:{op:"addNode", label:"开始", as:"start"} 之后,同一个 ops 数组里
1693
+ from/to/id 可以直接写 "start"。id 省略时是自动分配的(n 几/e 几),猜编号在非空画布上必错。
1694
+ - **返回值里看 created/changed/removed**:新建单元的 id(含自动分配的)在这里,接着引用它们
1695
+ 不必再 read 一次、也不用去解析 summary 文本。
1696
+ - id 省略会自动分配(n1/n2…、e1/e2…);addEdge 的 from/to 必须是**已存在的节点 id**,
1697
+ 引用了不存在的节点会**在写盘之前**直接报错并列出已知 id。
1698
+ - **线的两端可以给绝对点**(fromPoint/toPoint)而不是节点:两端都给点就是一条**独立线** ——
1699
+ drawio 里边的两端都可以是自由点,线可以完全不接节点(先画线、之后再拖端点接到节点上也行)。
1700
+ 只有一端给点就是**悬空端**(另一半接在节点上)。
1701
+ - **move**:改已有元素的位置。节点给 dx/dy(相对)或 x/y(绝对);连线只能给 dx/dy
1702
+ (平移它自己的折点与自由端点;两端接在节点上时端点由节点决定)。
1703
+ 一整组一起挪就写 ids:[...] + dx/dy —— 与画布上拖选区是同一套语义(相对位置一点不变)。
1704
+ 以前没有 move,想把某个节点挪一下就只好删掉重画 —— 那会丢 id、丢边上的端点约束与折点。
1705
+ - **line**(连线线型,四选一):straight(直线,不带折点)| sharp(直角折线,缺省)|
1706
+ rounded(**圆角折线**:只在折点处倒圆角,rounded=1)| curved(曲线,curved=1)。
1707
+ line:"straight" 会**清掉折点**;line:"rounded" 只改样式(折点就是折线的拐角,照旧有用);
1708
+ line:"curved" 在边本来就是两点直连时会补一个弓形中点 ——
1709
+ 因为 drawio 的曲线是把**现有折线**抹圆,两点时它退化成直线(屏幕上什么都看不出来)。
1710
+ 与它等价的键级写法是 edgeStyle:"none"(直线)、keys:{rounded:1}(圆角)、keys:{curved:1}(曲线)。
1711
+ - **fontSize**(字号):节点标签、独立文字、连线上的文字都吃它(drawio 的 fontSize 键);
1712
+ 给 null 就是删键回缺省。例:{op:"setStyle", id:"n1", fontSize:18}。
1713
+ - **fontColor**(**字色**):节点标签、独立文字、连线上的文字都吃它(drawio 的 fontColor 键)。
1714
+ 给调色板名(plain|blue|green|orange|yellow|red|purple|grey)就按画布上"文字换色"那一支取色
1715
+ (红 = #b85450,不是填充色),给 #b85450 这类色值就原样写,给 null 删键回缺省黑字。
1716
+ 例:{op:"setStyle", ids:["n1","n2"], fontColor:"#666666"};连线上的字同理
1717
+ (color 是**线本身**的颜色,fontColor 是**字**的颜色 —— 两件事,别混)。
1718
+ - **highlight** 只是"让画布选中这几个"给你看,不改文档、也不重排。
1719
+ - **图层现在已经能操作了**(见上面的 addLayer/setLayer/setLayerProps):新建一层、
1720
+ 让新单元进指定层、把已有单元移到别的层、改名/显示/隐藏/锁定。
1721
+ 隐藏层里的单元照旧读得到 —— 隐藏是**显示状态**,不是删除;用户想看见就提醒他去「图层」菜单打开。
1722
+ - **容器/分组**:单元带 parent 就说明它在某个容器(分组)里。画布按**绝对位置**显示,
1723
+ 所以 move 一个容器**不会**带走它的子单元、remove 容器则会把子单元接到还活着的祖先上 ——
1724
+ 要"整组一起动"就把容器和子单元一起写进 move 的 ids 里。分组本身 AI 还不能新建。
1725
+ - 糖(shape/style/dash/arrow/color/exit/entry/jettySize/edgeStyle/avoid)由宿主翻译成 drawio 的 style 键,
1726
+ **绝不落盘**;keys 用来写任意 drawio 键,值给 null = 删键回默认。
1727
+ - shape:rect | rounded | **text**(独立文字:无边框无底色的一段字)| stadium | ellipse | diamond | parallelogram | cylinder | document | hexagon
1728
+ 要"一段不带框的说明/标题文字"就用 addNode {shape:"text", label:"…", x, y} —— 它就是 drawio 的
1729
+ text 形状(样式原样写 text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0)。
1730
+ 文字元素想换颜色要写 **fontColor**(setStyle {id, keys:{fontColor:"#b85450"}}):
1731
+ 它没有填充与描边,往它身上写 fillColor/strokeColor 屏幕上不会有任何变化。
1732
+ - style:调色板名 plain|blue|green|orange|yellow|red|purple|grey,或直接给一段 style 串
1733
+ - dash:solid|dashed|dotted;arrow:end(单向)|both(双向)|none(无)|start(反向)
1734
+ - exit/entry:进出侧 n|e|s|w;edgeStyle:orthogonalEdgeStyle|none(none = 直线)
1735
+ - 自环写成 from === to(与 drawio 一致);自动布局忽略自环,只摆节点。
1736
+
1737
+ ## 一段完整的例子
1738
+ 用户说"画一个登录流程,失败回到登录页":
1739
+
1740
+ diagram_apply({ ops: [
1741
+ {op:"addNode", label:"开始", shape:"stadium", style:"green", as:"start"},
1742
+ {op:"addNode", label:"输入账号", shape:"rect", style:"blue", as:"input"},
1743
+ {op:"addNode", label:"校验", shape:"diamond", style:"yellow", as:"check"},
1744
+ {op:"addNode", label:"进入首页", shape:"rect", style:"blue", as:"home"},
1745
+ {op:"addEdge", from:"start", to:"input"},
1746
+ {op:"addEdge", from:"input", to:"check"},
1747
+ {op:"addEdge", from:"check", to:"home", label:"通过"},
1748
+ {op:"addEdge", from:"check", to:"input", label:"失败", dash:"dashed", color:"red"}
1749
+ ], layout:"dagre-tb" })
1750
+
1751
+ 写 as 别名而不是 n1/n2:画布上已经有节点时,自动分配的编号猜不准(错一次就白跑一轮)。
1752
+
1753
+ 用户说"把这几个(他在画布上选中的)换成绿色":
1754
+
1755
+ diagram_read() // 返回里的 selection 就是"这几个"
1756
+ diagram_apply({ ops: [{op:"setStyle", ids:[...selection], style:"green"}],
1757
+ expectRevision:"<上一步 read 回来的 revision>" })
1758
+
1759
+ ## 布局与坐标
1760
+ - **不要自己算坐标**:layout 选 dagre-tb(默认,贴合右栏窄高的形状)、dagre-lr(左到右)、
1761
+ grid(网格)、none(保留现有坐标,只给没有坐标的新节点补位)。
1762
+ - 只有"必须摆在某个位置"时才传 x/y(宿主仍会把它吸附到网格)。
1763
+ - 画布的吸附单位:**节点 10px,连线的折点/自由端点 5px**。手工摆过的画布带 meta.pinned,
1764
+ 这时**不加 layout 就不会重排**(否则你一改图,人手工排好的版面就被冲掉);想重排必须显式给 layout。
1765
+ - **ops 里自带几何时(addNode 给了 x/y、或者有 move)也不会重排** ——
1766
+ 否则"把 n1 往右挪 40"会被布局立刻冲掉,看起来像工具坏了。
1767
+ - 显式重排会清掉"端点已移动"的那些边上的**过期折点**(不然折点会留在旧位置,走线会绕圈)。
1768
+ - AI 改完的改动**不在用户本地的撤销历史里**(服务端推新版本时客户端会清空它,免得一次 Ctrl+Z
1769
+ 把 AI 的改动悄悄顶掉)。宿主留了一个**回退栈**(最多 8 步),用户界面「编辑 → 撤销 AI 改动」
1770
+ 每按一次退一步;read 返回里的 revertSteps 就是"还剩几步",canRevert 是"还有没有"。
1771
+ 所以:改了别慌,也别自己"改回去"—— 告诉用户可以连按几次撤销就行。
1772
+ - **纸张尺寸**(page):文件里写了就报出来(例如 850 × 1100 = drawio 的 A4 竖版缺省)。
1773
+ 布局取舍与"要不要给用户导出建议"都该看它 —— 内容远比纸张宽时提醒一句比闷头画更好。
1774
+
1775
+ ## 想看效果图的时候(diagram_read 带 render:true)
1776
+ - 传 render:true 就是"让我看一眼这张画布":正交路由、避让、圆角、文字断线这些
1777
+ **只有看图才知道**的东西,几何数据里没有(宿主也不算走线,那是画布算的)。
1778
+ - 渲染在画布那一半做,所以是两步:第一次返回 look.status="pending"(已请求),
1779
+ **等几秒再调一次** diagram_read(render:true),图就作为图片内容块跟回来了。
1780
+ - **不会在用户的工作区里生成任何文件**:图片存在宿主那边的附件库里(与用户上传图片同一套存储,
1781
+ 在工作区之外),看完即取走 —— 要再看就再要一次(图不会被反复塞给你)。
1782
+ - **纯改图不需要它**:几何、样式、图层、纸张从普通 diagram_read 里就有。
1783
+ 只在判断"这样摆好不好看/线有没有压住节点/别人看不看得懂"时用。
1784
+ - 看不了的情况会明说:这个部署没挂附件服务、或当前模型没声明图片输入 —— 那时
1785
+ look.status 是 unsupported(附原因),照常用几何数据干活即可。
1786
+ - 那张图是"你请求渲染那一刻"的画面:回执之后你又改过图的话 look.stale 会是 true,
1787
+ 想看新的就再 render:true 一次。
1788
+
1789
+ ## style 键与 drawio 同构
1790
+ 文档里存的就是 drawio 的 style 键:fillColor / strokeColor / shape= / rounded= / arcSize= /
1791
+ dashed / dashPattern / strokeWidth / fontSize / fontColor / edgeStyle / jettySize / orthogonalLoop /
1792
+ endArrow / startArrow / exitX·exitY·entryX·entryY / libavoidRouting …
1793
+ **默认值一律省略**(写出来等于多一份噪音),认不出的键原样保留。
1794
+ 边自己的文字存在边的 value 上(diagram_read 里是 label),可以写成多行;
1795
+ 拖动过的边标签位置存在边几何的 x/y/offset 里(沿边比例 + 垂距 + 残余),read 会作为
1796
+ labelX/labelY 报出来,要改就用 setLabelPos(见上)。
1797
+
1798
+ ## 画布表示不了、但会原样保留的东西(notes 里会列出来)
1799
+ 多页只显示第 1 页、分组/容器按绝对位置显示、图片按矩形显示、HTML 标签按纯文本显示、
1800
+ 图层结构原样保留(画布把它们叠在一起显示)、drawio 的独立边标签单元(edgeLabel)只读显示。
1801
+ **看到 notes 要如实告诉用户**:这份文件在 drawio 里打开时会和这里看到的略有差别。
1802
+
1803
+ ## 别做这些
1804
+ - 别直接改 .drawio 文件的文本(用 diagram_apply):手改会破坏"没动的地方逐字节不变"这条保证。
1805
+ - 别造别的后缀(.json/.xml/.svg):画布只认 .drawio,其他后缀会"看起来成功、其实没人能打开"。
1806
+ - 别把图重画一遍来表达"挪一下":用户手工摆过的位置是有意义的。
1807
+
1808
+ ## 用户可以这样操作(他问"怎么做"时照这个答)
1809
+ - 双击节点改文字;双击线段、线段上的文字、或线上的把手,改**线上的文字**
1810
+ - 按住线上的文字拖动 = 挪文字(有半格吸附,靠近线会贴回线上);右键「标签居中」放回中点
1811
+ - 选中连线后:绿/红端点拖到别的节点 = 改接;橙色空心把手拖 = 整段平移;右键有「自动路由」清掉折点
1812
+ - 空白处左键拖 = 框选(框到节点或线段都算);Shift 框选是加选;拖选区整体移动(连线形状一起走)
1813
+ - Ctrl+C/X/V 复制剪切粘贴、Ctrl+Z/Y 撤销重做、Delete 删除;中键拖动平移、滚轮缩放
1814
+ - 「文件 → 新建画布…/打开…/另存为…」;「视图 → 适应内容」;「整理几何(吸附到格线)」把整张图对齐
1815
+ - 右键还能改线型/箭头/颜色/顺序(置顶置底)、编辑数据(drawio 用户对象的自定义属性)、删除
1816
+ `
1817
+
1818
+ /**
1819
+ * 技能目录里那一段(模型先看到简介,需要时才取全文)。
1820
+ * 名字用 kebab-case —— DSH 的注册表按名字寻址并要求这个形状。
1821
+ */
1822
+ const SKILL_SUMMARY = {
1823
+ name: 'drawai-canvas',
1824
+ description:
1825
+ '读写工作区里的 DrawAI 画布(.drawio,就是 drawio 的 mxfile):diagram_read 看现状、diagram_apply 结构化改图(自动布局、无损写回)。',
1826
+ whenToUse:
1827
+ '用户让你画流程图/架构图/泳道图,或让你读/改某张 .drawio 画布,或问"这张图里有什么""画布上怎么操作"时。',
1828
+ }
1829
+
1830
+ /**
1831
+ * 列出工作区里的画布文档(非递归进子目录,最多扫 MAX_SCAN_DIRS 个)。
1832
+ *
1833
+ * **列目录的 API 是 `list`,不是 `readRelated`**。之前用 readRelated 是错的:
1834
+ * dsh-api-workspace-files 的类型注释写得很明确 ——
1835
+ * read / readBytes / stat / changes 用**绝对路径**;
1836
+ * `list` speaks workspace paths,因为它的消费者是"以工作区根为根的树"。
1837
+ * 而且旧实现把异常整个吞掉(catch → null),于是"读不到目录"只表现为**空列表**,
1838
+ * 界面上就是"找不到文件",看不出任何原因。现在:优先 list、失败退回 readRelated、
1839
+ * 两者都失败就把错误带回调用方显示。
1840
+ *
1841
+ * 只扫一层子目录:画布文档按惯例都在根目录或一层子目录下;递归全盘扫描既慢,
1842
+ * 又可能撞上 node_modules 这类大目录。
1843
+ */
1844
+ async function listCanvases(root, cwd, dir) {
1845
+ const found = []
1846
+ const seen = {}
1847
+ const dirs = []
1848
+ const notes = []
1849
+
1850
+ async function readDir(absPath) {
1851
+ try {
1852
+ const entries = await nodeReaddir(absPath, { withFileTypes: true })
1853
+ return { entries: entries, error: '' }
1854
+ } catch (error) {
1855
+ return { entries: null, error: messageOf(error) }
1856
+ }
1857
+ }
1858
+
1859
+ function collect(entries, prefix) {
1860
+ if (!Array.isArray(entries)) return
1861
+ for (let i = 0; i < entries.length; i += 1) {
1862
+ const entry = entries[i]
1863
+ const name = typeof entry === 'string' ? entry : entry !== null && typeof entry === 'object' ? entry.name : null
1864
+ if (typeof name !== 'string' || name.length === 0) continue
1865
+ const isDir =
1866
+ entry !== null && typeof entry === 'object'
1867
+ ? typeof entry.isDirectory === 'function'
1868
+ ? entry.isDirectory()
1869
+ : entry.kind === 'directory' || entry.type === 'directory' || entry.isDirectory === true
1870
+ : false
1871
+ const rel = prefix.length === 0 ? name : prefix + '/' + name
1872
+ // 载体就是 .drawio —— 列出来的每一个都能直接打开(不再有「先导入成别的东西」这一步)。
1873
+ if (!isDir && name.toLowerCase().endsWith('.drawio')) {
1874
+ if (seen[rel] !== true) {
1875
+ seen[rel] = true
1876
+ found.push(rel)
1877
+ }
1878
+ continue
1879
+ }
1880
+ // 只下钻一层,并跳过明显的重目录(node_modules / .git)—— 扫它们又慢又没意义。
1881
+ if (isDir && prefix.length === 0 && name !== 'node_modules' && name !== '.git' && dirs.length < MAX_SCAN_DIRS) dirs.push(rel)
1882
+ }
1883
+ }
1884
+
1885
+ const startAbs = toAbsolute(typeof dir === 'string' && dir.length > 0 ? dir : root, cwd)
1886
+ const first = await readDir(startAbs)
1887
+ if (first.entries === null) notes.push('无法列出 ' + startAbs + ':' + first.error)
1888
+ else collect(first.entries, '')
1889
+ for (let d = 0; d < dirs.length; d += 1) {
1890
+ const subAbs = toAbsolute(dirs[d], cwd)
1891
+ const sub = await readDir(subAbs)
1892
+ if (sub.entries === null) {
1893
+ notes.push('无法列出 ' + subAbs + ':' + sub.error)
1894
+ continue
1895
+ }
1896
+ collect(sub.entries, dirs[d])
1897
+ }
1898
+ found.sort()
1899
+ return { files: found, notes: notes }
1900
+ }
1901
+
1902
+
1903
+ export function apply(ctx) {
1904
+ function sessionOf(sessionId) {
1905
+ try {
1906
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined
1907
+ const session = ctx.sessions.get(sessionId)
1908
+ return session === undefined || session === null ? undefined : session
1909
+ } catch (error) {
1910
+ return undefined
1911
+ }
1912
+ }
1913
+
1914
+ /**
1915
+ * 按**调用会话**解析沙箱策略。
1916
+ * 不带 request 的 resolve() 是"无会话"回退,workspaceRoot 会落到部署默认(process.cwd()),
1917
+ * 那样工作区文件不在可写根内,writeText 会被 FS_SANDBOX_DENIED 拒掉。
1918
+ * 带上 session 后,会话的不可变 cwd 成为可写边界 —— 这不是提权,部署设成 read-only 时照样会拒。
1919
+ */
1920
+ function policyFor(sessionId) {
1921
+ try {
1922
+ const session = sessionOf(sessionId)
1923
+ return session === undefined ? ctx.sandboxPolicy.resolve() : ctx.sandboxPolicy.resolve({ session: session })
1924
+ } catch (error) {
1925
+ return undefined
1926
+ }
1927
+ }
1928
+
1929
+ function workspaceRootOf(sessionId) {
1930
+ try {
1931
+ const policy = policyFor(sessionId)
1932
+ if (policy !== undefined && typeof policy.workspaceRoot === 'string' && policy.workspaceRoot.length > 0) {
1933
+ return policy.workspaceRoot
1934
+ }
1935
+ const session = sessionOf(sessionId)
1936
+ if (session === undefined) return undefined
1937
+ const header = session.header
1938
+ if (header === undefined || header === null) return undefined
1939
+ return typeof header.cwd === 'string' && header.cwd.length > 0 ? header.cwd : undefined
1940
+ } catch (error) {
1941
+ return undefined
1942
+ }
1943
+ }
1944
+
1945
+ function sessionIdOf(exec) {
1946
+ try {
1947
+ if (exec === null || exec === undefined) return undefined
1948
+ const agent = exec.agent
1949
+ if (agent === null || agent === undefined) return undefined
1950
+ return typeof agent.id === 'string' && agent.id.length > 0 ? agent.id : undefined
1951
+ } catch (error) {
1952
+ return undefined
1953
+ }
1954
+ }
1955
+
1956
+ function absoluteHint(target, fallback) {
1957
+ try {
1958
+ return ctx.fs.processPath(target)
1959
+ } catch (error) {
1960
+ return fallback
1961
+ }
1962
+ }
1963
+
1964
+ async function resolveTarget(path, sessionId) {
1965
+ const root = workspaceRootOf(sessionId)
1966
+ return root === undefined ? await ctx.fs.resolve(path) : await ctx.fs.resolve(path, { cwd: root })
1967
+ }
1968
+
1969
+ /**
1970
+ * 读一份画布:**文件是真相**,读的就是 `.drawio`(mxfile)本身。
1971
+ *
1972
+ * revision 用文件内容指纹(`contentHash`),不落盘 —— 这样 drawio 或别的编辑器改过文件,
1973
+ * 指纹自然变,乐观锁不会因为"属性被 drawio 丢掉"而假冲突。
1974
+ */
1975
+ async function loadDoc(path, sessionId) {
1976
+ const target = await resolveTarget(path, sessionId)
1977
+ const absolute = absoluteHint(target, path)
1978
+ const info = await ctx.fs.stat(target)
1979
+ if (info === undefined) return { target: target, absolute: absolute, doc: emptyDoc(), notes: [], exists: false }
1980
+ const text = await ctx.fs.readText(target)
1981
+ const parsed = parseMxfile(text)
1982
+ return { target: target, absolute: absolute, doc: normalizeDoc(parsed.doc), notes: parsed.notes, exists: true, text: text }
1983
+ }
1984
+
1985
+ /**
1986
+ * 写一份画布:**在原文件上做无损写回**(只改我们拥有的单元,其余原文逐字节保留)。
1987
+ *
1988
+ * 文件不存在(或为空)时才从零生成 —— 那是"新建",没有原稿可保护。
1989
+ *
1990
+ * @returns { text, dropped, revision }
1991
+ */
1992
+ function renderDocText(originalText, doc, options) {
1993
+ const doc2 = normalizeDoc(doc)
1994
+ if (typeof originalText !== 'string' || originalText.trim().length === 0) {
1995
+ const built = buildMxfile(doc2, options)
1996
+ return { text: built.text, dropped: built.dropped, revision: contentHash(built.text) }
1997
+ }
1998
+ const applied = applyDocToMxfile(originalText, doc2, options)
1999
+ return { text: applied.text, dropped: applied.dropped, revision: contentHash(applied.text) }
2000
+ }
2001
+
2002
+ /**
2003
+ * 工具的路径护栏:只认 `.drawio`。
2004
+ *
2005
+ * 载体只有一种格式 —— 放开后缀就等于允许 AI 在工作区里造出第二种画布文件,
2006
+ * 而那些文件没有画布认领、也不会被「打开」列出来,属于"看起来成功、其实没人能看见"。
2007
+ */
2008
+ function assertCanvasPath(path) {
2009
+ if (typeof path !== 'string' || path.toLowerCase().endsWith('.drawio') === false) {
2010
+ throw new Error('画布路径必须以 .drawio 结尾(drawio 的 mxfile 就是画布文件):' + String(path))
2011
+ }
2012
+ }
2013
+
2014
+ const readTool = defineTool({
2015
+ name: 'diagram_read',
2016
+ description:
2017
+ '读回工作区里的 DrawAI 画布(.drawio,就是 drawio 自己的 mxfile 格式):节点 id/标签/形状/style 键,边 id/起点/终点/标签/画法/折点。做任何修改前先用它确认当前图。' +
2018
+ '文档存的是 drawio 的 style 键(dashed/dashPattern/edgeStyle/jettySize/libavoidRouting/exitX…/endArrow/strokeColor/shape=…),默认值省略;' +
2019
+ '返回体的 shape/dash/arrow/color/exit/entry/fontColor 是**从 style 串推导出来的便于阅读的名字**,改图请改 style 或对应 op 参数;' +
2020
+ 'notes 是这个文件里画布表示不了、但会原样保留的东西(多页、分组层级、图片…)。' +
2021
+ 'layers 是图层表(顺序 = 叠放顺序;visible:false 的层在画布上不画,但里面的单元照旧读得到它们带 layer 字段);加层/移层/改名/显示隐藏用 addLayer、setLayer、setLayerProps。' +
2022
+ '节点带 parent 说明它在某个容器(分组)里 —— 画布按绝对位置显示,所以移动容器不会带走子单元,要整组动就把它们一起写进 ids。' +
2023
+ 'selection 是**用户此刻在画布上选中的单元 id**(按文件配对、按存在过滤;没人选就是空数组)—— 用户说"把这几个挪一下/换个颜色"时,"这几个"就是它们,别靠坐标猜。' +
2024
+ '返回的 revision 可以直接当 diagram_apply 的 expectRevision 用(读→改之间被别人动过就会被拦下)。' +
2025
+ '不传 path 时读的是**用户当前打开的那张画布**(返回里的 path 就是它)。',
2026
+ parameters: {
2027
+ path: {
2028
+ type: 'string',
2029
+ description:
2030
+ '工作区相对路径或绝对路径。**不传 = 用户当前打开的那张画布**(没打开任何画布时才退回 ' +
2031
+ DEFAULT_PATH +
2032
+ ')—— 想知道用户在看哪张,直接不传 path 调一次,返回里的 path 就是它。',
2033
+ },
2034
+ layer: {
2035
+ type: 'string',
2036
+ description:
2037
+ '只看某一层(层 id 或层名):大图上先看一层能省掉一大段上下文。层表照旧整份回。不传 = 全部层。',
2038
+ },
2039
+ ids: {
2040
+ type: 'array',
2041
+ items: { type: 'string' },
2042
+ description: '只看这几个单元(节点/连线 id):想确认"刚改的那几个现在什么样"时用它,别把整张图再读一遍。',
2043
+ },
2044
+ render: {
2045
+ type: 'boolean',
2046
+ description:
2047
+ '**看一眼这张画布的渲染结果**(正交路由、避让、圆角、文字断线这些只有看图才知道的东西)。' +
2048
+ '渲染在画布那一半做,所以第一次调用通常回 look.status="pending"(已请求,画布几秒内渲染完),**再调一次**就会带上图片块。' +
2049
+ '图片经附件通道直接进上下文 —— **不会在你的工作区里生成任何文件**。' +
2050
+ '只在确实需要"看"的时候用(判断重叠/压线/观感),纯粹改图不需要它。',
2051
+ },
2052
+ },
2053
+ output: {
2054
+ schema: {
2055
+ type: 'object',
2056
+ additionalProperties: false,
2057
+ properties: {
2058
+ path: { type: 'string', required: true },
2059
+ // revision = 文件内容指纹(改由文件决定,不再是我们自己 +1 的计数器)
2060
+ revision: { type: 'string', required: true },
2061
+ // 纸张尺寸(文件里写了才有):布局与导出建议要看它。
2062
+ page: {
2063
+ type: 'object',
2064
+ additionalProperties: false,
2065
+ properties: {
2066
+ w: { type: 'number', required: true },
2067
+ h: { type: 'number', required: true },
2068
+ },
2069
+ },
2070
+ // 这次是不是被 layer/ids 过滤过(过滤时上面那些计数只是"看到的部分")。
2071
+ filtered: { type: 'boolean' },
2072
+ totalNodes: { type: 'number' },
2073
+ totalEdges: { type: 'number' },
2074
+ notes: { type: 'array', required: true, items: { type: 'string' } },
2075
+ // AI 的高亮请求(取走即清)与"有没有可退回的 AI 改动"。
2076
+ highlight: { type: 'array', items: { type: 'string' } },
2077
+ canRevert: { type: 'boolean' },
2078
+ // AI 改动的回退栈还剩几步(可以连着退;0 = 没有可退的了)。
2079
+ revertSteps: { type: 'number' },
2080
+ // 用户此刻在画布上选中的单元 id(按**文件**配对、按**存在**过滤)。
2081
+ // 用户说"把这几个挪一下"时,"这几个"就是它 —— 以前只能靠坐标猜。
2082
+ selection: { type: 'array', items: { type: 'string' } },
2083
+ // 导出状态(只有用过 `{op:'export'}` 才有):pending(画布还没渲染)/ done(文件已写出,
2084
+ // 带 path)/ downloaded(png 已由浏览器下载)/ failed(error 是原因)。
2085
+ export: {
2086
+ type: 'object',
2087
+ additionalProperties: false,
2088
+ properties: {
2089
+ status: { type: 'string', required: true },
2090
+ format: { type: 'string', required: true },
2091
+ path: { type: 'string' },
2092
+ error: { type: 'string' },
2093
+ },
2094
+ },
2095
+ // 「看一眼」的状态(只有传了 render:true 才有):pending / ready / unsupported / failed。
2096
+ // 图片本身不走 schema,而是走 render 的输出块(见下面的 render())。
2097
+ look: {
2098
+ type: 'object',
2099
+ additionalProperties: false,
2100
+ properties: {
2101
+ status: { type: 'string', required: true },
2102
+ stale: { type: 'boolean' },
2103
+ error: { type: 'string' },
2104
+ },
2105
+ },
2106
+ // 这张图的附件引用(有图才有)。render() 用它拼出 `{type:'image', attachment}` 块。
2107
+ image: {
2108
+ type: 'object',
2109
+ additionalProperties: false,
2110
+ properties: {
2111
+ attachmentId: { type: 'string', required: true },
2112
+ mediaType: { type: 'string', required: true },
2113
+ bytes: { type: 'number', required: true },
2114
+ width: { type: 'number', required: true },
2115
+ height: { type: 'number', required: true },
2116
+ name: { type: 'string' },
2117
+ },
2118
+ },
2119
+ nodes: {
2120
+ type: 'array',
2121
+ required: true,
2122
+ items: {
2123
+ type: 'object',
2124
+ additionalProperties: false,
2125
+ properties: {
2126
+ id: { type: 'string', required: true },
2127
+ label: { type: 'string', required: true },
2128
+ shape: { type: 'string', required: true },
2129
+ style: { type: 'string', required: true },
2130
+ x: { type: 'number', required: true },
2131
+ y: { type: 'number', required: true },
2132
+ w: { type: 'number', required: true },
2133
+ h: { type: 'number', required: true },
2134
+ // 这个单元在哪一层(图层 id)。多于一层的文档才带。
2135
+ layer: { type: 'string' },
2136
+ // 字色(设过且不是缺省黑字才有):命中调色板给名字,否则给十六进制。
2137
+ fontColor: { type: 'string' },
2138
+ // 容器父级(**只读信息**):单元挂在某个容器/分组里时才有。
2139
+ // 画布按绝对位置显示,所以"移动容器"不会带走它 —— 要一起动就把它们一起写进 ids。
2140
+ parent: { type: 'string' },
2141
+ },
2142
+ },
2143
+ },
2144
+ // 图层列表(顺序 = 叠放顺序,后面的在上面)。加层/移层/改名看 diagram_apply 的
2145
+ // addLayer / setLayer / setLayerProps。
2146
+ layers: {
2147
+ type: 'array',
2148
+ items: {
2149
+ type: 'object',
2150
+ additionalProperties: false,
2151
+ properties: {
2152
+ id: { type: 'string', required: true },
2153
+ name: { type: 'string', required: true },
2154
+ visible: { type: 'boolean', required: true },
2155
+ locked: { type: 'boolean', required: true },
2156
+ },
2157
+ },
2158
+ },
2159
+ edges: {
2160
+ type: 'array',
2161
+ required: true,
2162
+ items: {
2163
+ type: 'object',
2164
+ additionalProperties: false,
2165
+ properties: {
2166
+ id: { type: 'string', required: true },
2167
+ from: { type: 'string', required: true },
2168
+ to: { type: 'string', required: true },
2169
+ label: { type: 'string' },
2170
+ style: { type: 'string', required: true },
2171
+ dash: { type: 'string', required: true },
2172
+ arrow: { type: 'string', required: true },
2173
+ color: { type: 'string' },
2174
+ exit: { type: 'string' },
2175
+ entry: { type: 'string' },
2176
+ // 这几个是返回体里**确实会带**的字段(execute 里按需填)。
2177
+ // 漏声明的后果实测过:schema 是 additionalProperties:false,
2178
+ // 于是只要某条边带折点,diagram_read 整个调用被判为非法输出、
2179
+ // 直接报错 —— 连读都读不出来。新增返回字段时必须同步这里。
2180
+ points: {
2181
+ type: 'array',
2182
+ items: {
2183
+ type: 'object',
2184
+ additionalProperties: false,
2185
+ properties: {
2186
+ x: { type: 'number', required: true },
2187
+ y: { type: 'number', required: true },
2188
+ },
2189
+ },
2190
+ },
2191
+ sourcePoint: {
2192
+ type: 'object',
2193
+ additionalProperties: false,
2194
+ properties: {
2195
+ x: { type: 'number', required: true },
2196
+ y: { type: 'number', required: true },
2197
+ },
2198
+ },
2199
+ targetPoint: {
2200
+ type: 'object',
2201
+ additionalProperties: false,
2202
+ properties: {
2203
+ x: { type: 'number', required: true },
2204
+ y: { type: 'number', required: true },
2205
+ },
2206
+ },
2207
+ // 边上文字的位置(拖过才有):x = 沿边比例(-1..1,0 = 中点)、y = 垂直偏移 px。
2208
+ // 客户端能拖,AI 原来既看不见也改不了 —— 见 setLabelPos。
2209
+ labelX: { type: 'number' },
2210
+ labelY: { type: 'number' },
2211
+ labelOffsetX: { type: 'number' },
2212
+ labelOffsetY: { type: 'number' },
2213
+ // 线上文字的字色(设过且不是缺省黑字才有)。
2214
+ fontColor: { type: 'string' },
2215
+ layer: { type: 'string' },
2216
+ },
2217
+ },
2218
+ },
2219
+ },
2220
+ },
2221
+ render: function (args, value) {
2222
+ const layers = Array.isArray(value.layers) ? value.layers : []
2223
+ const layerNameOf = function (id) {
2224
+ for (let i = 0; i < layers.length; i += 1) if (layers[i].id === id) return layers[i].name
2225
+ return id
2226
+ }
2227
+ const inLayer = function (item) {
2228
+ return layers.length > 1 && typeof item.layer === 'string' && item.layer.length > 0 ? ' ⟨' + layerNameOf(item.layer) + '⟩' : ''
2229
+ }
2230
+ const lines = [
2231
+ '画布 ' + value.path + '(revision ' + value.revision + ',' + value.nodes.length + ' 节点 / ' + value.edges.length + ' 边' +
2232
+ (value.filtered === true ? '(**过滤后**;整张是 ' + value.totalNodes + ' 节点 / ' + value.totalEdges + ' 边)' : '') + ')',
2233
+ ]
2234
+ if (value.page !== undefined && value.page !== null) {
2235
+ lines.push(' 📄 纸张 ' + value.page.w + ' × ' + value.page.h)
2236
+ }
2237
+ for (let i = 0; i < value.nodes.length; i += 1) {
2238
+ const n = value.nodes[i]
2239
+ lines.push(
2240
+ ' 节点 ' + n.id + ' [' + n.shape + '] ' + n.label + ' @' + n.x + ',' + n.y + ' ' + n.w + '×' + n.h +
2241
+ (n.style.length === 0 ? '(默认样式)' : ' style: ' + n.style) + inLayer(n) +
2242
+ (typeof n.parent === 'string' ? ' ⌂在 ' + n.parent + ' 里' : ''),
2243
+ )
2244
+ }
2245
+ for (let i = 0; i < value.edges.length; i += 1) {
2246
+ const e = value.edges[i]
2247
+ const bits = [e.dash, e.arrow + ' arrow']
2248
+ if (e.color !== undefined) bits.push(e.color)
2249
+ // 线上文字的字色(与"线的颜色"是两件事,所以分开报)。
2250
+ if (e.fontColor !== undefined) bits.push('font ' + e.fontColor)
2251
+ if (e.exit !== undefined || e.entry !== undefined) bits.push((e.exit === undefined ? '?' : e.exit) + '->' + (e.entry === undefined ? '?' : e.entry))
2252
+ if (Array.isArray(e.points)) bits.push(e.points.length + ' waypoint(s)')
2253
+ // 边标签被拖过(不是弧长中点了):位置一起报出来,改它用 setLabelPos。
2254
+ if (e.labelX !== undefined) {
2255
+ const off = (e.labelOffsetX || e.labelOffsetY) ? '+off(' + numberOr(e.labelOffsetX, 0) + ',' + numberOr(e.labelOffsetY, 0) + ')' : ''
2256
+ bits.push('label@' + e.labelX + ',' + e.labelY + off)
2257
+ }
2258
+ lines.push(
2259
+ ' 边 ' + e.id + ' ' + e.from + ' -> ' + e.to + (typeof e.label === 'string' && e.label.length > 0 ? ' "' + e.label + '"' : '') +
2260
+ ' [' + bits.join(', ') + ']' + inLayer(e),
2261
+ )
2262
+ }
2263
+ for (let i = 0; i < value.notes.length; i += 1) lines.push(' ⚠ ' + value.notes[i])
2264
+ // 导出状态:请求过才有一行(让 AI 知道"那张 SVG 到底写出来了没有")。
2265
+ if (value.export !== undefined && value.export !== null && typeof value.export.status === 'string') {
2266
+ const ex = value.export
2267
+ lines.push(
2268
+ ' ⤓ 导出:' + ex.status + '(' + ex.format + ')' +
2269
+ (typeof ex.path === 'string' ? ' → ' + ex.path : '') +
2270
+ (typeof ex.error === 'string' ? ' ← ' + ex.error : ''),
2271
+ )
2272
+ }
2273
+ // 用户当前的选区:AI 说"这几个"时指的就是它们(不传 path 读的就是用户眼前那张)。
2274
+ if (Array.isArray(value.selection) && value.selection.length > 0) {
2275
+ lines.push(' 🔵 用户当前选中 ' + value.selection.length + ' 项:' + value.selection.join(', '))
2276
+ }
2277
+ // 图层表:v1 只读。名字 + 可见性(隐藏层里的东西 AI 也照读,但它得知道"用户现在看不见")。
2278
+ if (layers.length > 0) {
2279
+ lines.push(' 图层(顺序 = 叠放顺序,后面的在上面):')
2280
+ for (let i = 0; i < layers.length; i += 1) {
2281
+ const l = layers[i]
2282
+ lines.push(' ' + (l.visible === false ? '🚫' : '👁') + ' ' + l.name + ' (id ' + l.id + ')' + (l.locked === true ? ' 🔒' : ''))
2283
+ }
2284
+ }
2285
+ // 「看一眼」的状态:请求过才有 —— 模型要知道"图还没渲好"还是"这个部署/模型看不了图"。
2286
+ if (value.look !== undefined && value.look !== null && typeof value.look.status === 'string') {
2287
+ const look = value.look
2288
+ if (look.status === 'pending') {
2289
+ lines.push(' 👁 已请求渲染这张画布:画布几秒内渲好,**再调一次 diagram_read(render:true)**就能看到图')
2290
+ } else if (look.status === 'ready') {
2291
+ lines.push(' 👁 渲染结果在图块里' + (look.stale === true ? '(**注意:这是你请求渲染时的样子,之后文件又被改过**)' : ''))
2292
+ } else if (look.status === 'unsupported') {
2293
+ lines.push(' 👁 看不了图:' + String(look.error === undefined ? '这个部署没有可用的图片通道' : look.error))
2294
+ } else {
2295
+ lines.push(' 👁 渲染失败:' + String(look.error === undefined ? '未知原因' : look.error))
2296
+ }
2297
+ }
2298
+ const textBlock = { type: 'text', text: lines.join('\n') }
2299
+ // 有图就带上 **image 内容块**:图片走附件通道(DSH 的附件库,工作区零文件),
2300
+ // 与 `read_image` 返回的是同一种块,模型直接"看见"。
2301
+ if (value.image !== undefined && value.image !== null && typeof value.image.attachmentId === 'string') {
2302
+ return [textBlock, { type: 'image', attachment: Object.assign({}, value.image) }]
2303
+ }
2304
+ return [textBlock]
2305
+ },
2306
+ },
2307
+ async execute(args, exec) {
2308
+ // 与 diagram_apply 同一条回退链:显式 path > 用户当前打开的画布 > DEFAULT_PATH。
2309
+ // 两处必须一致 —— 否则 AI 会"读 A、写 B"。
2310
+ const focus = focusedPathFor(sessionIdOf(exec))
2311
+ const path =
2312
+ typeof args.path === 'string' && args.path.length > 0 ? args.path : focus !== undefined && focus.length > 0 ? focus : DEFAULT_PATH
2313
+ assertCanvasPath(path)
2314
+ const loaded = await loadDoc(path, sessionIdOf(exec))
2315
+ const doc = loaded.doc
2316
+ // 过滤(大图省上下文):`layer` 只看一层、`ids` 只看几个单元。
2317
+ // 计数与"整张有多少"分开报 —— 只报过滤后的数字会让模型以为图变小了。
2318
+ const wantLayer = typeof args.layer === 'string' && args.layer.length > 0 ? args.layer : undefined
2319
+ const wantIds = Array.isArray(args.ids) ? args.ids.filter((v) => typeof v === 'string' && v.length > 0) : []
2320
+ let layerFilter = null
2321
+ if (wantLayer !== undefined) {
2322
+ const list = Array.isArray(doc.layers) ? doc.layers : []
2323
+ for (let i = 0; i < list.length; i += 1) {
2324
+ if (String(list[i].id) === wantLayer) layerFilter = String(list[i].id)
2325
+ }
2326
+ if (layerFilter === null) {
2327
+ for (let i = 0; i < list.length; i += 1) {
2328
+ const name = typeof list[i].name === 'string' ? list[i].name : ''
2329
+ if (name.length > 0 && name === wantLayer) layerFilter = String(list[i].id)
2330
+ }
2331
+ }
2332
+ if (layerFilter === null) {
2333
+ throw new Error('没有叫 "' + wantLayer + '" 的图层。Layers: ' + list.map((l) => (l.name || '(无名)') + '#' + l.id).join(', '))
2334
+ }
2335
+ }
2336
+ const keepNode = (n) => {
2337
+ if (wantIds.length > 0 && wantIds.indexOf(String(n.id)) < 0) return false
2338
+ if (layerFilter !== null && String(n.layer) !== layerFilter) return false
2339
+ return true
2340
+ }
2341
+ const keepEdge = (e) => {
2342
+ if (wantIds.length > 0 && wantIds.indexOf(String(e.id)) < 0) return false
2343
+ if (layerFilter !== null && String(e.layer) !== layerFilter) return false
2344
+ return true
2345
+ }
2346
+ const filtered = layerFilter !== null || wantIds.length > 0
2347
+ const nodes = []
2348
+ for (let i = 0; i < doc.nodes.length; i += 1) {
2349
+ const n = doc.nodes[i]
2350
+ if (keepNode(n) === false) continue
2351
+ const style = typeof n.style === 'string' ? n.style : ''
2352
+ nodes.push({
2353
+ id: String(n.id),
2354
+ label: typeof n.label === 'string' ? n.label : String(n.id),
2355
+ shape: nodeShapeFromStyle(style),
2356
+ style: style,
2357
+ // 坐标与尺寸:**AI 也要看得见位置**。不给的话它判断不了"会不会重叠""有没有对齐"
2358
+ // (实测:想让它把某几个节点往右挪一点,它只能靠猜或者干脆删掉重画)。
2359
+ x: numberOr(n.x, 0),
2360
+ y: numberOr(n.y, 0),
2361
+ w: numberOr(n.w, DEFAULT_W),
2362
+ h: numberOr(n.h, DEFAULT_H),
2363
+ ...(typeof n.layer === 'string' && n.layer.length > 0 ? { layer: n.layer } : {}),
2364
+ // 容器父级:AI 得知道"这东西在哪个分组里",否则把容器挪走/删掉时它不知道会连带什么。
2365
+ ...(typeof n.parent === 'string' && n.parent.length > 0 ? { parent: n.parent } : {}),
2366
+ ...(fontColorFieldOf(style) !== undefined ? { fontColor: fontColorFieldOf(style) } : {}),
2367
+ })
2368
+ }
2369
+ const edges = []
2370
+ for (let i = 0; i < doc.edges.length; i += 1) {
2371
+ const e = doc.edges[i]
2372
+ if (keepEdge(e) === false) continue
2373
+ const style = typeof e.style === 'string' ? e.style : DEFAULT_EDGE_STYLE
2374
+ const item = {
2375
+ id: String(e.id),
2376
+ // 悬空端的这一端**没有真实顶点**:如实报空串,并把自由点一起给出
2377
+ // (报成 String(undefined) 会让模型以为那里真有个叫 "undefined" 的节点)。
2378
+ from: typeof e.from === 'string' ? e.from : '',
2379
+ to: typeof e.to === 'string' ? e.to : '',
2380
+ style: style,
2381
+ dash: dashFromStyle(style),
2382
+ arrow: arrowFromStyle(style),
2383
+ }
2384
+ if (typeof e.label === 'string' && e.label.length > 0) item.label = e.label
2385
+ // 派生 color:正好命中调色板就给名字(AI 好读),否则给原始 strokeColor ——
2386
+ // 不能因为"名字认不出"就让 AI 看不见颜色(drawio 的文档里本来就只有十六进制)。
2387
+ const name = colorNameFromStyle(style)
2388
+ if (name !== null && name !== 'plain') item.color = name
2389
+ else {
2390
+ const stroke = styleGet(style, 'strokeColor', null)
2391
+ if (stroke !== null && stroke !== DEFAULT_STROKE) item.color = stroke
2392
+ }
2393
+ const exit = sideFromStyle(style, 'source')
2394
+ const entry = sideFromStyle(style, 'target')
2395
+ if (exit !== null) item.exit = exit
2396
+ if (entry !== null) item.entry = entry
2397
+ const points = normalizePoints(e.points)
2398
+ if (points !== null) item.points = points
2399
+ if (e.sourcePoint !== undefined) item.sourcePoint = e.sourcePoint
2400
+ if (e.targetPoint !== undefined) item.targetPoint = e.targetPoint
2401
+ // 标签位置(拖过才有):给了 x/y 就一起回,改它用 setLabelPos。
2402
+ if (Number.isFinite(Number(e.labelX)) && Number.isFinite(Number(e.labelY))) {
2403
+ item.labelX = Number(e.labelX)
2404
+ item.labelY = Number(e.labelY)
2405
+ if (Number.isFinite(Number(e.labelOffsetX))) item.labelOffsetX = Number(e.labelOffsetX)
2406
+ if (Number.isFinite(Number(e.labelOffsetY))) item.labelOffsetY = Number(e.labelOffsetY)
2407
+ }
2408
+ if (typeof e.layer === 'string' && e.layer.length > 0) item.layer = e.layer
2409
+ const edgeFont = fontColorFieldOf(style)
2410
+ if (edgeFont !== undefined) item.fontColor = edgeFont
2411
+ edges.push(item)
2412
+ }
2413
+ // 顺路带两件事(都要在 output.schema 里声明 —— additionalProperties:false 会把
2414
+ // 没声明的字段整个判为非法输出,这条踩过一次):
2415
+ // highlight —— AI 用 `{op:'highlight'}` 请求的高亮,取走即清;
2416
+ // canRevert —— 这张画布上有没有可退回的 AI 改动。
2417
+ const pendingHighlight = highlightFor.get(sessionIdOf(exec))
2418
+ if (pendingHighlight !== undefined) highlightFor.delete(sessionIdOf(exec))
2419
+ const exportState = exportStateFor(sessionIdOf(exec), loaded.absolute)
2420
+ // 「看一眼」(render:true):交图,或挂一个渲染请求。
2421
+ //
2422
+ // 三道门依次过:附件服务在不在(图片的落脚处)→ 模型声明不声明图片输入 → 手上有没有渲好的图。
2423
+ // 前两道过不去时**给一句人话**(而不是抛错)—— AI 还能照常读几何、改图,只是看不到图。
2424
+ let lookState = undefined
2425
+ let imageValue = undefined
2426
+ if (args.render === true) {
2427
+ const attachments = attachmentsService()
2428
+ if (attachments === undefined || attachments === null) {
2429
+ lookState = { status: 'unsupported', error: '这个部署没有挂附件服务(attachments),图片进不了模型上下文' }
2430
+ } else {
2431
+ const capable = await imageInputSupported(exec)
2432
+ if (capable === false) {
2433
+ lookState = { status: 'unsupported', error: '当前模型没有声明图片输入能力;换一个支持图片的模型再看' }
2434
+ } else {
2435
+ const state = renderStateFor(sessionIdOf(exec), loaded.absolute, doc.revision)
2436
+ if (state !== undefined && state.status === 'failed') {
2437
+ lookState = { status: 'failed', error: state.error }
2438
+ renderedImages.delete(sessionIdOf(exec))
2439
+ } else if (state !== undefined && state.status === 'ready') {
2440
+ const taken = takeRenderedImage(sessionIdOf(exec))
2441
+ if (taken !== undefined) {
2442
+ lookState = taken.revision === doc.revision ? { status: 'ready' } : { status: 'ready', stale: true }
2443
+ imageValue = taken.image
2444
+ }
2445
+ } else {
2446
+ // 同一张画布已有请求就沿用(客户端按 requestId 去重,换 id 会让它再渲一遍)。
2447
+ const existing = renderRequests.get(sessionIdOf(exec))
2448
+ if (existing === undefined || existing.absolute !== loaded.absolute) {
2449
+ // 换了画布:上一张渲好的图作废(否则会把 A 图的画面当 B 图交给模型)。
2450
+ renderedImages.delete(sessionIdOf(exec))
2451
+ renderRequests.set(sessionIdOf(exec), {
2452
+ requestId: 'r' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
2453
+ absolute: loaded.absolute,
2454
+ revision: doc.revision,
2455
+ })
2456
+ }
2457
+ lookState = { status: 'pending' }
2458
+ }
2459
+ }
2460
+ }
2461
+ }
2462
+ return {
2463
+ path: loaded.absolute,
2464
+ revision: doc.revision,
2465
+ ...(doc.page !== undefined && doc.page !== null ? { page: { w: Number(doc.page.w), h: Number(doc.page.h) } } : {}),
2466
+ ...(filtered ? { filtered: true, totalNodes: doc.nodes.length, totalEdges: doc.edges.length } : {}),
2467
+ ...(lookState !== undefined ? { look: lookState } : {}),
2468
+ ...(imageValue !== undefined ? { image: imageValue } : {}),
2469
+ notes: loaded.notes,
2470
+ nodes: nodes,
2471
+ edges: edges,
2472
+ ...(exportState !== undefined ? { export: exportState } : {}),
2473
+ highlight: Array.isArray(pendingHighlight) ? pendingHighlight : [],
2474
+ canRevert: revertDepth(snapshotKey(sessionIdOf(exec), loaded.absolute)) > 0,
2475
+ // 还能退回几步(栈深):模型据此告诉用户"可以连按几次撤销"。
2476
+ revertSteps: revertDepth(snapshotKey(sessionIdOf(exec), loaded.absolute)),
2477
+ // 用户当前的选区(按文件配对、按存在过滤):用户说"把这几个…"时指的就是它们。
2478
+ // 过滤过也要照旧回 —— "用户选中的那几个"正是最该看的东西。
2479
+ selection: selectionForCanvas(sessionIdOf(exec), loaded.absolute, doc),
2480
+ // 图层表(顺序 = 叠放顺序):加层/移层/改名看 diagram_apply 的 addLayer/setLayer/setLayerProps。
2481
+ layers: (Array.isArray(doc.layers) ? doc.layers : []).map((l) => ({
2482
+ id: String(l.id),
2483
+ name: typeof l.name === 'string' ? l.name : '',
2484
+ visible: l.visible !== false,
2485
+ locked: l.locked === true,
2486
+ })),
2487
+ }
2488
+ },
2489
+ })
2490
+
2491
+ const applyTool = defineTool({
2492
+ name: 'diagram_apply',
2493
+ description:
2494
+ '对工作区里的 DrawAI 画布文档施加一组结构化编辑(加节点/连边/改标签/改样式/移动/删除/高亮),然后自动布局并写回文件。布局默认由这里算;ops 里一旦自带几何(addNode 给了 x/y,或有 move)就不再重排,想重排请显式给 layout。' +
2495
+ 'ops 的每一项形如 {op:"addNode", label:"...", as?, shape?, style?, keys?, w?, h?, x?, y?, fontColor?} / ' +
2496
+ '{op:"addEdge", from?, to?, fromPoint?, toPoint?, as?, label?, style?, dash?, arrow?, color?, exit?, entry?, jettySize?, edgeStyle?, line?, avoid?, fontSize?, fontColor?, keys?} / ' +
2497
+ '{op:"setLabel", id|ids, label} / {op:"setLabelPos", id, x?, y?, dx?, dy?, center?} / {op:"setStyle", id|ids, shape?, style?, keys?, w?, h?, dash?, arrow?, color?, exit?, entry?, jettySize?, edgeStyle?, line?, avoid?, fontSize?, fontColor?, clearPoints?} / ' +
2498
+ '{op:"setEdge", id, from?, to?, fromPoint?, toPoint?} / {op:"order", id|ids, to:"front"|"back"|"up"|"down"} / ' +
2499
+ '{op:"duplicate", id|ids, dx?, dy?, withEdges?, layer?, as?} / {op:"addLayer", name?, as?} / {op:"setLayer", id|ids, layer} / {op:"setLayerProps", layer, name?, visible?, locked?} / ' +
2500
+ '{op:"export", format?:"svg"|"png", name?} / ' +
2501
+ '{op:"move", id|ids, dx?, dy?, x?, y?} / {op:"remove", id|ids} / {op:"highlight", ids:[...]};节点 id 省略时自动分配。' +
2502
+ '**改接一条边**用 setEdge(换 from/to 或某一端改成自由点)—— id、标签、标签位置、折点、端点约束全都留着;' +
2503
+ '不要 remove + addEdge 重画(那会把这些丢掉)。' +
2504
+ '**边上的文字压住线**时用 setLabelPos {id, center:true} 放回弧长中点,或用 {id, dy:12} 把它往上/下挪 12px' +
2505
+ '(x 是沿边比例 -1..1,0 = 中点;diagram_read 回 labelX/labelY 告诉你它现在在哪)。' +
2506
+ '**顺序**用 order(front 到最上面 / back / up / down,与画布右键同一套;它改的是文件里单元的先后)。' +
2507
+ '**再复制一份**用 duplicate(新 id + 整体平移两格,折点/端点约束/标签位置都带过去;默认连"两端都在复制范围内的边"一起复制)。' +
2508
+ '**图层**用 addLayer/setLayer/setLayerProps(加层、把单元移到别的层、改名/显示/隐藏/锁定;隐藏≠删除)。' +
2509
+ '**导出**用 export:format:"svg" 会让画布渲染一张 SVG 落在 .drawio 旁边、format:"png" 是浏览器下载;' +
2510
+ '这一步由画布执行,返回值里的 export.status 是 pending/done/downloaded/failed(pending = 画布还没刷新)。' +
2511
+ '**一次改一组**就写 ids:[...](setStyle/setLabel/move/remove 都收,move 的批量只收 dx/dy 相对位移)—— 用户说"把这几个换成绿色"时不要逐个发 op。' +
2512
+ '**引用刚建的那个**用 as:"名字":{op:"addNode", label:"开始", as:"start"} 之后同一个 ops 数组里 from/to/id 可以直接写 "start",不必猜自动分配的 n7。' +
2513
+ '返回值里的 created/changed/removed 是结构化 id(含自动分配的那些),不用去解析 summary 文本。' +
2514
+ 'expectRevision:把上一次 diagram_read 返回的 revision 传进来当乐观锁 —— 对不上(用户同时在 drawio 里改过)就**直接报错、一个字节都不写**,重读一次再改。' +
2515
+ 'addEdge 的两端各自可以给**节点 id** 或**绝对点**(fromPoint/toPoint)—— 两端都给点就是一条**独立线**' +
2516
+ '(drawio 里边的两端都可以是自由点,线可以完全不接节点)。' +
2517
+ 'move 改已有元素的位置:节点可以给 dx/dy(相对)或 x/y(绝对),连线只能给 dx/dy(平移它自己的折点与自由端点)。' +
2518
+ '以前没有 move,只能删掉重画 —— 那会丢 id、丢边上的端点约束与折点。' +
2519
+ 'highlight 只让客户端选中那几个(不改文档,也不重排)。' +
2520
+ '文档里存的是 **drawio 的 style 键**(dashed/dashPattern/edgeStyle/jettySize/libavoidRouting/exitX·exitY·entryX·entryY/endArrow·startArrow/strokeColor/fillColor/shape=/rounded=/arcSize=…),默认值一律省略、认不出的键原样保留;' +
2521
+ '上面这些 shape/style/dash/arrow/color/exit/entry 是给模型用的**糖**,由宿主翻译成 style 键,绝不落盘。' +
2522
+ 'style 既可以是调色板名(plain/blue/green/orange/yellow/red/purple/grey),也可以直接是一段 style 串;keys 用来写任意 drawio 键(值给 null = 删键回默认)。' +
2523
+ 'setStyle 的 id 可以是节点也可以是连线:节点用 shape/style/keys/w/h/fontColor,连线用 dash(solid|dashed|dotted)、arrow(end 单向|both 双向|none 无箭头|start 反向)、color(**线本身**的颜色)、exit/entry(n|e|s|w 进出侧)、jettySize(引出段长度,数字或 auto)、edgeStyle(orthogonalEdgeStyle|none)、line(straight 直线|sharp 直角折线|rounded 圆角折线|curved 曲线)、avoid(是否参与避让路由)、fontSize(字号)、fontColor(**文字**颜色)、clearPoints(清掉折点)。' +
2524
+ 'fontColor 给调色板名(plain/blue/green/orange/yellow/red/purple/grey)就按画布上"文字换色"那一支取色,给 #b85450 这类色值就原样写;fontColor:null 删键回缺省(黑字)。' +
2525
+ '文档若带 meta.pinned(人手工摆过位置),不加 layout 就不会重排;显式重排会清掉端点已移动的那些边上的过期折点。' + '边引用了不存在的节点会直接报错,且失败发生在写盘之前。自环写成 from === to(与 drawio 一致);自动布局会忽略自环,只摆节点。' +
2526
+ '不传 path 时改的是**用户当前打开的那张画布**(没打开才退回 ' + DEFAULT_PATH + ')—— 用户说"这张图"时不用传 path。',
2527
+ parameters: {
2528
+ path: {
2529
+ type: 'string',
2530
+ description:
2531
+ '工作区相对路径或绝对路径。**不传 = 用户当前打开的那张画布**(没打开任何画布时才退回 ' +
2532
+ DEFAULT_PATH +
2533
+ ')—— 想知道用户在看哪张,直接不传 path 调一次,返回里的 path 就是它。',
2534
+ },
2535
+ ops: {
2536
+ type: 'array',
2537
+ required: true,
2538
+ items: { type: 'json' },
2539
+ description: '结构化编辑数组,见工具描述里的 ops 语法',
2540
+ },
2541
+ layout: {
2542
+ type: 'string',
2543
+ enum: ['dagre-lr', 'dagre-tb', 'grid', 'none'],
2544
+ description: '自动布局:dagre-tb 上到下(默认,贴合右栏窄高形状)、dagre-lr 左到右、grid 网格、none 保留现有坐标(只给无坐标的新节点补位)',
2545
+ },
2546
+ expectRevision: {
2547
+ type: 'string',
2548
+ description:
2549
+ '乐观锁:上一次 diagram_read 返回的 revision(文件内容指纹)。对不上说明这份文件在你会话中途被别人改过(用户同时在 drawio 里编辑),本次调用**直接失败且不写盘**,重新读一次再改。不传 = 不检查。',
2550
+ },
2551
+ },
2552
+ output: {
2553
+ schema: {
2554
+ type: 'object',
2555
+ additionalProperties: false,
2556
+ properties: {
2557
+ path: { type: 'string', required: true },
2558
+ revision: { type: 'string', required: true },
2559
+ nodeCount: { type: 'number', required: true },
2560
+ edgeCount: { type: 'number', required: true },
2561
+ layout: { type: 'string', required: true },
2562
+ summary: { type: 'string', required: true },
2563
+ // 这次调用真的建了哪些单元(含自动分配的 id 与别名指向的那个)——
2564
+ // 模型要接着引用它们时不必再读一次、也不必解析 summary 文本。
2565
+ created: {
2566
+ type: 'array',
2567
+ required: true,
2568
+ items: {
2569
+ type: 'object',
2570
+ additionalProperties: false,
2571
+ properties: {
2572
+ id: { type: 'string', required: true },
2573
+ type: { type: 'string', required: true },
2574
+ label: { type: 'string' },
2575
+ },
2576
+ },
2577
+ },
2578
+ changed: { type: 'array', required: true, items: { type: 'string' } },
2579
+ removed: { type: 'array', required: true, items: { type: 'string' } },
2580
+ // 导出状态(只有请求过导出才有):pending = 画布还没渲染,done = 文件已写出(path),
2581
+ // downloaded = png 已由浏览器下载,failed = 渲染失败(error 是原因)。
2582
+ export: {
2583
+ type: 'object',
2584
+ additionalProperties: false,
2585
+ properties: {
2586
+ status: { type: 'string', required: true },
2587
+ format: { type: 'string', required: true },
2588
+ path: { type: 'string' },
2589
+ error: { type: 'string' },
2590
+ },
2591
+ },
2592
+ },
2593
+ },
2594
+ render: function (args, value) {
2595
+ const created = Array.isArray(value.created) ? value.created : []
2596
+ const bits = []
2597
+ if (created.length > 0) {
2598
+ bits.push('新建 ' + created.map((c) => c.id + '(' + c.type + ')').join(', '))
2599
+ }
2600
+ if (Array.isArray(value.changed) && value.changed.length > 0) bits.push('改动 ' + value.changed.join(', '))
2601
+ if (Array.isArray(value.removed) && value.removed.length > 0) bits.push('删除 ' + value.removed.join(', '))
2602
+ return [
2603
+ {
2604
+ type: 'text',
2605
+ text:
2606
+ '已更新 ' + value.path + ':revision ' + value.revision + ',' + value.nodeCount + ' 节点 / ' + value.edgeCount +
2607
+ ' 边,布局 ' + value.layout + (bits.length > 0 ? '\n' + bits.join(';') : '') + '\n' + value.summary,
2608
+ },
2609
+ ]
2610
+ },
2611
+ },
2612
+ async execute(args, exec) {
2613
+ // 没有显式 path 时,优先用"用户当前打开的那张画布"(客户端上报的聚焦路径),
2614
+ // 最后才退回 DEFAULT_PATH。这样"帮我在这张画布上画"改的就是屏幕上那张,
2615
+ // 而不是工作区里的 demo.drawio(那个坑实测踩过)。
2616
+ const focused = focusedPathFor(sessionIdOf(exec))
2617
+ const path =
2618
+ typeof args.path === 'string' && args.path.length > 0 ? args.path : focused !== undefined && focused.length > 0 ? focused : DEFAULT_PATH
2619
+ const ops = Array.isArray(args.ops) ? args.ops : []
2620
+ if (ops.length === 0) throw new Error('ops must contain at least one operation')
2621
+
2622
+ const sessionId = sessionIdOf(exec)
2623
+ assertCanvasPath(path)
2624
+ const loaded = await loadDoc(path, sessionId)
2625
+ const doc = loaded.doc
2626
+
2627
+ // 乐观锁:`revision` 是**文件内容指纹**(见 loadDoc),把它传给 expectRevision 就能保证
2628
+ // "我读到的那一版"和"我现在要改的那一版"是同一份。
2629
+ //
2630
+ // 为什么需要:read 返回的 revision 原来在 AI 侧**没有任何用处**(409 只存在于画布保存那条
2631
+ // 路由上),于是"AI 读 → 用户在 drawio 里改 → AI 写"这条时序会把用户的改动**静默**覆盖掉。
2632
+ // 现在基线对不上就直接报错、**一个字节都不写**,让模型重读一次再改。
2633
+ const expect = typeof args.expectRevision === 'string' && args.expectRevision.length > 0 ? args.expectRevision : undefined
2634
+ if (expect !== undefined && expect !== doc.revision) {
2635
+ throw new Error(
2636
+ 'revision conflict:你给的是 ' + expect + ',"' + loaded.absolute + '" 现在是 ' +
2637
+ (loaded.exists === true ? doc.revision : '(文件不存在)') +
2638
+ ' —— 别覆盖别人的改动。重新 diagram_read 一次,拿新的 revision 再改。',
2639
+ )
2640
+ }
2641
+
2642
+ // 人工摆过的文档(meta.pinned)默认不再自动重排 —— 否则 AI 一改图,
2643
+ // 人手工调好的位置就被 dagre 全冲掉了。想重排必须显式指定 layout。
2644
+ const pinned = doc.meta !== null && typeof doc.meta === 'object' && doc.meta.pinned === true
2645
+ // `highlight` 只是"让客户端选中这几个"、`export` 只是"让画布渲染一张图"—— 都不改文档:
2646
+ // · 一次全是这类 op 的调用**不写盘**(也不会顺手重排,那条特别危险);
2647
+ // · 混着编辑时,它们跟着一起生效。
2648
+ const edits = ops.filter((op) => op === null || typeof op !== 'object' || (op.op !== 'highlight' && op.op !== 'export'))
2649
+ const noDocEdit = edits.length === 0
2650
+ // 默认布局:pinned、纯 highlight、以及**ops 里自带几何**(addNode 给了 x/y、有 move、
2651
+ // 或有 duplicate —— 复制出来的位置是相对原件的)时都不重排 —— 否则"把 n1 往右挪 40"
2652
+ // 会被 dagre 立刻冲掉(实测:move 完坐标原样回来,看起来像工具坏了),
2653
+ // 复制也一样(刚贴出来的那份会连同原件一起被重排走)。
2654
+ const explicitGeometry = ops.some(
2655
+ (op) =>
2656
+ op !== null &&
2657
+ typeof op === 'object' &&
2658
+ (op.op === 'move' ||
2659
+ op.op === 'duplicate' ||
2660
+ (op.op === 'addNode' && (Number.isFinite(Number(op.x)) || Number.isFinite(Number(op.y))))),
2661
+ )
2662
+ const mode = typeof args.layout === 'string' ? args.layout : pinned || noDocEdit || explicitGeometry ? 'none' : 'dagre-tb'
2663
+ if (LAYOUTS.indexOf(mode) < 0) throw new Error('unknown layout "' + mode + '"; use one of ' + LAYOUTS.join(', '))
2664
+
2665
+ const out = {}
2666
+ const notes = applyOps(doc, ops, out)
2667
+ if (Array.isArray(out.highlight)) highlightFor.set(sessionId, out.highlight)
2668
+ // 删掉的东西不该还挂在"用户选中"里(id 会被复用,见 forgetSelection)。
2669
+ if (Array.isArray(out.removed) && out.removed.length > 0) forgetSelection(sessionId, loaded.absolute, out.removed)
2670
+ // 导出请求:登记下来(画布下一次轮询时取走并渲染),并把上一次的结果清掉,免得模型看到旧的。
2671
+ if (out.export !== undefined && out.export !== null) {
2672
+ const requestId = 'x' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
2673
+ exportRequests.set(sessionId, { requestId: requestId, absolute: loaded.absolute, format: out.export.format, name: out.export.name })
2674
+ exportResults.delete(sessionId)
2675
+ }
2676
+ if (noDocEdit) {
2677
+ return {
2678
+ path: loaded.absolute,
2679
+ revision: doc.revision,
2680
+ nodeCount: doc.nodes.length,
2681
+ edgeCount: doc.edges.length,
2682
+ layout: 'none',
2683
+ created: [],
2684
+ changed: [],
2685
+ removed: [],
2686
+ ...(exportStateFor(sessionId, loaded.absolute) !== undefined ? { export: exportStateFor(sessionId, loaded.absolute) } : {}),
2687
+ summary: notes.length === 0 ? '(no change)' : notes.join('\n'),
2688
+ }
2689
+ }
2690
+ if (mode === 'none') placeMissing(doc)
2691
+ else {
2692
+ const before = nodePositions(doc)
2693
+ autoLayout(doc, mode)
2694
+ const dropped = invalidateStalePoints(doc, before)
2695
+ if (dropped > 0) notes.push('~ 重排后端点位置变了,已清掉 ' + dropped + ' 条边上的过期折点')
2696
+ }
2697
+ // 写回:**在原文件上做无损写回**(只改我们拥有的单元,其余原文逐字节保留)。
2698
+ // revision 由文件内容算出来,不再是"我们自己 +1"—— 载体即真相,没有第二个计数器。
2699
+ // meta.pinned 的合并规则见下面那句注释。
2700
+ //
2701
+ // 这里曾经写的是 doc.meta = { engine, layout },于是**第一次 AI 改图就把 pinned 擦掉了**:
2702
+ // 那一次没事(mode 已经是 'none',人不人的位置都保住了),但从第二次起 pinned 读不到,
2703
+ // mode 回落到 'dagre-tb',人手工摆好的版面被整张重排 —— 正是上面那个判断要防的事。
2704
+ // 教训:用整体赋值覆盖 meta 时,"没被显式处理"的字段会静默消失;新增 meta 字段时先看这里。
2705
+ doc.meta = Object.assign({}, doc.meta, { layout: mode }, pinned ? { pinned: true } : {})
2706
+ const rendered = renderDocText(loaded.text, doc)
2707
+ doc.revision = rendered.revision
2708
+ if (rendered.dropped.length > 0) notes.push('~ ' + rendered.dropped.length + ' 条边的两端都没有落点,已略过:' + rendered.dropped.join(', '))
2709
+ const policy = policyFor(sessionId)
2710
+ // 落盘前留一份"上一版":AI 的改动**不在客户端的撤销历史里**(服务端推新版本时
2711
+ // 客户端会清空本地历史,免得一次 Ctrl+Z 把 AI 的改动悄悄顶掉),
2712
+ // 所以这边给一个一层的回退点,界面上对应「编辑 → 撤销 AI 改动」。
2713
+ if (loaded.exists === true && typeof loaded.text === 'string') {
2714
+ pushRevertSnapshot(snapshotKey(sessionId, loaded.absolute), { text: loaded.text, revision: doc.revision })
2715
+ }
2716
+ try {
2717
+ if (policy === undefined) await ctx.fs.writeText(loaded.target, rendered.text)
2718
+ else await ctx.fs.writeText(loaded.target, rendered.text, undefined, undefined, policy)
2719
+ } catch (error) {
2720
+ const scope = policy === undefined ? 'unresolved policy' : policy.mode + ' @ ' + String(policy.workspaceRoot)
2721
+ throw new Error('write "' + loaded.absolute + '" failed: ' + messageOf(error) + ' [sandbox: ' + scope + ']')
2722
+ }
2723
+ if (revertDepth(snapshotKey(sessionId, loaded.absolute)) > 0) {
2724
+ notes.push('↩ 这次改动可以退回(还能退 ' + revertDepth(snapshotKey(sessionId, loaded.absolute)) + ' 步):界面上「编辑 → 撤销 AI 改动」')
2725
+ }
2726
+ return {
2727
+ path: loaded.absolute,
2728
+ revision: doc.revision,
2729
+ nodeCount: doc.nodes.length,
2730
+ edgeCount: doc.edges.length,
2731
+ layout: mode,
2732
+ // 结构化"这次动了什么":新建的 id(含自动分配的)与别名解析结果都在这里,
2733
+ // 模型不必再从 summary 文本里认 id。改完的 revision 也可以直接当 expectRevision 用。
2734
+ created: Array.isArray(out.created) ? out.created : [],
2735
+ changed: Array.isArray(out.changed) ? out.changed : [],
2736
+ removed: Array.isArray(out.removed) ? out.removed : [],
2737
+ ...(exportStateFor(sessionId, loaded.absolute) !== undefined ? { export: exportStateFor(sessionId, loaded.absolute) } : {}),
2738
+ summary: notes.length === 0 ? '(no change)' : notes.join('\n'),
2739
+ }
2740
+ },
2741
+ })
2742
+
2743
+ /**
2744
+ * 校验"新建画布"的文件名。
2745
+ *
2746
+ * 只允许**单个文件名**(不含路径分隔符),且必须以 .drawio 结尾。
2747
+ * 这样即使用户在界面上输入 `..\\..\\x` 也进不来 —— 新建永远落在工作区根目录,
2748
+ * 不存在"写到哪里去了"的空间。想放到子目录请自己先建好再打开。
2749
+ */
2750
+ function sanitizeNewName(raw) {
2751
+ const name = String(raw === undefined || raw === null ? '' : raw).trim()
2752
+ if (name.length === 0) return { ok: false, error: '请输入文件名' }
2753
+ if (/[\\/]/.test(name)) return { ok: false, error: '文件名不能包含路径分隔符(新建只落在工作区根目录)' }
2754
+ if (name === '.' || name === '..') return { ok: false, error: '文件名非法' }
2755
+ // 禁止 Windows 非法字符,省得写盘才失败
2756
+ if (/[<>:"|?*\u0000-\u001f]/.test(name)) return { ok: false, error: '文件名包含非法字符' }
2757
+ const withExt = name.toLowerCase().endsWith('.drawio') ? name : name + '.drawio'
2758
+ if (withExt.length > 120) return { ok: false, error: '文件名过长' }
2759
+ return { ok: true, name: withExt }
2760
+ }
2761
+
2762
+ /**
2763
+ * 「画布聚焦」:记录每个会话当前**打开着**的画布路径。
2764
+ *
2765
+ * 为什么需要:AI 改图只认路径。不传 path 时原来会落到 demo.drawio ——
2766
+ * 于是"帮我在这张画布上画"会画到别的文件里,用户屏幕上毫无反应(实测踩过)。
2767
+ * 有了聚焦记录,不传 path 就改"用户正看着的那张"。
2768
+ *
2769
+ * 只存路径、不读内容:画布内容始终以文件为唯一真相源,这里记的只是"在看哪一张"。
2770
+ */
2771
+ const focusedCanvas = new Map()
2772
+ /** 每个会话最近**注入过**的聚焦路径(同一张不重复打扰模型)。 */
2773
+ const injectedCanvas = new Map()
2774
+ /**
2775
+ * AI 改动前的"上一版"文本(按 会话+路径 存,**一个栈**,最多 `REVERT_STEPS` 层)。
2776
+ *
2777
+ * 客户端的撤销历史在服务端推来新版本时会被清空(故意的:否则一次 Ctrl+Z
2778
+ * 会把 AI 刚写的改动悄悄顶掉),所以 AI 的改动要能退,就得宿主自己留回退点。
2779
+ *
2780
+ * 为什么是栈而不是一层:一轮会话里 AI 往往连改好几次("再挪一点""这两条也换颜色"),
2781
+ * 只留一层的话,用户按一下「撤销 AI 改动」就直接跳回最开始 —— 中间那几步无从恢复。
2782
+ * 现在每按一次退一步(界面上的菜单项可以连按),read 的 `revertSteps` 告诉模型还剩几步。
2783
+ */
2784
+ const revertSnapshots = new Map()
2785
+ /** 回退栈深度上限:够用(一轮对话的改图次数)又不至于把内存堆起来(每个快照就是一份文件全文)。 */
2786
+ const REVERT_STEPS = 8
2787
+ function pushRevertSnapshot(key, entry) {
2788
+ const stack = revertSnapshots.get(key)
2789
+ if (stack === undefined) revertSnapshots.set(key, [entry])
2790
+ else {
2791
+ stack.push(entry)
2792
+ while (stack.length > REVERT_STEPS) stack.shift()
2793
+ }
2794
+ }
2795
+ function revertDepth(key) {
2796
+ const stack = revertSnapshots.get(key)
2797
+ return stack === undefined ? 0 : stack.length
2798
+ }
2799
+ /** AI 请求的"高亮这些"(按会话,客户端下次来读时取走并清空)。 */
2800
+ const highlightFor = new Map()
2801
+ /**
2802
+ * 「看一眼这张画布」:把画布渲染成一张 PNG,经**附件的通道**直接进模型。
2803
+ *
2804
+ * 为什么不是落一个文件在工作区:用户的诉求是"AI 看图,但别在我的项目里留东西"。
2805
+ * 所以这条路**一个字节都不写工作区** —— 渲染好的 PNG 交给 DSH 的附件服务
2806
+ * (`attachments.saveImage`,内容寻址、在工作区之外,与用户上传图片是同一套存储),
2807
+ * 工具结果里带一个 `{type:'image', attachment}` 内容块,模型就"看见"了。
2808
+ *
2809
+ * 渲染仍然在浏览器那一半(正交路由、避让、圆角、断线都在那儿),所以这是一条
2810
+ * **请求/回执**通道:宿主挂请求 → 客户端轮询取走 → canvas 渲成 PNG → base64 回执
2811
+ * → 宿主存成附件 → 下一次 `diagram_read {render:true}` 把图带回去。
2812
+ */
2813
+ const renderRequests = new Map()
2814
+ /** 渲染好的图(按会话):`{ absolute, revision, image, at }` —— 取走即清,超时作废。 */
2815
+ const renderedImages = new Map()
2816
+ /** 渲染结果的保鲜期:超过它就不再交付(要新的请重新请求一次,免得给模型看一张老图)。 */
2817
+ const RENDER_TTL_MS = 5 * 60 * 1000
2818
+
2819
+ /** 附件服务(可选依赖):这个部署没装它就没法"看图",但插件其余功能照旧。 */
2820
+ function attachmentsService() {
2821
+ if (typeof ctx.get !== 'function') return undefined
2822
+ try {
2823
+ return ctx.get('attachments')
2824
+ } catch (error) {
2825
+ return undefined
2826
+ }
2827
+ }
2828
+
2829
+ /**
2830
+ * 当前路由的模型是否声明了**图片输入**。
2831
+ *
2832
+ * 与 `read_image` 同一套判据(provider/model 取会话的路由配置,回落到 agent options,
2833
+ * 再问 llm 服务的 `resolveModelInfo`)。**判不出来时不算失败**:DSH 自己会把图片降级成
2834
+ * 一句文字占位符(text-only 模型),所以这里只用来提前给一句人话,而不是拦死。
2835
+ */
2836
+ async function imageInputSupported(exec) {
2837
+ if (typeof ctx.get !== 'function') return undefined
2838
+ let llm
2839
+ try {
2840
+ llm = ctx.get('llm')
2841
+ } catch (error) {
2842
+ return undefined
2843
+ }
2844
+ if (llm === undefined || llm === null || typeof llm.resolveModelInfo !== 'function') return undefined
2845
+ const agent = exec === undefined || exec === null ? undefined : exec.agent
2846
+ let provider
2847
+ let model
2848
+ try {
2849
+ const header = agent !== undefined && agent.session !== undefined && typeof agent.session.requestHeader === 'function' ? agent.session.requestHeader() : undefined
2850
+ const routed = header === undefined || header === null ? undefined : header.config
2851
+ provider = routed === undefined || routed === null ? undefined : routed.provider
2852
+ model = routed === undefined || routed === null ? undefined : routed.model
2853
+ if (provider === undefined && agent !== undefined && agent.options !== undefined) provider = agent.options.provider
2854
+ if (model === undefined && agent !== undefined && agent.options !== undefined) model = agent.options.model
2855
+ } catch (error) {
2856
+ return undefined
2857
+ }
2858
+ if (typeof provider !== 'string' || typeof model !== 'string') return undefined
2859
+ try {
2860
+ const info = await llm.resolveModelInfo(provider, model, exec === undefined ? undefined : exec.signal)
2861
+ if (info === undefined || info === null || Array.isArray(info.inputModalities) === false) return undefined
2862
+ return info.inputModalities.indexOf('image') >= 0
2863
+ } catch (error) {
2864
+ return undefined
2865
+ }
2866
+ }
2867
+
2868
+ /**
2869
+ * 「看图」这条路当前的可用性 + 待交付的图。
2870
+ *
2871
+ * 状态:`pending`(已请求,等画布渲染)/ `ready`(有图,这一次带回模型)/
2872
+ * `unsupported`(这个部署/模型看不了图,附一句为什么)/ `failed`(渲染回执说失败了)。
2873
+ */
2874
+ function renderStateFor(sessionId, absolute, revision) {
2875
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined
2876
+ const pending = renderRequests.get(sessionId)
2877
+ if (pending !== undefined && pending.absolute === absolute) return { status: 'pending' }
2878
+ const done = renderedImages.get(sessionId)
2879
+ if (done === undefined || done.absolute !== absolute) return undefined
2880
+ if (Date.now() - done.at > RENDER_TTL_MS) {
2881
+ renderedImages.delete(sessionId)
2882
+ return undefined
2883
+ }
2884
+ if (done.status === 'failed') return { status: 'failed', error: String(done.error) }
2885
+ return { status: 'ready', stale: done.revision !== revision, image: done.image }
2886
+ }
2887
+
2888
+ /** 取走那张图(一次性):模型看过就不再重复塞给它,要再看就再请求一次。 */
2889
+ function takeRenderedImage(sessionId) {
2890
+ const done = renderedImages.get(sessionId)
2891
+ if (done === undefined || done.status !== 'ready') return undefined
2892
+ renderedImages.delete(sessionId)
2893
+ return done
2894
+ }
2895
+
2896
+ /**
2897
+ * 用户当前**选中**了哪几个单元(按会话):`{ absolute, ids }`。
2898
+ *
2899
+ * 为什么需要:用户说"把这几个换成绿色""把它们挪右边一点"时,AI 原来只能靠坐标猜
2900
+ * (README 的 D 节第一条)。客户端本来就有选区状态,只是从来没告诉宿主。
2901
+ *
2902
+ * 两条设计约束:
2903
+ * · **认文件**:记录里带绝对路径,只有读的正是那一张时才回 —— 否则"在 A 图选中的 n1"
2904
+ * 会被当成 B 图里的 n1(两个文件里都叫 n1 太常见了);
2905
+ * · **认存在**:回给模型前按**当前文档**过滤一遍(见 selectionForCanvas)—— 用户或 AI
2906
+ * 刚删掉的东西不该还在"选中"里。
2907
+ * 选区只是"用户在看什么"的提示,不是文档状态:**不进文件**,关掉画布就清。
2908
+ */
2909
+ const canvasSelection = new Map()
2910
+ /**
2911
+ * AI 请求的导出(按会话):`{ requestId, absolute, format, name }`。
2912
+ *
2913
+ * 渲染只在浏览器里(路由、避让、圆角都在画布那一半),所以导出是一条**请求/回执**通道:
2914
+ * 宿主挂上请求 → 客户端下次轮询的 `read` 里取走 → 渲染完 POST 回 `export-result`
2915
+ * → 宿主写文件(svg)或记一笔"已下载"(png 走浏览器下载,与菜单导出同一条路)。
2916
+ */
2917
+ const exportRequests = new Map()
2918
+ /** 最近一次导出的结果(`diagram_read` 把它带回给模型)。 */
2919
+ const exportResults = new Map()
2920
+
2921
+ /** 路径小工具:导出文件落在 `.drawio` 旁边,所以只需要在同级拼名字。 */
2922
+ function dirOf(absolute) {
2923
+ const text = String(absolute)
2924
+ const at = Math.max(text.lastIndexOf('\\'), text.lastIndexOf('/'))
2925
+ return at > 0 ? text.slice(0, at) : ''
2926
+ }
2927
+ function baseNameOf(absolute) {
2928
+ const text = String(absolute)
2929
+ const at = Math.max(text.lastIndexOf('\\'), text.lastIndexOf('/'))
2930
+ return (at >= 0 ? text.slice(at + 1) : text).replace(/\.drawio$/i, '')
2931
+ }
2932
+
2933
+ /**
2934
+ * 这张画布上的导出状态:还挂着请求 = pending;已经回过结果就报结果;什么都没发生过 = undefined。
2935
+ *
2936
+ * 路径必须对得上 —— 导出是"这张画布"的事,换了标签之后不该把上一张的结果报成这一张的。
2937
+ */
2938
+ function exportStateFor(sessionId, absolute) {
2939
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined
2940
+ const pending = exportRequests.get(sessionId)
2941
+ if (pending !== undefined && pending.absolute === absolute) {
2942
+ return { status: 'pending', format: pending.format }
2943
+ }
2944
+ const done = exportResults.get(sessionId)
2945
+ if (done === undefined || done.absolute !== absolute) return undefined
2946
+ if (done.status === 'done') return { status: 'done', format: done.format, path: done.path }
2947
+ if (done.status === 'downloaded') return { status: 'downloaded', format: done.format }
2948
+ return { status: 'failed', format: done.format, error: String(done.error) }
2949
+ }
2950
+
2951
+ /**
2952
+ * 把"这次被删掉的单元"从选区记录里划掉。
2953
+ *
2954
+ * 光靠读时的存在性过滤不够:id 是**会被复用的**(删掉 n3 再建一个,下一个还是 n3),
2955
+ * 于是"用户之前选中的 n3"会凭空变成刚新建的那个节点 —— 删除时就划掉才干净。
2956
+ */
2957
+ function forgetSelection(sessionId, absolute, ids) {
2958
+ const entry = canvasSelection.get(sessionId)
2959
+ if (entry === undefined || entry.absolute !== absolute) return
2960
+ entry.ids = entry.ids.filter((id) => ids.indexOf(id) < 0)
2961
+ if (entry.ids.length === 0) canvasSelection.delete(sessionId)
2962
+ }
2963
+
2964
+ function snapshotKey(sessionId, absolute) {
2965
+ return String(sessionId) + '\n' + String(absolute)
2966
+ }
2967
+
2968
+ /**
2969
+ * 把选区记录过滤成"这张文档里真的还存在"的 id 列表。
2970
+ *
2971
+ * 路径对不上就返回空数组(切了标签、或 AI 读的是另一张图)。
2972
+ * 过滤而不是原样回:`remove` 之后残留的 id 会让模型以为东西还在。
2973
+ */
2974
+ function selectionForCanvas(sessionId, absolute, doc) {
2975
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return []
2976
+ const entry = canvasSelection.get(sessionId)
2977
+ if (entry === undefined || entry.absolute !== absolute) return []
2978
+ const alive = {}
2979
+ for (let i = 0; i < doc.nodes.length; i += 1) alive[String(doc.nodes[i].id)] = true
2980
+ for (let i = 0; i < doc.edges.length; i += 1) alive[String(doc.edges[i].id)] = true
2981
+ const out = []
2982
+ for (let i = 0; i < entry.ids.length; i += 1) if (alive[entry.ids[i]] === true) out.push(entry.ids[i])
2983
+ return out
2984
+ }
2985
+
2986
+ function focusedPathFor(sessionId) {
2987
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined
2988
+ return focusedCanvas.get(sessionId)
2989
+ }
2990
+
2991
+ /**
2992
+ * 用户切换画布时,把"当前打开的是哪一张"**注入**到那个会话(Agent 的 model-facing context)。
2993
+ *
2994
+ * 为什么值得主动说一句:工具不传 path 时本来就落到"用户当前打开的那张",
2995
+ * 但模型在此之前**只能靠先调一次 diagram_read、看返回里的 path** 才知道是哪张 ——
2996
+ * 用户问"我现在看的是哪张图""在这张图上加个节点"时,它得先探一次才敢动。
2997
+ * 注入之后,模型在下一步就能直接看到,不用探。
2998
+ *
2999
+ * `agents` 是**可选**服务(走 ctx.get):没有它的部署、或注入形状对不上老版本时,
3000
+ * 都只是少了这条提示 —— 工具那条回退链(显式 path > 聚焦画布 > DEFAULT_PATH)仍然是兜底。
3001
+ */
3002
+ function notifyFocusedCanvas(sessionId, path) {
3003
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return
3004
+ const agents = typeof ctx.get === 'function' ? ctx.get('agents') : undefined
3005
+ if (agents === undefined || agents === null || typeof agents.get !== 'function') return
3006
+ let agent
3007
+ try {
3008
+ agent = agents.get(sessionId)
3009
+ } catch (error) {
3010
+ return
3011
+ }
3012
+ if (agent === undefined || agent === null || typeof agent.inject !== 'function') return
3013
+ // 只记"注入过哪一张",同一张反复报(客户端每次挂载都会报一次)就不再打扰模型。
3014
+ if (injectedCanvas.get(sessionId) === path) return
3015
+ injectedCanvas.set(sessionId, path)
3016
+ const text =
3017
+ path.length > 0
3018
+ ? 'DrawAI 画布:用户现在打开的是 ' + path + '。diagram_read / diagram_apply 不传 path 时默认就操作这一张。'
3019
+ : 'DrawAI 画布:用户关掉了画布(此刻没有打开任何一张)。不传 path 时工具会退回 ' + DEFAULT_PATH + '。'
3020
+ try {
3021
+ // 形状照 @deepseek-ai/dsh-llm 的 UserMessage:role + content + source。
3022
+ // source 用 `{ kind: 'plugin', plugin: <本插件名> }`(与第一方 dsh-agent-instructions 同款);
3023
+ // 不带 form —— 它只是个"环境事实",不是 instructions/catalog 那几类。
3024
+ agent.inject({ role: 'user', content: [{ type: 'text', text: text }], source: { kind: 'plugin', plugin: name } })
3025
+ } catch (error) {
3026
+ // 注入失败不该影响画布本身:工具那条回退链还在。
3027
+ }
3028
+ }
3029
+
3030
+ /**
3031
+ * 人工编辑的写回端点。
3032
+ *
3033
+ * 为什么必须自带:客户端能用的 Remote 面**只有读方法**(workspaceFiles 的
3034
+ * read/readBytes/readAll/readRelated/stat/list/changes),整个 DSH 没有通用的
3035
+ * "写文件" Remote。人拖完节点要落盘,只能自己开一条通道 —— dsh-better-sidebar
3036
+ * 的编辑器 tab 保存文件走的也是这个办法(/sidebar/api)。
3037
+ *
3038
+ * 信任围栏(方案 §6.4 明确警告:开任意路径路由会绕过围栏,所以这里逐条校验):
3039
+ * 1. 只接受 POST,且必须带自定义头 —— 跨站表单设不了它,跨域 fetch 会触发预检被浏览器拦掉
3040
+ * 2. 带 Origin 时必须同源
3041
+ * 3. 只写会话工作区根内的 .drawio(用 fs.contains 做真实路径包含判断,不是字符串前缀)
3042
+ * 4. revision 乐观锁:对不上就 409,绝不覆盖别人的改动
3043
+ *
3044
+ * 顺带把 meta.pinned 打成 true —— 从此这个文档被人手工摆过,
3045
+ * diagram_apply 不该再默认自动重排把它冲掉。
3046
+ */
3047
+ async function handleSave(req, res) {
3048
+ if (req.method !== 'POST') {
3049
+ sendJson(res, 405, { ok: false, error: 'POST only' })
3050
+ return
3051
+ }
3052
+ if (req.headers[SAVE_HEADER] !== '1') {
3053
+ sendJson(res, 403, { ok: false, error: 'missing ' + SAVE_HEADER + ' header' })
3054
+ return
3055
+ }
3056
+ const origin = req.headers.origin
3057
+ if (typeof origin === 'string' && origin.length > 0) {
3058
+ let sameOrigin = false
3059
+ try {
3060
+ sameOrigin = new URL(origin).host === req.headers.host
3061
+ } catch (error) {
3062
+ sameOrigin = false
3063
+ }
3064
+ if (!sameOrigin) {
3065
+ sendJson(res, 403, { ok: false, error: 'cross-origin request rejected' })
3066
+ return
3067
+ }
3068
+ }
3069
+
3070
+ let text
3071
+ try {
3072
+ text = await readBody(req, SAVE_MAX_BYTES)
3073
+ } catch (error) {
3074
+ sendJson(res, 413, { ok: false, error: messageOf(error) })
3075
+ return
3076
+ }
3077
+ let body
3078
+ try {
3079
+ body = JSON.parse(text)
3080
+ } catch (error) {
3081
+ sendJson(res, 400, { ok: false, error: 'request body is not JSON' })
3082
+ return
3083
+ }
3084
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
3085
+ sendJson(res, 400, { ok: false, error: 'request body must be a JSON object' })
3086
+ return
3087
+ }
3088
+
3089
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined
3090
+ const rawPath = typeof body.path === 'string' ? body.path : undefined
3091
+
3092
+ // action: 'list' —— 列出工作区里可打开的 .drawio,供「打开」菜单用。
3093
+ // 复用同一条路由而不是另开一个:信任围栏(POST + 自定义头 + 同源)只写一次,
3094
+ // 少一个口子就少一处可能忘记校验的地方。
3095
+ if (body.action === 'list') {
3096
+ if (sessionId === undefined) {
3097
+ sendJson(res, 400, { ok: false, error: 'sessionId is required' })
3098
+ return
3099
+ }
3100
+ const root = workspaceRootOf(sessionId)
3101
+ if (root === undefined) {
3102
+ sendJson(res, 403, { ok: false, error: 'no workspace root resolved for this session' })
3103
+ return
3104
+ }
3105
+ // dir:只列出**这一个目录**(不递归下钻)。给"用文件管理器挑了一个目录"用 ——
3106
+ // 用户从系统选择器选中的目录可能在别处,所以这里必须做工作区包含校验,
3107
+ // 否则它就成了"列任意目录"的口子(信任围栏的意义就在这)。
3108
+ const dir = typeof body.dir === 'string' && body.dir.length > 0 ? body.dir : null
3109
+ if (dir !== null) {
3110
+ let dirTarget
3111
+ try {
3112
+ dirTarget = await ctx.fs.resolve(dir)
3113
+ } catch (error) {
3114
+ sendJson(res, 400, { ok: false, error: 'resolve failed: ' + messageOf(error) })
3115
+ return
3116
+ }
3117
+ try {
3118
+ const rootTarget = await ctx.fs.resolve(root)
3119
+ if (!ctx.fs.contains(rootTarget, dirTarget)) {
3120
+ sendJson(res, 403, { ok: false, error: '目录不在工作区内:' + absoluteHint(dirTarget, dir) })
3121
+ return
3122
+ }
3123
+ } catch (error) {
3124
+ sendJson(res, 403, { ok: false, error: 'containment check failed: ' + messageOf(error) })
3125
+ return
3126
+ }
3127
+ try {
3128
+ const listed = await listCanvases(root, root, dir)
3129
+ // 同时给出**绝对路径**:客户端按路径去重(同一个文件不该开出两个标签),
3130
+ // 而标签上的路径可能来自 tab 的资源地址(绝对),与列表里的相对路径不相等。
3131
+ // 让客户端用绝对路径当"身份",就不会重复开标签。
3132
+ const abs = []
3133
+ for (let i = 0; i < listed.files.length; i += 1) abs.push(toAbsolute(listed.files[i], root))
3134
+ sendJson(res, 200, {
3135
+ ok: true,
3136
+ files: listed.files,
3137
+ absolute: abs,
3138
+ notes: listed.notes,
3139
+ root: absoluteHint(root, root),
3140
+ dir: absoluteHint(dirTarget, dir),
3141
+ })
3142
+ } catch (error) {
3143
+ sendJson(res, 500, { ok: false, error: 'list failed: ' + messageOf(error) })
3144
+ }
3145
+ return
3146
+ }
3147
+ try {
3148
+ const listed = await listCanvases(root, root, root)
3149
+ const abs = []
3150
+ for (let i = 0; i < listed.files.length; i += 1) abs.push(toAbsolute(listed.files[i], root))
3151
+ sendJson(res, 200, {
3152
+ ok: true,
3153
+ files: listed.files,
3154
+ absolute: abs,
3155
+ notes: listed.notes,
3156
+ root: absoluteHint(root, root),
3157
+ })
3158
+ } catch (error) {
3159
+ sendJson(res, 500, { ok: false, error: 'list failed: ' + messageOf(error) })
3160
+ }
3161
+ return
3162
+ }
3163
+
3164
+ // action: 'read' —— 把 .drawio 读成语义文档交给界面。
3165
+ //
3166
+ // **为什么读也走宿主**:drawio 默认把页体压成 base64(raw deflate(xml)),而浏览器没有 zlib
3167
+ // (DecompressionStream 是异步的,塞不进同步渲染路径)。与其把解析器抄一份到客户端,
3168
+ // 不如让唯一的解析器待在它本来就在的地方:宿主。客户端只认文档,不认 mxfile。
3169
+ if (body.action === 'read') {
3170
+ if (sessionId === undefined || rawPath === undefined) {
3171
+ sendJson(res, 400, { ok: false, error: 'sessionId and path are required' })
3172
+ return
3173
+ }
3174
+ const readRoot = workspaceRootOf(sessionId)
3175
+ if (readRoot === undefined) {
3176
+ sendJson(res, 403, { ok: false, error: 'no workspace root resolved for this session' })
3177
+ return
3178
+ }
3179
+ let readTarget
3180
+ try {
3181
+ readTarget = await ctx.fs.resolve(rawPath, { cwd: readRoot })
3182
+ } catch (error) {
3183
+ sendJson(res, 400, { ok: false, error: 'resolve failed: ' + messageOf(error) })
3184
+ return
3185
+ }
3186
+ try {
3187
+ const rootTarget = await ctx.fs.resolve(readRoot)
3188
+ if (!ctx.fs.contains(rootTarget, readTarget)) {
3189
+ sendJson(res, 403, { ok: false, error: 'path escapes the workspace root: ' + absoluteHint(readTarget, rawPath) })
3190
+ return
3191
+ }
3192
+ } catch (error) {
3193
+ sendJson(res, 403, { ok: false, error: 'containment check failed: ' + messageOf(error) })
3194
+ return
3195
+ }
3196
+ try {
3197
+ const info = await ctx.fs.stat(readTarget)
3198
+ if (info === undefined) {
3199
+ sendJson(res, 200, { ok: true, exists: false, path: rawPath, absolute: absoluteHint(readTarget, rawPath), revision: '', doc: emptyDoc(), notes: [] })
3200
+ return
3201
+ }
3202
+ const loaded = await loadDoc(rawPath, sessionId)
3203
+ // 两件"顺路带给客户端"的东西:
3204
+ // highlight —— AI 用 `{op:'highlight', ids}` 请求的高亮,**取走即清**(一次性);
3205
+ // canRevert —— 这张画布上有没有可退回的 AI 改动(菜单项据此启用)。
3206
+ const pendingHighlight = highlightFor.get(sessionId)
3207
+ if (pendingHighlight !== undefined) highlightFor.delete(sessionId)
3208
+ // 导出请求顺路带给客户端:它渲染完再 POST 回 export-result(渲染器在浏览器那一半)。
3209
+ const pendingExport = exportRequests.get(sessionId)
3210
+ const exportForThis =
3211
+ pendingExport !== undefined && pendingExport.absolute === loaded.absolute
3212
+ ? { requestId: pendingExport.requestId, format: pendingExport.format, name: pendingExport.name }
3213
+ : null
3214
+ // 「看一眼」的渲染请求同理:客户端渲成 PNG 回执给 render-result,宿主存成**附件**
3215
+ // (工作区零文件),下一次 diagram_read 把 image 块交给模型。
3216
+ const pendingRender = renderRequests.get(sessionId)
3217
+ const renderForThis =
3218
+ pendingRender !== undefined && pendingRender.absolute === loaded.absolute ? { requestId: pendingRender.requestId } : null
3219
+ sendJson(res, 200, {
3220
+ ok: true,
3221
+ exists: true,
3222
+ path: rawPath,
3223
+ absolute: loaded.absolute,
3224
+ revision: loaded.doc.revision,
3225
+ doc: loaded.doc,
3226
+ notes: loaded.notes,
3227
+ highlight: Array.isArray(pendingHighlight) ? pendingHighlight : [],
3228
+ canRevert: revertDepth(snapshotKey(sessionId, loaded.absolute)) > 0,
3229
+ // 用户当前的选区(客户端可选地用来做提示;AI 侧由工具读回)。
3230
+ selection: selectionForCanvas(sessionId, loaded.absolute, loaded.doc),
3231
+ export: exportForThis,
3232
+ render: renderForThis,
3233
+ })
3234
+ } catch (error) {
3235
+ sendJson(res, 400, { ok: false, error: '这个文件读不出画布:' + messageOf(error) })
3236
+ }
3237
+ return
3238
+ }
3239
+
3240
+ // action: 'render-result' —— 客户端把画布渲成 PNG 回执给宿主(AI 的 `diagram_read {render:true}` 走这条)。
3241
+ //
3242
+ // **这条路的产物不进工作区**:图片交给附件服务存(内容寻址,在工作区之外,与用户上传图片同一套),
3243
+ // 下一次 read 用一个 `{type:'image', attachment}` 内容块把它交给模型。
3244
+ // 与 export-result 同一条护栏:只有"这个会话此刻真的挂着一个请求"才收。
3245
+ if (body.action === 'render-result') {
3246
+ if (sessionId === undefined) {
3247
+ sendJson(res, 400, { ok: false, error: 'sessionId is required' })
3248
+ return
3249
+ }
3250
+ const pending = renderRequests.get(sessionId)
3251
+ if (pending === undefined) {
3252
+ sendJson(res, 409, { ok: false, error: 'no pending render request' })
3253
+ return
3254
+ }
3255
+ if (typeof body.requestId === 'string' && body.requestId !== pending.requestId) {
3256
+ sendJson(res, 409, { ok: false, error: 'stale render request' })
3257
+ return
3258
+ }
3259
+ const noteFailure = (message) => {
3260
+ renderRequests.delete(sessionId)
3261
+ renderedImages.set(sessionId, {
3262
+ absolute: pending.absolute,
3263
+ revision: pending.revision,
3264
+ status: 'failed',
3265
+ error: message,
3266
+ at: Date.now(),
3267
+ })
3268
+ }
3269
+ // 失败回执也要收下:否则请求一直挂着,模型永远看到 pending。
3270
+ if (body.ok === false) {
3271
+ noteFailure(typeof body.error === 'string' && body.error.length > 0 ? body.error : '画布没能渲染出这张图')
3272
+ sendJson(res, 200, { ok: true })
3273
+ return
3274
+ }
3275
+ const attachments = attachmentsService()
3276
+ if (attachments === undefined || attachments === null || typeof attachments.saveImage !== 'function') {
3277
+ noteFailure('这个部署没有可用的附件服务(attachments),图片存不下来')
3278
+ sendJson(res, 409, { ok: false, error: 'no attachment service' })
3279
+ return
3280
+ }
3281
+ const base64 = typeof body.png === 'string' ? body.png : ''
3282
+ const bytes = base64.trim().length === 0 ? Buffer.alloc(0) : Buffer.from(base64, 'base64')
3283
+ if (bytes.length === 0) {
3284
+ sendJson(res, 400, { ok: false, error: 'png is empty' })
3285
+ return
3286
+ }
3287
+ const limits = attachments.imageLimits === undefined || attachments.imageLimits === null ? {} : attachments.imageLimits
3288
+ if (Number.isFinite(Number(limits.maxImageBytes)) && bytes.length > Number(limits.maxImageBytes)) {
3289
+ noteFailure('这张图 ' + bytes.length + ' 字节,超过这个部署的图片上限 ' + Number(limits.maxImageBytes) + ' 字节')
3290
+ sendJson(res, 413, { ok: false, error: 'image too large: ' + bytes.length + ' > ' + Number(limits.maxImageBytes) })
3291
+ return
3292
+ }
3293
+ if (Array.isArray(limits.mediaTypes) && limits.mediaTypes.indexOf('image/png') < 0) {
3294
+ noteFailure('这个部署不接受 image/png')
3295
+ sendJson(res, 415, { ok: false, error: 'image/png is not accepted by this deployment' })
3296
+ return
3297
+ }
3298
+ const fallbackName = baseNameOf(pending.absolute) + '.png'
3299
+ let ref = null
3300
+ try {
3301
+ ref = await attachments.saveImage({ data: new Uint8Array(bytes), mediaType: 'image/png', name: fallbackName })
3302
+ } catch (error) {
3303
+ noteFailure(messageOf(error))
3304
+ sendJson(res, 500, { ok: false, error: 'save failed: ' + messageOf(error) })
3305
+ return
3306
+ }
3307
+ if (ref === null || typeof ref !== 'object' || typeof ref.attachmentId !== 'string') {
3308
+ noteFailure('附件服务没有返回可用的图片引用')
3309
+ sendJson(res, 500, { ok: false, error: 'attachment service returned no usable reference' })
3310
+ return
3311
+ }
3312
+ renderRequests.delete(sessionId)
3313
+ const width = Number.isFinite(Number(ref.width)) ? Number(ref.width) : Number(body.width)
3314
+ const height = Number.isFinite(Number(ref.height)) ? Number(ref.height) : Number(body.height)
3315
+ renderedImages.set(sessionId, {
3316
+ absolute: pending.absolute,
3317
+ revision: pending.revision,
3318
+ status: 'ready',
3319
+ at: Date.now(),
3320
+ image: {
3321
+ attachmentId: String(ref.attachmentId),
3322
+ mediaType: typeof ref.mediaType === 'string' && ref.mediaType.length > 0 ? ref.mediaType : 'image/png',
3323
+ bytes: Number.isFinite(Number(ref.bytes)) ? Number(ref.bytes) : bytes.length,
3324
+ width: Number.isFinite(width) ? width : 0,
3325
+ height: Number.isFinite(height) ? height : 0,
3326
+ name: typeof ref.name === 'string' && ref.name.length > 0 ? ref.name : fallbackName,
3327
+ },
3328
+ })
3329
+ sendJson(res, 200, { ok: true, bytes: bytes.length })
3330
+ return
3331
+ }
3332
+
3333
+ // action: 'revert' —— 退回**上一次 AI 改动**(就一层)。
3334
+ //
3335
+ // 为什么需要这条:客户端的撤销历史在服务端推来新版本时会被清空,
3336
+ // 所以 AI 改完以后用户按 Ctrl+Z 是退不回去的。宿主留一份改动前的文本,这里把它写回去。
3337
+ if (body.action === 'revert') {
3338
+ if (sessionId === undefined || rawPath === undefined) {
3339
+ sendJson(res, 400, { ok: false, error: 'sessionId and path are required' })
3340
+ return
3341
+ }
3342
+ let revertTarget
3343
+ let revertAbsolute
3344
+ try {
3345
+ const loaded = await loadDoc(rawPath, sessionId)
3346
+ revertTarget = loaded.target
3347
+ revertAbsolute = loaded.absolute
3348
+ } catch (error) {
3349
+ sendJson(res, 400, { ok: false, error: '这个文件读不出画布:' + messageOf(error) })
3350
+ return
3351
+ }
3352
+ const key = snapshotKey(sessionId, revertAbsolute)
3353
+ const stack = revertSnapshots.get(key)
3354
+ const snapshot = stack === undefined || stack.length === 0 ? undefined : stack[stack.length - 1]
3355
+ if (snapshot === undefined) {
3356
+ sendJson(res, 200, { ok: false, error: '这张画布没有可退回的 AI 改动了' })
3357
+ return
3358
+ }
3359
+ const policy = policyFor(sessionId)
3360
+ try {
3361
+ if (policy === undefined) await ctx.fs.writeText(revertTarget, snapshot.text)
3362
+ else await ctx.fs.writeText(revertTarget, snapshot.text, undefined, undefined, policy)
3363
+ } catch (error) {
3364
+ sendJson(res, 500, { ok: false, error: 'write failed: ' + messageOf(error) })
3365
+ return
3366
+ }
3367
+ // 退一步:把这一层弹掉(界面上的「撤销 AI 改动」可以接着按,read 的 revertSteps 是剩余步数)。
3368
+ stack.pop()
3369
+ if (stack.length === 0) revertSnapshots.delete(key)
3370
+ sendJson(res, 200, { ok: true, revision: contentHash(snapshot.text), path: revertAbsolute, stepsLeft: revertDepth(key) })
3371
+ return
3372
+ }
3373
+
3374
+ // action: 'export-result' —— 客户端把渲染好的图回执给宿主(AI 的 `{op:'export'}` 走这条)。
3375
+ //
3376
+ // 只有"这个会话此刻真的挂着一个请求"才收:否则任何能发这个 POST 的东西都能往工作区里写文件。
3377
+ // svg → 落在 `.drawio` 旁边(同名,可用请求里的 name 改基名);png 在浏览器里直接下载,
3378
+ // 这里只记一笔"已下载"(宿主写二进制要另开通道,而用户点一次就有的东西不值得为它搬 base64)。
3379
+ if (body.action === 'export-result') {
3380
+ if (sessionId === undefined) {
3381
+ sendJson(res, 400, { ok: false, error: 'sessionId is required' })
3382
+ return
3383
+ }
3384
+ const pending = exportRequests.get(sessionId)
3385
+ if (pending === undefined) {
3386
+ sendJson(res, 409, { ok: false, error: 'no pending export request' })
3387
+ return
3388
+ }
3389
+ if (typeof body.requestId === 'string' && body.requestId !== pending.requestId) {
3390
+ sendJson(res, 409, { ok: false, error: 'stale export request' })
3391
+ return
3392
+ }
3393
+ if (body.ok === false) {
3394
+ exportRequests.delete(sessionId)
3395
+ exportResults.set(sessionId, {
3396
+ absolute: pending.absolute,
3397
+ status: 'failed',
3398
+ format: pending.format,
3399
+ error: typeof body.error === 'string' && body.error.length > 0 ? body.error : '客户端没能渲染出这张图',
3400
+ })
3401
+ sendJson(res, 200, { ok: true })
3402
+ return
3403
+ }
3404
+ if (pending.format === 'png') {
3405
+ exportRequests.delete(sessionId)
3406
+ exportResults.set(sessionId, { absolute: pending.absolute, status: 'downloaded', format: 'png' })
3407
+ sendJson(res, 200, { ok: true, downloaded: true })
3408
+ return
3409
+ }
3410
+ const svg = typeof body.svg === 'string' ? body.svg : ''
3411
+ if (svg.trim().length === 0) {
3412
+ sendJson(res, 400, { ok: false, error: 'svg is empty' })
3413
+ return
3414
+ }
3415
+ if (Buffer.byteLength(svg, 'utf8') > SAVE_MAX_BYTES) {
3416
+ sendJson(res, 413, { ok: false, error: 'svg exceeds ' + SAVE_MAX_BYTES + ' bytes' })
3417
+ return
3418
+ }
3419
+ const base = pending.name !== undefined && pending.name.length > 0 ? pending.name : baseNameOf(pending.absolute)
3420
+ const dir = dirOf(pending.absolute)
3421
+ let exportTarget = null
3422
+ try {
3423
+ exportTarget = dir.length > 0 ? await ctx.fs.resolve(base + '.svg', { cwd: dir }) : await ctx.fs.resolve(base + '.svg')
3424
+ } catch (error) {
3425
+ sendJson(res, 400, { ok: false, error: 'resolve failed: ' + messageOf(error) })
3426
+ return
3427
+ }
3428
+ const exportPolicy = policyFor(sessionId)
3429
+ try {
3430
+ if (exportPolicy === undefined) await ctx.fs.writeText(exportTarget, svg)
3431
+ else await ctx.fs.writeText(exportTarget, svg, undefined, undefined, exportPolicy)
3432
+ } catch (error) {
3433
+ exportRequests.delete(sessionId)
3434
+ exportResults.set(sessionId, { absolute: pending.absolute, status: 'failed', format: 'svg', error: messageOf(error) })
3435
+ sendJson(res, 500, { ok: false, error: 'write failed: ' + messageOf(error) })
3436
+ return
3437
+ }
3438
+ exportRequests.delete(sessionId)
3439
+ exportResults.set(sessionId, {
3440
+ absolute: pending.absolute,
3441
+ status: 'done',
3442
+ format: 'svg',
3443
+ path: absoluteHint(exportTarget, base + '.svg'),
3444
+ })
3445
+ sendJson(res, 200, { ok: true, path: absoluteHint(exportTarget, base + '.svg') })
3446
+ return
3447
+ }
3448
+
3449
+ // action: 'focus' —— 画布告诉宿主"用户现在打开的是哪一张"。
3450
+ // 之后 diagram_apply 不传 path 时就改它(见下面的 execute)。
3451
+ // 校验后**只存相对工作区的路径**:绝对路径能存但会让日志/提示变长,
3452
+ // 而且工作区被移动后相对路径仍然有效。
3453
+ if (body.action === 'focus') {
3454
+ if (sessionId === undefined) {
3455
+ sendJson(res, 400, { ok: false, error: 'sessionId is required' })
3456
+ return
3457
+ }
3458
+ const focusRoot = workspaceRootOf(sessionId)
3459
+ if (focusRoot === undefined) {
3460
+ sendJson(res, 403, { ok: false, error: 'no workspace root resolved for this session' })
3461
+ return
3462
+ }
3463
+ const raw = typeof body.path === 'string' ? body.path : ''
3464
+ const before = focusedCanvas.get(sessionId)
3465
+ // 空路径 = 清掉聚焦(画布未绑定文件时)
3466
+ if (raw.length === 0) {
3467
+ focusedCanvas.delete(sessionId)
3468
+ // 画布都关了,选区当然也不算数了
3469
+ canvasSelection.delete(sessionId)
3470
+ if (before !== undefined) notifyFocusedCanvas(sessionId, '')
3471
+ sendJson(res, 200, { ok: true, focused: null })
3472
+ return
3473
+ }
3474
+ let focusTarget
3475
+ try {
3476
+ focusTarget = await ctx.fs.resolve(raw, { cwd: focusRoot })
3477
+ } catch (error) {
3478
+ sendJson(res, 400, { ok: false, error: 'resolve failed: ' + messageOf(error) })
3479
+ return
3480
+ }
3481
+ try {
3482
+ const rootTarget = await ctx.fs.resolve(focusRoot)
3483
+ if (!ctx.fs.contains(rootTarget, focusTarget)) {
3484
+ sendJson(res, 403, { ok: false, error: '目录不在工作区内:' + absoluteHint(focusTarget, raw) })
3485
+ return
3486
+ }
3487
+ } catch (error) {
3488
+ sendJson(res, 403, { ok: false, error: 'containment check failed: ' + messageOf(error) })
3489
+ return
3490
+ }
3491
+ focusedCanvas.set(sessionId, raw)
3492
+ // 换了一张画布 = 上一张的选区作废(否则"在 A 图选中的 n1"会变成 B 图里的 n1 ——
3493
+ // 两个文件里都叫 n1 太常见了)。客户端切换标签后会重新上报新选区。
3494
+ if (before !== raw) canvasSelection.delete(sessionId)
3495
+ if (before !== raw) notifyFocusedCanvas(sessionId, raw)
3496
+ sendJson(res, 200, { ok: true, focused: raw })
3497
+ return
3498
+ }
3499
+
3500
+ // action: 'selection' —— 画布告诉宿主"用户现在选中了哪几个单元"。
3501
+ //
3502
+ // 为什么需要:用户说"把这几个换成绿色""把它们往右挪一点"时,AI 原来只能靠坐标猜
3503
+ // ("这几个"是哪几个它看不见)。客户端本来就有选区状态,只是从来没告诉宿主。
3504
+ //
3505
+ // 与聚焦同一条规矩:**只存路径与 id,不读内容、也不进文件** —— 选区是"用户在看什么"
3506
+ // 的提示,不是文档状态。存的是**解析后的绝对路径**,读的时候按它配对(见 selectionForCanvas)。
3507
+ if (body.action === 'selection') {
3508
+ if (sessionId === undefined) {
3509
+ sendJson(res, 400, { ok: false, error: 'sessionId is required' })
3510
+ return
3511
+ }
3512
+ const selRoot = workspaceRootOf(sessionId)
3513
+ if (selRoot === undefined) {
3514
+ sendJson(res, 403, { ok: false, error: 'no workspace root resolved for this session' })
3515
+ return
3516
+ }
3517
+ const selRaw = typeof body.path === 'string' ? body.path : ''
3518
+ const selIds = []
3519
+ if (Array.isArray(body.ids)) {
3520
+ // 上限只是护栏:一次点击不可能选到 500 个,写进来的是别的客户端时别把内存撑爆。
3521
+ for (let i = 0; i < body.ids.length && selIds.length < 500; i += 1) {
3522
+ const v = body.ids[i]
3523
+ if (typeof v === 'string' && v.length > 0) selIds.push(v)
3524
+ }
3525
+ }
3526
+ // 空路径(画布没绑定文件)或空选区 = 清掉记录:别让上一次的选区继续跟着走。
3527
+ if (selRaw.length === 0 || selIds.length === 0 || selRaw.toLowerCase().endsWith('.drawio') === false) {
3528
+ canvasSelection.delete(sessionId)
3529
+ sendJson(res, 200, { ok: true, count: 0 })
3530
+ return
3531
+ }
3532
+ let selTarget
3533
+ try {
3534
+ selTarget = await ctx.fs.resolve(selRaw, { cwd: selRoot })
3535
+ } catch (error) {
3536
+ sendJson(res, 400, { ok: false, error: 'resolve failed: ' + messageOf(error) })
3537
+ return
3538
+ }
3539
+ try {
3540
+ const rootTarget = await ctx.fs.resolve(selRoot)
3541
+ if (!ctx.fs.contains(rootTarget, selTarget)) {
3542
+ sendJson(res, 403, { ok: false, error: '目录不在工作区内:' + absoluteHint(selTarget, selRaw) })
3543
+ return
3544
+ }
3545
+ } catch (error) {
3546
+ sendJson(res, 403, { ok: false, error: 'containment check failed: ' + messageOf(error) })
3547
+ return
3548
+ }
3549
+ canvasSelection.set(sessionId, { absolute: absoluteHint(selTarget, selRaw), ids: selIds })
3550
+ sendJson(res, 200, { ok: true, count: selIds.length })
3551
+ return
3552
+ }
3553
+
3554
+ // action: 'suggest' —— 只**探**一个还没被占用的默认文件名,不建任何文件。
3555
+ //
3556
+ // 给界面上"新建"对话框预填用:用户要自己输名字,但默认值应该是
3557
+ // 当前可用的下一个(untitled / untitled-2 / untitled-3 …),而不是让人从空白开始想。
3558
+ // 刻意不落盘:预填只是提示,用户可能改主意取消 —— 不该留下空文件。
3559
+ if (body.action === 'suggest') {
3560
+ if (sessionId === undefined) {
3561
+ sendJson(res, 400, { ok: false, error: 'sessionId is required' })
3562
+ return
3563
+ }
3564
+ const suggestRoot = workspaceRootOf(sessionId)
3565
+ if (suggestRoot === undefined) {
3566
+ sendJson(res, 403, { ok: false, error: 'no workspace root resolved for this session' })
3567
+ return
3568
+ }
3569
+ const base = typeof body.base === 'string' && body.base.trim().length > 0 ? body.base.trim() : 'untitled'
3570
+ let name = base
3571
+ for (let n = 2; n <= 999; n += 1) {
3572
+ const candidate = sanitizeNewName(name)
3573
+ if (!candidate.ok) break
3574
+ let taken = true
3575
+ try {
3576
+ const target = await ctx.fs.resolve(candidate.name, { cwd: suggestRoot })
3577
+ taken = (await ctx.fs.stat(target)) !== undefined
3578
+ } catch (error) {
3579
+ taken = false
3580
+ }
3581
+ if (!taken) {
3582
+ sendJson(res, 200, { ok: true, name: candidate.name })
3583
+ return
3584
+ }
3585
+ name = base + '-' + n
3586
+ }
3587
+ sendJson(res, 200, { ok: true, name: base })
3588
+ return
3589
+ }
3590
+
3591
+ // action: 'create' —— 新建一张**真实存在**的空画布并绑定到它。
3592
+ //
3593
+ // 为什么不"留着以后再说":画布一旦没有文件,AI 对话就够不到它
3594
+ // (diagram_apply 只按路径工作,不传 path 会落到 demo.drawio —— 实测过)。
3595
+ // 与其让用户对着"未命名画布"说"画一张图"却改到别的文件,不如新建时就落一个空文件。
3596
+ // 同名文件已存在时**不覆盖**,返回 exists 让界面提示改名。
3597
+ if (body.action === 'create') {
3598
+ if (sessionId === undefined) {
3599
+ sendJson(res, 400, { ok: false, error: 'sessionId is required' })
3600
+ return
3601
+ }
3602
+ const nameCheck = sanitizeNewName(body.name)
3603
+ if (!nameCheck.ok) {
3604
+ sendJson(res, 400, { ok: false, error: nameCheck.error })
3605
+ return
3606
+ }
3607
+ const createRoot = workspaceRootOf(sessionId)
3608
+ if (createRoot === undefined) {
3609
+ sendJson(res, 403, { ok: false, error: 'no workspace root resolved for this session' })
3610
+ return
3611
+ }
3612
+ let createTarget
3613
+ try {
3614
+ createTarget = await ctx.fs.resolve(nameCheck.name, { cwd: createRoot })
3615
+ } catch (error) {
3616
+ sendJson(res, 400, { ok: false, error: 'resolve failed: ' + messageOf(error) })
3617
+ return
3618
+ }
3619
+ try {
3620
+ const rootTarget = await ctx.fs.resolve(createRoot)
3621
+ if (!ctx.fs.contains(rootTarget, createTarget)) {
3622
+ sendJson(res, 403, { ok: false, error: 'path escapes the workspace root: ' + absoluteHint(createTarget, nameCheck.name) })
3623
+ return
3624
+ }
3625
+ } catch (error) {
3626
+ sendJson(res, 403, { ok: false, error: 'containment check failed: ' + messageOf(error) })
3627
+ return
3628
+ }
3629
+ try {
3630
+ const info = await ctx.fs.stat(createTarget)
3631
+ if (info !== undefined) {
3632
+ sendJson(res, 200, { ok: false, exists: true, error: 'already exists', path: nameCheck.name })
3633
+ return
3634
+ }
3635
+ } catch (error) {
3636
+ sendJson(res, 500, { ok: false, error: 'stat failed: ' + messageOf(error) })
3637
+ return
3638
+ }
3639
+ // 空画布也打 meta.pinned:它是人手工建的,不该被 AI 的自动布局重排。
3640
+ // 文档形状直接用 emptyDoc()(**含那个缺省图层**)—— 别再手搭一份
3641
+ // `{version, meta, nodes, edges}`:这就是同一个"逐个字段抄"的毛病,
3642
+ // 抄漏 layers 之后内存里的画布与盘上的文件对不上。
3643
+ const blankDoc = Object.assign({}, emptyDoc(), { meta: { pinned: true } })
3644
+ const blankBuilt = buildMxfile(blankDoc, { name: nameCheck.name.replace(/\.drawio$/i, '') })
3645
+ const createPolicy = policyFor(sessionId)
3646
+ try {
3647
+ if (createPolicy === undefined) await ctx.fs.writeText(createTarget, blankBuilt.text)
3648
+ else await ctx.fs.writeText(createTarget, blankBuilt.text, undefined, undefined, createPolicy)
3649
+ } catch (error) {
3650
+ const scope = createPolicy === undefined ? 'unresolved policy' : createPolicy.mode + ' @ ' + String(createPolicy.workspaceRoot)
3651
+ sendJson(res, 500, { ok: false, error: 'write failed: ' + messageOf(error) + ' [sandbox: ' + scope + ']' })
3652
+ return
3653
+ }
3654
+ sendJson(res, 200, {
3655
+ ok: true,
3656
+ path: nameCheck.name,
3657
+ absolute: absoluteHint(createTarget, nameCheck.name),
3658
+ revision: contentHash(blankBuilt.text),
3659
+ })
3660
+ return
3661
+ }
3662
+
3663
+ if (sessionId === undefined || rawPath === undefined) {
3664
+ sendJson(res, 400, { ok: false, error: 'sessionId and path are required' })
3665
+ return
3666
+ }
3667
+ if (!rawPath.toLowerCase().endsWith('.drawio')) {
3668
+ sendJson(res, 403, { ok: false, error: 'only .drawio documents may be written' })
3669
+ return
3670
+ }
3671
+ const doc = body.doc
3672
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
3673
+ sendJson(res, 400, { ok: false, error: 'doc must be a JSON object' })
3674
+ return
3675
+ }
3676
+
3677
+ const root = workspaceRootOf(sessionId)
3678
+ if (root === undefined) {
3679
+ sendJson(res, 403, { ok: false, error: 'no workspace root resolved for this session' })
3680
+ return
3681
+ }
3682
+
3683
+ let target
3684
+ try {
3685
+ target = await ctx.fs.resolve(rawPath, { cwd: root })
3686
+ } catch (error) {
3687
+ sendJson(res, 400, { ok: false, error: 'resolve failed: ' + messageOf(error) })
3688
+ return
3689
+ }
3690
+ const absolute = absoluteHint(target, rawPath)
3691
+
3692
+ try {
3693
+ const rootTarget = await ctx.fs.resolve(root)
3694
+ if (!ctx.fs.contains(rootTarget, target)) {
3695
+ sendJson(res, 403, { ok: false, error: 'path escapes the workspace root: ' + absolute })
3696
+ return
3697
+ }
3698
+ } catch (error) {
3699
+ sendJson(res, 403, { ok: false, error: 'containment check failed: ' + messageOf(error) })
3700
+ return
3701
+ }
3702
+
3703
+ // 乐观锁:基线是**文件内容指纹**(客户端打开时拿到的那个 revision)。
3704
+ // 不用"我们自己的计数器 +1":载体是唯一真相,drawio 或别的编辑器改过文件之后,
3705
+ // 计数器无从得知;而指纹天然会变,冲突判定才是真的。
3706
+ let originalText
3707
+ let exists = false
3708
+ try {
3709
+ const info = await ctx.fs.stat(target)
3710
+ if (info !== undefined) {
3711
+ exists = true
3712
+ originalText = await ctx.fs.readText(target)
3713
+ }
3714
+ } catch (error) {
3715
+ sendJson(res, 500, { ok: false, error: 'read current failed: ' + messageOf(error) })
3716
+ return
3717
+ }
3718
+ const baseRevision = typeof body.revision === 'string' && body.revision.length > 0 ? body.revision : undefined
3719
+ // createOnly(「另存为」):目标**不该已存在**。已存在就直接拒绝,绝不静默覆盖。
3720
+ if (body.createOnly === true && exists) {
3721
+ sendJson(res, 409, { ok: false, error: 'already exists', path: absolute })
3722
+ return
3723
+ }
3724
+ if (baseRevision !== undefined && exists && contentHash(originalText) !== baseRevision) {
3725
+ sendJson(res, 409, { ok: false, error: 'revision conflict', currentRevision: contentHash(originalText) })
3726
+ return
3727
+ }
3728
+
3729
+ // 人工编辑一律打 pinned:从此这张画布是人摆过版面的,AI 的自动布局不该再冲掉它。
3730
+ // (空文件 → 从零生成;已有文件 → 无损写回,见 renderDocText。)
3731
+ const next = normalizeDoc(doc)
3732
+ next.meta = Object.assign({}, next.meta, { pinned: true })
3733
+ let rendered
3734
+ try {
3735
+ rendered = renderDocText(exists ? originalText : undefined, next)
3736
+ } catch (error) {
3737
+ sendJson(res, 400, { ok: false, error: '写入前解析原文件失败:' + messageOf(error) })
3738
+ return
3739
+ }
3740
+ const policy = policyFor(sessionId)
3741
+ try {
3742
+ if (policy === undefined) await ctx.fs.writeText(target, rendered.text)
3743
+ else await ctx.fs.writeText(target, rendered.text, undefined, undefined, policy)
3744
+ } catch (error) {
3745
+ const scope = policy === undefined ? 'unresolved policy' : policy.mode + ' @ ' + String(policy.workspaceRoot)
3746
+ sendJson(res, 500, { ok: false, error: 'write failed: ' + messageOf(error) + ' [sandbox: ' + scope + ']' })
3747
+ return
3748
+ }
3749
+ sendJson(res, 200, { ok: true, revision: rendered.revision, path: absolute, dropped: rendered.dropped })
3750
+ }
3751
+
3752
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: SAVE_PATH, handler: handleSave }), 'drawai: ' + SAVE_PATH)
3753
+
3754
+ ctx.effect(() => ctx.tools.register(readTool))
3755
+ ctx.effect(() => ctx.tools.register(applyTool))
3756
+
3757
+ // 把"怎么用这张画布"注册成**内嵌技能**(见 SKILL_BODY 的注释):
3758
+ // 换了电脑、只装了插件(没有源码/README/tools)时,模型依然知道该怎么操作。
3759
+ //
3760
+ // `skills` 是**可选**依赖:没装 skill 注册表的部署里照样能用(工具描述本身已经够启动),
3761
+ // 所以走 ctx.get 而不是写进 inject —— 缺它不该让整个插件停在等待态。
3762
+ const skills = typeof ctx.get === 'function' ? ctx.get('skills') : undefined
3763
+ if (skills !== undefined && skills !== null && typeof skills.register === 'function') {
3764
+ ctx.effect(() =>
3765
+ skills.register({
3766
+ name: SKILL_SUMMARY.name,
3767
+ description: SKILL_SUMMARY.description,
3768
+ whenToUse: SKILL_SUMMARY.whenToUse,
3769
+ // `source: 'runtime'` 不能省:注册时只校验 name/description/invocation,
3770
+ // 但**取全文**时注册表会再跑一次 validateDefinition,那里要求 source 是字符串
3771
+ // (少了它,目录里看得见、一 load 就抛 "source must be a string")。
3772
+ source: 'runtime',
3773
+ content: SKILL_BODY,
3774
+ }),
3775
+ )
3776
+ }
3777
+ }