Bundle
dsh-model-in-use
DSH model-menu enhancements: dots for models in use by other running sessions, per-model reasoning-effort memory, collapsed group state, and menu spacing fixes.
- Source
- Faide-cyber
- License
- MIT
- Updated
- Updated 3 days ago
Readme
<h1 align="center">dsh-model-in-use</h1>
<p align="center">
<em>让 DSH 的模型菜单看见会话占用、记住模型推理档位与折叠状态。</em>
</p>
<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-65a30d?style=flat" alt="MIT license"></a>
<img src="https://img.shields.io/badge/DSH-cordis--plugin-3cc400?style=flat" alt="DSH cordis plugin">
<img src="https://img.shields.io/badge/platform-web-0078D4?style=flat" alt="web">
<img src="https://img.shields.io/badge/runtime_dependencies-zero-brightgreen?style=flat" alt="zero runtime dependencies">
<img src="https://img.shields.io/badge/storage-localStorage-8b5cf6?style=flat" alt="localStorage">
</p>
---
<p align="center">
<img src="./docs/images/model-menu.png" alt="模型菜单:被其他会话占用的模型行尾带圆点,悬浮卡显示当前模型与推理档位" width="100%">
</p>
<p align="center">
<img src="./docs/images/session-hover.png" alt="会话悬浮卡的「进行中」后追加当前模型与推理档位" width="342">
</p>
## 它做什么
一个独立的 DSH **客户端(web)cordis 插件**,只装饰宿主已有的模型菜单,不改宿主包、不引入运行时依赖。
| 功能 | 说明 |
| --- | --- |
| **占用标记** | 已被其他**正在运行**的会话使用的模型,行尾显示 `#3cc400` 圆点,悬停提示「其他会话正在使用中」 |
| **分组级圆点** | 该分组内任一模型被占用时,标题前也点一个小圆点——分组折叠后仍能看出「这里面有模型在跑」 |
| **推理档位记忆** | 为某个模型选过的推理档位被记住;切走再切回、或**新开对话**选同一模型,都会恢复上次的档位 |
| **折叠记忆** | 每个服务商分组可折叠,状态记在 `localStorage`,重开菜单或刷新页面均保留 |
| **间距修正** | 分组间距按**视觉顺序**排布,不再出现某两个分组贴在一起 |
| **吸顶不透明** | 吸顶的分组标题底色钉为不透明,二级菜单文字不再从标题下方透出 |
| **悬浮详情** | 会话悬浮卡的「进行中」后追加当前模型与推理档位,如 `进行中 ● Deepseek-V4.1-Flash · x0.00 Low` |
关于占用标记:
- **非互斥**:只是提示,不阻止选择,多个会话可以选同一个模型。
- **实时**:对方开始 / 停止运行或切换模型,圆点立即出现 / 消失。
- 当前会话自身那一行不标记(该行已有对勾)。
- 被选中那一行同时有对勾与圆点时,圆点向左让位到对勾格前的间隙(`right:27px`),两者并排而非重叠。
- 只统计主会话,subagent 会话不计入。
- 只装饰 composer 的模型下拉;`/model` 斜杠命令的弹窗不受影响。
## 安装
```bash
# 从 GitHub 安装(推荐)
dsh plugin --profile desktop add github:Faide-cyber/dsh-model-in-use
```
也可以在 **dshmarket 插件市场**搜索 `dsh-model-in-use` 一键安装。本仓库已打 `dsh-plugin` topic,市场每 2 小时自动收录。
> [!IMPORTANT]
> 装完必须**完全退出 DSH 再重开**。客户端 bundle 的字节在激活时被快照进 module table,没有 HMR watcher 时不会重读。
仓库已提交构建产物(`lib/`),安装过程不执行构建步骤。插件自带 `cordis.patch.yml`(`dsh.bundle.patch`),向 Web roster 插入一行 `model-in-use`;客户端半边只在 web 平台加载。
本地开发时可以直接 link:
```jsonc
// <profile>/package.json
"dependencies": { "dsh-model-in-use": "link:E:/dsh/plugin/dsh-model-in-use" },
"dsh": { "profile": { "bundles": [ /* …, */ "dsh-model-in-use" ] } }
```
## 使用
装好即生效,没有配置项。三个约定:
- **推理档位按「模型」记忆,不按「服务商」**。同一个模型常常能在多个 provider id 下取到(例如国际站与国内站都提供同一个模型),按模型记才能保证换站点也读得到。
- 在档位菜单里选「**跟随服务商默认**」时,只对本次对话生效,**不会删除已记住的档位**——下次再点这个模型仍会恢复它自己的档位。
- 分组默认**全部展开**。默认折叠会让用户误以为「模型列表没了」。
## 兼容性
| 宿主包 | 用途 |
| --- | --- |
| `@deepseek-ai/dsh-api-session-controller` | 会话与模型选择投影 |
| `@deepseek-ai/dsh-client-locale` | 文案命名空间 |
| `@deepseek-ai/dsh-client-ui-model-selection` | 模型目录与 `ModelDirectory.select()` |
| `@deepseek-ai/dsh-client-ui-workspace` | 侧边栏 Slot |
这些包由宿主提供,插件**不把它们放进 `dependencies`**(那会遮蔽宿主实例),仅声明于 `dsh.client.inject`。插件只在 web 平台加载。
## 已知边界
- 圆点的水平位置(未选中 `right:14px`、选中 `right:27px`)按当前行的 `padding:6px 8px` + `gap:8px` + 18px 对勾格量出。行距或内边距变化后需要重新量;彻底解耦需要上游组件开放行装饰 slot。
- 折叠通过 CSS 隐藏行,键盘方向键仍会走过被隐藏的行。被隐藏的行不可见,可接受。
- 推理档位记忆依赖宿主的 `directory.select()`、`directory.store` 与 `modelDirectories.live.directories.values` 形状不变。宿主若同时改掉这些入口或字段,记忆会被静默旁路——但只丢记忆,不会挡住选择本身。
- 恢复的档位以「发请求那一刻能查到的 catalog」为准;菜单 UI 上的勾选落位依赖宿主 `select()` 成功后自身的 `syncInputs()`,插件不再二次刷新。
- CSS 排版类修复无法在本装置的断言中自证(没有渲染引擎),只用断言钉住决定性 token。
## 测试
```bash
node verify.mjs # 行为断言,直接加载 lib/client.js,不复制不重写
node negative-proof.mjs # 反例:故意写坏的实现必须全部被装置抓住
node scope-proof.mjs # 装置自检:菜单不是 body 子节点、观察者尊重 subtree、不支持的 selector 抛错
```
三个脚本共用 `fixture.mjs`,不需要安装任何依赖。
- **装置按真实形状搭建**:`mountComposer()` 先挂出闭合状态的 composer,`openCodexMenu()` 再往其子树里插菜单——菜单永远不是 `<body>` 的直接子节点。`FixtureMutationObserver` 尊重 `observe()` 的 `options`,只给「watched 是变更目标、或带 subtree 的祖先」投递记录。`modelDirectories` 由 traceable face 包住:字符串赋值会像真实 Cordis shadow 一样消失,只有通过 `cordis.original` 改到 origin 才能拦住稍后才创建的目录。
- **反例用单点替换**:`negative-proof.mjs` 每个变体以 `from` 字符串锚定 shipped 源码,并要求该串在源码里**恰好出现一次**——同时出现在注释里的锚点会生成「真实代码未被改动」的假变体,这类装置 bug 直接判 FAIL。
---
## 实现要点
以下记录几个关键行为「为什么这么做」。修改本插件前建议先读这一节。
### 分组间距:flex `gap` 而非 `margin-top`
上游规则是 `section + section { margin-top: 4px }`,按 **DOM 相邻**生效。而 `freecodego` 用 CSS `order` 给分组排序、从不移动节点,于是这个 margin 落在「DOM 里相邻的那一对」而非「用户看到的那一对」,表现为某两个分组贴在一起。
修法是把容器改为纵向 flex + `gap: 4px`:`gap` 按**视觉顺序**排布,与 `order` 取值无关。同时将原 `margin-top` 归零,避免叠加两次。
### 吸顶标题:相对颜色语法中的 `/ 1` 不可省略
分组标题为 `position: sticky; top: 0`,会覆盖滚过的行,底色取自 `--dsw-alias-bg-layer-2`。玻璃皮肤把该 token 调成了半透明,于是标题下方的行文字透上来,两层文字重叠。
修法是在同一条规则里把**同一颜色**的 alpha 钉为 1:
```css
background: var(--dsw-alias-bg-layer-2, #2c2c2e);
background: rgb(from var(--dsw-alias-bg-layer-2, #2c2c2e) r g b / 1);
```
`rgb(from …)` 保留皮肤色相,`/ 1` 钉死 alpha,因此换任何皮肤都是正确的实色。第一条声明是给不支持相对颜色语法的引擎兜底。
> [!WARNING]
> `/ 1` 不能省。相对颜色语法中**省略 alpha 不等于 1**,它会继承**源颜色**的 alpha(见 [MDN: Using relative colors](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Colors/Using_relative_colors):*"If the alpha channel value is not explicitly specified, it defaults to the alpha channel value of the origin-color (not 100%)"*)。写成 `rgb(from … r g b)` 会原样复现半透明,文字照样透出。
`verify.mjs` 场景 14 与 `negative-proof.mjs` 变体 12 均钉住这一条,后者就是删掉 `/ 1` 的单点改写。
### 为什么走 DOM 装饰
composer 的模型下拉落在 `conversation.input.model` 这个 `single` 槽上,实际生效的 occupant 是 `dsh-codex-subscription` 的 `CodexModelSelect`(`priority: -10`,把随包发行的 `ModelSelect` 压为 `active: false`),而它没有行装饰扩展点。因此插件:
1. 给命中的模型行 `<button class="codexModelSelectOption" role="menuitemradio">` 打 `data-dsh-model-in-use` 属性;
2. 给分组标题 `<div class="codexModelSelectGroupTitle">` 打 `data-dsh-group-toggle`,给 `<section>` 打 `data-dsh-group-collapsed`;
3. 注入样式,用 `::after` 伪元素绘制圆点与折角箭头,用一条 `display:none` 收起整组。
伪元素不在 React 的 reconciliation 范围内,React 重渲染行或标题时不会触碰它;反过来插件也只增删属性,不挪动 React 管理的子节点——折叠完全依靠 section 上的状态属性与 CSS,因此不存在 React 重排子节点导致菜单异常的风险。
行 DOM 内没有 provider / model id,因此行是靠**组标题 + 模型名**这两个组件实际渲染的字符串,与共享目录 `modelDirectories.catalog` 对齐还原出 `(provider, model)`。Codex 版渲染的组标题是 `group.name` **原文**(而非本地化的 `provider.account`),所以不需要 `model` 命名空间的翻译。
### 为什么 `title` 打在名字 span 上
`title`(悬停说明)刻意加在行的 `span.codexModelSelectOptionName` 上,**不加在 `button` 上**。`freecodego` 的菜单装饰器以 `group.querySelectorAll('button[role="menuitemradio"][title]').length > 0` 判定一个分组是否归它管;一旦行按钮带 `title`,它就会认领同一批分组,导致同一个标题上挂两套折角与折叠状态、互相冲突。`title` 留在名字 span 上可让那道闸门始终关闭,该装饰器独享这些分组。
`verify.mjs` 的 `the tooltip is not on the row button` 钉住这一条。
### 分组级圆点与折叠记忆
- 分组内任一模型被占用时,给 `<section>` 打 `data-dsh-group-in-use`,标题前用 `::before` 点一个同样的 `#3cc400` 圆点,**标题文字颜色不变**。折叠会把行藏起来,标题是折叠状态下唯一还能表达「有会话在跑」的位置。
- `::before` 与折叠箭头 `::after` 同挂标题,标题为 flex 行,两者分居两端。箭头用 `margin-left:auto` 顶到右侧,**不用** `justify-content:space-between`——标题文字是裸文本节点(匿名 flex item),加上圆点后成为三个 item,`space-between` 会把文字挤到中间。
- 分组身份取**服务商 id**,从 section 的 `aria-labelledby`(组件写的是 `${reactId}-${group.id}`,菜单 id 是 `${reactId}-menu`,前缀可推)切出,**不用**标题文字。标题文字是本地化显示名(内置账号路由在中文下是「DeepSeek 账号」),以文字为 key 时用户切换界面语言会丢失全部折叠记忆。
- 存储键 `dsh-model-in-use:collapsed-groups`,值形如 `{ [providerId]: true }`。`localStorage` 被禁用或写满时全部读写吞掉异常:折叠当次仍生效,只是不持久化,菜单不会因此失效。
### 占用判定
一个模型被标记,当且仅当存在另一个会话同时满足:
- 是**主会话**(`origin !== 'subagent'` 且没有 `parentId`);
- 此刻**正在运行**(`running`);
- 其当前模型即该模型 —— `projectionValues.modelSelection.next ?? catalog.default`。
其中 `next` 为 `pending ?? lastUsed`,即「它现在真正在用哪个模型」。当前会话自身那一行永不标记。
### 推理档位记忆
为模型选过的档位记在 `localStorage`,键 `dsh-model-in-use:model-efforts`,条目键名即模型 id,**只按模型、不带 provider**。
**为什么按模型而非按服务商**:同一个模型常常能在多个 provider id 下取到(例如 `workbuddyai` 国际站与 `workbuddyai-cn` 国内站都提供 `deepseek-v4.1-flash`)。真实会话日志中同一模型正是在这两个 id 之间来回出现;若键里带 provider,换一个站点就读不到自己刚写的记忆,切走再切回、新开对话都会退回 Default。
**写侧**。模型切换通常汇入 `directory.select(selection)`,插件包装了它。但 Codex 在点击「已经是当前模型」的行时只关菜单、不调用 `select()`,因此插件还会遍历宿主正式的 `modelDirectories.live.directories.values`,订阅每个已在使用的目录 store,把已就绪的官方默认档位校正为该模型记住的档位。会话目录按需创建,而 `sessions.list` 的通知可能早于该会话 scope 可用,插件通过 `Symbol.for('cordis.original')` 取得 Cordis Service 的真实对象,在真实 `directoryFor` 上挂创建拦截。**不能给 Service face 直接赋值**——那只会写入一次性的 traceable shadow,后续选择路径看不到。
包装同时覆盖原型与**实例 own 属性**上的 `select`。`freecodego` 的 `installModelSelectionEcho` 会把 `select` 装成 directory 的 own 属性并遮蔽原型补丁,且 bind 的是补丁前的方法;只包原型会导致首次 RPC 不带档位、插件被迫补发第二条修正请求(真实日志中表现为 2–5ms 成对的 `undefined → level`)。插件用 `hookedInstances` 追踪已包装实例,在多个时机重复检查,`dispose` 时还原 own 属性。
判别规则由上游调用点决定,不能靠猜:
- `chooseModel`(模型行)只在模型真的换了才调用 `select()`;其 selection 通常只带 `{ provider, model }`,不带档位。
- `chooseEffort`(档位子菜单)**总是带当前模型**:真实选档位时带 `reasoningEffort`,「跟随服务商默认」那项不带。若点击的档位已经生效,Codex 会只关菜单、不调用 `select()`,插件通过菜单与会话目录的映射补记这次显式点击。
由此得到三条规则:
1. 同模型且 `reasoningEffort` 是字符串并改变当前有效档位 ⇒ 用户明确选了档位,**包括它刚好等于官方默认档位**,记下它。
2. 同模型且完全不带档位 ⇒ 选了「服务商默认」,**只对本次对话生效,不删记忆**。
3. 换模型 ⇒ 查目标模型已有记忆,仅在它自己的 `reasoning.efforts` 仍支持该档位时覆盖。
> [!NOTE]
> 不能用 `selection.reasoningEffort === defaultEffort` 否定同模型的真实档位点击。那样会让用户选 Low 后没有绑定,随后 A→B→A 回到 Default。
其余细节:
- `ModelDirectory.select()` 先把目标写入 `store.pending`,再等待 Host projection;判别当前模型时取 `pending ?? current`,避免快速 A→B→A 拿到尚未结算的旧值而误判为同模型。
- 新对话若已默认落在同一模型、因 Codex 的 close-only 分支根本没有发出选择,由该 live directory 的 store 校正。校正按 (目录, 目标档位) 只尝试一次:宿主拒绝该档位时会更新 store 并再次通知订阅,缺少这道闸门会对同一次被拒的选择无限重试。
- 每个主会话首次出现时,其 durable `modelSelection.next ?? lastUsed` 若带受支持的明确档位,会为**尚未记忆**的模型补种一次;因此插件启动前已有的绑定同样有效。每个会话只参与补种一次,避免用户清除绑定后又被后续 projection 更新写回。
- 读路径每次都重新读 `localStorage`(内存副本仅作读不到时的回退),多个窗口之间互相可见。存储完全不可用时只丢持久化,不影响选择。
- **读侧替换**:组合框标签只读 `store.current`,而 `select()` 的同步阶段只写 `status` / `pending`、不碰 `current`,因此首帧会先显示默认档位。插件在 `directoryFor` 渲染路径上**同步**改写 `store.current` 的档位使首帧即正确,用 `substitutingDirectories` WeakSet 防重入回声、`projectedSelections` WeakMap 存宿主原值供写穿读取。
### 悬浮详情与菜单挂载
- 悬浮详情使用 workspace 正式提供的 `sidebar.session.row.hover` Slot,由 Slot 传入稳定的 `sessionId`,不按对话标题猜是哪张卡。renderer 会先包一层 `div[data-slot="sidebar.session.row.hover"]`,因此隐藏 anchor 必须先 `closest()` 回到这层 Slot wrapper,再找 wrapper 后面的「进行中」行;随后用 React portal 把文字作为该行最后一项渲染进去——既满足同行展示,也不直接改写 React 管理的 DOM。模型名**原样使用 catalog 的写法**(其自带的 `·` 保留自己的空格),档位与模型名之间只加一个普通空格,不再追加 `·`。
- `CodexModelSelect` 把菜单**内联渲染在 composer 子树深处**(源码中没有 `createPortal`),打开菜单只会往 `<body>` 的深层子孙插入节点,`<body>` 自身的 childList 不变。因此 `<body>` 的观察者必须带 `subtree: true`,否则菜单打开对插件完全不可见;但裸的 subtree 观察会在每个流式 token 上触发,所以 mutation records 先经 `paneTouched()` 过滤,只有「增删了 `codexModelSelectGroups`」才唤醒一次 `scan()`。
`verify.mjs` 场景 4 与 `negative-proof.mjs` 变体 6 钉住这一条。
---
## License
[](LICENSE)
MIT © 2026 Faide-cyber
Install
dsh plugin --profile web add github:Faide-cyber/dsh-model-in-use
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-model-in-use from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.