DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

Faide-cyber /

Faide-cyber/dsh-model-in-use

Verified

标记正在被其他会话占用的模型,记住每个模型的推理档位与分组折叠状态。

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@14236ac5

dsh-model-in-use

让 DSH 的模型菜单看见会话占用、记住模型推理档位与折叠状态。

MIT license DSH cordis plugin web zero runtime dependencies localStorage


模型菜单:被其他会话占用的模型行尾带圆点,悬浮卡显示当前模型与推理档位

会话悬浮卡的「进行中」后追加当前模型与推理档位

它做什么

一个独立的 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),而它没有行装饰扩展点。因此插件:

  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

MIT

MIT © 2026 Faide-cyber

—/ 5

No ratings yet

Verified DSH bundle

Commit 14236ac5d033

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout