# CLAUDE.md

本文件为 Claude Code 在本仓库工作时的向导：项目结构、模块职责、开发命令与必须遵守的契约。

## 项目概览

`dsh-comfyui` 是 **DeepSeek Harness (DSH / cordis) 的 ComfyUI 插件**：让 Agent 通过工具直接驱动 ComfyUI 生成、处理图像与视频，并在浏览器端提供工作流库 / 资产 / 队列面板、对话内媒体卡片和设置页。

- 包名 `dsh-comfyui`，ESM（`"type": "module"`），Node ≥ 22.19，MIT。
- 入口：host 侧 `lib/index.js`（由 `src/*.ts` 经 tsc 编译）；浏览器侧 `client/client.js`（由 `src/client/*` 经 tsdown 打包）。
- peer 依赖：`@deepseek-ai/cordis`（必需）、`@deepseek-ai/dsh-settings`（`^0.1.2-alpha.4 || ^0.1.5-alpha.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1`——semver 的预发布版只匹配同一 x.y.z，所以每条宿主线都要单列，**必选**——0.4.0 起摘掉 optional，老宿主安装会触发 dshmarket 风险横幅而非静默降级）。运行时依赖只有 `@deepseek-ai/schemastery`。
- `cordis.patch.yml` 把插件以 `id: comfyui` 插入 profile 层栈；`package.json` 的 `dsh` 字段声明 bundle patch 与 client 平台/注入。

## 常用命令

```sh
npm run typecheck    # host + client 双 tsconfig 类型检查（不产物）
npm run build        # build:host (tsc → lib/) && build:client (tsdown → client/client.js)
npm run build:host   # 仅 host
npm run build:client # 仅客户端 bundle
npm pack --dry-run   # 发布前必查：README 引用的资源都要在 files 白名单里
node scripts/test-store-params.mjs   # 离线自测：参数保存/重载（需先 build）
node scripts/test-skillpack.mjs      # 离线自测：技能包 CRUD / 路径穿越防护（需先 build）
node scripts/test-transfer.mjs       # 离线自测：预设导出/导入回环（需先 build）
node scripts/test-convert.mjs        # 离线自测：画布分析与图→API 转换回归（需先 build）
```

**改动生效规则**：`src/*.ts`（host：tools/routes/store/params/skill…）改完要 **重启 DSH**；`src/client/*` 改完 **刷新页面** 即可。skill 文本编译进 `lib/skill.js`，属 host 侧。

构建产物 `lib/`、`client/` 不入库（见 `.gitignore`），clone 后需 `npm install && npm run build`。

## 架构

插件分两半，通过同源 HTTP 路由通信，浏览器 **从不** 直连 ComfyUI（无 CORS、无混合内容、密钥不下发）：

```
Agent ──tools──┐
               ├─→ ComfyUIRuntime（src/index.ts 组装的能力对象）
浏览器 ─routes─┘        │
                        ├─ ComfyUIClient (comfyui.ts) ──HTTP/WS──→ ComfyUI 服务器
                        ├─ ComfyUIStore  (store.ts)   ──JSON 文件─→ $DSH_HOME/data/dsh-comfyui/
                        ├─ QueueTracker  (queue.ts)
                        └─ ProgressTracker (progress.ts, WebSocket)
浏览器 ←媒体─ /comfyui/media 同源代理 (proxy.ts)
```

`ComfyUIRuntime`（定义在 `src/tools.ts`，实例化在 `src/index.ts`）是唯一的能力面：工具层和路由层都只依赖它，不各自持有 client/store。新增功能优先加 runtime 方法，而不是在 routes/tools 里直接 new client。

### 两个"工作流主题"（贯穿全项目的核心概念）

- **图工作流（衍生主题）**：ComfyUI 端保存的画布 UI 图（nodes/links/widgets），是"源"，**不能直接运行**；一张画布可能含多个互相独立的流程。
- **API 工作流（运行主题）**：API 格式 prompt（node id → `{ class_type, inputs }`），是"运行单元"，由图 **提取（extract）** 而来或用户直接粘贴导入。

提取路径：`analyzeGraph`（连通分量分析）→ 用户在面板选 整体 / 按分量 / 主流程 → `convertGraphToApi`（图→API）→ `analyzeWorkflowParameters`（识别可调参数）→ `store.saveWorkflow`。

## host 侧模块（`src/`）

| 文件 | 职责 |
| --- | --- |
| `index.ts` | 插件入口：解析配置、组装 `ComfyUIRuntime`、注册设置节 / 工具 / skill / 路由 / 媒体代理，全部挂在 fiber 上随插件卸载。`export const inject = ['tools']`。设置有两代宿主：dsh 0.1.2–0.1.5 经 `ctx.inject(['settings'])` 子 fiber 调 `settings.installSection(ctx, 'comfyui', Config, resolved, { setSource, onChange })`；dsh 0.1.7+ 没有 `installSection`，设置服务只编辑 Config 里 `.volatile()` 的字段（写进 profile 的 `cordis.patch.yml`），Loader 就地更新引用后发 `loader/volatile-update`，插件据此 `resolveConfig` 重读并在 `baseUrl` 变化时重连进度 WS（#11）；同时 `settings.configure({ auto: false }, ctx.fiber)` 声明自带设置页。settings 是可选服务，无该服务的 headless 宿主静默跳过；命名空间就是 profile 条目 id `'comfyui'`。 |
| `config.ts` | schemastery 配置 schema（同时供 cordis.yml 入口配置和设置页使用）+ 同形状的 TS 类型 + `resolveConfig`（把可能是 volatile 引用的字段解成普通对象，全插件只读这一个 `resolved`）。运行时读取的字段标 `.volatile()`（baseUrl/apiKeyEnv/超时/轮询/媒体上限/skillsDir/mediaHost/comfyuiDirs），`dataDir`/`maxAssets`/`outputDir` 保持普通字段（改了会重启插件）；需 schemastery ≥ 3.18.4。`outputDir` 只走 cordis.yml，留空时删除资产会自行推断 ComfyUI 输出目录。`skillsDir` 指定技能包根目录（留空 = `<dataDir>/skills`，必须绝对路径，相对值忽略；运行时经 getter 现读，设置页改完即生效，但不会自动搬走已有目录）。`comfyuiDirs`（字符串数组，可多填）记录用户本机 ComfyUI 安装目录（目录映射/多实例/便携版），Agent 据此定位 models、自定义节点、TTS 音色库等文件；变更经 `onChange` 热同步到 runtime 配置，无需重启。 |
| `comfyui.ts` | ComfyUI HTTP 客户端：queuePrompt / history / queue / jobs / userdata / object_info / view / upload / interrupt 等，加上 `collectMedia`、`mediaProxyUrl`、`waitForCompletion`。上传有两个入口：`uploadFile` 转发浏览器原样的 multipart，`uploadMedia` 用 FormData 包好字节再传（`/upload/image` 只吃 multipart，裸 body 会 400）。模块级 `CLIENT_ID` 让排队与 WS 进度同源。路由前缀按 baseUrl 自学习（模块级 `routePrefix`）：裸路由 404 时重试 `/api` 前缀，成功就记住——兼容只转发 `/api/*` 的反代（comfy-api-proxy，#6）；`listUserData` 在 `/v2/userdata` 404 时回退 v1 `/userdata?dir=…&recurse=true&full_info=true`（#5）。 |
| `store.ts` | 持久化：工作流库、资产索引、加载区加载位（`LoadSlot[]`，`null` = 空位，兼容旧的单图格式）、媒体尺寸、上传哈希、任务跟踪，均为 dataDir 下的 JSON 文件；`skillsRoot` 指向技能包目录树，`updateWorkflowSkill` 单独维护 `skillDir` / `requireSkill`（普通保存不碰这两个字段）。 |
| `queue.ts` | `QueueTracker`：记住本插件提交过的 prompt，`sweep()` 在读取（queue/assets 路由）时把完成的运行归档进资产索引；无后台定时器。 |
| `progress.ts` | `ProgressTracker`：连 ComfyUI `/ws` 收 `progress` 事件，best-effort（远端鉴权代理下可能无进度），断线重连直到 dispose。 |
| `analyze.ts` | 画布分析：groups 无执行语义，可执行单元 = 激活节点的连通分量，忽略 bypass(mode 4) 与悬空 UI 节点；bypass 节点按与 `convert.ts` 相同的直通规则（输出跟随**第一个**有连线的输入）连接上下游，否则分量会被切断、「主流程」漏掉上游（#8）。 |
| `graph.ts` | 图的公共预处理：`normalizeLinks`（位置行 / v0.4 对象行两种 links 统一成 `[id, origin, slot, target, slot, type]`）；`resolveVirtualLinks` 在分析与转换**之前**把虚拟节点改线——KJNodes `SetNode`/`GetNode` 按名称配对（同名取 `order` 小于 getter 的最大者），rgthree `Mute / Bypass Relay/Repeater` 的模式传播连线直接丢弃（#8）；link id 保持不变，节点 `inputs[].link` 引用仍有效。 |
| `convert.ts` | 图 → API 转换：链接变 `[String(nodeId), slot]`，widgets_values 按图节点自身 `inputs` 顺序对齐，Reroute/bypass 直通，无法表达的节点报错。产物做引用完整性检查（所有 `[id, slot]` 必须指向同一产物内的节点）。`COMFY_DYNAMICCOMBO_V3` 保持**扁平**（见契约 15），并校验所选 key 在可选项内；`flattenDynamicCombos` 在排队时把 0.2.0–0.5.1 存下的 `{ key, inputs }` 旧形状摊平。 |
| `params.ts` | 可调参数：自动识别（提示词/分辨率/步数/种子/时长/宽高比/加载节点）+ 用户高级参数；`numberSpecOf` 从 object_info 读数字输入的声明类型（INT/FLOAT + min/max/step），存进参数的 `numberKind`；`applyWorkflowParameters` 在运行时写回工作流（int 四舍五入、bool 归一化 `"true"`/`0` 这类写法、连线输入与加载参数不被空默认值覆盖、未传值的加载参数按加载位顺序取用）；`refreshParameterMetadata` 按最新 object_info 重算**已有**参数的 options / numberKind / min/max/step（参数集合与默认值一个不动，供快照刷新路由/工具用，返回 `{ parameters, changed }`）。 |
| `templates.ts` | 内置 API 模板：`txt2img`、`img2img`（核心节点）、`video`（Wan 2.1，需 ComfyUI-WanVideoWrapper）。 |
| `tools.ts` | 模型侧工具定义与注册 + `ComfyUIRuntime` 接口 + 后台任务/结果回显。 |
| `routes.ts` | 浏览器侧同源 HTTP 路由（面板与设置页的全部数据来源），写操作强制同源。 |
| `proxy.ts` | `/comfyui/media` 媒体代理：主路径按 `file`+`subfolder`+`type` 直取（不依赖 history），旧的 prompt/node/index 链接先查 history，查不到再回落到资产索引里的文件引用。 |
| `http.ts` | 路由小工具：`sendJson` / `readJsonBody` / `readRawBody` / `sameOrigin` / `errorMessage`。 |
| `host-hint.ts` | 记住浏览器实际访问用的 origin（Host/Referer），让生成的媒体 URL 对远端浏览器可达；回环 origin 不覆盖已知外部 origin；`detectLanOrigin` 作兜底。 |
| `skillpack.ts` | 每个工作流可挂一个**技能包**：`<dataDir>/skills/<slug>/` 下的 `SKILL.md` + **任意一层子目录**（`SKILL_PRESET_DIRS` 只是面板下拉的建议名：references/scripts/assets/templates/agents/examples/prompts/commands/docs/data，用户与 Agent 都可以自建目录，`parseSkillDir` 按 `DIR_NAME` 文法校验）。`info()` 从磁盘枚举真实目录（含空目录，走 `dirs` 字段），`makeDir` 建空目录。`SkillPackStore` 管文件读写/重命名/删除并守住路径（文件名文法 + 目录名文法 + `relative()` containment + 扩展名白名单）；**尺寸上限按文件类型而不是按目录**：文本 256KB、二进制（`ASSET_EXTENSIONS`）4MB、每包 20MB/2000 文件（字节上限才是存储保护，文件数只挡病态噪音——真实的外部批量拷贝包如音乐模板库可达上千个小文件，必须完整走通导出→导入回环）；`splitFrontmatter`/`joinFrontmatter` 只认 `summary:` 一个键（不引 YAML 依赖）；`writeBytes`/`readBytes` 走字节，导入的图片与文本共用同一套上限检查（文本 256KB、`assets/` 4MB、每包 20MB/2000 文件），`defaultBucketFor` 按扩展名决定导入落点（md/txt/json/yaml→references、脚本→scripts、图片/csv→assets，导入时可用 `bucket` 显式覆盖）；`createWorkflowSkillPacks` 把目录树绑到工作流库，供 tools 与 routes 共用。slug = `<安全名>-<id前8位>`，工作流改名不迁目录。 |
| `skill.ts` | 配套 skill `dsh-comfyui-workflows`（runtime，rank 250）：两个主题的区分、画布分析规则、图→API 技术规则、参数与加载区说明、省 token 的运行流程；另含「工作流技能包」（三级披露：list 里的一行摘要 → `action: skill` 取正文 → 正文点名时才读 references 里的单个文件；必读工作流不读会被 run 拒绝）、「本机环境」（先跑 `comfyui_workflow list` 读 `env`，不要反复问用户目录）与「TTS-Audio-Suite 音色库查询」三节（统一章节，正确流程 = **先刷新快照再查询**：刷新用 `action: refresh` / refresh-params 路由按最新 object_info 重算写回 workflows.json 里保存时拷贝的 options，`?refresh=1` 只重扫 TTS 进程缓存；查询分 HTTP 接口 / 文件系统两种方案）。 |
| `transfer.ts` | 工作流**预设导出/导入**（fflate 打 zip）：`buildExportPackage` 把选中工作流连同 `parameters`/`tags`/`requireSkill`/技能包字节打成一个 `.zip`（`preset.json` 清单 + `skills/<原id>/` 文件区）；`analyzeImportPackage` 只解清单列出可导入项（含逐项告警，不写盘）；`applyImportPackage` 把选中项**创建为新工作流**（新 `randomUUID`，重名追加 `（导入）`，技能包经 `enable` + `importFiles` 批量写（限额只查一次；逐文件写每次全量枚举包目录，千文件包是 O(n²) 文件系统调用、表现为导入假死数分钟）+ `makeDir` 原路还原，全部重过文法与尺寸校验）。安全姿态：文件系统路径从不取自 zip 条目名（落盘目录由 `skillSlug(新名, 新id)` 现生成），包内相对路径过 `parseSkillPath`，解压有单条目/总量上限，`version` 主版本更新拒绝导入。选择用**清单下标**（analyze/apply 解析同一份字节，下标稳定），避免对任意包内 id 做转义。 |

### Agent 工具（`registerComfyUITools`）

| 工具 | 作用 |
| --- | --- |
| `comfyui_run` | 提交 `workflow`（API 格式）或 `template`（txt2img / img2img / video），`inputs` 按节点 id 覆盖输入；`mode: sync`（默认，返回媒体）/ `async`（返回 job id，用 `job_output` 收结果）。 |
| `comfyui_object_info` | 列出服务器支持的节点定义，可用 `filter` 按类名子串收窄。 |
| `comfyui_skill` | 读写某个工作流的技能包（Agent 自治维护文档）：`list` / `read` / `write` / `append` / `mkdir` / `rename` / `delete` / `enable` / `require`，都要带 `workflow_id`。`read` 到 `SKILL.md` 同样解除 `requireSkill` 拦截（与 `comfyui_workflow action: skill` 共用那个 WeakMap）。**没有 destroy**：整包删除是用户在面板上的决定。单次 `read` 超过 40k 字符会截断。 |
| `comfyui_workflow` | `action: list` 列出库里可运行工作流（参数清单含 `numberKind` 整数/小数标注；挂了技能包的多一个 `skill` 字段：一句摘要 + 文件数 + 是否必读，**只有这一行进上下文**）+ ComfyUI 端图工作流（含 `extracted` / `derived`）+ `loadArea`（加载位数量与已放入的素材，Agent 据此知道用户加载了什么）+ `env`（**每次调用现读**：`baseUrl` 服务器地址 + `comfyuiDirs` 用户配置的本机 ComfyUI 目录，供定位文件/音色库用；改动契约，勿改形状）；`action: run` 按 id 运行并传 `parameters` 覆盖；`action: skill` 按 id 返回该工作流技能包的 `SKILL.md` 正文 + 绝对目录 + 文件清单（渲染成 `<skill_content>` 块；读过才解除 `requireSkill` 拦截）；`action: get` 仅供诊断（输出完整 JSON，很费 token）；`action: refresh` 按 id 重算该工作流的参数快照并写回（先强制 TTS 音色库重扫 `?refresh=1` 再读最新 object_info，只更新 options / numberKind / min/max/step 等派生字段，**参数集合与用户手加的高级参数原样保留**，返回 `changed` 清单——音色库/节点定义变更后跑它，否则 run 对新音色会报"not one of the allowed options"）。 |

### 浏览器路由（全部挂在 `webServer` 子 fiber 上）

`/comfyui/` 前缀，写操作要求同源：

- 配置与探活：`ping`(GET)、`config`(GET 读脱敏，含 `skillsDir` 与实际生效的 `skillsRoot` / POST 写)、`test`(POST 连接探测)
- 工作流库：`workflows`(GET/POST)、`workflows/recognize`(POST)、`workflows/input-options`(POST)、`workflows/refresh-params`(POST，body `{ id }`，按最新 object_info 重算该工作流参数快照并写回——先强制 TTS 音色库 `?refresh=1` 重扫，只更新派生字段、保留参数集合，返回 `{ ok, parameters, changed }`)、`workflows/skill`(GET 读技能包清单（含 `dirs` 与 `presetDirs`） / 带 `path` 读单个文件，SKILL.md 额外返回拆好的 `summary`+`body`；POST 动作 `enable` / `disable` / `destroy` / `require` / `mkdir` / `write` / `rename` / `delete`)、`workflows/skill/import`(POST，裸字节 body + query `id`/`name`/可选 `bucket`，落点由扩展名决定，返回最终 `path`)、`workflows/skill/raw`(GET/HEAD，按 `id`+`path` 原样吐字节，带 `nosniff`，供面板预览图片)、`workflows/skill/reveal`(POST，body `{ id }`，在 **DSH 所在机器**上用 explorer/open/xdg-open 打开技能包目录——全项目唯一会拉起本地进程的路由：目录取自 pack store 而非请求、`spawn` 传参数数组不走 shell、要求同源；Agent 工具**没有**这个能力)、`workflows/delete`(POST，body 可带 `deleteSkill: true` 一并销毁技能包)、`workflows/run`(POST)、`workflows/export`(POST，body 收 JSON `{ ids }` **或** urlencoded 表单（`ids` 重复字段）——面板用隐藏 form 原生提交下载：fetch→blob→objectURL 的 JS 下载链路在真实环境产出过 0 字节文件，故弃用；成功时写 `ctx.logger.info` 审计日志（数量+名单+警告，用于排查下载管理器混淆文件），并把打包事实（at/count/names/warnings）存进挂载期闭包变量供 `export/last`(GET) 读回——表单下载的响应体对 JS 不可见，面板靠它向用户报告**实际**打包内容而非勾选框以为的内容；另注册 `export/<文件名>`(**prefix** 路由，GET，`ids` 走 query 重复参数，文件名客户端生成、服务端按 `^dsh-comfyui-presets-[0-9A-Za-z-]+\.zip$` 白名单后用作 content-disposition——面板用 GET form 提交下载：本机的下载管理器会接管/改名/自行重取 POST 下载（"skills 时有时无"的元凶），GET + 文件名入路径使保存名恒等于回执名、重取也得到同 ids 的真实导出)；见 `transfer.ts`)、`workflows/import/analyze`(POST，裸字节 = 预设包，只解析列清单不写盘)、`workflows/import/apply`(POST，裸字节 + query `select=<清单下标逗号列表>`，把选中项创建为新工作流并还原技能包)
- ComfyUI 端图工作流：`comfy-workflows`(GET)、`comfy-workflows/analyze`(GET)、`comfy-workflows/extract`(POST)
- 加载区与媒体：`loadarea`(GET，返回全部加载位)、`current-image`(POST，动作 `pick` / `addSlot` / `clear` / `removeSlot`)、`upload`(POST)、`media-size`(POST)、`media-lookup`(POST)、`media-hash`(POST)、`media`(GET/HEAD，见 `proxy.ts`)
- 资产与队列：`assets`(GET)、`assets/delete`(POST，删记录 + 删输出文件)、`queue`(GET)、`jobs`(GET)、`jobs/media`(GET，history 里没有时查 `/queue` 区分 `queued`（仍在排队）与 `unknown`（已被清掉），后台任务卡片只对 `unknown` 计超时并尝试从资产索引恢复)、`jobs/actions`(POST)

## 客户端模块（`src/client/`，React + slots）

`index.ts` 通过 `ctx.slots.inject` 注册四处贡献：

| 槽位 | 内容 |
| --- | --- |
| `tool.call.toolview`（keys `comfyui_run` / `comfyui_workflow`） | `card.tsx`：对话内媒体墙卡片（生成中状态 / 结果 / 点击放大） |
| `settings.section`（id `comfyui`） | `settings.tsx`：设置页，读写 `/comfyui/config`，`/comfyui/test` 探测，含 zh/en 界面语言切换 |
| `shell.overlay`（id `comfyui.panel`） | `panel.tsx`：浮动面板，三个页签 **工作流 / 资产 / 队列**，可拖拽缩放，几何信息存 localStorage（`clampPos`/`healGeom` 在拖拽、窗口缩放、换显示器、开闭面板时把标题栏钳在视口内，永不失去抓取面；标题栏「↺」按钮复位位置与大小、清 localStorage）；工作流页底部是多加载位的加载区；技能包编辑器支持「刷新」重新读盘与子目录折叠；资产页卡片带删除确认框 |
| `conversation.session.header.actions`（id `comfyui`） | `trigger.tsx`：会话头部按钮，开关面板 |

配套：`panel-store.ts`（面板开关/页签的模块级 store + `useSyncExternalStore`）、`api.ts`（同源 fetch 封装）、`lightbox.tsx`（共享灯箱）、`i18n.ts`（zh/en 词典，语言存 localStorage，不跟随 host locale）、`styles.ts`（一次性注入样式，颜色取 host 主题 token `--dsw-alias-*`）。

客户端 bundle 由 `tsdown.config.mjs` 产出：CJS + `window.__ModuleLoader__.load({ id, factory })` banner/footer；`PLATFORM_EXTERNALS` 里的模块（react、cordis、dsh-client-ui-*）**必须保持 external**，其余全部内联。

## 运行时数据文件

默认 `$DSH_HOME/data/dsh-comfyui/`（未设则 `~/.dsh/data/dsh-comfyui/`），可用配置 `dataDir` 覆盖：

`workflows.json`（工作流库）、`assets.json`（资产索引）、`current-image.json`（加载区加载位列表，`null` = 空位）、`media-sizes.json`（上传图像素尺寸）、`media-hashes.json`（内容哈希 → 文件名，去重）、`tracked.json`（本插件提交的任务）；另有 `skills/<slug>/` 子目录树（位置可用配置 `skillsDir` 挪到别处）存放各工作流的技能包（`SKILL.md` + `references/` + `scripts/` + `assets/`），由面板编辑、`comfyui_workflow action: skill` 读取。

## 必须遵守的契约（踩过坑）

1. **服务注入**：任何通过 `ctx.<服务名>` 访问的 cordis 服务都必须出现在 `export const inject` 里，否则 cordis 在 boot 期抛 `cannot get property "<name>" without inject`，报错点远离肇事代码。可选服务（`webServer` / `settings` / `credentials` / `skills`）**不要**进 `inject`，改用 `ctx.get(...)` 或 `ctx.inject([...], cb)` 子 fiber，让插件在无该服务的 headless 宿主上优雅降级。
2. **注入会话的消息必须符合 host 的 `Message` 契约**：`id`（UUID 非空）+ `role` + `content` + `source` 四者齐全。缺 `id` 会写坏 append-only 会话日志，加载时 `assertMessageEventShape` 让 **整个会话报废**。见 `docs/INCIDENTS.md`；相关代码在 `src/tools.ts` 的 `echoCompletion`（带 CONTRACT 注释）。新增任何"向会话/模型注入内容"的路径后，用 `scripts/analyze-sessions.mjs` 复检会话日志。
3. **API 工作流的节点引用必须是字符串**：`[String(nodeId), slot]`，服务端按字符串键字典查找，数字会 KeyError。
4. **生命周期**：所有注册（工具、路由、代理、样式、WS）都要走 `ctx.effect` / 子 fiber 并返回 disposer，随插件卸载干净收回；不要留裸定时器（`queue.sweep` 就是为此改成"读时清扫"）。
5. **视频/音频工作流一律 `mode: "async"`**：同步等待会超时中断。
6. **不要把完整工作流 JSON 输出到回复里**：运行走 `comfyui_workflow run { id, parameters }`，`action: get` 只用于诊断。
7. **媒体 URL 必须按文件寻址**（`file` + `subfolder` + `type`），不要依赖 ComfyUI 的 `/history`：history 是内存态，ComfyUI 重启或用户点"清空历史"就没了，按 prompt/node/index 生成的链接会全部 404，面板显示"源文件已被 ComfyUI 清理"，而文件其实还在 output 目录。`collectMedia` 走 `mediaProxyUrl`，历史记录里的旧链接由 `/comfyui/assets`、`/comfyui/loadarea` 的 `healAssetUrls` 与代理的资产索引回落救回。
8. **参数写回要区分"没传"和"传了空值"**：`applyWorkflowParameters` 默认会把 `default` 写回节点输入，所以空默认值（`default: ''`）在未显式传值时必须跳过两类参数——暴露在**连线输入**上的高级参数（面板已不再提供这类输入，但库里可能有历史参数，写回会冲掉连线）和**加载参数**（否则清空节点的文件名，运行直接失败）。例外是 `upload: 'media'` 参考位，那里空值的语义就是"移除该位"。
9. **加载节点的输入键要从 object_info 读**（`loaderInputKey` 找带 `*_upload` 标记的输入），不要写死：`LoadImage` 是 `image`，`LoadVideo` 是 **`file`**，`LoadAudio` 是 `audio`——写死 `video` 会让整类媒体在加载区里凭空消失。文件类型按扩展名判定（同一个 `.mp4` 会同时出现在 LoadVideo 与 LoadAudio 的候选列表里）。
10. **删除资产只能自己动文件系统**：ComfyUI 全服务端唯一的 DELETE 路由是 `/userdata/{file}`（用户目录），0.32 的 `DELETE /api/assets/{uuid}` 需 `--enable-assets` 且源码里 `delete_content_if_orphan=False`——只软删数据库引用，**文件保留**。所以 `assets/delete` 路由用 `resolveOutputDir`（配置 `outputDir` 优先，否则从记录里的 `fullpath` 反推）+ `unlink`；务必保留那道 `relative()` 路径穿越检查，`subfolder` 是 ComfyUI 给的、不可信。输出目录不可达时降级为只删索引记录。
11. **加载区是一组加载位而不是一张图**：`store.loadSlots()` 返回 `Array<CurrentImage | null>`（`null` = 用户加过但还没放素材的空位），已放入的按顺序填进未显式传值的加载参数（同类型匹配）。加载位数量与内容通过 `comfyui_workflow action: list` 的 `loadArea` 字段暴露给 Agent，别让模型去猜或反复问用户。

## 辅助脚本（`scripts/`，不随 npm 包发布）

| 脚本 | 用途 |
| --- | --- |
| `analyze-sessions.mjs` | 只读扫描会话日志（zstd 逐帧解压），找缺 `id` 的 message 事件——新增任何"向会话注入消息"的路径后必须跑 |
| `inspect-tool-meta.mjs` | 按 promptId 查会话日志里的 tool-result meta，排查媒体回显 |
| `test-store-params.mjs` | 离线自测：参数经 store 保存 → 重载 → 编辑后仍保留（需先 `npm run build`，它导入 `lib/store.js`） |
| `test-skillpack.mjs` | 离线自测：技能包 slug / frontmatter / 文件 CRUD / 尺寸上限 / 路径穿越防护 / 工作流绑定（需先 `npm run build`，它导入 `lib/skillpack.js`） |
| `test-convert.mjs` | 离线自测：画布分析与图→API 转换——DynamicCombo 扁平（#10）、bypass 直通与越界引用拒绝（#8）、Set/Get 按名配对（作用域、链式、直通输出、缺 setter）、rgthree Relay/Repeater 连线丢弃（#8）（需先 `npm run build`，它导入 `lib/analyze.js`、`lib/convert.js`） |
| `test-transfer.mjs` | 离线自测：预设导出→分析→导入回环（参数/技能包字节/空目录/requireSkill 逐项断言）、重名 `（导入）` 后缀、zip-slip 防护、格式与版本拒绝（需先 `npm run build`，它导入 `lib/transfer.js`） |
| `run-cg-portrait.mjs` / `run-16x9.mjs` | 真机冒烟：打同源路由（默认 `http://127.0.0.1:3080`）跑一次带自定义 prompt 的工作流，需 DSH + ComfyUI 都在运行 |

12. **技能包是"按需披露"，不是常驻 skill**：per-workflow 的技能包**不注册进 `ctx.skills`**。宿主的 skill 目录（`dsh-tool-skill`）会把每个 model-invocable skill 无条件写进一条常驻消息，`modelInvocable: false` 的又会被 `skill` 工具拒绝加载——两头都不满足"用到才可见"。所以披露阶梯挂在 `comfyui_workflow` 上：list 一行摘要 → `action: skill` 取正文 → 模型自己用文件工具读 references。常驻的 `dsh-comfyui-workflows` skill 只负责放那条路由规则。**别把技能包改成 `ctx.skills.register`**，那会让每个工作流都在每轮请求里收费。
13. **技能包的路径全部不可信**：`path` 来自浏览器、`skillDir` 来自 workflows.json，两者都要过 `parseSkillPath`（文件名文法 + 桶白名单 + 扩展名白名单）再过 `relative()` containment 检查，和 `assets/delete` 同款。根目录只放 `SKILL.md`（它是 `action: skill` 的正文来源，禁改名禁删）。导入路径同理：上传文件名先剥到 base name（`/` 与 `\` 都剥）再进同一套校验，浏览器给的路径一律不当路径用。文本的尺寸上限不是防滥用而是防上下文爆炸（SKILL.md 会整份进模型），`assets/` 的上限才是防滥用。
14. **删工作流不默认删技能包**：技能包是用户手写的文档，没有别的副本。`workflows/delete` 只在 body 显式带 `deleteSkill: true` 时销毁目录，面板在有技能包时会先问"一起删/只删工作流"。停用（`disable`）同理，只清 `skillDir` 字段、留着目录。

15. **DynamicCombo V3 在 API prompt 里是扁平的**：主输入的值 = 选中项的 key 字符串，子控件以 `master.sub` 为同级键（如 `codec: "auto"`、`aspect_ratio.size`）。服务端自己重组嵌套结构；包成 `{ key, inputs }` 会匹配不上、输入被丢弃，节点在 `execute()` 才报 `missing ... 'codec'`（#10，视频要白跑整轮才发现）。`params.ts` 的 combo 子参数也按扁平键工作。

## 代码风格约定

- ESM + NodeNext：host 侧相对导入**必须带 `.js` 后缀**（`./store.js`）。客户端侧由打包器解析，现有代码 `.ts` / `.tsx`（`allowImportingTsExtensions`）与 `.js` 后缀混用（如 `./lightbox.js`），两种都能构建，改动时跟随所在文件的写法即可。
- `strict` + `noUncheckedIndexedAccess` 全开：索引访问结果按 `T | undefined` 处理；判空一律显式 `=== undefined` / `!== undefined`，不用真值判断（现有代码通篇如此）。
- 每个文件顶部有一段块注释说明该模块"为什么存在"，新增文件保持同样风格；关键取舍（如为什么不用 WS 拿结果、为什么 sweep 放在读路径）就地写进注释。
- 客户端不用 JSX 语法而是 `createElement as h` 调用，样式集中在 `styles.ts` 的单个 CSS 字符串里，颜色只用 host 主题变量 `--dsw-alias-*`。
- 面向用户/Agent 的文案是中文（skill、面板、错误提示），代码注释与标识符是英文。

## 相关文档

| 文件 | 内容 |
| --- | --- |
| `README.md` / `README.en.md` | 用户文档（中 / 英，npm 首页渲染 `README.md`）。改功能时同步更新两份的功能列表与截图。 |
| `DEVELOPMENT.md` | 维护者笔记：版本状态、时间线、辅助脚本、git / npm 发布流程、运行时数据文件。**本地文件，不入版本库**。 |
| `docs/INCIDENTS.md` | 事故复盘：注入消息缺 `id` 写坏会话日志导致整个会话无法加载。 |
| `AWESOME.md` | 上架 awesome-dsh-plugin / dshmarket 的材料与核对清单。 |

改动时的同步义务：**改功能** → 两份 README + `src/skill.ts`；**改结构 / 新增模块、路由、工具或契约** → 本文件；**发版** → `DEVELOPMENT.md` 的版本表与时间线。
