# dsh-graphics 设计文档 > 本文是实现的真源。改动设计先改这里,再改代码。 --- ## 1. 目标与非目标 ### 目标 1. **工作区内的画布**:画布是工作区里的普通文件(`.excalidraw`),可被 DSH 现有文件工具读、可进 Git、随项目走。 2. **Agent 会画图**:提供语义化的画图工具,模型不必手写底层元素 JSON。 3. **Agent 会读图**:`graphics_read` 返回结构化摘要(页面、元素、文字、关系),而不是把整份元素数组塞进上下文。 4. **模型能"看见"图**:渲染成 PNG 后作为**图像内容块**回传,模型下一轮就能看到自己的结果并自我修正。 5. **能导出**:SVG / PNG 落地到工作区。 6. **有人用的界面**:DSH Web 里一个 Excalidraw 编辑面板,直接编辑工作区中的画布文件。 ### 非目标(v1 明确不做) - 不做协作 / 多人实时同步。 - 不做图库、素材市场。 - 不做 `.drawio` / Figma 等其它格式的导入导出。 - 不做视频、动画。 - **不自己实现渲染器**——渲染交给 Excalidraw 官方导出路径(理由见 §5)。 --- ## 2. 架构总览 ```text ┌─────────────────────── DSH Host(Node)───────────────────────┐ │ │ 模型 ──工具调用──▶ │ ctx.tools.register(defineTool(...)) │ │ graphics_list / read / create / update / render │ │ │ │ │ ▼ │ │ scene.mjs ──▶ ctx.fs(工作区沙箱边界) │ │ 读/写 /**.excalidraw │ │ │ │ │ ▼ │ │ render.mjs ──▶ @excalidraw/utils + jsdom ──▶ SVG │ │ │ │ │ ▼ │ │ @resvg/resvg-js ──▶ PNG 字节 │ │ │ │ │ ▼ │ │ ctx.attachments.saveImage() ──▶ ImageAttachmentRef │ │ │ │ │ ▼ │ │ output.render() ──▶ [{type:'text'}, {type:'image'}] │ │ │ │ ctx.webServer.register(prefix) ──▶ 伺服 editor/ 静态资源 │ └──────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────▼──────────── DSH Web(浏览器)────────┐ │ lib/client.js → window.__ModuleLoader__.load({id, factory}) │ │ ctx.slots.register(...) ──▶ Excalidraw 编辑面板 │ └──────────────────────────────────────────────────────────────┘ ``` **一次操作、两个调用者**(遵循官方 `references/user-actions.md`): - 画布的读 / 写 / 渲染只在**宿主侧实现一次**(`scene.mjs` + `render.mjs`)。 - Agent 工具调用它;UI 面板通过客户端可调用的宿主入口调用它。 - **不允许** UI 和工具各写一套逻辑。 --- ## 3. 数据模型 ### 3.1 画布文件 标准 Excalidraw 场景 JSON: ```jsonc { "type": "excalidraw", "version": 2, "source": "dsh-graphics", "elements": [ /* ExcalidrawElement[] */ ], "appState": { "viewBackgroundColor": "#ffffff", "gridSize": null }, "files": { /* 内嵌图片,二进制资源以 data URL 形式存放 */ } } ``` ### 3.2 路径规则 - 画布扩展名固定 `.excalidraw`。 - 所有路径经 `ctx.fs.resolve(path, { cwd })` 解析——**工作区边界由 `fs-sandbox` 后端强制**,插件不自己判断越界。 - 新画布默认落在配置项 `directory`(默认 `graphics`)下,相对工作区根。 - **不使用 node 的 `fs`**:那会绕过 DSH 沙箱。 ### 3.3 已知 seam 限制 `ctx.fs` **没有删除 / 重命名 / 复制原语**(`listDir` 只列一层,无递归 / glob / 分页)。 → 影响:v1 **不提供 `graphics_delete`**;`graphics_list` 自己实现受深度与数量上限约束的递归扫描。 需要删除能力时,走 DSH 既有的文件工具,或在设计上重新考虑(不要退回 node `fs`)。 --- ## 4. Agent 工具设计(草案) 统一前缀 `graphics_`。参数用 `defineTool` 的 schema DSL;`output.render` 返回 `ContentBlock[]`。 ### 4.1 `graphics_list` 列出工作区内的画布。 ```ts parameters: { directory: { type: 'string', description: '相对工作区根的子目录,默认取配置' }, maxDepth: { type: 'integer', description: '递归深度上限,默认 4' }, } output.schema: { type: 'object', additionalProperties: false, properties: { root: { type: 'string', required: true }, files: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: { path: { type: 'string', required: true }, bytes: { type: 'integer', required: true }, modified: { type: 'string' }, // ISO 时间 elementCount: { type: 'integer' }, title: { type: 'string' }, }, } }, }, } ``` ### 4.2 `graphics_read` 读画布,返回**受控摘要**而不是整份元素 JSON。 ```ts parameters: { path: { type: 'string', required: true }, detail: { type: 'string', enum: ['summary', 'elements'], description: '默认 summary' }, pageIds: { type: 'array', items: { type: 'string' }, description: '只读指定页面/Frame' }, elementIds: { type: 'array', items: { type: 'string' } }, } ``` - `summary`:画布尺寸、元素数量按类型分布、每个元素的 id / 类型 / 包围盒 / 文字内容、frame 分组关系。**这是模型默认看到的。** - `elements`:完整元素数组(仅当模型明确需要精确坐标时)。 - 上限:单次返回有元素数与字节上限,超出时明确报错并要求缩小范围(不静默截断)。 ### 4.3 `graphics_create` ```ts parameters: { path: { type: 'string', required: true }, title: { type: 'string' }, background: { type: 'string', description: '画布背景色,默认 #ffffff' }, elements: { type: 'array', required: true, items: { type: 'json' } }, // 语义化元素描述 } ``` 语义化元素描述 → 规范化 Excalidraw 元素:由 `scene.mjs` 里的编译器负责补全 `id` / `seed` / `version` / `versionNonce` / `updated` / `groupIds` / `boundElements` 等必填字段。 **模型不该、也不需要知道这些字段。** ### 4.4 `graphics_update` ```ts parameters: { path: { type: 'string', required: true }, ops: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: { action: { type: 'string', required: true, enum: ['add', 'update', 'remove'] }, element: { type: 'json' }, // add / update 用 id: { type: 'string' }, // update / remove 用 patch: { type: 'json' }, // update 用,浅合并 }, } }, expectedVersion: { type: 'string', description: '可选乐观锁;与文件当前版本不符则拒绝' }, } ``` **冲突保护**:写回经 `ctx.fs` 的版本防护;若用户在编辑器里同时改了文件,工具返回冲突而不是静默覆盖。 ### 4.5 `graphics_render` ★ 核心 渲染并把图**直接交给模型**。 ```ts parameters: { path: { type: 'string', required: true }, // ★ 实现修正:没有 format 参数。ctx.fs 的变更操作只支持 UTF-8 文本 // (dsh-fs README:「二进制安全的变更操作仍延期」),所以 PNG 写不进工作区。 // 只有 SVG 能落盘;PNG 走附件服务,作为图像交给模型。 outputPath: { type: 'string', description: '可选:把 SVG 写到工作区这个路径(自动补 .svg)' }, maxWidth: { type: 'integer', description: 'PNG 宽度,默认 1600;高度按宽高比' }, scale: { type: 'number', description: '导出倍率,默认 2' }, maxWidth: { type: 'integer', description: 'PNG 最大宽度,默认 1600(避免被附件服务重编码)' }, frameIds: { type: 'array', items: { type: 'string' }, description: '只渲染指定 Frame' }, } output: { schema: { type:'object', additionalProperties:false, properties: { path: { type:'string', required:true }, exported: { type:'array', items: { /* {path, mediaType, bytes} */ } }, image: { type:'object', required:true, properties: { /* ImageAttachmentRef 字段 */ } }, } }, render: (_args, value) => [ { type: 'text', text: imageEnvelope(value) }, // /image/ 信封 { type: 'image', attachment: imageRefFromValue(value.image) }, ], } ``` **`render` 返回的是 `ContentBlock[]`,第二块就是图片本身。** 模型下一轮直接看到它。 --- ## 5. 渲染管线 ### 5.1 选型(已实测,见 `docs/research.md`) ```text .excalidraw scene │ @excalidraw/utils@0.1.5 的 exportToSvg({ data, config }) │ ← 需要 jsdom 提供 window/document,外加 FontFace + document.fonts + canvas 2d 桩 ▼ SVG 字符串(自包含,字体内联为 data:font/woff2;base64) │ ① 去掉重复的 xmlns(否则 librsvg / Skia 直接拒收) │ @resvg/resvg-js,font: { loadSystemFonts:false, fontFiles:[7 个自带 ttf] } ▼ PNG 字节 ``` ### 5.2 为什么是这条路 - 用的是 **Excalidraw 自己的渲染器** → 与官方渲染一致,不会长期落后上游。 - **无浏览器、无系统级依赖**(不需要 Chrome、不需要 brew 装 librsvg/ImageMagick)。 - **确定性**:element 自带 `seed`,roughjs 是确定性伪随机;实测同一 scene 多次渲染 SHA-256 一致。 ### 5.3 必做的四件事(每一条都有实测依据) 1. **必须用 `@excalidraw/utils`,不是 `@excalidraw/excalidraw`**——后者在 Node ESM 下 import 就失败。 2. **必须补 `FontFace`(含 `unicodeRange`)+ `document.fonts` + canvas 2d 桩**——jsdom 没实现。 3. **必须去掉重复的 `xmlns`**——Excalidraw 导出的 SVG 有重复属性,是非法 XML。 4. **必须显式传 `fontFiles` 且 `loadSystemFonts:false`**——resvg/sharp/Skia **都忽略 SVG 内嵌的 `@font-face`**, 不给字体文件时**文字会整段消失**(不是变方块,是没了)。 ### 5.4 已知代价 - **依赖体积 ~101MB**(`@excalidraw/utils` 92.6MB 占大头)。这是"与官方渲染一致"的代价。 - 导出路径只用 `ctx.measureText()`,用固定桩即可;代价是文本换行宽度与真实浏览器有偏差。 v1 接受这个偏差(确定性更重要),需要更高保真时再评估。 - 老画布里 `fontFamily: 1 (Virgil)` / `3 (Cascadia)` 不会内联 `@font-face`(上游未修)。 PNG 不受影响;SVG 给浏览器看时可能缺字体 → 渲染前把 1/3 规范化为 5。 --- ## 6. 图像回传链路("让模型直接看图") ```text PNG 字节 │ ctx.attachments.saveImage({ data, mediaType:'image/png', name }) ▼ ImageAttachmentRef { attachmentId:'sha256:...', mediaType, bytes, width, height } │ 放进 output.render() 的 ContentBlock ▼ [{ type:'text', text:'…\nimage\n\n…\n' }, { type:'image', attachment: }] ▼ 会话日志 tool/result 事件的 message.content ▼ provider 适配器转成 base64 发给模型 ``` **要点**: - **内容块里只放引用,绝不放 base64。** - **`attachments.saveImage()` 是唯一合法登记点**;没有挂载附件服务时工具应拒绝执行。 - 文本块写成 `/image/` 信封,Web GUI 才会用专用图片卡渲染。 - **路由能力闸门**:执行前用 `ctx.llm.resolveModelInfo(provider, model)` 检查模型是否声明 `image` 输入能力; 不声明就明确报错,而不是等文件都写完了在适配器层失败。 - 附件服务会**重编码**:不透明的图会被转成 JPEG,带 alpha 转 WebP。 所以 PNG 建议自己先缩到 ≤1600px 宽,避免被规范化缩放。 --- ## 7. 客户端 UI ### 7.1 落点 - 左侧栏入口:`sidebar.panellist`(list slot,需 `id` + `order` + `label`) - 主面板:`main`(keyed slot,`key: 'graphics'`) - 备选:右侧栏标签页 `sidebar.right.pane.tab`(keyed,配 `ctx.sidebarRightTabs.register`) ### 7.2 待决策:Excalidraw 编辑器怎么进页面 Excalidraw 的编辑器是一个 React 组件,需要 `react` + `react-dom`。两条路: | 方案 | 做法 | 优点 | 风险 | |---|---|---|---| | **A. 直接挂载** | 把 Excalidraw 打进 `lib/client.js`,`require('react')` 从浏览器模块表取,`react-dom` 走 `dsh.client.external` 或一并打包 | 原生面板、继承主题与 locale、无 iframe | **`react-dom` 是否在平台模块表里尚未确认**;若不在,打包 react-dom 会与宿主的 React 形成双实例 | | **B. 伺服 + iframe** | 把 Excalidraw 打成独立静态应用放 `editor/`,宿主用 `ctx.webServer` 伺服,面板里 iframe 嵌入 | 与平台模块表完全解耦;**`@guowenzhang/dsh-drawioedit` 已验证这条路可行** | 官方指南不推荐(iframe 不继承主题 token 与 locale);需要自己桥接主题、保存、错误显示 | **M3 的第一步是先做探针**:查清 `react-dom` 是否在浏览器模块表里,再定方案。 探针之前不写任何 UI 代码。 ### 7.3 客户端 bundle 的硬约束 | 约束 | 值 | |---|---| | 外层 | `window.__ModuleLoader__.load({ id: '<包名>', factory })` | | `id` | **必须等于包名** | | 导出 | `{ inject: string[], apply(ctx) }`(`inject` 是**客户端服务名**,如 `slots`、`locale`) | | 产物路径 | `lib/client.js`,且 `exports["./client"]` 必须**显式声明** | | 分块名 | `^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$` | | 尾部 | 必须有 `//# sourceMappingURL=...` | | React | `require('react')` / `require('react/jsx-runtime')`,**从平台模块表取,不要重复安装** | | 禁止 | **不要 `require` 任何 `@deepseek-ai/dsh-client-*` 包**(会导致 slot entry crash) | | CSS | 内联成字符串,在模块体里 `