@civilization/dsh-drawio 0.3.0 → 0.4.1
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/README.md +33 -5
- package/lib/client.js +95 -33
- package/package.json +1 -1
- package/skills/drawio-operation/SKILL.md +76 -49
- package/src/client/index.tsx +105 -38
- package/src/index.js +12 -7
- package/src/xml.js +119 -14
package/README.md
CHANGED
|
@@ -14,13 +14,12 @@
|
|
|
14
14
|
|
|
15
15
|
## Agent 工具与 Skill
|
|
16
16
|
|
|
17
|
-
- `drawio-operation` Skill:向模型注入 Draw.io
|
|
18
|
-
- `drawio_inspect
|
|
19
|
-
- `drawio_edit
|
|
17
|
+
- `drawio-operation` Skill:向模型注入 Draw.io 画板整体架构、工业级排版规范、现代配色方案、离线导出机制与最佳绘图工作流。
|
|
18
|
+
- `drawio_inspect`:把画板读取为页面、节点、连线和几何信息,自动提取去除 HTML 的纯文本标题与容器子节点列表。
|
|
19
|
+
- `drawio_edit`:批量新增、更新、删除节点与连线,支持 `add_page`、`rename_page`、`delete_page` 多页面管理,以及 `stylePatch` 局部增量更新样式。
|
|
20
20
|
- `drawio_write`:校验并写入完整的 Draw.io XML。
|
|
21
21
|
|
|
22
|
-
工具统一保存为未压缩 XML,便于 Agent 继续修改,也便于 Git
|
|
23
|
-
自动保存,并轮询同步 Agent 对同一文件的修改。
|
|
22
|
+
工具统一保存为未压缩 XML,便于 Agent 继续修改,也便于 Git 查看差异。画板具备真乐观并发控制(防静默覆盖冲突),并采用轻量版本感知机制自动同步 Agent 对同一文件的修改。
|
|
24
23
|
|
|
25
24
|
## 本地离线图片导出 (Export)
|
|
26
25
|
|
|
@@ -50,6 +49,35 @@ dsh web --port 3082 --no-open
|
|
|
50
49
|
`.drawio` 文件时,由官方资源路由选择本插件的画板;读取和自动保存通过插件自有的
|
|
51
50
|
`/dsh-drawio/api` 接口完成,并限制在当前会话工作区内。
|
|
52
51
|
|
|
52
|
+
## 💡 DSH Tab 认领机制与模式声明(避坑指南)
|
|
53
|
+
|
|
54
|
+
本插件向 DSH 官方 `sidebarRightTabs` 注册画板 Tab 类型时,声明了双重匹配模式:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
patterns: ['dsh-resource://file/**/*.drawio', '*.drawio']
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 为什么必须包含整地址模式?
|
|
61
|
+
|
|
62
|
+
DSH 的 `sidebarRightTabs` 在判定「由谁打开此文件」时,采用如下候选排序规则(详见官方 `tab-registry.ts` 的 `candidates()`):
|
|
63
|
+
|
|
64
|
+
1. **优先级分档(Priority Band)**:如 `builtin`、`extension` 等;
|
|
65
|
+
2. **命中模式长度(Matched Pattern Length)**:同档位下,**匹配命中的模式字符数更长者胜**;
|
|
66
|
+
3. **注册顺序(Registration Order)**。
|
|
67
|
+
|
|
68
|
+
#### 潜在冲突场景
|
|
69
|
+
若插件仅声明裸扩展名 `*.drawio`(8 字符),一旦用户安装了带有“接管所有文件”的通用编辑器插件(例如某些侧边栏/文件编辑插件声明了 `dsh-resource://file/**`,长 22 字符):
|
|
70
|
+
- 二者同属 `extension` 优先级;
|
|
71
|
+
- 长度比对:`22 > 8`,通用文件编辑器**按字符长度胜出并强行接管**;
|
|
72
|
+
- 结果:用户在文件树点击 `.drawio`,打开的是别人的纯文本编辑器并展示 XML 源码,本插件画板排第二永不渲染(即便 `*.drawio` 在语义上更精确)。
|
|
73
|
+
|
|
74
|
+
#### 最佳实践解法
|
|
75
|
+
- **`dsh-resource://file/**/*.drawio`**(31 字符):含冒号的分隔符模式执行整地址匹配(如 `dsh-resource://file/session/<sid>/out/arch.drawio`),以 31 > 22 字符在同档位中反超通用编辑器,稳稳赢得 `.drawio` 文件的专属画板渲染权;
|
|
76
|
+
- **`*.drawio`**:不带分隔符的模式按 URI basename 匹配,继续兼容覆盖非 `dsh-resource://` 开头的任意形态地址。
|
|
77
|
+
|
|
78
|
+
> [!TIP]
|
|
79
|
+
> 此规则对所有**“按特定扩展名注册定制编辑器 / 视图 Tab”**的 DSH 插件均成立。编写特定扩展名 Tab 时,务必补全 `dsh-resource://file/**/*.ext` 整地址模式,防止被通用文件接管插件按模式长度压制。
|
|
80
|
+
|
|
53
81
|
已在 DSH 0.1.5-rc.2 上验证构建、XML 操作、本地静态资源加载和官方右侧栏注册。
|
|
54
82
|
|
|
55
83
|
当前内置的精简 Draw.io runtime 固定为 31.4.5。包含完整满血版内置图库(通用、流程图、UML、ER、BPMN、网络、Kubernetes、AWS、GCP、Cisco、电子、平面图等全部分类均可自由开启)以及 PlantUML 离线渲染模块;
|
package/lib/client.js
CHANGED
|
@@ -37,7 +37,14 @@ function apply(ctx) {
|
|
|
37
37
|
ctx.effect(() => official.sidebarRightTabs.register({
|
|
38
38
|
id: TAB_ID,
|
|
39
39
|
kind: TAB_KIND,
|
|
40
|
-
|
|
40
|
+
// The whole-address glob is what actually wins this file: tab types are
|
|
41
|
+
// ranked by priority band, then by the length of the matched pattern, so a
|
|
42
|
+
// bare `*.drawio` (8 characters) loses the claim to any type declaring a
|
|
43
|
+
// broader address glob in the same `extension` band — e.g.
|
|
44
|
+
// dsh-better-sidebar's file editor (`dsh-resource://file/**`, 22), which
|
|
45
|
+
// then renders the XML instead of this canvas. Keeping both patterns makes
|
|
46
|
+
// the specific one win wherever it matches.
|
|
47
|
+
patterns: ["dsh-resource://file/**/*.drawio", "*.drawio"],
|
|
41
48
|
priority: "extension",
|
|
42
49
|
title: (address) => fileName(parseDrawioAddress(address)?.path ?? "Draw.io")
|
|
43
50
|
}), "dsh-drawio: official tab definition");
|
|
@@ -73,8 +80,8 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }) {
|
|
|
73
80
|
if (!resource?.path || !effectiveSessionId || !cwd) return;
|
|
74
81
|
const controller = new AbortController();
|
|
75
82
|
setError(null);
|
|
76
|
-
void fsRead({ sessionId: effectiveSessionId, cwd }, resource.path, controller.signal).then((
|
|
77
|
-
if (!controller.signal.aborted) setFile({ key, content });
|
|
83
|
+
void fsRead({ sessionId: effectiveSessionId, cwd }, resource.path, controller.signal).then((res) => {
|
|
84
|
+
if (!controller.signal.aborted) setFile({ key, content: res.content, version: res.version });
|
|
78
85
|
}).catch((reason) => {
|
|
79
86
|
if (!controller.signal.aborted) setError(messageOf(reason));
|
|
80
87
|
});
|
|
@@ -100,12 +107,13 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }) {
|
|
|
100
107
|
}
|
|
101
108
|
if (error) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u753B\u677F\u8F7D\u5165\u5931\u8D25", detail: error });
|
|
102
109
|
if (file?.key !== key) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u6B63\u5728\u8F7D\u5165\u753B\u677F", detail: fileName(resource.path) });
|
|
103
|
-
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawioCanvas, { content: file.content, path: resource.path, scope: { sessionId: effectiveSessionId, cwd } });
|
|
110
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawioCanvas, { content: file.content, version: file.version, path: resource.path, scope: { sessionId: effectiveSessionId, cwd } });
|
|
104
111
|
}
|
|
105
|
-
function DrawioCanvas({ content, truncated, path, scope }) {
|
|
112
|
+
function DrawioCanvas({ content, version, truncated, path, scope }) {
|
|
106
113
|
const frame = (0, import_react.useRef)(null);
|
|
107
114
|
const latestFile = (0, import_react.useRef)(content ?? "");
|
|
108
115
|
const latestEditor = (0, import_react.useRef)(content ?? "");
|
|
116
|
+
const latestVersion = (0, import_react.useRef)(version);
|
|
109
117
|
const saveTimer = (0, import_react.useRef)(void 0);
|
|
110
118
|
const saveGeneration = (0, import_react.useRef)(0);
|
|
111
119
|
const [ready, setReady] = (0, import_react.useState)(false);
|
|
@@ -114,24 +122,46 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
114
122
|
const lastUserActivity = (0, import_react.useRef)(0);
|
|
115
123
|
const isInteracting = (0, import_react.useRef)(false);
|
|
116
124
|
const { dark, accent: accentColor } = useDshTheme();
|
|
117
|
-
const
|
|
125
|
+
const initialDark = (0, import_react.useRef)(dark);
|
|
126
|
+
const src = (0, import_react.useMemo)(() => `/dsh-drawio/runtime/index.html?embed=1&proto=json&spin=1&offline=1&local=1&ui=min&libraries=1&configure=1&noExitBtn=1&saveAndExit=0&dark=${initialDark.current ? "1" : "0"}`, []);
|
|
118
127
|
const post = (0, import_react.useCallback)((message) => {
|
|
119
128
|
frame.current?.contentWindow?.postMessage(JSON.stringify(message), window.location.origin);
|
|
120
129
|
}, []);
|
|
130
|
+
const reloadFromDisk = (0, import_react.useCallback)(async () => {
|
|
131
|
+
try {
|
|
132
|
+
setSaveState("loading");
|
|
133
|
+
const result = await fsRead(scope, path);
|
|
134
|
+
latestFile.current = result.content;
|
|
135
|
+
latestEditor.current = result.content;
|
|
136
|
+
latestVersion.current = result.version;
|
|
137
|
+
post({ action: "load", xml: result.content, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 });
|
|
138
|
+
setSaveState("saved");
|
|
139
|
+
setError(null);
|
|
140
|
+
} catch (reason) {
|
|
141
|
+
setError(messageOf(reason));
|
|
142
|
+
}
|
|
143
|
+
}, [scope, path, dark, post]);
|
|
121
144
|
const persist = (0, import_react.useCallback)(async (xml) => {
|
|
122
145
|
const generation = ++saveGeneration.current;
|
|
123
146
|
setSaveState("saving");
|
|
124
147
|
try {
|
|
125
|
-
await fsWrite(scope, path, xml);
|
|
148
|
+
const outcome = await fsWrite(scope, path, xml, latestVersion.current);
|
|
126
149
|
if (generation !== saveGeneration.current) return;
|
|
127
150
|
latestFile.current = xml;
|
|
128
151
|
latestEditor.current = xml;
|
|
152
|
+
if (outcome?.version) latestVersion.current = outcome.version;
|
|
129
153
|
setSaveState("saved");
|
|
130
154
|
setError(null);
|
|
131
155
|
} catch (reason) {
|
|
132
156
|
if (generation !== saveGeneration.current) return;
|
|
133
|
-
|
|
134
|
-
|
|
157
|
+
const msg = messageOf(reason);
|
|
158
|
+
if (msg.includes("modified by an external process") || msg.includes("409")) {
|
|
159
|
+
setSaveState("conflict");
|
|
160
|
+
setError("\u6587\u4EF6\u5DF2\u88AB\u5916\u90E8\u6216 AI \u4FEE\u6539\uFF0C\u5F53\u524D\u5B58\u5728\u5E76\u53D1\u51B2\u7A81\uFF0C\u70B9\u51FB\u53EF\u91CD\u65B0\u8F7D\u5165\u6700\u65B0\u5185\u5BB9");
|
|
161
|
+
} else {
|
|
162
|
+
setSaveState("error");
|
|
163
|
+
setError(msg);
|
|
164
|
+
}
|
|
135
165
|
}
|
|
136
166
|
}, [scope.sessionId, scope.cwd, path]);
|
|
137
167
|
const scheduleSave = (0, import_react.useCallback)((xml, immediate = false) => {
|
|
@@ -153,7 +183,8 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
153
183
|
(0, import_react.useEffect)(() => {
|
|
154
184
|
latestFile.current = content ?? "";
|
|
155
185
|
latestEditor.current = content ?? "";
|
|
156
|
-
|
|
186
|
+
latestVersion.current = version;
|
|
187
|
+
}, [content, version, path]);
|
|
157
188
|
const markActivity = (0, import_react.useCallback)(() => {
|
|
158
189
|
lastUserActivity.current = Date.now();
|
|
159
190
|
}, []);
|
|
@@ -227,15 +258,25 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
227
258
|
document.body.removeChild(link);
|
|
228
259
|
showToast(`\u5DF2\u5F00\u59CB\u4E0B\u8F7D\uFF1A${fileName(pending.targetPath)}`);
|
|
229
260
|
} else if (pending.target === "clipboard") {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
261
|
+
try {
|
|
262
|
+
if (pending.format === "svg") {
|
|
263
|
+
const svgText = data.startsWith("data:image/svg+xml") ? decodeURIComponent(data.split(",")[1] || "") : data;
|
|
264
|
+
await navigator.clipboard.writeText(svgText);
|
|
265
|
+
showToast("\u5DF2\u590D\u5236 SVG \u4EE3\u7801\u5230\u526A\u8D34\u677F");
|
|
266
|
+
} else {
|
|
267
|
+
const res = await fetch(data);
|
|
268
|
+
const blob = await res.blob();
|
|
269
|
+
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
|
270
|
+
showToast("\u5DF2\u590D\u5236\u56FE\u7247\u5230\u526A\u8D34\u677F");
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
const link = document.createElement("a");
|
|
274
|
+
link.download = fileName(pending.targetPath);
|
|
275
|
+
link.href = data;
|
|
276
|
+
document.body.appendChild(link);
|
|
277
|
+
link.click();
|
|
278
|
+
document.body.removeChild(link);
|
|
279
|
+
showToast(`\u526A\u8D34\u677F\u65E0\u6743\u9650\uFF0C\u5DF2\u81EA\u52A8\u4E0B\u8F7D\uFF1A${fileName(pending.targetPath)}`);
|
|
239
280
|
}
|
|
240
281
|
} else if (pending.target === "workspace") {
|
|
241
282
|
const result = await fsSaveImage(scope, pending.targetPath, data);
|
|
@@ -294,12 +335,19 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
294
335
|
if (saveTimer.current !== void 0) return;
|
|
295
336
|
if (typeof document !== "undefined" && document.hidden) return;
|
|
296
337
|
try {
|
|
297
|
-
const
|
|
338
|
+
const stat = await fsStat(scope, path);
|
|
298
339
|
if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return;
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
340
|
+
if (stat.version && latestVersion.current && stat.version === latestVersion.current) return;
|
|
341
|
+
const res = await fsRead(scope, path);
|
|
342
|
+
if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return;
|
|
343
|
+
if (res.content === latestFile.current || res.content === latestEditor.current) {
|
|
344
|
+
latestVersion.current = res.version;
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
latestFile.current = res.content;
|
|
348
|
+
latestEditor.current = res.content;
|
|
349
|
+
latestVersion.current = res.version;
|
|
350
|
+
post({ action: "load", xml: res.content, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 });
|
|
303
351
|
setSaveState("external");
|
|
304
352
|
window.setTimeout(() => {
|
|
305
353
|
if (!cancelled) setSaveState("saved");
|
|
@@ -318,7 +366,7 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
318
366
|
}, [ready, truncated, scope.sessionId, scope.cwd, path, dark, post]);
|
|
319
367
|
(0, import_react.useEffect)(() => () => {
|
|
320
368
|
if (saveTimer.current !== void 0) window.clearTimeout(saveTimer.current);
|
|
321
|
-
if (latestEditor.current !== latestFile.current) void fsWrite(scope, path, latestEditor.current, true);
|
|
369
|
+
if (latestEditor.current !== latestFile.current) void fsWrite(scope, path, latestEditor.current, latestVersion.current, true);
|
|
322
370
|
}, [scope.sessionId, scope.cwd, path]);
|
|
323
371
|
if (truncated) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u65E0\u6CD5\u6253\u5F00\u753B\u677F", detail: "\u6587\u4EF6\u5185\u5BB9\u5DF2\u88AB\u622A\u65AD\uFF0C\u7EE7\u7EED\u7F16\u8F91\u53EF\u80FD\u635F\u574F\u539F\u6587\u4EF6\u3002" });
|
|
324
372
|
const targetExt = exportFormat === "svg" ? ".svg" : exportFormat === "xmlpng" ? ".drawio.png" : ".png";
|
|
@@ -419,11 +467,23 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
419
467
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(CheckIcon, { size: 14 }),
|
|
420
468
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: toast })
|
|
421
469
|
] }) : null,
|
|
422
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { role: "status", "aria-live": "polite", style: { ...styles.status, ...saveState === "error" ? styles.statusError : {} }, children: [
|
|
423
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { ...styles.dot, background: accentColor } }),
|
|
470
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { role: "status", "aria-live": "polite", style: { ...styles.status, ...saveState === "error" || saveState === "conflict" ? styles.statusError : {} }, children: [
|
|
471
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { ...styles.dot, background: saveState === "conflict" ? "var(--dsw-alias-warning, #e6a23c)" : accentColor } }),
|
|
424
472
|
statusText(saveState)
|
|
425
473
|
] }),
|
|
426
|
-
error ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
474
|
+
error ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
475
|
+
"button",
|
|
476
|
+
{
|
|
477
|
+
type: "button",
|
|
478
|
+
title: error,
|
|
479
|
+
onClick: () => {
|
|
480
|
+
if (saveState === "conflict") void reloadFromDisk();
|
|
481
|
+
setError(null);
|
|
482
|
+
},
|
|
483
|
+
style: styles.error,
|
|
484
|
+
children: saveState === "conflict" ? "\u26A0\uFE0F \u68C0\u6D4B\u5230\u5916\u90E8\u4FEE\u6539 \xB7 \u70B9\u51FB\u91CD\u65B0\u8F7D\u5165" : "\u4FDD\u5B58\u5931\u8D25 \xB7 \u70B9\u51FB\u5173\u95ED"
|
|
485
|
+
}
|
|
486
|
+
) : null
|
|
427
487
|
] });
|
|
428
488
|
}
|
|
429
489
|
function useDshTheme() {
|
|
@@ -464,12 +524,14 @@ function detectDark() {
|
|
|
464
524
|
if (numbers?.length === 3) return numbers[0] * 0.299 + numbers[1] * 0.587 + numbers[2] * 0.114 < 128;
|
|
465
525
|
return document.documentElement.classList.contains("dark") || window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
466
526
|
}
|
|
527
|
+
async function fsStat(scope, path, signal) {
|
|
528
|
+
return call("stat", scope, { path }, false, signal);
|
|
529
|
+
}
|
|
467
530
|
async function fsRead(scope, path, signal) {
|
|
468
|
-
|
|
469
|
-
return value.content;
|
|
531
|
+
return call("read", scope, { path }, false, signal);
|
|
470
532
|
}
|
|
471
|
-
async function fsWrite(scope, path, content, keepalive = false) {
|
|
472
|
-
|
|
533
|
+
async function fsWrite(scope, path, content, version, keepalive = false) {
|
|
534
|
+
return call("write", scope, { path, content, ...version ? { version } : {} }, keepalive);
|
|
473
535
|
}
|
|
474
536
|
async function fsSaveImage(scope, path, data) {
|
|
475
537
|
return call("save-image", scope, { path, data });
|
|
@@ -559,7 +621,7 @@ function parseDrawioAddress(address, fallbackSessionId) {
|
|
|
559
621
|
}
|
|
560
622
|
}
|
|
561
623
|
var messageOf = (reason) => reason instanceof Error ? reason.message : String(reason);
|
|
562
|
-
var statusText = (state) => state === "loading" ? "\u6B63\u5728\u8F7D\u5165" : state === "saving" ? "\u6B63\u5728\u4FDD\u5B58" : state === "external" ? "\u5DF2\u540C\u6B65 AI \u4FEE\u6539" : state === "error" ? "\u4FDD\u5B58\u5931\u8D25" : "\u5DF2\u4FDD\u5B58";
|
|
624
|
+
var statusText = (state) => state === "loading" ? "\u6B63\u5728\u8F7D\u5165" : state === "saving" ? "\u6B63\u5728\u4FDD\u5B58" : state === "external" ? "\u5DF2\u540C\u6B65 AI \u4FEE\u6539" : state === "conflict" ? "\u68C0\u6D4B\u5230\u51B2\u7A81" : state === "error" ? "\u4FDD\u5B58\u5931\u8D25" : "\u5DF2\u4FDD\u5B58";
|
|
563
625
|
var fg = "var(--dsw-alias-label-primary, currentColor)";
|
|
564
626
|
var muted = "var(--dsw-alias-label-secondary, rgba(127,127,127,.9))";
|
|
565
627
|
var layer = "var(--dsw-alias-container-bg, Canvas)";
|
package/package.json
CHANGED
|
@@ -1,53 +1,80 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: drawio-operation
|
|
3
|
-
description:
|
|
3
|
+
description: 读取、检查、创建和语义化编辑 Draw.io 架构图与流程图,协同原生离线画板,并指导导出与排版工作流。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Draw.io
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
##
|
|
13
|
-
|
|
14
|
-
1.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
|
|
53
|
-
-
|
|
6
|
+
# Draw.io 画板与图表协同指南
|
|
7
|
+
|
|
8
|
+
`dsh-drawio` 插件为 DeepSeek Harness (DSH) 提供 100% 本地离线的 Draw.io 画板,并向 Agent 注册结构化图表编辑工具。
|
|
9
|
+
|
|
10
|
+
当用户询问 Draw.io 功能或需要绘制图表时,直接说明支持的画板功能与工具,无需探测工作区无关文件。
|
|
11
|
+
|
|
12
|
+
## 核心模型工具
|
|
13
|
+
|
|
14
|
+
### 1. `drawio_inspect`
|
|
15
|
+
- **功能**:将现有 `.drawio` 文件解析为结构化的页面、节点、连线、文本内容及几何坐标。
|
|
16
|
+
- **最佳实践**:修改已有图表前必须先调用 `drawio_inspect`,获取已有 cell ID、父子层级关系及连线端点。
|
|
17
|
+
- **字段**:返回的 `plainText` 是已去除 HTML 格式的纯净标签;`children` 标识该容器节点包含的子节点 ID。
|
|
18
|
+
- 仅在需要查看原始底层 XML 属性时设置 `include_xml: true`。
|
|
19
|
+
|
|
20
|
+
### 2. `drawio_edit`
|
|
21
|
+
- **功能**:对现有 `.drawio` 文件应用批量语义化增量修改,支持多页面管理。
|
|
22
|
+
- **支持的批量操作类型**:
|
|
23
|
+
- `add_node`:新增节点。属性:`id`, `label`, `style`, `parent`, `x`, `y`, `width`, `height`。
|
|
24
|
+
- `add_edge`:新增连线。属性:`id`, `source`, `target`, `label`, `style`, `parent`。
|
|
25
|
+
- `update`:更新已有节点或连线属性。
|
|
26
|
+
- 推荐使用 `stylePatch: { fillColor: '#dae8fc', strokeColor: '#6c8ebf' }` 局部增量修改颜色或边框,避免冲掉节点的已有样式(如圆角、对齐等)。
|
|
27
|
+
- 也支持整体更新 `label`, `style`, `x`, `y`, `width`, `height`。
|
|
28
|
+
- `delete`:安全删除节点,并自动级联清理连接到该节点的孤立连线。
|
|
29
|
+
- `add_page`:在当前文档中新增图表页面。属性:`id`, `name`(如 `部署拓扑`、`时序交互`)。
|
|
30
|
+
- `rename_page`:重命名页面。属性:`page` (索引或ID), `name`。
|
|
31
|
+
- `delete_page`:删除指定页面。属性:`page`。
|
|
32
|
+
- **规范**:节点 ID 应具备稳定且语义化的英文标识(例如 `client`, `api_gateway`, `auth_service`, `db_cluster`),严禁使用易冲突的纯数字 ID。
|
|
33
|
+
|
|
34
|
+
### 3. `drawio_write`
|
|
35
|
+
- **功能**:校验并写入完整的 Draw.io `mxfile` 或 `mxGraphModel` XML 文档。
|
|
36
|
+
- 自动格式化为未压缩的规范 XML,便于后续 AI 审查与 Git diff 跟踪。
|
|
37
|
+
- **建议**:从零构建新图表时首选 `drawio_write`;对现有图表进行局部修改或扩展时首选 `drawio_edit`。
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 架构图排版与设计规范
|
|
42
|
+
|
|
43
|
+
为了保证大模型生成的图表整洁、专业、无重叠,请遵循以下工业级排版规范:
|
|
44
|
+
|
|
45
|
+
### 1. 坐标与网格系统
|
|
46
|
+
- **基准网格**:Draw.io 默认 `gridSize=10`,所有坐标 `x`, `y`, `width`, `height` 请取 10 的整数倍。
|
|
47
|
+
- **标准节点尺寸**:
|
|
48
|
+
- 常规服务/模块:`width: 140, height: 60`
|
|
49
|
+
- 数据库/存储:`width: 120, height: 70`
|
|
50
|
+
- 外部系统/客户端:`width: 130, height: 50`
|
|
51
|
+
- 分组容器(Group/VPC):根据内部节点数量自适应(如 `width: 600, height: 320`)
|
|
52
|
+
- **间距推荐**:
|
|
53
|
+
- 横向流式布局:节点水平间距保持在 `80px ~ 120px`(如 `x1=40, x2=240, x3=440`);
|
|
54
|
+
- 纵向分层架构:分层垂直间距保持在 `90px ~ 130px`(如 Layer 1: `y=40`,Layer 2: `y=160`,Layer 3: `y=280`)。
|
|
55
|
+
|
|
56
|
+
### 2. 经典高质感现代配色方案(Flat Palette)
|
|
57
|
+
|
|
58
|
+
| 分类/层级 | 填充色 `fillColor` | 边框色 `strokeColor` | 字体色 `fontColor` | 推荐使用场景 |
|
|
59
|
+
| :--- | :--- | :--- | :--- | :--- |
|
|
60
|
+
| **接入/网关** | `#dae8fc` (浅蓝) | `#6c8ebf` (深蓝) | `#000000` | Client, Nginx, API Gateway, CDN |
|
|
61
|
+
| **业务服务** | `#d5e8d4` (浅绿) | `#82b366` (深绿) | `#000000` | OrderService, UserService, K8s Pods |
|
|
62
|
+
| **存储/数据库** | `#ffe6cc` (暖橙) | `#d79b00` (深橙) | `#000000` | MySQL, Redis, MongoDB, S3 |
|
|
63
|
+
| **异步队列/通信** | `#e1d5e7` (浅紫) | `#9673a6` (深紫) | `#000000` | Kafka, RabbitMQ, EventBus |
|
|
64
|
+
| **告警/高风险** | `#f8cecc` (浅红) | `#b85450` (深红) | `#000000` | ErrorHandler, DeadLetter, Alert |
|
|
65
|
+
| **容器/分组边框** | `#f5f5f5` (极浅灰) | `#cccccc` (浅灰) | `#333333` | VPC, Kubernetes Cluster, Subnet |
|
|
66
|
+
|
|
67
|
+
### 3. 连线样式(Edge Styles)
|
|
68
|
+
- 统一使用正交折线:`edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;`
|
|
69
|
+
- 若要强调特定流向,可增加线宽 `strokeWidth=2;`,异步/订阅连线推荐增加虚线 `dashed=1;`。
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 离线画板协同与图片导出
|
|
74
|
+
|
|
75
|
+
- **右侧栏实时双向协同**:
|
|
76
|
+
- 用户在 DSH 中打开任何 `.drawio` 文件即可在右侧栏全功能画板中直观交互;
|
|
77
|
+
- 模型调用 `drawio_edit` 或 `drawio_write` 保存后,画板会自动轻量检测版本变化并同步刷新,且不会中断用户正在进行的画布拖拽。
|
|
78
|
+
- **纯本地离线图片导出**:
|
|
79
|
+
- 画板右上角内置纯本地导出面板,支持导出为 **PNG (2x 超清)**、**SVG (矢量无损)** 和 **XML-PNG (双模可编辑图)**;
|
|
80
|
+
- 支持一键**保存到工作区**(自动生成同名图片)、**写入系统剪贴板**(便于直接粘贴到文档或聊天窗口)或**下载到本地磁盘**。
|
package/src/client/index.tsx
CHANGED
|
@@ -6,7 +6,7 @@ const TAB_ID = '@civilization/dsh-drawio'
|
|
|
6
6
|
const TAB_KIND = 'drawio'
|
|
7
7
|
|
|
8
8
|
interface SessionScope { sessionId: string; cwd?: string }
|
|
9
|
-
interface FileViewerProps { content?: string; truncated?: boolean; path: string; scope: SessionScope }
|
|
9
|
+
interface FileViewerProps { content?: string; version?: string; truncated?: boolean; path: string; scope: SessionScope }
|
|
10
10
|
interface OfficialContext extends Context { slots: any; sidebarRightTabs: any }
|
|
11
11
|
|
|
12
12
|
export function apply(ctx: Context): void {
|
|
@@ -14,7 +14,14 @@ export function apply(ctx: Context): void {
|
|
|
14
14
|
ctx.effect(() => official.sidebarRightTabs.register({
|
|
15
15
|
id: TAB_ID,
|
|
16
16
|
kind: TAB_KIND,
|
|
17
|
-
|
|
17
|
+
// The whole-address glob is what actually wins this file: tab types are
|
|
18
|
+
// ranked by priority band, then by the length of the matched pattern, so a
|
|
19
|
+
// bare `*.drawio` (8 characters) loses the claim to any type declaring a
|
|
20
|
+
// broader address glob in the same `extension` band — e.g.
|
|
21
|
+
// dsh-better-sidebar's file editor (`dsh-resource://file/**`, 22), which
|
|
22
|
+
// then renders the XML instead of this canvas. Keeping both patterns makes
|
|
23
|
+
// the specific one win wherever it matches.
|
|
24
|
+
patterns: ['dsh-resource://file/**/*.drawio', '*.drawio'],
|
|
18
25
|
priority: 'extension',
|
|
19
26
|
title: (address: string) => fileName(parseDrawioAddress(address)?.path ?? 'Draw.io'),
|
|
20
27
|
}), 'dsh-drawio: official tab definition')
|
|
@@ -44,7 +51,7 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX
|
|
|
44
51
|
const cwd = activeCwd || resourceCwd
|
|
45
52
|
const effectiveSessionId = sessionId || resource?.sessionId || ''
|
|
46
53
|
|
|
47
|
-
const [file, setFile] = useState<{ key: string; content: string } | null>(null)
|
|
54
|
+
const [file, setFile] = useState<{ key: string; content: string; version?: string } | null>(null)
|
|
48
55
|
const [error, setError] = useState<string | null>(null)
|
|
49
56
|
const key = `${effectiveSessionId}\0${cwd ?? ''}\0${resource?.path ?? ''}`
|
|
50
57
|
|
|
@@ -53,7 +60,7 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX
|
|
|
53
60
|
const controller = new AbortController()
|
|
54
61
|
setError(null)
|
|
55
62
|
void fsRead({ sessionId: effectiveSessionId, cwd }, resource.path, controller.signal)
|
|
56
|
-
.then(
|
|
63
|
+
.then(res => { if (!controller.signal.aborted) setFile({ key, content: res.content, version: res.version }) })
|
|
57
64
|
.catch(reason => { if (!controller.signal.aborted) setError(messageOf(reason)) })
|
|
58
65
|
return () => controller.abort()
|
|
59
66
|
}, [key, resource?.path, effectiveSessionId, cwd])
|
|
@@ -76,41 +83,65 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX
|
|
|
76
83
|
}
|
|
77
84
|
if (error) return <CenteredMessage title="画板载入失败" detail={error} />
|
|
78
85
|
if (file?.key !== key) return <CenteredMessage title="正在载入画板" detail={fileName(resource.path)} />
|
|
79
|
-
return <DrawioCanvas content={file.content} path={resource.path} scope={{ sessionId: effectiveSessionId, cwd }} />
|
|
86
|
+
return <DrawioCanvas content={file.content} version={file.version} path={resource.path} scope={{ sessionId: effectiveSessionId, cwd }} />
|
|
80
87
|
}
|
|
81
88
|
|
|
82
|
-
function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX.Element {
|
|
89
|
+
function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerProps): JSX.Element {
|
|
83
90
|
const frame = useRef<HTMLIFrameElement>(null)
|
|
84
91
|
const latestFile = useRef(content ?? '')
|
|
85
92
|
const latestEditor = useRef(content ?? '')
|
|
93
|
+
const latestVersion = useRef(version)
|
|
86
94
|
const saveTimer = useRef<number | undefined>(undefined)
|
|
87
95
|
const saveGeneration = useRef(0)
|
|
88
96
|
const [ready, setReady] = useState(false)
|
|
89
|
-
const [saveState, setSaveState] = useState<'loading' | 'saved' | 'saving' | 'external' | 'error'>('loading')
|
|
97
|
+
const [saveState, setSaveState] = useState<'loading' | 'saved' | 'saving' | 'external' | 'conflict' | 'error'>('loading')
|
|
90
98
|
const [error, setError] = useState<string | null>(truncated ? '文件过大,DSH 只返回了截断内容,无法安全打开。' : null)
|
|
91
99
|
const lastUserActivity = useRef(0)
|
|
92
100
|
const isInteracting = useRef(false)
|
|
93
101
|
const { dark, accent: accentColor } = useDshTheme()
|
|
94
|
-
const
|
|
102
|
+
const initialDark = useRef(dark)
|
|
103
|
+
const src = useMemo(() => `/dsh-drawio/runtime/index.html?embed=1&proto=json&spin=1&offline=1&local=1&ui=min&libraries=1&configure=1&noExitBtn=1&saveAndExit=0&dark=${initialDark.current ? '1' : '0'}`, [])
|
|
95
104
|
|
|
96
105
|
const post = useCallback((message: unknown) => {
|
|
97
106
|
frame.current?.contentWindow?.postMessage(JSON.stringify(message), window.location.origin)
|
|
98
107
|
}, [])
|
|
99
108
|
|
|
109
|
+
const reloadFromDisk = useCallback(async () => {
|
|
110
|
+
try {
|
|
111
|
+
setSaveState('loading')
|
|
112
|
+
const result = await fsRead(scope, path)
|
|
113
|
+
latestFile.current = result.content
|
|
114
|
+
latestEditor.current = result.content
|
|
115
|
+
latestVersion.current = result.version
|
|
116
|
+
post({ action: 'load', xml: result.content, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 })
|
|
117
|
+
setSaveState('saved')
|
|
118
|
+
setError(null)
|
|
119
|
+
} catch (reason) {
|
|
120
|
+
setError(messageOf(reason))
|
|
121
|
+
}
|
|
122
|
+
}, [scope, path, dark, post])
|
|
123
|
+
|
|
100
124
|
const persist = useCallback(async (xml: string) => {
|
|
101
125
|
const generation = ++saveGeneration.current
|
|
102
126
|
setSaveState('saving')
|
|
103
127
|
try {
|
|
104
|
-
await fsWrite(scope, path, xml)
|
|
128
|
+
const outcome = await fsWrite(scope, path, xml, latestVersion.current)
|
|
105
129
|
if (generation !== saveGeneration.current) return
|
|
106
130
|
latestFile.current = xml
|
|
107
131
|
latestEditor.current = xml
|
|
132
|
+
if (outcome?.version) latestVersion.current = outcome.version
|
|
108
133
|
setSaveState('saved')
|
|
109
134
|
setError(null)
|
|
110
135
|
} catch (reason) {
|
|
111
136
|
if (generation !== saveGeneration.current) return
|
|
112
|
-
|
|
113
|
-
|
|
137
|
+
const msg = messageOf(reason)
|
|
138
|
+
if (msg.includes('modified by an external process') || msg.includes('409')) {
|
|
139
|
+
setSaveState('conflict')
|
|
140
|
+
setError('文件已被外部或 AI 修改,当前存在并发冲突,点击可重新载入最新内容')
|
|
141
|
+
} else {
|
|
142
|
+
setSaveState('error')
|
|
143
|
+
setError(msg)
|
|
144
|
+
}
|
|
114
145
|
}
|
|
115
146
|
}, [scope.sessionId, scope.cwd, path])
|
|
116
147
|
|
|
@@ -134,7 +165,8 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
134
165
|
useEffect(() => {
|
|
135
166
|
latestFile.current = content ?? ''
|
|
136
167
|
latestEditor.current = content ?? ''
|
|
137
|
-
|
|
168
|
+
latestVersion.current = version
|
|
169
|
+
}, [content, version, path])
|
|
138
170
|
|
|
139
171
|
const markActivity = useCallback(() => {
|
|
140
172
|
lastUserActivity.current = Date.now()
|
|
@@ -215,17 +247,28 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
215
247
|
document.body.removeChild(link)
|
|
216
248
|
showToast(`已开始下载:${fileName(pending.targetPath)}`)
|
|
217
249
|
} else if (pending.target === 'clipboard') {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
250
|
+
try {
|
|
251
|
+
if (pending.format === 'svg') {
|
|
252
|
+
const svgText = data.startsWith('data:image/svg+xml')
|
|
253
|
+
? decodeURIComponent(data.split(',')[1] || '')
|
|
254
|
+
: data
|
|
255
|
+
await navigator.clipboard.writeText(svgText)
|
|
256
|
+
showToast('已复制 SVG 代码到剪贴板')
|
|
257
|
+
} else {
|
|
258
|
+
const res = await fetch(data)
|
|
259
|
+
const blob = await res.blob()
|
|
260
|
+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
|
261
|
+
showToast('已复制图片到剪贴板')
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
// 剪贴板受限或报错时降级为自动下载
|
|
265
|
+
const link = document.createElement('a')
|
|
266
|
+
link.download = fileName(pending.targetPath)
|
|
267
|
+
link.href = data
|
|
268
|
+
document.body.appendChild(link)
|
|
269
|
+
link.click()
|
|
270
|
+
document.body.removeChild(link)
|
|
271
|
+
showToast(`剪贴板无权限,已自动下载:${fileName(pending.targetPath)}`)
|
|
229
272
|
}
|
|
230
273
|
} else if (pending.target === 'workspace') {
|
|
231
274
|
const result = await fsSaveImage(scope, pending.targetPath, data)
|
|
@@ -289,14 +332,23 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
289
332
|
if (typeof document !== 'undefined' && document.hidden) return
|
|
290
333
|
|
|
291
334
|
try {
|
|
292
|
-
|
|
335
|
+
// 轻量检查版本号
|
|
336
|
+
const stat = await fsStat(scope, path)
|
|
337
|
+
if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return
|
|
338
|
+
if (stat.version && latestVersion.current && stat.version === latestVersion.current) return
|
|
339
|
+
|
|
340
|
+
const res = await fsRead(scope, path)
|
|
293
341
|
// 5. 响应返回后二次防护:若这期间用户按下了鼠标或开始拖动,果断丢弃本次结果
|
|
294
342
|
if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return
|
|
295
|
-
if (
|
|
343
|
+
if (res.content === latestFile.current || res.content === latestEditor.current) {
|
|
344
|
+
latestVersion.current = res.version
|
|
345
|
+
return
|
|
346
|
+
}
|
|
296
347
|
|
|
297
|
-
latestFile.current =
|
|
298
|
-
latestEditor.current =
|
|
299
|
-
|
|
348
|
+
latestFile.current = res.content
|
|
349
|
+
latestEditor.current = res.content
|
|
350
|
+
latestVersion.current = res.version
|
|
351
|
+
post({ action: 'load', xml: res.content, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 })
|
|
300
352
|
setSaveState('external')
|
|
301
353
|
window.setTimeout(() => { if (!cancelled) setSaveState('saved') }, 1200)
|
|
302
354
|
} catch (reason) {
|
|
@@ -310,7 +362,7 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
310
362
|
|
|
311
363
|
useEffect(() => () => {
|
|
312
364
|
if (saveTimer.current !== undefined) window.clearTimeout(saveTimer.current)
|
|
313
|
-
if (latestEditor.current !== latestFile.current) void fsWrite(scope, path, latestEditor.current, true)
|
|
365
|
+
if (latestEditor.current !== latestFile.current) void fsWrite(scope, path, latestEditor.current, latestVersion.current, true)
|
|
314
366
|
}, [scope.sessionId, scope.cwd, path])
|
|
315
367
|
|
|
316
368
|
if (truncated) return <CenteredMessage title="无法打开画板" detail="文件内容已被截断,继续编辑可能损坏原文件。" />
|
|
@@ -411,10 +463,22 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
411
463
|
</div>
|
|
412
464
|
) : null}
|
|
413
465
|
|
|
414
|
-
<div role="status" aria-live="polite" style={{ ...styles.status, ...(saveState === 'error' ? styles.statusError : {}) }}>
|
|
415
|
-
<span style={{ ...styles.dot, background: accentColor }} />{statusText(saveState)}
|
|
466
|
+
<div role="status" aria-live="polite" style={{ ...styles.status, ...(saveState === 'error' || saveState === 'conflict' ? styles.statusError : {}) }}>
|
|
467
|
+
<span style={{ ...styles.dot, background: saveState === 'conflict' ? 'var(--dsw-alias-warning, #e6a23c)' : accentColor }} />{statusText(saveState)}
|
|
416
468
|
</div>
|
|
417
|
-
{error ?
|
|
469
|
+
{error ? (
|
|
470
|
+
<button
|
|
471
|
+
type="button"
|
|
472
|
+
title={error}
|
|
473
|
+
onClick={() => {
|
|
474
|
+
if (saveState === 'conflict') void reloadFromDisk()
|
|
475
|
+
setError(null)
|
|
476
|
+
}}
|
|
477
|
+
style={styles.error}
|
|
478
|
+
>
|
|
479
|
+
{saveState === 'conflict' ? '⚠️ 检测到外部修改 · 点击重新载入' : '保存失败 · 点击关闭'}
|
|
480
|
+
</button>
|
|
481
|
+
) : null}
|
|
418
482
|
</div>
|
|
419
483
|
)
|
|
420
484
|
}
|
|
@@ -460,13 +524,16 @@ function detectDark(): boolean {
|
|
|
460
524
|
return document.documentElement.classList.contains('dark') || window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
461
525
|
}
|
|
462
526
|
|
|
463
|
-
async function
|
|
464
|
-
|
|
465
|
-
|
|
527
|
+
async function fsStat(scope: SessionScope, path: string, signal?: AbortSignal): Promise<{ version: string; size: number }> {
|
|
528
|
+
return call<{ version: string; size: number }>('stat', scope, { path }, false, signal)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function fsRead(scope: SessionScope, path: string, signal?: AbortSignal): Promise<{ content: string; version: string }> {
|
|
532
|
+
return call<{ content: string; version: string }>('read', scope, { path }, false, signal)
|
|
466
533
|
}
|
|
467
534
|
|
|
468
|
-
async function fsWrite(scope: SessionScope, path: string, content: string, keepalive = false): Promise<
|
|
469
|
-
|
|
535
|
+
async function fsWrite(scope: SessionScope, path: string, content: string, version?: string, keepalive = false): Promise<{ version?: string }> {
|
|
536
|
+
return call<{ version?: string }>('write', scope, { path, content, ...(version ? { version } : {}) }, keepalive)
|
|
470
537
|
}
|
|
471
538
|
|
|
472
539
|
async function fsSaveImage(scope: SessionScope, path: string, data: string): Promise<{ path: string; bytes: number }> {
|
|
@@ -555,7 +622,7 @@ export function parseDrawioAddress(address?: string, fallbackSessionId?: string)
|
|
|
555
622
|
}
|
|
556
623
|
}
|
|
557
624
|
const messageOf = (reason: unknown): string => reason instanceof Error ? reason.message : String(reason)
|
|
558
|
-
const statusText = (state: string): string => state === 'loading' ? '正在载入' : state === 'saving' ? '正在保存' : state === 'external' ? '已同步 AI 修改' : state === 'error' ? '保存失败' : '已保存'
|
|
625
|
+
const statusText = (state: string): string => state === 'loading' ? '正在载入' : state === 'saving' ? '正在保存' : state === 'external' ? '已同步 AI 修改' : state === 'conflict' ? '检测到冲突' : state === 'error' ? '保存失败' : '已保存'
|
|
559
626
|
const fg = 'var(--dsw-alias-label-primary, currentColor)'
|
|
560
627
|
const muted = 'var(--dsw-alias-label-secondary, rgba(127,127,127,.9))'
|
|
561
628
|
const layer = 'var(--dsw-alias-container-bg, Canvas)'
|
package/src/index.js
CHANGED
|
@@ -46,7 +46,7 @@ export async function apply(ctx) {
|
|
|
46
46
|
}))
|
|
47
47
|
register(ctx, defineTool({
|
|
48
48
|
name: 'drawio_edit',
|
|
49
|
-
description: 'Apply a batch of semantic edits to an existing .drawio file. Supported operations
|
|
49
|
+
description: 'Apply a batch of semantic edits to an existing .drawio file. Supported operations: add_node, add_edge, update, delete, add_page, delete_page, rename_page. Cell ids must be stable and unique. Inspect the file first when modifying an existing diagram.',
|
|
50
50
|
parameters: {
|
|
51
51
|
path: pathParameter,
|
|
52
52
|
page: { oneOf: [{ type: 'string' }, { type: 'number' }] },
|
|
@@ -55,8 +55,9 @@ export async function apply(ctx) {
|
|
|
55
55
|
items: {
|
|
56
56
|
type: 'object', additionalProperties: false,
|
|
57
57
|
properties: {
|
|
58
|
-
type: { type: 'string', enum: ['add_node', 'add_edge', 'update', 'delete'], required: true },
|
|
59
|
-
id: { type: 'string'
|
|
58
|
+
type: { type: 'string', enum: ['add_node', 'add_edge', 'update', 'delete', 'add_page', 'delete_page', 'rename_page'], required: true },
|
|
59
|
+
id: { type: 'string' }, label: { type: 'string' }, name: { type: 'string' }, page: { oneOf: [{ type: 'string' }, { type: 'number' }] },
|
|
60
|
+
style: { type: 'string' }, stylePatch: { type: 'object', additionalProperties: true }, parent: { type: 'string' },
|
|
60
61
|
source: { type: 'string' }, target: { type: 'string' },
|
|
61
62
|
x: { type: 'number' }, y: { type: 'number' }, width: { type: 'number' }, height: { type: 'number' },
|
|
62
63
|
},
|
|
@@ -100,7 +101,7 @@ async function serveCanvasApi(ctx, req, res) {
|
|
|
100
101
|
try {
|
|
101
102
|
const pathname = new URL(req.url || '/', 'http://dsh.internal').pathname
|
|
102
103
|
const method = pathname.slice('/dsh-drawio/api/'.length)
|
|
103
|
-
if (!['read', 'write', 'save-image'].includes(method)) return respondJson(res, 404, { ok: false, error: { message: 'Unknown Draw.io operation.' } })
|
|
104
|
+
if (!['stat', 'read', 'write', 'save-image'].includes(method)) return respondJson(res, 404, { ok: false, error: { message: 'Unknown Draw.io operation.' } })
|
|
104
105
|
const payload = await readJsonBody(req)
|
|
105
106
|
|
|
106
107
|
if (method === 'save-image') {
|
|
@@ -131,12 +132,16 @@ async function serveCanvasApi(ctx, req, res) {
|
|
|
131
132
|
const info = await ctx.fs.stat(target)
|
|
132
133
|
if (!info || info.type !== 'file') throw new Error('Draw.io file was not found.')
|
|
133
134
|
if (Number(info.size) > MAX_DIAGRAM_BYTES) throw new Error('Draw.io file is too large to open safely.')
|
|
134
|
-
if (method === '
|
|
135
|
+
if (method === 'stat') return respondJson(res, 200, { ok: true, value: { version: info.version, size: Number(info.size), mtime: info.mtime } })
|
|
136
|
+
if (method === 'read') return respondJson(res, 200, { ok: true, value: { content: await ctx.fs.readText(target), version: info.version } })
|
|
135
137
|
if (typeof payload.content !== 'string' || Buffer.byteLength(payload.content) > MAX_DIAGRAM_BYTES) throw new Error('Draw.io content is too large to save safely.')
|
|
138
|
+
if (payload.version !== undefined && info.version !== undefined && String(payload.version) !== String(info.version)) {
|
|
139
|
+
return respondJson(res, 409, { ok: false, conflict: true, error: { message: 'File has been modified by an external process or agent.' } })
|
|
140
|
+
}
|
|
136
141
|
const xml = normalizeDrawio(payload.content)
|
|
137
142
|
const sandboxPolicy = { mode: 'workspace-write', workspaceRoot: payload.cwd }
|
|
138
|
-
await ctx.fs.writeText(target, xml, { kind: 'replaceIfVersion', version: info.version }, undefined, sandboxPolicy)
|
|
139
|
-
return respondJson(res, 200, { ok: true, value: { ok: true } })
|
|
143
|
+
const outcome = await ctx.fs.writeText(target, xml, { kind: 'replaceIfVersion', version: payload.version ?? info.version }, undefined, sandboxPolicy)
|
|
144
|
+
return respondJson(res, 200, { ok: true, value: { ok: true, version: outcome?.version ?? info.version } })
|
|
140
145
|
} catch (error) {
|
|
141
146
|
return respondJson(res, 400, { ok: false, error: { message: String(error?.message || error).slice(0, 500) } })
|
|
142
147
|
}
|
package/src/xml.js
CHANGED
|
@@ -10,18 +10,42 @@ export function inspectDrawio(source, includeXml = false) {
|
|
|
10
10
|
const pages = pageNodes(document).map((page, index) => {
|
|
11
11
|
const model = modelForPage(page)
|
|
12
12
|
const cells = Array.from(model.getElementsByTagName('mxCell'))
|
|
13
|
+
const nodeCells = cells.filter(cell => cell.getAttribute('vertex') === '1')
|
|
14
|
+
const edgeCells = cells.filter(cell => cell.getAttribute('edge') === '1')
|
|
15
|
+
const childMap = new Map()
|
|
16
|
+
for (const cell of nodeCells) {
|
|
17
|
+
childMap.set(cell.getAttribute('id'), [])
|
|
18
|
+
}
|
|
19
|
+
for (const cell of nodeCells) {
|
|
20
|
+
const parentId = cell.getAttribute('parent')
|
|
21
|
+
if (parentId && childMap.has(parentId)) {
|
|
22
|
+
childMap.get(parentId).push(cell.getAttribute('id'))
|
|
23
|
+
}
|
|
24
|
+
}
|
|
13
25
|
return {
|
|
14
26
|
index,
|
|
15
27
|
id: page.getAttribute('id') || `page-${index + 1}`,
|
|
16
28
|
name: page.getAttribute('name') || `Page-${index + 1}`,
|
|
17
|
-
nodes:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
nodes: nodeCells.map(cell => {
|
|
30
|
+
const id = cell.getAttribute('id')
|
|
31
|
+
const rawValue = cell.getAttribute('value') || ''
|
|
32
|
+
return {
|
|
33
|
+
...cellSummary(cell),
|
|
34
|
+
plainText: stripHtml(rawValue),
|
|
35
|
+
children: childMap.get(id) || [],
|
|
36
|
+
}
|
|
37
|
+
}),
|
|
38
|
+
edges: edgeCells.map(cell => {
|
|
39
|
+
const rawValue = cell.getAttribute('value') || ''
|
|
40
|
+
return {
|
|
41
|
+
id: cell.getAttribute('id'),
|
|
42
|
+
label: rawValue,
|
|
43
|
+
plainText: stripHtml(rawValue),
|
|
44
|
+
source: cell.getAttribute('source') || '',
|
|
45
|
+
target: cell.getAttribute('target') || '',
|
|
46
|
+
style: cell.getAttribute('style') || '',
|
|
47
|
+
}
|
|
48
|
+
}),
|
|
25
49
|
}
|
|
26
50
|
})
|
|
27
51
|
return { pages, ...(includeXml ? { xml: serializer.serializeToString(document) } : {}) }
|
|
@@ -29,14 +53,51 @@ export function inspectDrawio(source, includeXml = false) {
|
|
|
29
53
|
|
|
30
54
|
export function editDrawio(source, operations, pageSelector) {
|
|
31
55
|
const document = parseDrawio(source || EMPTY_DRAWIO)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
56
|
+
let pages = pageNodes(document)
|
|
57
|
+
let currentPage = selectPage(pages, pageSelector)
|
|
58
|
+
let model = modelForPage(currentPage)
|
|
59
|
+
let root = model.getElementsByTagName('root')[0]
|
|
36
60
|
if (!root) throw new Error('The selected page has no mxGraphModel/root element.')
|
|
37
61
|
|
|
38
|
-
for (const operation of operations)
|
|
39
|
-
|
|
62
|
+
for (const operation of operations) {
|
|
63
|
+
if (!operation || typeof operation !== 'object') throw new Error('Every operation must be an object.')
|
|
64
|
+
const { type } = operation
|
|
65
|
+
if (type === 'add_page') {
|
|
66
|
+
const newPageId = operation.id || `page-${Date.now().toString(36)}`
|
|
67
|
+
const newPageName = operation.name || `Page-${pages.length + 1}`
|
|
68
|
+
const newDiagram = document.createElement('diagram')
|
|
69
|
+
newDiagram.setAttribute('id', newPageId)
|
|
70
|
+
newDiagram.setAttribute('name', newPageName)
|
|
71
|
+
const templateDoc = parseDrawio(EMPTY_DRAWIO)
|
|
72
|
+
const templateModel = modelForPage(pageNodes(templateDoc)[0])
|
|
73
|
+
newDiagram.appendChild(document.importNode(templateModel, true))
|
|
74
|
+
document.documentElement.appendChild(newDiagram)
|
|
75
|
+
pages = pageNodes(document)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (type === 'delete_page') {
|
|
79
|
+
if (pages.length <= 1) throw new Error('Cannot delete the only remaining diagram page.')
|
|
80
|
+
const targetPage = selectPage(pages, operation.page)
|
|
81
|
+
document.documentElement.removeChild(targetPage)
|
|
82
|
+
pages = pageNodes(document)
|
|
83
|
+
if (currentPage === targetPage) {
|
|
84
|
+
currentPage = pages[0]
|
|
85
|
+
model = modelForPage(currentPage)
|
|
86
|
+
root = model.getElementsByTagName('root')[0]
|
|
87
|
+
}
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (type === 'rename_page') {
|
|
91
|
+
const targetPage = operation.page !== undefined ? selectPage(pages, operation.page) : currentPage
|
|
92
|
+
const newName = required(operation.name, 'rename_page.name')
|
|
93
|
+
targetPage.setAttribute('name', newName)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
applyOperation(document, root, operation)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
replacePageModel(document, currentPage, model)
|
|
40
101
|
document.documentElement.setAttribute('compressed', 'false')
|
|
41
102
|
document.documentElement.setAttribute('modified', new Date().toISOString())
|
|
42
103
|
return serializer.serializeToString(document)
|
|
@@ -49,6 +110,46 @@ export function normalizeDrawio(source) {
|
|
|
49
110
|
return serializer.serializeToString(document)
|
|
50
111
|
}
|
|
51
112
|
|
|
113
|
+
export function stripHtml(input) {
|
|
114
|
+
if (!input || typeof input !== 'string') return ''
|
|
115
|
+
return input
|
|
116
|
+
.replace(/<[^>]+>/g, ' ')
|
|
117
|
+
.replace(/ /g, ' ')
|
|
118
|
+
.replace(/&/g, '&')
|
|
119
|
+
.replace(/</g, '<')
|
|
120
|
+
.replace(/>/g, '>')
|
|
121
|
+
.replace(/"/g, '"')
|
|
122
|
+
.replace(/'/g, "'")
|
|
123
|
+
.replace(/\s+/g, ' ')
|
|
124
|
+
.trim()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function patchStyle(currentStyle = '', patch = {}) {
|
|
128
|
+
const parts = currentStyle.split(';').map(s => s.trim()).filter(Boolean)
|
|
129
|
+
const map = new Map()
|
|
130
|
+
const bareKeys = []
|
|
131
|
+
for (const part of parts) {
|
|
132
|
+
const eqIdx = part.indexOf('=')
|
|
133
|
+
if (eqIdx >= 0) {
|
|
134
|
+
map.set(part.slice(0, eqIdx), part.slice(eqIdx + 1))
|
|
135
|
+
} else {
|
|
136
|
+
bareKeys.push(part)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const [key, val] of Object.entries(patch)) {
|
|
140
|
+
if (val === null || val === undefined || val === '') {
|
|
141
|
+
map.delete(key)
|
|
142
|
+
} else {
|
|
143
|
+
map.set(key, String(val))
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const serialized = [
|
|
147
|
+
...bareKeys,
|
|
148
|
+
...Array.from(map.entries()).map(([k, v]) => `${k}=${v}`),
|
|
149
|
+
].join(';')
|
|
150
|
+
return serialized ? `${serialized};` : ''
|
|
151
|
+
}
|
|
152
|
+
|
|
52
153
|
function applyOperation(document, root, operation) {
|
|
53
154
|
if (!operation || typeof operation !== 'object') throw new Error('Every operation must be an object.')
|
|
54
155
|
const type = operation.type
|
|
@@ -97,6 +198,10 @@ function applyOperation(document, root, operation) {
|
|
|
97
198
|
const cell = assertCell(root, required(operation.id, 'update.id'))
|
|
98
199
|
if (operation.label !== undefined) cell.setAttribute('value', stringValue(operation.label))
|
|
99
200
|
if (operation.style !== undefined) cell.setAttribute('style', stringValue(operation.style))
|
|
201
|
+
if (operation.stylePatch && typeof operation.stylePatch === 'object') {
|
|
202
|
+
const current = cell.getAttribute('style') || ''
|
|
203
|
+
cell.setAttribute('style', patchStyle(current, operation.stylePatch))
|
|
204
|
+
}
|
|
100
205
|
if (operation.parent !== undefined) cell.setAttribute('parent', stringValue(operation.parent))
|
|
101
206
|
const geometryKeys = ['x', 'y', 'width', 'height']
|
|
102
207
|
if (geometryKeys.some(key => operation[key] !== undefined)) {
|