dsh-model-in-use
让 DSH 的模型菜单看见会话占用、记住模型推理档位与折叠状态。
它做什么
一个独立的 DSH 客户端(web)cordis 插件,只装饰宿主已有的模型菜单,不改宿主包、不引入运行时依赖。
| 功能 | 说明 |
|---|---|
| 占用标记 | 已被其他正在运行的会话使用的模型,行尾显示 #3cc400 圆点,悬停提示「其他会话正在使用中」 |
| 分组级圆点 | 该分组内任一模型被占用时,标题前也点一个小圆点——分组折叠后仍能看出「这里面有模型在跑」 |
| 推理档位记忆 | 为某个模型选过的推理档位被记住;切走再切回、或新开对话选同一模型,都会恢复上次的档位 |
| 折叠记忆 | 每个服务商分组可折叠,状态记在 localStorage,重开菜单或刷新页面均保留 |
| 间距修正 | 分组间距按视觉顺序排布,不再出现某两个分组贴在一起 |
| 吸顶不透明 | 吸顶的分组标题底色钉为不透明,二级菜单文字不再从标题下方透出 |
| 悬浮详情 | 会话悬浮卡的「进行中」后追加当前模型与推理档位,如 进行中 ● Deepseek-V4.1-Flash · x0.00 Low |
关于占用标记:
- 非互斥:只是提示,不阻止选择,多个会话可以选同一个模型。
- 实时:对方开始 / 停止运行或切换模型,圆点立即出现 / 消失。
- 当前会话自身那一行不标记(该行已有对勾)。
- 被选中那一行同时有对勾与圆点时,圆点向左让位到对勾格前的间隙(
right:27px),两者并排而非重叠。 - 只统计主会话,subagent 会话不计入。
- 只装饰 composer 的模型下拉;
/model斜杠命令的弹窗不受影响。
安装
# 从 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:
// <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。
测试
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:
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:"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),而它没有行装饰扩展点。因此插件:
- 给命中的模型行
<button class="codexModelSelectOption" role="menuitemradio">打data-dsh-model-in-use属性; - 给分组标题
<div class="codexModelSelectGroupTitle">打data-dsh-group-toggle,给<section>打data-dsh-group-collapsed; - 注入样式,用
::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(),插件通过菜单与会话目录的映射补记这次显式点击。
由此得到三条规则:
- 同模型且
reasoningEffort是字符串并改变当前有效档位 ⇒ 用户明确选了档位,包括它刚好等于官方默认档位,记下它。 - 同模型且完全不带档位 ⇒ 选了「服务商默认」,只对本次对话生效,不删记忆。
- 换模型 ⇒ 查目标模型已有记忆,仅在它自己的
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的档位使首帧即正确,用substitutingDirectoriesWeakSet 防重入回声、projectedSelectionsWeakMap 存宿主原值供写穿读取。
悬浮详情与菜单挂载
- 悬浮详情使用 workspace 正式提供的
sidebar.session.row.hoverSlot,由 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
MIT © 2026 Faide-cyber
No comments yet. Be the first to write one.