# 调研结论(可验证事实清单) > 本文件只记录**有证据的结论**。每条都标注来源与"已证实 / 推测"。 > 标注为「推测」的内容在实现时必须先验证,不得当成结论使用。 调研环境:macOS 26.5.1 / arm64,DSH `/Applications/DeepSeek Harness.app` **0.2.0-rc.2**, Node v26.11.1(nvm),bun 1.4.3。 证据代号: - **[asar]** — `app.asar` 内的实现代码(`dsh/node_modules/@deepseek-ai//lib/…`)。 归档不是目录,需解析脚本;行号与归档内文件一致。 - **[npm]** — npm 上同版本(0.2.0-rc.2)的官方发布包,**含 `.d.ts`**(asar 里没有类型声明)。 - **[session]** — 真实会话日志 `~/.dsh/sessions/**/session.v4.jsonl.zstd`。 - **[skill]** — 官方插件开发指南,asar 内 `dsh/node_modules/@deepseek-ai/dsh-agent-preset/skills/cordis-plugin-development/`。 --- ## 1. 工具注册与返回值 | 结论 | 证据 | 状态 | |---|---|---| | `ctx.tools.register(defineTool({…}))`;返回精确 disposer | [asar] `dsh-tools/lib/index.js:2878` | ✅ | | `defineTool` 强制要求 `output: { schema, render, presentationMeta? }`,缺失即 throw | [asar] 同上 | ✅ | | **`output.render(args, value)` 的返回类型是 `ContentBlock[]`** | [npm] `dsh-tools/lib/types/index.d.ts:110` | ✅ | | `execute` 返回的是**规范 JSON 值**,不是内容块;`render` 才投影成模型可见内容 | [npm] 同上 | ✅ | | 工具结果的 `value` **不进会话日志、不给模型**("deliberately omitted from durable events") | [npm] `index.d.ts:424` + [session] 真实 `tool/result` 无 `value` | ✅ | | 运行时**不校验**内容块类型(`snapshotProjection` 只要求无损 JSON)→ 拼错的图片块会在**请求时**才失败 | [asar] `dsh-tools/lib/index.js` | ✅ 风险 | **工具结果实际只有 `text` 和 `image` 能到达 provider**: - [asar] `dsh-llm-deepseek/lib/index.js:1580` — 非 text/image/reasoning/tool-call 的块走 `unsupported()` 抛 `UNSUPPORTED_CONTENT` - [asar] `dsh-llm-pi-ai/lib/index.js:1188` — `userContent` 只 switch `text`/`image`,其余静默丢弃 --- ## 2. 把图像交给模型(本项目核心) | 结论 | 证据 | 状态 | |---|---|---| | 图片块 = `{ type:'image', attachment: ImageAttachmentRef, offloaded?: true }`,**引用而非 base64** | [npm] `dsh-llm/lib/types/types.d.ts:53` | ✅ | | 图片来源 = **`ctx.attachments.saveImage({ data, mediaType, name? })`** | [npm] `dsh-attachment/lib/types/index.d.ts:73` | ✅ | | 内置 `read_image` 的 render 就是 `[{type:'text',…}, {type:'image', attachment}]` | [asar] `dsh-tool-fs/lib/index.js:955` | ✅ | | **DeepSeek 与 pi-ai 适配器都显式允许 tool 角色携带图片** | [asar] `dsh-llm-deepseek/lib/index.js:1416`(原话:"DeepSeek Messages supports images only in user messages and tool results")、[asar] `dsh-llm-pi-ai/lib/index.js:1185` | ✅ | | 注册时机用 `ctx.inject(['attachments'], …)` 做可选能力门 | [asar] `dsh-tool-fs/lib/index.js:1187` | ✅ | | **路由能力闸门**:`ctx.llm.resolveModelInfo(provider, model, signal)` 检查 `inputModalities` 含 `image`,否则明确报错 | [asar] `dsh-tool-fs/lib/index.js:890` | ✅ 建议照抄 | | 文本模型会降级为 `[image omitted …]`,但**仅当 `inputModalities` 已声明且不含 image**;若为 `undefined` 则图片直送适配器并抛错 | [asar] `dsh-llm/lib/index.js:2310` | ✅ 故闸门必要 | | Web GUI 的专用图片卡靠 `…\nimage\n\n…\n` 信封识别 | [asar] `dsh-client-ui-tool/lib/client.js:2684`(正则 `IMAGE_ENVELOPE`) | ✅ | | `presentationMeta` 只管 UI 展示,**与模型看到什么无关** | [npm] `dsh-tools/lib/types/index.d.ts:112` | ✅ | | `dsh-agent-tool-presentation` 与图片**完全无关**(只管 native/ptc 呈现模式) | [npm] 该包 `README.zh.md:12,40` | ✅ | **格式与限额**([asar] `dsh-attachment-local/lib/index.js`): | 项 | 值 | |---|---| | 格式白名单 | `image/png` / `image/jpeg` / `image/webp` / `image/gif`(**没有 SVG、bmp、tiff、avif**) | | 源图字节上限 | 20 MiB | | 规范化后 | 2048×2048 像素 / 4 MiB(超出则缩放) | | **会重编码** | 带 alpha → WebP;不透明 → JPEG(`encodingLadder`) | | 错误码 | `IMAGE_TOO_LARGE`、`IMAGE_TYPE_MISMATCH`、`INVALID_IMAGE`、`ATTACHMENT_WRITE_FAILED` 等,用 `ctx.attachments.isAttachmentError()` 判定 | ⚠️ **限额可被部署级配置覆盖**——运行时读 `attachments.imageLimits`,不要硬编码。 --- ## 3. 客户端插件 | 结论 | 证据 | 状态 | |---|---|---| | 外层是 `window.__ModuleLoader__.load({ id, factory })`,`id` **必须等于包名** | [asar] `dsh-client-modules/lib/index.js:17,98` | ✅ | | 导出 `{ inject: string[], apply(ctx) }`;`inject` 是**客户端服务名** | [skill] `templates/decoration/client.js` | ✅ | | **`exports["./client"]` 必须显式声明**(不是"默认 lib/client.js") | [asar] `dsh-client-modules/lib/index.js:170` | ✅ | | 产物路径约定 `lib/client.js`;chunk 名须匹配 `^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$` | [asar] 同上 `:169,204` | ✅ | | React 从浏览器模块表取(`require('react')`),**不要重复安装** | [skill] `references/ui-plugin.md:9` | ✅ | | **不要 require 任何 `@deepseek-ai/dsh-client-*` 包** → 否则 `slot entry crashed` | [skill] `references/practices.md:35` | ✅ | | `dsh.client.platform` / `inject` / `external` / `immediately` 是合法字段 | [asar] `dsh-client-modules/lib/index.js:61-75`(`parseDshClient`) | ✅ | | `dsh.client.immediately` 语义**未在任何 README 写明** | — | 🔶 照抄官方模板写 `true` | **Slot API**: - `ctx.slots.register(options, Component)` — 返回幂等 disposer,内部已包 `ctx.effect` - `ctx.slots.inject(key, cb)` — **必须用它包一层**;直接 register 到未声明的 slot 会 throw - options:`name`(必填)、`key`(keyed 必填)、`id`(list 必填)、`order`(仅 list)、`priority`(**最低者渲染**)、`label`、`locale`、`inject`(额外 props)、`children` - 四种 kind:`single` / `list` / `keyed` / `chain` **已确认存在的 slot**([asar] 各 UI 包的 `children` 声明): | slot | kind | 用途 | |---|---|---| | `sidebar.panellist` | list | 左侧栏图标入口(需 `id` + `order` + `label`) | | `main` | keyed | 主面板(`conversation` 是保留 key) | | `rightbar` / `rightbar.session` | single | 右侧栏 | | `sidebar.right.pane.tab` | keyed | 右侧栏标签页正文 | | `conversation.composer.dock` | list | 输入框下方 | | `shell.overlay` | list | 浮层 | **静态资源伺服**([asar] `dsh-host-webserver/lib/index.js`): - `ctx.webServer.register({ kind:'exact'|'prefix', path, handler })` → 返回 disposer - 匹配:精确表优先,前缀要求 `pathname === prefix || pathname.startsWith(prefix + '/')`,**最长前缀胜出** - handler 自己完全拥有响应生命周期 - **不要占用 `/plugins/<包名>/client.js`**——那是 DSH 自己的路由(`PLUGIN_ROUTE = "/plugins"`) - 官方指南**不推荐**用 iframe 渲染插件页面(不继承主题 token 与 locale), 但嵌入完整第三方编辑器应用是既有先例(`@guowenzhang/dsh-drawioedit` 就是这么做的) --- ## 4. 工作区文件访问 `ctx.fs`([npm] `dsh-fs/README.zh.md`): - `resolve(path, { cwd })` → `FsTarget`(不透明 `targetKey` + 供模型/UI 的 `displayPath`) - 读:`readText` / `streamText` / `readBytes(target, signal, maxBytes)` / `readByteRange` - 列:`listDir`(**只列一层**,无递归 / glob / 分页) - 写:`writeText`(原子创建或替换,**版本防护可选**) - 编辑:`editText`(原子字面量编辑,**版本防护可选**) - 监听:`watch(target, changed, signal)` - 错误:类型化 `FsError`,错误码 `FS_NOT_FOUND` / `FS_STALE_VERSION` / `FS_AMBIGUOUS_EDIT` / `FS_NOT_TEXT` / `FS_TOO_LARGE` —— **按错误码分支,绝不解析消息文本** ⚠️ **该 seam 没有删除 / 重命名 / 复制原语**,且变更操作**只支持文本**。 ⚠️ 挂载 `fs-sandbox` 时会强制工作区边界。**用 node 的 `fs` 会绕过 DSH 沙箱**——禁止。 --- ## 5. Excalidraw 无头渲染(已在本机端到端实测) ### 5.1 结论 **可行。** `@excalidraw/utils@0.1.5` + jsdom 30 + 约 40 行 polyfill → `exportToSvg()`; 再交 `@resvg/resvg-js@2.6.2` → PNG。无浏览器、无系统级依赖、**输出确定性**。 实测输出(460×160 场景,含 rectangle + ellipse + arrow + 中文 text): ``` SVG 6995B PNG 20627B @font-face=2 resvg loadSystemFonts:false + fontFiles → 文字带 darkPx=322 ✓ resvg loadSystemFonts:false 无 fontFiles → 文字带 darkPx=0 ✗ 文字整段消失 连续 3 次全流程 → PNG/SVG SHA-256 完全一致 ✓ ``` ### 5.2 三个必须知道的坑 1. **`@excalidraw/excalidraw` 在 Node ESM 下 import 就失败** (`ERR_IMPORT_ATTRIBUTE_MISSING` on `open-color.json`;subpath 也 `ERR_PACKAGE_PATH_NOT_EXPORTED`)。 **必须用 `@excalidraw/utils`。** 2. **`exportToSvg` 的签名是 `{ data: { elements, appState, files }, config }`**(0.1.5)。 网上老教程的 `{ elements, appState, files }` 会抛 `TypeError: Cannot read properties of undefined (reading 'appState')`。 3. **resvg / sharp / Skia 三者都忽略 SVG 内嵌的 `@font-face`**(字节级相同的实测结果)。 必须显式传 `fontFiles` + `loadSystemFonts:false`,否则文字**整段消失**。 字体直接取 `@excalidraw/utils` 自带的 7 个 `.ttf`(随依赖装好,不用额外下载)。 ### 5.3 需要的 DOM polyfill `window` / `document` / `navigator` / `DOMParser` / `XMLSerializer` / `HTMLElement` / `HTMLCanvasElement` / `Image` / `getComputedStyle` / `ResizeObserver` / `matchMedia` / `requestAnimationFrame`,外加: - **`FontFace`(必须暴露 `unicodeRange`)+ `document.fonts`** —— jsdom 没实现。 缺 `unicodeRange` 会让 Excalidraw 的 `getUnicodeRangeRegex()` 抛 `TypeError`,字体内联静默失败 (对应上游 issue [#10604](https://github.com/excalidraw/excalidraw/issues/10604),**仍 open**; 修复 PR [#10728](https://github.com/excalidraw/excalidraw/pull/10728) **已关闭未合并**)。 - **canvas 2d 桩**:导出路径只调 `ctx.measureText()`,给固定宽度即可,**且固定桩反而保证确定性**。 ### 5.4 后处理与字体 - **必须去掉重复的 `xmlns`**:Excalidraw 导出的 SVG 带重复属性(非法 XML), librsvg(sharp)报 `Attribute xmlns redefined`、Skia 报 `Invalid SVG image` 直接拒收。resvg 宽容但也要修。 - **`fontFamily: 1 (Virgil)` / `3 (Cascadia)` 不会内联 `@font-face`**(descriptors 无 `unicodeRange`)。 新文件默认 `fontFamily: 5 (Excalifont)` 没问题;老文件需规范化。**PNG 不受影响。** - Excalidraw **不用 ``**(文字是普通 ``),所以 resvg 够用。 ### 5.5 光栅化器对比(全部实测) | 库 | 体积 | macOS arm64 预编译 | 内嵌 @font-face | ``(SVG2) | `` data URL | 备注 | |---|---|---|---|---|---|---| | **`@resvg/resvg-js`** ⭐ | 0.06MB + 3.4MB 二进制,0 依赖 | ✅ 无需 node-gyp | ❌ 忽略 | ✅ | ✅ | 最轻最快最稳 | | `@napi-rs/canvas` | +27MB | ✅ | ❌ | ❌ | ❌ | Skia | | `sharp` | +18MB libvips | ✅ | ❌ | ✅ | ✅ | 严格 XML,原始 SVG 直接拒收 | | `node-canvas` | 需 cairo/pango | ⚠️ | — | — | — | **违反"不装 brew 包"约束,排除** | ### 5.6 依赖代价 `@excalidraw/utils` unpacked **92.6MB**(`dist/dev` 42MB + `dist/prod` 53MB,其中 assets 34MB) → 总计约 **101MB**。这是"与官方渲染一致"的代价。 备选:`@moona3k/excalidraw-export`(~80KB,纯 JS 重写,roughjs + resvg)。 **但它是重写实现、2026-02 才发布、无社区验证,保真度未知** → 仅作降级 fallback。 自写渲染器**不建议**:要复刻文本换行、容器绑定文本、箭头绑定与 elbow 路由、frame + clipPath、 图片裁剪、四种 fillStyle、元素 schema 版本迁移……保守 2–4 周且会长期落后上游。 --- ## 6. 官方插件开发指南的位置 asar 内 `dsh/node_modules/@deepseek-ai/dsh-agent-preset/skills/cordis-plugin-development/`: ``` SKILL.md references/host-plugin.md # bundle manifest、Host 导出形式、Config references/ui-plugin.md # Client manifest、模块加载器、slot 注册 references/practices.md # 扩展点选型、状态机制 references/user-actions.md # UI 操作与 agent 工具共享同一实现 references/verification.md # 无浏览器控制时的验证边界 references/mcp-bundle.md templates/decoration/{package.json,cordis.patch.yml,index.js,client.js} ``` **关键规则**([skill] `SKILL.md:8,10`): - 用工作区文件写 bundle,然后 `plugin_manager` 的 `install_bundle` 安装 - **不要**手写 profile 的 `package.json` / `cordis.patch.yml`、**不要**在 `$DSH_HOME` 下建包、**不要**在 profile 目录跑 pnpm - 每次 `plugin_manager` 调用都需要审批,所以只在结果决定下一步时才调 --- ## 7. 本机环境注意事项 - **本机有两个 DSH 应用**:`/Applications/DeepSeek Harness.app`(官方,0.2.0-rc.2,**当前在跑**) 与 `/Applications/DSH Desktop Beta.app`(社区版,内含 DSH 0.1.6-alpha.1)。 查版本时别搞混。 - **Node 有两个来源**:nvm 的 v26.11.1(`node` 在 PATH 上)与 App 自带的 Node 24.21.0。 本机**没有 Node 24.x 的独立安装**,所以"目标 Node 24"的行为属于**推测**(相关代码路径只用 `Buffer` / `fetch(data:)` / `typeof Worker`,都是 Node 18+ 就有的;napi 向前兼容)。 - **沙箱**:本会话文件策略是 `workspace-write`。在 `~/Projects/Workspaces/dsh-graphics` 里写入 需要逐次提权——所以开发在新工作区里进行。 - `~/.npm/_cacache` 里有 root-owned 文件(既有状况,需 `sudo chown` 才能修,属全局操作,未处理)。