DeepSeek Harness 插件

ZZJQ678/dsh-model-picker

Star 数 ★ 0 分类 UI 增强 收录于 2026-09-20

把聊天输入框的模型选择器换成按渠道商分组、可折叠的列表:按渠道商归组,把视觉桥镜像渠道商折叠回上游,并逐模型带上设置里声明的思考强度选项。仅在 DSH 桌面版实测,Web 版未测试。

安装

# GitHub 源码(首次需按提示配置 allowBuilds 构建授权后重试)

dsh plugin --profile web add github:ZZJQ678/dsh-model-picker

装任何插件都等于在你的机器上跑第三方代码,权限和你本人一样大——能读你的文件、用你的凭据、访问网络,工具审批管不到它。GitHub 来源的插件还会在安装时执行构建脚本——pnpm 默认拦截,所以安装可能停在 ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWEDERR_PNPM_IGNORED_BUILDS;dsh 会打印出需要添加的确切键名,把它加进该 profile 的 pnpm-workspace.yamlallowBuilds 下,重跑一次即可装上。放行构建本身就是一次信任判断:请只安装可信来源,并尽量锁定 commit(github:owner/repo#sha)。

README

按渠道商(Provider)分组、可折叠的 DSH 模型选择器。

适用范围:仅在 DSH 桌面版实测通过(桌面版的 profile 名为 web)。 Web 版(浏览器部署的 DSH)未做测试,不保证可用。

来源说明:本插件的代码、测试与文档全程由 DSH 的 deepseek-4.1-flash 模型完成。

替换聊天输入框右下角原生的模型选择器,把扁平列表改成「按渠道商分组 + 可折叠」, 并逐模型继承设置里声明的思考强度。

功能

  • 按渠道商分组,组头显示该组模型数量
  • 可折叠:默认只展开当前模型所在的组,其余折叠;点击组头切换
  • 合并重复渠道商(modlens vision) 这类「视觉桥」镜像会被折叠回上游渠道商
  • 两个视觉标签,都继承设置
    • 「视觉」= 该模型在设置里勾了视觉(inputimage),原生就能读图
    • 「视觉桥」= 该模型自己没勾视觉,但 modlens 为它套了桥,可经桥读图
    • 两者互斥;设置里读不到声明时两个都不显示(不瞎猜)
  • 搜索:匹配渠道商名称/ID,或模型名称/ID;搜索时自动展开命中分组
  • 思考强度逐模型继承:面板顶部显示当前模型自己的那套强度选项,切换模型时 带上该模型的默认强度
  • 失败可见:渠道商目录加载失败时显示原因,并提供重试

视觉标签是怎么来的

和思考强度一样,完全继承设置,不需要额外配置。但数据来源和强度不同:

settings.yaml 的 llm-pi-ai.providers.<渠道商>.models[].input
   = ["text","image"](勾了视觉) | ["text"](取消) | [](未声明)
  → 设置文档镜像(settingsScope.describe())
  → 本插件折叠成「提供方\n模型」集合

为什么要绕这一圈:模型目录(session/modelCatalog不含视觉字段。 服务端 buildModelCatalog 明明从 resolveModelInfo 拿到了 inputModalities, 却显式把它丢掉,每个条目只保留 {id, name, description?, reasoning?}dsh-api-session-controller/lib/index.js:1994-1999)。已用 CDP 拦截真实响应核实: 78 个模型的字段无一例外。所以只能直接读设置文档 —— 官方设置页用的也是同一份镜像。

两个容易踩错的细节:

  • input: [] 不等于「没勾视觉」,而是「此处未声明、向下继承」 (dsh-llm-pi-ai/lib/index.js:286declaredInput)。勾选写 ["text","image"], 取消写 ["text"]。因此只有明确含 image 才算勾了视觉。
  • 官方 DeepSeek 路由的提供方 id 与它的设置命名空间不同名: 提供方是 deepseek-official,命名空间是 llm-deepseekdsh-llm-deepseek/lib/index.js:2038-2043)。插件经 remote.llm.listConfigurableProviders() 取这张别名表,否则那个渠道商的 「视觉」标签会整片认不出来。

服务端动态发现型提供方没有视觉声明。 CodeBuddy / WorkBuddy / CodeArts 这类 渠道商的设置命名空间里 providers 是空的(模型由服务端下发),因此读不到任何 视觉声明 —— 此时两个标签都不显示。这是刻意的保守行为:读不到就不猜。

关于「视觉桥」镜像的合并

@liustack/modlens 这类视觉桥插件会为每个上游渠道商注册一个镜像 provider:

  • id:modlens-<上游id>deepseek-official 特殊为 deepseek-modlens
  • 名称:<上游名> (modlens vision)

镜像的 listModels 只给同一个模型换个带后缀的显示名,模型 id 与上游完全一致 (依据:modlens 的 dsh/index.js:782-788)。直接列出会让同一渠道商重复出现两遍。

本插件的处理:把镜像折叠回上游渠道商,同名模型只列一条,并给它打上「视觉桥」 标记——信息保留,重复消除。合并要求镜像的 id 特征与名称后缀同时命中, 且上游渠道商确实存在于同一份目录中,因此该插件被卸载后镜像会自然消失, 无需改动这里的代码。

为什么「视觉桥」早先会标错

改版前,「视觉桥」直接由「该模型出现在镜像里」推导。而 modlens 只给没勾视觉的 模型套桥

// modlens/dsh/index.js:690
if (Array.isArray(info?.inputModalities) && info.inputModalities.includes('image')) return false

于是标签表达的是「这个模型看不了图、但有桥可走」,与「勾了视觉」正好相反。 本机实测:11 个带标签的模型里,勾了视觉的 0 个;而 25 个勾了视觉的模型 全都没有标签。现在改成:勾了视觉 → 「视觉」;没勾但有桥 → 「视觉桥」。

思考强度是怎么来的

不需要额外配置——设置里已经配好的东西会自动生效。完整链路:

settings.yaml 的 llm-pi-ai.providers.<渠道商>.models[].reasoningEfforts
  → dsh-llm-pi-ai 的 resolveModelReasoning      把声明的级别写进 thinkingLevelMap,
                                                 未声明的级别 pin 成 null(不支持)
  → pi-ai 的 getSupportedThinkingLevels         算出这个模型真正支持哪些级别
  → dsh-llm-pi-ai 的 reasoningInfo              写进目录的 reasoning.efforts
  → session/modelCatalog → 本插件读取

所以本插件只是,不自己判断哪个模型支持什么。你在设置里给某个模型声明了 off/minimal/low/medium/high,它就显示这五个;声明了 low/medium/high/xhigh, 就显示那四个;没声明 reasoningEfforts 的模型不显示强度控件。

两个细节与官方实现保持一致:

  • 只有 off 一个级别的模型不显示控件。 off 的语义是「不带参数」,单独存在 等同于没有这个能力(这也是 dsh-llm-pi-ai 自己 reasoningInfo 的判断)。
  • 切换模型时带上该模型的 defaultEffort,与原生 selectionOfdsh-client-ui-model-selection/lib/client.js:908-918)行为一致。

defaultEffort 来自渠道商配置里的 profile 级 reasoning(不是每个模型单独声明), 由 dsh-llm-pi-ai 解析后随目录下发。

设计取舍

只换视图,不碰数据。 目录、选择、思考强度全部复用官方服务 ctx.modelDirectoriesdirectory.select(),与原生选择器共用同一份会话级目录。 两个入口(/model 命令与输入框槽位)永远显示一致,也不会修改任何 Provider 插件。

绝不因「加载中」而禁用。 原生触发器只在 locked 时禁用,「加载中」仅是文案。 本插件遵循同一约定——把 loading 做成 disabled 会让用户彻底无法操作。

取不到目录时抛异常,而不是渲染死壳。 槽位是 single 类型,本插件以 priority: -1 遮住原生(priority 0)的条目。若 directoryFor() 抛错,异常会被 ui-renderer 的 SlotErrorBoundary 捕获,并按 abdicate: true 把本条目从格子中 退役,原生选择器随即自动接管。宁可退回原生,也不给死界面。

能力标签只做有真实依据的。 官方模型目录(session/modelCatalog)的 model 对象 只有 {id, name, description?, reasoning?},没有任何视觉/免费/工具调用字段,因此 不凭名字或描述猜这类标签。唯一的两个标签(「视觉」「视觉桥」)都有确切来源: 前者读设置的视觉声明,后者读 modlens 的镜像事实。

UI 用官方组件库。 图标与配色来自 @deepseek-ai/dsh-client-ui-primitives (原版界面用的就是它),并全部走 DSH 的 CSS 变量,因此自动跟随明暗主题。 该包属于模块系统的静态基座,无需在 dsh.client.external 里声明——这一点由已 正常工作的第三方插件 @liustack/modlens 证明(它的 dsh.client.inject 是空 数组,却直接 require 了本包)。仍保留 try/catch 兜底:万一基座里没有它,插件 退化成无图标的纯 DOM 版本,而不是整个崩掉。

增益功能绝不连累核心。 视觉标签依赖 settingsScope / remote.llm 两个服务; 它们各自单独一层 ctx.inject + try/catch。若把它们混进注册槽位的那一层, 服务一旦缺席就会连累整个选择器不注册——用一个可选增益去赌核心功能,不值得。

安装

从 GitHub 装(公开仓库):

dsh plugin --profile web add "github:ZZJQ678/dsh-model-picker"

本地开发时用 link: 装到桌面版实际使用的 profile

dsh plugin --profile web add "link:E:\DSH\dsh-model-picker"

装完重启 DSH Desktop(或应用内「重启 Harness」)。补丁层是 live 重载, 但bundle 列表只在启动时固化——新增一个 bundle 必须重启才会进入前端 boot 图, 只按 F5 刷新页面是不够的。

⚠️ 桌面版的 profile 名是 web,不是 desktop

网上不少插件(例如 dshhub 上的 dsh-model-picker-plus)README 写的是 dsh plugin --profile desktop add …。桌面版没有 desktop 这个 profile, 照抄会装到一个用不上的地方,表现就是「装成功了但界面毫无变化」。 桌面版的 profile 名是 web,目录在 %APPDATA%\dsh-desktop\harness\profiles\web

卸载

dsh plugin --profile web remove dsh-model-picker

原生模型选择器会自动恢复。

开发

node --check client.js
node tests/logic.mjs            # 装配、槽位注册、inject 契约、失败让位约定
node tests/panel.mjs            # 分组、折叠、搜索(桩 React)
node tests/real-render.mjs      # 真实 React 18 + jsdom 渲染
node tests/effort.mjs           # 思考强度逐模型继承(真实渲染)
node tests/vision.mjs           # 「视觉」/「视觉桥」双标签与设置继承(真实渲染)
node tests/reasoning-chain.mjs  # 设置 → 目录 的强度解析链路(真实 settings.yaml)
node tests/scan-mojibake.mjs    # 扫描双重编码破坏(改源码后顺手跑一次)

real-render.mjs / effort.mjs / vision.mjs 用 DSH profile 自带的 react / react-dom / jsdom,版本与桌面端运行时一致,能捕获真实 hooks 规则违例与 渲染警告。reasoning-chain.mjs 读本机真实的 settings.yaml,用真实 pi-ai 算法 验证「不同模型强度不同」这个前提成立。

写测试桩的三个坑(都已踩过,记录避免重复):

  1. getSnapshot 必须返回稳定引用。 官方的 dsh-client-store 就是如此 (getSnapshot 即 zustand 的 getState())。若桩每次调用都造新对象, useSyncExternalStore 会认为状态一直在变并无限重渲染。
  2. jsdom 里 React 的 onChange 不会被 input 事件触发(React 依赖自己的 事件系统模拟 change,jsdom 下该链路不完整)。这是测试环境的限制,不是插件 缺陷——真实浏览器正常。因此搜索相关的测试直接调用元素的 onChange prop。
  3. 桩的 ctx.inject 必须只给被声明的服务。 真实 Cordis 的 scope 是受门禁 约束的:没在那一层 inject 里声明的服务根本取不到。若桩把所有服务一股脑 塞进去,就会盖住「别名表取不到」这类真实 bug(vision.mjs 正是靠如实模拟 才抓到它)。另外,异步 RPC 的 .then 是微任务,断言前要 await act(async () => { await flush() }),否则测的是中间态。

纯客户端插件:client.js 通过 window.__ModuleLoader__.load 注册, cordis.patch.yml 声明加载器条目,宿主侧 index.js 是空实现,无构建步骤。

踩过的坑(真实浏览器实测)

上面那套测试都在 Node + jsdom 里跑,盖不住插件与宿主之间的服务契约。 以下失败只在真实浏览器里出现,所以真实环境复验不可省。

1. inject 少写 remote / remote.session → 插件静默失效

症状:装好后界面毫无变化,原生扁平列表照旧;控制台里只有一行

slot entry crashed in 'conversation.input.model':
Error: cannot get property "remote.session" without inject

界面没有任何提示——因为槽位边界把失败条目按 abdicate 退役了,原生随即接管。

原因modelDirectories.directoryFor() 内部会读 this.ctx.remote.session (官方 dsh-client-ui-model-selection/lib/client.js:304new ModelDirectory(this.ctx.remote.session, …))。Cordis 对未在 inject 里声明的服务属性访问会直接抛错,而 ctx.inject([...], cb) 拿到的正是一个受 门禁约束的 ctx。我们以 priority: -1 遮住原生,一旦注册项在注入期抛错,就被 退役、原生静默接管。

修法inject 与官方 dsh-client-ui-model-selection 对齐 (['locale','sessions','slots','remote','remote.session'])。 tests/logic.mjs 已加断言锁死这一点。

教训:「复用官方服务」不等于「只声明官方服务的名字」——被复用服务自己 依赖什么,调用方也必须声明什么

2. 同一个坑踩了第二遍:别名表取不到

做视觉标签时又栽在同一件事上。remote.llm.listConfigurableProviders() 能给出 「提供方 id ↔ 设置命名空间」的别名表,但最初把它写在注册槽位那一层的 ctx.inject(['slots','modelDirectories'], …) 里 —— 那个 scope 没有 remote, 于是 scope.remote?.llm 静默短路成 undefined,别名表永远拿不到, deepseek-official 渠道商的「视觉」标签整片认不出来。

这次 vision.mjs 的单测抓到了它,因为它的 ctx.inject如实只给被声明的 服务(若桩把所有服务都塞进去,这个 bug 会被盖住)。

修法:别名映射单独一层 ctx.inject(['remote','remote.llm'], …),并且 不把 remote.llm 混进注册槽位那一层 —— 那样一旦该服务缺席,就会连累整个 选择器不注册。

教训ctx.inject(deps, cb)cb 只在依赖齐备时执行,这一条既是保护也是 陷阱。增益功能要单独分层,别让可选依赖挡住核心路径。

3. --dsw-alias-* 变量挂在 body 上,不在 :root

我一度用 getComputedStyle(document.documentElement) 检查这些变量,得到 「21 个全部缺失」,差点以为整套配色在靠兜底值硬撑。实际上它们定义在 body 上(主题提供者那一层),而 documentElement 在 body 之上,取不到是必然的。

正确做法:在插件自己的元素上取(getComputedStyle(panel).getPropertyValue(…)), 那才是真正参与渲染的值。实测浅色/深色都能正确解析并跟随 body[data-ds-dark-theme] 切换(面板背景 #fff#353638),无需额外适配。

4. 桌面版 profile 名是 web

见上文「安装」。这一条是第三方插件「不适配桌面版」的常见根因。

5. 别用 PowerShell 的 Get-Content | Set-Content 改 UTF-8 源码

改版本号时用了 (Get-Content x -Raw) -replace … | Set-Content x -Encoding utf8, 结果把已按 UTF-8 存的中文当成 GBK 读、再按 UTF-8 写出,整份文件变乱码 (导出瀵煎嚭),还吞掉了一个引号导致 JSON 失效。破坏是: 原始 UTF-8 字节 --GBK解码--> 乱码 --UTF-8编码--> 新字节

这次靠 DSH 自己的 change-ledger(内容寻址的 blob 库, $DSH_HOME/change-ledger/v1/workspaces/<id>/blobs/)找回了完整原件。 查乱码用 \uFFFD 替换字符 + 「瀵煎嚭」这类高频 mojibake 词最可靠。

正确做法:改 UTF-8 源码一律用编辑器工具(本仓的 edit / write), 不要过 PowerShell 的文本管道。真要用 PowerShell,就走 [System.IO.File]::ReadAllText / WriteAllText 并显式指定 UTF-8。

License

MIT

内容来自项目 README(GitHub)↗

链接

同类插件

查看整个分类 →

社区评论

评论公开保存在 GitHub Discussions。加载评论会连接 GitHub 和 Giscus;发表内容需要 GitHub 账号。