zhiwuli0228/dsh-image-router

dsh-vision-router

在准入前把提示词里的图片换成视觉模型的文字分析,因此任何模型(包括纯文本模型)都能读图且会话不切换模型;另提供 describe_image 工具处理图片路径。

待分类deepseek-harnessimage-routervision

dsh-image-router

[!IMPORTANT]
当前版本 `0.6.0`,已发布到 npm。从 0.6.0 起它是「带左侧栏入口」的 DSH 插件:侧栏多一行「图片路由」,点开是独立配置页(0.2.1 的 `main` keyed 槽 + `sidebar.panellist` list 槽契约),同时保留原来的设置卡片。适配 DSH `0.1.5-rc.1+` 与 `0.2.x`:`0.1.5-rc.1`、`dsh-desktop 0.2.0-rc.2` 为真机验证线;`0.2.1-rc.3` 按 npm 发布包契约适配,侧栏入口 + 配置页已在 0.1.5 真机 GUI 渲染通过,0.2.1 桌面端外观待重启确认。
若你装到的是更早的 0.1.0,那是本插件的第一个(已废弃的)构建,会让「设置 → 模型」页加载失败 —— 用 dsh plugin --profile web add dsh-image-router@latest 直接装最新版即可。
`0.4.4` 及更早的版本装不进 `0.2.x`:插件的浏览器半边声明了 0.2.x 已删除的 settingsScope 服务,条目永久 pending,web boot 会以 1 entry did not activate 中止整次启动(详见〈0.2.x 与 0.1.x 的差异〉)。

让纯文本模型也能「看图」 图片在进入会话之前,先由你指定的视觉模型转成文字 —— 你的会话模型自始至终不变,模型选择器也不会跳。

你贴一张截图,问「这个报错怎么修」
      ↓  插件在「提示词准入」之前拦下这次提示词
qwen-token-plan-cn / qwen3.8-flash   ← 一次旁路调用(不改会话路由、不写 model/selection)
      ↓  分析结果替换掉图片,成为提示词里的一段文字
你原本选定的模型(GUI 里显示的那个)继续作答

📑 目录

  • ⚡ 一分钟安装
  • ✨ 功能一览
  • 🖼️ 它长什么样
  • 🧭 给视觉模型的两种方式
  • 🔐 安全 · ⚠️ 已知限制 · 🖥️ 兼容性
  • 🛠️ 开发与测试 · 🤝 参与贡献
  • 🔍 审计与排查(折叠) · ⚙️ 配置项(折叠) · 🧩 工作原理(折叠)

⚡ 一分钟安装

前置:已装好 DSH(dsh web 能正常跑),Node.js ≥ 20。

方式一:命令行一条

dsh plugin --profile web add dsh-image-router && dsh web

就这一条。 dsh plugin 是 pnpm 的薄封装:它把包装进 profile,并自动把声明了 dsh.bundle 的依赖加进 dsh.profile.bundles(按已安装状态 reconcile),不需要手动编辑 package.json。卸载同理:dsh plugin --profile web remove dsh-image-router。

不想走 npm?把 dsh-image-router 换成 github:zhiwuli0228/dsh-image-router 即为等价形态(前沿代码、未经 registry 校验)。

方式二:让 DSH 自己装 —— 把下面这段发给任意一个 DSH 会话:

帮我安装 dsh-image-router 图片旁路识别插件(DSH 插件,让纯文本模型也能看图),步骤:
1. 执行 dsh plugin --profile web add dsh-image-router
2. 确认 dsh-image-router 已出现在 $DSH_HOME/profiles/web/package.json 的 dsh.profile.bundles 里
3. 提醒我重启 dsh web
4. 重启后告诉我:左侧栏多出来的「图片路由」行点开是什么样,视觉模型那栏怎么填
遇到报错先查 https://github.com/zhiwuli0228/dsh-image-router README 的常见问题。

方式三:本地源码(想改代码时)——见下方〈从源码安装〉。

装完 2 步

  1. 点左侧栏的「图片路由」行(0.6.0+:中间栏直接打开配置页;再点会话行回去);也可以走设置里的同款卡片 —— 设置 → 插件 → 插件配置 → image-router(0.2.x 上这个页面变成了插件管理页:在 设置 → 插件 里找到 dsh-image-router 这个 bundle,它的配置区就是同一张卡片)。
  2. 「视觉模型」下拉里选一个支持图片的模型;没有现成的就切到「自定义端点」,只填三样:地址、模型名、API Key → 保存。

然后随便发一张图 —— 你会看到 [图片分析 · ] 开头的文字,代替图片进入会话。

更新

# 装/升到最新版
dsh plugin --profile web add dsh-image-router@latest

若它打印 `Already up to date` 却仍是旧版:pnpm 11 的发布年龄门槛(minimumReleaseAge)把「刚发布不久」的版本排除在版本解析之外。全新安装不受影响(pnpm 会把该版本自动加进排除项,实测发布 20 分钟后 @latest 即可装到),但升级已有依赖时会把新版本滤掉、解析回旧版 —— 看起来像更新失败,其实是策略挡的。这时显式写出版本号即可,pnpm 会打印 Added 1 entry to minimumReleaseAgeExclude 并立即安装:

dsh plugin --profile web add dsh-image-router@<版本号>

改完硬刷新浏览器(Ctrl/Cmd+Shift+R)。配置项的改动不需要重启(保存后下一次判定即生效);插件代码的改动需要重启 dsh web。

常见问题

现象 · 原因与解决 发图后什么都没发生 · 先看审计文件有没有 mounted mode=digest。没有 → 插件没挂上,确认包在 dsh.profile.bundles 里并重启 dsh web。有 → 看贴图那一刻有没有 digest session=… chars=… 行。 卡片不出现,控制台也没有报错 · 浏览器一半的 inject 声明了加载器满足不了的依赖,entry 会永久 pending 且毫无提示。当前版本已修正;若仍在旧版本上,升级即可。浏览器里读 window.__imageRouter 可看到走到哪一步、以及卡片最终挂在哪个槽位(registered)。 装完就起不来:web boot: 1 entry did not activate,并指名 dsh-image-router: pending (waiting for service: settingsScope) · 0.4.4 及更早的浏览器半边声明了 0.2.x 已删除的 settingsScope。启动时它永久 pending,web boot 等它就绪,于是整次启动被中止(桌面端会在 %APPDATA%\@deepseek-ai\dsh-desktop\logs\crash-*-web-boot.log 留一份崩溃日志)。升级到 `0.5.0+`;暂时脱困可以把插件从该 profile 的 dsh.profile.bundles 里摘掉。 「设置 → 模型」打不开,或提示「填入各提供方的 API 密钥」 · 0.3.1 之前版本的已知问题(schema 描述为空导致设置页整体加载失败)。升级即可。 分析很慢(半分钟以上) · 多半是推理模型把 token 预算花在思考上。插件会自动用更大预算重试一次(审计记 digest-retry),但你可以把卡片里的 maxTokens 从 900 直接改成 4000 省掉那一轮;或换非推理的视觉模型。 分析是空的,收到的是图片占位 · 看审计里的 digest-empty / digest-failed 行 —— 现在会带上实际收到的 chunk 类型与 finish 原因,据此判断是模型没输出、预算被思考吃光、还是路由配置问题。 保存端点后仍走别的模型 · 升级到 0.4.0+。旧版本会让 profile 里的默认路由遮蔽你保存的端点。 想彻底移除 · dsh plugin --profile web remove dsh-image-router(包会从依赖与 bundles 一起消失)。它写下的派生路由 Image Router Vision 与凭据会保留,要清就在 设置 → 模型 里删掉那条路由。

从源码安装 / 开发

# 1. 装进 profile(file: 指向你的克隆目录)
dsh plugin --profile web add file:E:\path\to\dsh-image-router

# 2. 重启 dsh web
dsh web

零安装也可以:不装依赖,直接按路径挂载(见 examples/cordis.patch.yml):

# $DSH_HOME/profiles/web/cordis.patch.yml
- insert:
    - id: image-router
      name: '<repo>\lib\index.js'      # 绝对路径或 ./-相对路径都接受
      config:
        mode: digest
        vision: { provider: qwen-token-plan-cn, model: qwen3.8-flash }
        traceFile: '<repo>\trace.log'

profile 补丁层有 live reload(patchReload: live),实测配置改动会重新 apply —— 但它不会重新 import 已经加载过的宿主模块,所以代码变更只有重启才可靠生效。

✨ 功能一览

  • 🖼️ 提示词里的图片自动转文字:贴图 / 上传的图片在准入前被旁路分析,图片块替换成文字分析 —— 连纯文本模型也能收到「带图」的提示词,而它收到的是文字。
  • 🔒 会话模型一动不动:这是与「切换到视觉模型」类方案的根本区别。不写 model/selection,模型选择器不跳,没有 [model changed] 提示,会话历史里留下的是文字分析。
  • 🔧 `describe_image` 工具(按需):模型可以像调用普通工具那样分析磁盘上的图片文件(describe_image(file_path, question?)),任何模型都能用 —— 因为图片从不进入会话,read_image 那道「当前路由必须声明 image」的门槛不适用。
  • 🧭 两种给视觉模型的方式:① 从「设置 → 模型」里已配好的模型中点选支持图片的(宿主侧图片能力 oracle 按 inputModalities 过滤);② 填自定义端点(baseURL + 模型 + API Key),插件把它翻译成上游 llm-pi-ai 的一条普通路由。
  • ⚙️ 图形界面配置,保存即生效:两处入口、同一张表单 —— 设置 → 插件 → 插件配置 里的卡片,以及左侧栏「图片路由」行打开的那一页。改完立刻用于下一次判定(不需要重启、不需要手写 YAML)。
  • 🧭 左侧栏常驻入口(0.6.0 新增):侧栏面板行多一个图标 + 「图片路由」一行,点开在中间栏打开独立配置页,返回会话只需再点会话行 —— 不用每次进设置里翻三层。它注册的是 sidebar.panellist 的新增一行(order: 40),不替换任何内置面板;插件未配置时页面照常打开,只是提示先选视觉路由。
  • 🔐 API Key 只写不落盘:密钥写进凭据库(refs.IMAGE_ROUTER_VISION_API_KEY),只留引用名在路由里;解析结果、设置节、卡片草稿三处都会剥掉它,不回显、不写进配置文件、不进审计日志。
  • 🧩 不自己实现协议:线协议、模型发现、图片投影、重试全部交给 @deepseek-ai/dsh-llm-pi-ai,插件只负责把卡片上的字段写成 DSH 的配置。
  • 🛡️ 失败即放行:视觉调用失败(配额 / 超时 / 报错)时不替换图片,把原请求交给原路径,让它给出真实的错误,而不是被插件掩盖。任何插件内部异常都只写审计 + 一条 warning。
  • 📝 审计文件:挂载、每次分析、用了哪条路由、重试、失败原因都记一行 —— 「我配的端点到底生效了吗」有一个可读的答案。
  • 🧪 74 个单测 + 全平台 CI:Ubuntu + Windows × Node 20/22/24,外加一份打包契约(含「侧栏入口与主面板必须同 id」的断言)。

🧭 左侧栏入口

除了设置里的卡片,插件在侧栏注册一行入口,并把「配置页」注册到布局的主面板槽:

┌──── 侧栏 ────┬──────────── 中间栏 ────────────┐
│  + 新会话    │  图片路由          dsh-image-router │
│  …会话…      │  图片在进入会话之前先由视觉路由分析成文字,  │
│  ⬚ 插件      │  会话模型不会被切换;这里保存的值从下一轮起生效。│
│  🖼 图片路由  │  ┌──────────────────────────┐  │
│  ⚙ 设置      │  │ 视觉来源 / 模型 / 端点 / 保存 / 还原 │  │
└──────────────┴──────────────────────────┘
  • 一行两槽、同一个 id:侧栏行注册在 sidebar.panellist(list 槽,id: image-router),配置页注册在 main(keyed 槽,key: image-router)。侧栏行被点击时布局按该 id 切换中间栏 —— 两个槽里的 id 必须一致,否则就是「点了没反应」或「页面进不去」,单测与打包契约都把这一条钉住了。
  • 不碰内置面板:这是新增注册(order: 40),不会覆盖 / 移除任何自带行;卸载插件后这一行随之消失。
  • 语言跟随界面:行标签是 thunk(label: () => ...),按 document.lang 返回「图片路由 / Image Router」,不需要重新注册。
  • 图标内联 SVG:手写客户端包只能 require('react')(平台 seed 词),拿不到内置图标包 —— 所以图标用 currentColor 内联 SVG,颜色跟随侧栏主题,选中态由宿主传 active。
  • 降级:两个槽都用 slots.inject 认领(槽被声明才回调)。运行时不声明这两个槽时认领保持惰性,客户端条目照常激活 —— 不会因为侧栏入口把整个 web boot 拖成 pending。

🖼️ 它长什么样

贴图之后,会话里收到的是这样一段(label: true 时会带前缀标记):

[图片分析 · qwen-token-plan-cn/qwen3.8-flash]
1. 文字内容:
设置 / 通用设置 / Theme · 外观 / 模型 / 插件 / Agent 预设 / 插件市场
模型 —— 填入各提供方的 API 密钥即可使用其模型。/ DeepSeek / qwen-token-plan-cn / huawei / Image Router Vision
2. 画面描述:
浅色背景的设置页面。左侧为垂直导航栏,当前选中「模型」;右侧列出四个已配置的模型服务卡片,
每个名称旁有绿色圆点,后三项带红色「删除」按钮;底部两个虚线框按钮「+ 添加提供方」「+ 添加自定义提供方」。

模型收到的是这段文字,不是图片。审计文件里对应这一行:

digest session=<id> vision=image-router-vision/qwen3.8-max images=1 chars=408 names=image.png

🧭 给视觉模型的两种方式

· ① 用这个部署里已经配好的模型 · ② 自定义端点 适用 · 你已经在 设置 → 模型 里配过某个多模态模型 · 手上有别的视觉 API 要填什么 · 下拉里点一个(只列支持图片的模型) · baseURL + model + apiKey 落到哪里 · 无(引用现有路由) · 自动写进 llm-pi-ai.providers.image-router-vision + 凭据库;只动这一条路径,你原有的 provider 一个字节都不改 生效 · 保存后立即(不重启) · 保存后立即(不重启)

优先级:设置节写了端点 → 端点赢;设置节写了路由 → 路由赢;两者都没写 → 用 profile 的默认配置。合并 vision 时按字段进行,空值不覆盖有内容的值。

⚙️ 配置项

全部配置项与默认值

键 · 默认 · 说明 mode · digest · digest=旁路分析并替换文字(不改模型);switch=图片轮次临时借用视觉路由,之后归还 vision · 必填(二选一) · ① provider / model / 可选 reasoningEffort;② endpoint: { baseURL, model, api?, name?, apiKey?, apiKeyEnv?, images? } —— 由宿主翻译成上游 llm-pi-ai 的一条路由 instruction · 内置(提取文字 + 描述画面) · 给视觉模型的指令 maxTokens · 900 · 分析结果上限。在 `openai-completions` 这类协议上,这个预算与模型自己的思考 token 共享(pi-ai 源码原文:*reasoning and the answer share max_tokens here, so an uncapped reasoning phase can consume the whole response and leave no answer*)。推理模型常把 900 全花在思考上、正文一字不出 —— 此时插件会自动用更大预算重试一次(max(4×, 4096),上限 32768),审计记 digest-retry / digest-retry-ok timeoutMs · 120000 · 单次旁路调用的超时 label · true · 在替换文本前加 [图片分析 · provider/model] 标记 tool · true · 是否注册 describe_image(挂载时确定,改动需重启;卡片里没有这一项) traceFile · 无 · 审计文件路径(挂载时确定,改动需重启) dryRun · false · 只写审计、不改提示词、不调视觉模型 text / holdTurns / sticky · — · 仅 switch 模式:归还兜底路由、图片后再保持视觉模型的轮数、永不归还 imageExtensions / hint · — · 仅 switch 模式:文本里的图片扩展名 / 正则判定

两种模式

· mode: digest(默认) · mode: switch(可选) 会话模型 · 永不改动 · 图片轮次临时借给视觉模型,之后归还 图片去向 · 旁路分析后替换为文字 · 原样发给视觉模型 会话历史里 · 留下文字分析(图片块被替换) · 留下图片 模型选择器 / [model changed] 提示 · 不出现 · 会出现

auto / sticky 是旧的模式名,仍被接受并映射到 switch。

🔍 审计与排查

审计行清单与排查顺序

mounted mode=digest vision=image-router-vision/qwen3.8-max maxTokens=900 timeoutMs=120000 label=true dryRun=false
config-resolved overrides=… visionKeys=endpoint -> image-router-vision/qwen3.8-max   # 生效的是哪条路由
endpoint-route-live provider=image-router-vision model=qwen3.8-max modalities=text+image
tool-registered name=describe_image vision=…
digest session=<id> vision=<provider>/<model> images=1 chars=147 names=probe.png   # 一次成功的旁路分析
digest-retry reason=reasoning-consumed-budget budget=900->4096 images=1            # 预算被思考吃光,重试
digest-retry-ok budget=4096 chars=408
tool session=<id> path=<路径> mediaType=image/png bytes=2322 chars=113             # 一次 describe_image 调用
digest-dryRun session=<id> images=2                                                # dryRun 下的判定
digest-failed reason=<消息> code=<码> images=1                                     # 视觉调用失败 → 保留图片
digest-empty images=1 budget=900 chunks=… finish=…                                 # 模型没输出文本 → 保留图片
digest-admit-failed <原因>                                                         # 图片落盘失败 → 不替换
digest-skipped no attachment service                                               # 没有 attachments 服务
settings-invalid <问题>                                                            # 保存的配置没通过校验,沿用上一份
session=<id> error=<消息> at=<栈帧>                                                # 插件内部异常(已兜住,提示词照常发出)

排查顺序:有没有 mounted(没有 → 没挂上)→ 有没有 config-resolved … -> (确认生效的是哪条)→ 贴图那一刻有没有 digest … chars=(没有 → 看是 digest-failed、digest-empty 还是 digest-skipped)→ 工具是否可用看有没有 tool-registered。

🔐 安全

  • API Key 只写不读:Host 收到后写进凭据库;normalizeConfig 从解析结果里剥掉 endpoint.apiKey(env.config 的任何读者都看不到密钥),configBase 不把它写进设置节,卡片编辑器用 withoutKey(...) 播种(旧版本写下的密钥不会被回填进草稿、也就不会被下一次保存写回去)。端点档写凭据时从声明它的那一层现场读取明文。
  • 凭据存成引用名:$DSH_HOME/.credentials.yaml 的 refs.IMAGE_ROUTER_VISION_API_KEY(可用 apiKeyEnv 改名)。选引用名而不是泛型 key,是因为 pi-ai 解析 apiKeyEnv 走的正是这一层(credentialRef(name) → ctx.credentials.resolve(...)),与你原有的 provider 同一种形态。
  • 只写自己那一条路径:端点档用 settings.mutate('llm-pi-ai', [{op:'set', path:['providers','image-router-vision'], …}]),你原有的 provider 不会被整体覆盖;写入前先读回现值做合并 —— 你在「设置 → 模型」给这条路由加过的 headers / compat / timeoutMs 不会被一次保存抹掉。
  • 旁路调用计入你自己的额度:走的是 vision 路由的 provider 与凭证(和正常请求同一套配额),但不写入会话日志。

⚠️ 已知限制

  • 会话历史里留下的是文字分析,不是图片:这是「不切模型」的必然代价(图片块被替换)。附件本身仍持久保存在 $DSH_HOME/attachments/ 下,审计行也记了文件名。
  • 准入会等旁路调用:发送时 session/prompt 会等到分析完成才返回(通常几秒到十几秒;推理模型可能 40 秒以上,必要时再加一次重试),GUI 的发送按钮在此期间保持等待。
  • 一次提示词一次调用:同一条提示词里的多张图合并成一次视觉调用,产出一个文字块;describe_image 每次调用只处理一个文件。
  • `describe_image` 与内置 `read_image` 的分工:read_image 受「当前步骤路由必须声明 image」限制;describe_image 走旁路调用,任何模型都能用。主力换成纯文本模型后让模型改用后者即可。
  • 包装 `sessionController.prompt` 是非官方扩展点:未来版本若改名 / 换实现,插件会退化为「不处理 + 记 warning」,不会崩。工具注册走官方 ctx.tools.register()。
  • switch 模式会出现模型切换提示:借出视觉路由会写 model/selection,模型选择器会跳、GUI 会出现 [model changed]。想要全程无感请用默认的 digest。

🖥️ 兼容性

· DSH 0.1.5-rc.1+(CLI:dsh web / headless / sdk / acp) · 真机验证。设置节走 settings.register,卡片挂在 settings.plugin.item;侧栏入口(若该版本声明 sidebar.panellist / main)同一套代码。 DSH 0.2.x(dsh-desktop 0.2.0-rc.2) · 真机验证。设置节=条目自己的 Config,卡片挂在 plugins.bundle.config。 DSH 0.2.1(dsh-desktop 0.2.1-rc.3 内嵌运行时) · 按 npm 发布的 0.2.1-rc.3 包契约适配:main keyed 槽(按 entryKey 分发)+ sidebar.panellist list 槽(id/order/label 元数据)+ 原 plugins.bundle.config 卡片。单测与打包契约覆盖,同一套注册代码已在 0.1.5 真机 GUI 渲染通过;0.2.1 桌面端外观待重启 GUI 确认。 Node.js · ≥ 20(CI 覆盖 20 / 22 / 24) 平台 · Windows / Linux / macOS profile · web / desktop 验证最充分;headless / sdk / acp 也能激活(sessionController 缺席时插件不包装,只注册工具)

🧬 0.2.x 与 0.1.x 的差异

两代换了客户端服务名与设置模型,插件对两者都支持,靠能力探测而不是版本号判断:

· 0.1.x · 0.2.x 设置节的来源 · settings.register(ns, schema, { base }),插件的 base 层叠在用户节之下 · 条目自己的 `Config` 就是该节(ns = loader 条目 id),服务只投影(describe)与写入(mutate) 可写字段 · 无限制 · 只有 schema 里标了 volatile 的路径可写(插件标的是顶层字段,vision 覆盖其子树) volatile 字段怎么读 · 标记对解析无影响,值就是普通值 · 标记会改变解析:该字段解析成 cosmokit 实时取值器({ get(), [Symbol(cosmokit.volatile.write)] }),~standard.validate(loader 交给 apply 的那份)也一样。插件因此先把配置解包(plainConfig)再判读;官方 pi-ai 也是 config.providers.get() 这么读的 配置落在哪 · $DSH_HOME/settings.yaml 的节(0.2.x 启动时会被导入并改名 .imported) · 该 profile 的补丁层(cordis.patch.yml) 卡片挂在哪 · settings.plugin.item,按设置命名空间 key · plugins.bundle.config,按包名 key(在插件页上该 bundle 的页里) 侧栏入口 · 两侧都有 sidebar.panellist(list 槽)与 main(keyed 槽);行 id 即主面板 key · 同左;布局按当前面板 id 以 entryKey 分发 main 浏览器侧绑定 · settingsScope 服务 + scope.set(field, value) · 没有;一律走 remote.settings.describe() / mutate(ns, ops, revision) 密钥 · 卡片写进节,Host 随即取走存进凭据库 · 同上,并且 Host 存完凭据后会把明文从配置里 `unset`(否则它会留在 profile 补丁里) package.json 的 dsh.client.inject · 声明客户端模块依赖 · 官方包把它写成客户端包名行(参考 plugin-manager:api-remotes + locale + layout + sidebar)。0.6.0 起插件改同一形状:@deepseek-ai/dsh-api-remotes、@deepseek-ai/dsh-client-ui-layout、@deepseek-ai/dsh-client-ui-sidebar —— 这三行是侧栏入口所在的组合。真正的服务闸门仍是模块自己的 inject 列表(slots/remote/remote.settings/remote.llm)

为什么必须这么做:0.4.4 把 settingsScope 写进了浏览器半边的 inject。0.2.x 的 web boot 会遍历每个客户端条目,凡是 fiber.state !== 'active' 就报错退出,pending 的还会列出解析不到的服务名 —— 于是整次启动被一句 web boot: 1 entry did not activate … pending (waiting for service: settingsScope) 中止,配置写什么都没用,因为这一步发生在读取配置之前。现在两半都只用两代都有的服务(客户端 slots / remote / remote.settings / remote.llm,宿主侧 settings / llm / credentials / tools / fs / attachments / sessionController),缺 settings.register 时只记一行 settings-unsupported。

🛠️ 开发与测试

node --test                  # 标准写法(Node 自带发现,20/22/24 通用)
node test/routing.test.js    # 单进程直跑:没有管道支持的沙箱里用这个
node tools/check-contract.mjs   # 打包契约(CI 跑的就是它)
node --test test/ 不可移植:Node 20 会扫描目录,Node 22+ 把 test/ 当成单个入口文件去加载而报 MODULE_NOT_FOUND。

打包契约由 tools/check-contract.mjs 一处定义,ci.yml 与 release.yml 都调它,所以两边不会漂移、你也能跑 CI 跑的同一份。它守住五件事:① 声明的补丁文件存在;② 声明的浏览器一半存在且真的注册了侧栏入口与主面板(字符串断言:sidebar.panellist 行 + name: 'main' 页);③ 入口导出满足 loader 消费的那套(含 Config 必须能答 Cordis 的 ~standard.validate、settings 服务的调用与 toJSON() 字段图 —— 断言的是两种 schema 形态都满足的契约,safeParse 刻意不在其中,因为 fallback 有而真 schemastery 没有);④ dsh.client.inject 里写着侧栏入口所在的两个包行(ui-layout / ui-sidebar);⑤ README 顶部那行版本号与 `package.json` 一致 —— 这两个文件靠人手同步,而 npm 页面显示一个、registry 服务另一个,除此之外没人会发现它们漂移。

74 个用例:配置归一化与校验(含旧模式名映射、schema 物化出的空对象/空数组、端点档补齐 vision 路由与两档优先级)、图片信号判定、digest 的替换/多图合并/失败保留/无图直通/dryRun/落盘失败/完成块兜底/对象 finish 原因/预算被思考吃光后重试、describe_image 的注册/读文件/问题透传/拒绝路径、设置节的注册与「保存后下一轮即生效」、0.2.x 式服务(没有 `register`)只记审计不失败、校验失败被忽略、自定义端点写入上游路由与凭据(含"跳过/失败不得记为已同步"的可重试契约、"合并而非覆盖上游 profile"、以及密钥写进凭据库后从配置里抹掉)、图片能力 oracle、schema 契约(含 `volatile` 与 secret 标记,且不得父子同时标)、浏览器半边激活契约(四个槽位各挂一次:两代设置卡片 + 侧栏行 + 主面板;`inject` 不得再出现 `settingsScope`;summary/panel 视图分离;侧栏行与主面板必须同 id;行标签跟随界面语言;侧栏图标为内联 SVG)、section base 不得遮蔽端点、switch 的借出‑归还‑放弃状态机、holdTurns、sticky、冷会话不路由也不告警、审计可写与不可写、无 sessionController 时不包装、卸载恢复。

CI(.github/workflows/ci.yml)在 Ubuntu + Windows × Node 20/22/24 上跑同一套用例,并跑上面那份打包契约。

仓库结构

lib/index.js             宿主一半:配置归一化、准入前替换、describe_image、审计、端点档
lib/endpoint.js          自定义端点档:翻译成上游 llm-pi-ai 路由 + 凭据
lib/settings.js          设置命名空间注册
client/client.js         浏览器一半:设置卡片 + 侧栏入口(图标行 + 配置页,共用同一张表单)
cordis.patch.yml         包内补丁:只插入一条 loader 条目(部署无关)
examples/                配置示例
tools/check-contract.mjs 打包契约(两个工作流共用)
tools/release.mjs        发版准备:同步两处版本号 → 校验 → 提交 → 打 tag
tools/*-probe.mjs        五个运行时探针(digest / tool / switch 序列 / 服务形状 / 调用链)

🤝 参与贡献

  • 改代码走 PR(feat/* / fix/*);纯文档可直接推 main。
  • 提交前自检:node --test 与 node tools/check-contract.mjs,并确认 README 里描述的行为与代码一致 —— 本仓库的约定是每条实现要点都来自实测,README 里的「已验证」表也是这么攒出来的。
  • 报 bug 时请附上审计文件里对应的那几行(mounted / config-resolved / digest 或 digest-failed)—— 这条链路上的失败方式大多是静默的,那几行是唯一可读的线索。
  • 发版:一条命令准备,一个 tag 发布。版本号写在两个文件里(package.json 与 README 顶部),release.mjs 会同时改掉、并在打 tag 前跑完契约检查与单测 —— CI 会拒绝的东西,本地就先拒绝掉:
node tools/release.mjs 0.4.5          # 改版本号 → 校验 → 提交 → 打 tag(不推送)
  git push origin main v0.4.5           # 这一步触发发布
  # 想一步到底:node tools/release.mjs 0.4.5 --push
  # 只看会发生什么:node tools/release.mjs 0.4.5 --dry-run

它还会拦住几种"不可撤销"的情形:工作树脏(tag 会指向一个不存在的树)、版本号不递增、tag 已存在、该版本已在 npm 上(版本号不可覆盖)。--push 是显式选填的 —— 不可逆的那一步应该是决定,而不是某个参数的副作用。

CI 会先跑单测与打包契约检查,再校验 tag 与 `package.json` 版本一致(不一致直接失败,避免把错版本发出去),然后发布到 npm 并创建 GitHub Release。用的是 npm Trusted Publishing(OIDC):仓库里不存任何 token,也不需要交互式 2FA,npm 会为产物生成 provenance 签名。也可以在 Actions 页面手动触发,默认是 dry run(只打包校验、不发布)。