# dsh-ofm-model-manager [English](README.md) | **中文** ![license](https://img.shields.io/badge/license-MIT-blue) ![DSH plugin](https://img.shields.io/badge/DSH-plugin-4c8bf5) ![version](https://img.shields.io/badge/version-2.0.0-brightgreen) ![runtime deps](https://img.shields.io/badge/runtime%20deps-none-success) > DSH 的**全体模型管理**插件:在「设置 → 模型」页底部加一块面板,管理 provider 目录里 **每一个**提供商的 **每一条**模型 —— 逐条启用/停用、逐条自定义显示名。 ![设置 → 模型 页底部的模型管理面板](assets/model-manager-panel.png) *设置 → 模型 页底部的管理面板:搜索框、状态筛选、提供商下拉,下面按提供商分组的逐模型开关与「自定义名称」。* ![带自定义名称的一行](assets/custom-name-row.png) *勾上「自定义名称」的一行:开关、勾选框、名称输入框与「恢复默认」。* --- ## 目录 - [安装 / 升级](#安装--升级) - [许可](#许可) - [界面](#界面) - [怎么实现的](#怎么实现的) - [偏好键的口径](#偏好键的口径) - [v1 兼容](#v1-兼容) - [离线验证台](#离线验证台) - [端到端验证](#端到端验证) - [与别的插件的兼容](#与别的插件的兼容) - [已知边界](#已知边界) - [故障排查](#故障排查) --- ## 安装 / 升级 从 GitHub 一行装(推荐,等价于下面的本地安装): ```sh dsh plugin --profile desktop add github:DeepseekDays/dsh-ofm-model-manager ``` (`--profile` 换成你自己的 profile 名;装完同样要完全退出 DSH 再启动。) 本地安装:本插件零运行时依赖,只需要目录联接,**不跑 pnpm**(DSH 运行时跑 pnpm 会重建 node_modules、打断正在跑的会话): ```powershell # 默认 desktop profile node install.mjs # 指定 profile node install.mjs --profile web # 只看要做哪些改动 node install.mjs --dry # 卸载(移除 junction 与两条清单记录,保留插件目录) node install.mjs --uninstall ``` 做三件事(全部可重复执行): 1. 在 `/node_modules/` 下建 junction → 本目录; 2. 在 `/package.json` 的 `dependencies` 里加 `"link:<本目录>"`; 3. 在 `dsh.profile.bundles` 末尾追加 `dsh-ofm-model-manager`。 改 profile 清单前会先备份成 `/package.json.bak-ofm-model-manager-<时间戳>`。 **装完必须完全退出 DSH(含托盘)再启动**:bundle 列表与 client 模块表都是启动期读的, HMR 不会热加载**新**包。(已经装过的插件,之后改 `client.js` 会被 HMR 热重载; 改 `index.js` 仍然要重启。) --- ## 界面 落点只有一处:「设置 → 模型」页底部的面板(`settings.models.footer` 席位,list 类型, 按 id 与别的注册方共存)。用户正是在这个页面上管模型,面板放在这里最顺手。 ### 三层控件各管一段 | 控件 | 管什么 | | --- | --- | | 搜索框 | 精确匹配:名称、自定义名、模型 id、描述、提供商名 | | 状态 chips | 属性筛选:全部 / 已启用 / 地区受限 / 已停用,各带计数 | | 提供商下拉 | 分组筛选:`全部提供商` 或某一条路由(带模型数) | 三个条件同时生效,都是 AND。三者都空时显示全部。 ### 分组、折叠与批量 - 列表按**提供商分组**(provider 是用户的心智单位),组头显示 `名称 · 已启用/总数` 与一个**组级开关**:全部停用时写「全部启用」,否则写「全部停用」。 - 组头可折叠,折叠状态只影响渲染,不影响筛选与计数。 - 行级批量按钮写的是**当前视图(筛选结果)**:文案带数量,下面还有一行小字说明 「批量操作只影响上面筛选出来的结果,不会动没显示出来的模型」。刻意不做无声的 「全选全库」,避免误关。 ### 每行有什么 `生效名(= 选择器里显示的名字) + 状态徽标 / 模型 id / 描述` —— 右侧是同一容器里的 一组控件,顺序就是阅读顺序:**模型开关 → 自定义名称勾选框 → 名称输入框**(勾上才可编辑, 未勾选时输入框禁用并灰显系统默认名)。自定义名生效时行上会打一个「自定义」标记; 如果这条名字是**通配**的(对所有提供商下同名模型生效,见下文),标记里还会显示「通配」。 ### 空态与错误态 三种空态分开处理,都不留白: 1. **一条模型都没有**:说明可能是「所有提供商都还没列出模型(比如没配密钥)」或 「提供商被整体关掉了」+「重新读取」; 2. **全部被停用**:给一句「你把所有模型都停用了」+ 一键「全部恢复启用」; 3. **筛选无结果**:回显关键词 +「清除筛选」。 读失败(清单接口 / settings 文档)进错误态,带原因与「重试」;写失败(拨开关 / 改名) 只在面板上挂一条提示并把乐观更新回滚。 ### 大清单 首屏只渲染 **40** 行(`PAGE_SIZE`),下面给「显示更多(还有 N 个)」与一行说明, 筛选条件一变就重置额度。这样几百上千条模型也不会把设置页拖住 —— 代价是不引虚拟滚动库 (本插件是手写 ModuleLoader bundle,除 `react` 与 `@deepseek-ai/dsh-client-ui-primitives` 之外没有依赖)。若将来条目真的到几千条,再考虑把 PAGE_SIZE 换成窗口化渲染。 --- ## 怎么实现的 ### host 半端(`index.js`) **1. 改写模型目录。** 包一层 `ctx.llm.listModels()`:被停用的模型从返回值里滤掉,带自定义 名称的模型的 `name` 换成用户填的那个(浅拷贝,**不在原地改** provider 交出来的对象)。 DSH 的模型目录 `buildModelCatalog()`(`@deepseek-ai/dsh-api-session-controller/lib/types/catalog.js`) 里有一句 ```js groups.filter(group => group.models.length > 0) ``` 模型数为 0 的 provider 分组会被整组丢弃 —— 所以「停用某条路由的最后一个模型」会让这个 分组从选择器里消失,这正是需求要的语义。做法与 `dsh-provider-toggle` 同源,区别只在这里 是**逐模型**而不是整个 provider。 边界(dsh-llm 源码原话):`Core routing accepts unlisted model ids; catalog-driven entry points such as the GUI may require membership.` 也就是说,已经选着这个模型的旧会话不会被 弄坏,请求照常发;只是选择器里不再提供它。这与「停用」的直觉一致。 **2. 只读清单接口** `GET /api/ofm-model-manager/models`。被停用的模型在模型目录里已经 看不见了,管理面板必须另有一条数据来源 —— 而且 `LlmModelInfo` 只给 `id/name/description/inputModalities`,拿不到「这个模型属于哪条路由(是不是地区受限)」。 这条路由一次性给全(`routes` / `models` / `counts` / `disabled` / `names` / 失效名单 / `generatedAt` / `cached`)。只读、只认 `GET`/`HEAD`,走 `src/trust.js` 的信任闸门 (回环 Host + 拒绝跨站 fetch + Origin/Referer 同源;组合里挂了 `connection` 服务时用 它自己的准入判断)。`?force=1` 让面板上的「重新读取」绕开缓存。 **3. 状态持久化与热生效。** 偏好存在本插件自己的 settings 命名空间(就是 profile 里那条 entry 的 id),两个 `.volatile()` 字段:`disabled` 与 `names`。 - volatile 是硬要求:只有 volatile 字段才允许被 settings 服务写入 (`dsh-settings` 的 `write()` 里有 `isVolatilePath` 检查),也只有 volatile 字段走热更新 而不是重启插件; - 浏览器半端用现成的 `ctx.remote.settings.describe()/mutate()` 读写,不自己造远程接口; - 写入触发 `loader/volatile-update` → 本插件重读配置并广播 `llm/adapters-updated` → 浏览器的模型目录当场重载。 **4. 清单缓存。** 面板一次要读**所有** provider 的清单,而每次拨开关 / 敲名字都会触发一轮 重读。`src/manager.js` 的 `createModelCache` 按 TTL(60s)缓存每条路由的清单:TTL 内复用, `llm/adapters-updated`(别处改了路由/目录)与「重新读取」按钮会让它失效。 ### client 半端(`client.js`) **乐观更新。** 拨开关 / 敲名字时本地立刻改(`publish`),再写 settings;失败回滚到 **最后一次确认落盘**的那份(不是回滚到写之前的内存值 —— 后者已经被乐观值改过了), 并把错误挂到面板上。 **改名防抖。** 敲字本地立即生效,停手 500ms 才写一次盘 —— 一次写会把整份 volatile 表单 写回 profile 的 `cordis.patch.yml`,而且每次写都会让 loader 广播 `llm/adapters-updated` (模型目录重载),连打会把界面拖住。写入串行化:两次写撞同一个 `revision` 会被 settings 判 conflict。 **输入框的状态机。** 勾选框是「这条自定义名称在不在」的唯一表示:未勾选 → 输入框禁用、 显示系统默认名;勾选 → 用默认名打底、可编辑;「恢复默认」/ 取消勾选 / 清空后失焦 → 删掉 这条。空串是**合法值**(用户清空了、开关还勾着),取用时才回落默认名 —— 否则清空输入的 瞬间勾选框会自己弹回去,等于打字打到一半被抢走。 **名字永不自动清理。** 上游下线一个模型、provider 暂时没列出模型,用户手填的名字都留着 (需求原话:「系统升级或模型更新时需保留用户自定义名称设置」)。面板只把它们点名出来, 清不清由用户决定。停用记录同理,但多给一个「清理」按钮。 ### 过渡态保护 浏览器半端是**热重载**的(client-hmr 每 500ms 比对 `client.js` 的 mtime/ctime/size,变了就 通过 `/plugins/events` 的 SSE 推一次 `rebuilt`,页面重新拉一份带新 `rev` 的 bundle),host 半端不是(要完全退出 DSH 再启动)。所以「界面是新的、宿主还是旧的」这个过渡态真实存在:此时 settings 命名空间里根本没有 `names` 字段,勾选框点了只会写失败。 > 顺带一个好处:改了 `client.js` **不需要重启 DSH**——HMR 自己会在 500ms 内发现并热替换。 > 但 HMR 换版时旧实例可能还挂着一个防抖写盘,所以卸载回调里会先把它落盘再交班(见「已知边界」第 9 条)。 判据:宿主半端在 `webserver/index-inject` 里注入 `apiVersion: 2`;浏览器半端也检查 settings 命名空间里有没有 `names` 字段。过渡态里**不显示**自定义名称这组控件,并在面板上 说清怎么让它可用(完全退出 DSH 含托盘再启动)。 --- ## 偏好键的口径 每一条偏好(停用 / 自定义名称)的键都是**一个字符串**: ``` provider|modelId 只对这一条路由下的这个模型生效 modelId 不带分隔符 = 对**所有** provider 生效(通配) ``` 为什么是这个形状: - **模型 id 会重名。** `deepseek-v4.1-flash` 同时出现在 `deepseek-official`、 `our-free-model`、`z-ai` 下,v1 只按模型 id 记账,停用一个会把三家的全停掉 —— 这是 v2 修掉的核心问题; - **通配桶让 v1 配置原样兼容**(裸 id 就是通配),不需要任何迁移动作; - **一个字段、一种元素类型**,schema 简单到不会踩 schemastery 的类型坑; - **分隔符选竖线**:provider id(`z-ai`、`our-free-model`)与模型 id (`z-ai/glm-5.3-flash`、`gpt-oss:20b`)都不会含它,而且它是 YAML 不用转义的普通字符 —— 键直接写进 `cordis.patch.yml` 也可读(不选 NUL 就是为了这一点)。 优先级:**精确键 > 通配键**。所以「通配改名 + 某一条单独改名」能共存。 --- ## v1 兼容 v1 只用两个字段:`hidden`(裸模型 id 数组)与扁平的 `names`(模型 id → 名称)。在本版的 键口径下它们**原样读得动**(裸 id = 通配): | v1 配置 | v2 读到的效果 | | --- | --- | | `hidden: ['jev-1.13-free']` | 所有 provider 下 id 为 `jev-1.13-free` 的模型都停用 | | `names: {'mimo-v2.6-flash-free': '小咪'}` | 所有 provider 下同名模型都显示「小咪」 | 写回时: - 权威字段是 `disabled` / `names`; - 每次写偏好都会把其中的**通配部分**镜像回 `hidden` —— 万一回退到 v1,旧版照样 认得出自己那份设置(精确键旧版读不懂,不放进去污染它)。 用户什么都不用做。想一键恢复「全部启用、全部默认名」,把 profile 里那条 `id: dsh-ofm-model-manager` 的 `config` 删掉即可。 --- ## 离线验证台 四个测试文件,**不启动 DSH、不碰网络、不开浏览器**: ```powershell node test/all.mjs ``` (`test/all.mjs` 把四个文件按顺序 import 到同一个进程里跑。等价于 `node --test --test-isolation=none test/manager.test.mjs test/host.test.mjs test/client.test.mjs test/manifest.test.mjs`,但 DSH 的文件沙箱下 `node --test` 给每个文件 开子进程会被拒(EPERM),所以默认入口是前者。) | 文件 | 咬什么 | | --- | --- | | `test/manager.test.mjs` | 纯逻辑:偏好键口径、通配/精确优先级、路由认领、清单合成、三态、搜索口径、TTL 缓存 | | `test/host.test.mjs` | 把 `apply()` 挂到假 ctx 上:停用是否真的从模型目录消失、面板是否仍看得到、广播、只读接口、信任闸门、与 `provider-toggle` 共用补丁槽 | | `test/client.test.mjs` | 按 ModuleLoader 协议加载 `client.js`,用最小 React 替身**真的渲染**面板:分组/计数/折叠/空态/批量范围/部分渲染,以及写入形状与回滚 | | `test/manifest.test.mjs` | 清单一致性:包名、bundle、席位、字段名、分隔符、自包含性(无反引号) | 口径独立实现:测试里的「可见分组」复刻的是 DSH `buildModelCatalog()` 里那句 `groups.filter(group => group.models.length > 0)`,而不是引用被测代码的判断 —— 否则等于自证。 --- ## 端到端验证 离线台替代不了下面这些,装好后请手工过一遍: 1. **重启后**打开「设置 → 模型」,确认页底出现管理面板,且列出的 provider 覆盖你实际接的 全部家(DeepSeek / Our Free Model / Z.ai / …); 2. 停用一个模型 → 打开 composer 的「选择模型」,确认它**不见了**,同名的别的家的还在; 3. 在**已经选着**那个模型的旧会话里发一条消息,确认请求照常(停用只影响目录,不影响路由); 4. 勾上自定义名称填一个名字 → 选择器里显示的名字**当场**变(不用刷新页面); 5. 改一个几百条模型的情况:搜索 / 筛选 / 折叠是否跟手,滚到底点「显示更多」; 6. 手改 profile 的 `cordis.patch.yml` 里那条 entry(比如删掉 `config`),确认面板收敛成 「全部启用 / 全部默认名」。 --- ## 与别的插件的兼容 **`dsh-provider-toggle`(整个 provider 一键开关)** —— 两边争用同一个补丁槽:llm 实例上的 自有属性 `listModels`。`provider-toggle` 卸载时用的是 `Reflect.deleteProperty(llm, 'listModels')`,自有属性同名,删一次会把**我们**的包装一起带走,那一刻之后逐模型过滤会 静默失效(用户看到的是「开关点了没用」)。 对策是 `ensureFilter()`:每次重读配置时,只要发现自有属性**整个消失**就补装一次。判据刻意 收得很紧 —— 别人只是又包了一层(自有属性还在)时不补装,否则包装会越堆越多。两侧的措辞都 在测试里钉住了(`test/host.test.mjs` 的「兼容」两条)。 **`dsh-opencode-go-model-list` 这类「往 pi-ai 目录里塞模型」的插件** —— 它们在更底层 (pi-ai 的 `getModels`)动手,本插件在 `listModels` 出口过滤,顺序无冲突: 新塞进来的模型同样会被本插件管理与展示。 **命名空间不撞车** —— `provider-toggle` 用 `disabled`(字符串数组,provider id),本插件 的 `disabled` 是偏好键数组(含竖线,或裸模型 id)。为保险起见,浏览器半端找命名空间时 首选注入进来的 id,兜底按「值里带 `disabled` 数组」反查 —— 但那个判据用的是本插件自己 注入的 id 优先,实际不会串。 **核心的模型目录** —— 不修改任何内核文件,只包一层运行期方法,卸载时原样还原(引用计数, 重复 apply 不叠加包装)。 --- ## 已知边界 1. **停用只影响目录,不影响路由。** 已选着该模型的会话继续可用;新会话在选择器里选不到它。 这是内核的既定行为,本插件没有改变它。 2. **改名只改显示名,不改模型 id。** 配置、会话记录、凭据引用全部仍用原始 id(这是对的)。 3. **不接管 provider 级开关。** 「整个提供商关掉」是 `dsh-provider-toggle` 的职责;本插件 只管逐模型。两者可以同时装、同时生效。 4. **settings 写入整份 volatile 表单。** 因此偏好是**最后写入者胜**,两个标签页同时改会有一方 收到 `settings/conflict`(面板会提示并回滚,不会静默丢数据)。 5. **通配键是全局广播。** 面板写入的永远是精确键;通配键只从 v1 配置或手改文件里来。 通配名会在每一行显示「通配」标记,通配停用同理,避免「我只改了一个怎么全变了」的困惑。 6. **部分渲染而非虚拟滚动。** 超过 40 行的清单需要点「显示更多」;筛选是解决长清单的主要 手段。 7. **只读接口跑在 `/api/ofm-model-manager` 前缀上**(比内核 `/api` 长,webServer 最长前缀 优先 → 跑在 connection 自己的准入检查之前),所以自己实现了等价的信任闸门;组合里挂了 `connection` 时优先用它的准入判断,保证不比应用本身更弱。 8. **改名期间的写回不锁面板、不重画。** 名称写回走独立路径:不置 `busy`(否则每一行的 勾选框与「恢复默认」在打字期间反复变灰再变回来),并且只有**界面上真的会显示**的字段 变化才通知订阅者(`RENDER_KEYS`,见 `client.js`)。纯记账字段(`revision`、`namesSeq`、 `savedNames`)更新不重画 —— 它们面板一处都不读。往面板加新字段时必须同步登记进 `RENDER_KEYS`,否则该字段更新会静默不重画(有用例钉住这条)。 9. **热重载(HMR)换版时,挂着的防抖写盘会先落盘再交班。** 浏览器半端是 HMR 换版的: 宿主每 500ms 比对 `client.js` 的 mtime/size,变了就推一次 `rebuilt`,页面用**新的**模块 实例替换旧的。旧实例的防抖定时器如果只是被丢掉,用户刚敲、还没到 500ms 的内容就没了; 如果不管,它到点会拿旧实例的 state 去写盘。所以卸载回调里先 `clearTimeout`,再**立刻 落盘一次**(`flushNames()`),写完才交班。这条对「正在打字时恰好触发 HMR」是必需的。 --- ## 故障排查 | 现象 | 原因 / 处理 | | --- | --- | | 面板不出现 | bundle 没加载。确认 profile `package.json` 的 `dsh.profile.bundles` 里有它,然后**完全退出 DSH(含托盘)再启动** | | 面板显示「读不到本插件的 settings 命名空间」 | 同上;另外确认 profile 的 `cordis.patch.yml` 里那条 entry 的 `id` 与注入的 ns 一致 | | 开关点了没用 | 多半是补丁被别的插件卸载时带走了。本插件会在下次重读配置时自动补装;若持续,重启 DSH | | 输入框不显示 | 宿主半端还是 v1(命名空间里没有 `names`)。完全退出 DSH 再启动 | | 改名后选择器没变 | 等一次防抖(500ms);仍不变就看浏览器控制台有没有 `settings.mutate` 失败 | | 打字时面板闪 / 卡 / 吞字 | 已修复(见「已知边界」第 8 条)。浏览器半端热重载即可生效;若仍复现,看控制台有没有本插件的 `listener failed` 或 `settings/conflict` | | 想彻底重来 | 删掉 profile `cordis.patch.yml` 里 `id: dsh-ofm-model-manager` 那条的 `config` | --- ## 文件 ``` index.js host 半端:目录过滤 + 只读接口 + 状态读取 client.js 浏览器半端:设置页面板 src/manager.js 纯逻辑:键口径、清单合成、缓存、搜索口径 src/trust.js 只读接口的信任闸门 cordis.patch.yml 插件自带挂载声明 install.mjs 安装 / 卸载 / 干跑 test/ 离线验证台(见上) ``` --- ## 许可 MIT,见 [LICENSE](LICENSE)。 想让它出现在 DSH 的插件市场里,收录方式是往精选列表 [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) 提一个 YAML 文件的 PR——本插件已按该列表的要求声明 `dsh.bundle` 并补上截图。