DeepSeek Harness 插件

ttmouse/dsh-model-picker

Star 数 ★ 4 分类 UI 增强 收录于 2026-09-15

顶替 DSH Web 编辑器模型座位的模型选择器:检索优先的弹层,左栏是供应商索引、点击滚动右侧分组列表,收藏组固定在首位,用图片图标区分原生与桥接的视觉输入,点触发器右区单独打开推理档位面板。

安装

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

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

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

README

模型选择器,已收藏两个模型

English | 简体中文

dsh-model-picker

面向 dsh Web GUI 的增强版模型选择器,接管输入框里的模型座位 (conversation.input.model)。

设计

双区触发按钮把「选模型」和「选推理档位」拆成两个独立弹窗:

区域 点击 弹窗内容
左侧(模型名) 打开模型选择器 顶部搜索框(自动聚焦)、左栏供应商、右栏模型列表
右侧(档位) 打开档位选择器 可选的推理档位列表

鼠标悬停任一区域会说明它将要做什么。模型区显示 供应商 · 模型名:同一个模型 id 常常由多个供应商提供,光看名字说不清模型跑在哪里。这里用的是展示用的供应商名, 所以在折叠过的 (modlens vision) 路由上选中时,显示的仍是它的基供应商——也就是你 刚才在左栏点的那个名字。

面板宽 540px、最高 500px,两者在空间吃紧时都是让位而不是溢出。高度按触发器上方实测 可用空间收敛(首屏布局下约为 50vh + 61px,已跨六个窗口高度核对),再扣掉面板自身的 边框与内边距:900px 高的窗口下面板实测正好 500px,顶边还留 11px 余量。供应商列折叠时, 面板让出那一列的宽度,而不是把模型列表拉宽(见配置官方模型)。

供应商徽标

模型行前面是做这个模型的厂商自己的标——DeepSeek 的鲸鱼、智谱的标、通义的标—— 关闭状态的触发器上和列表的每一行都会显示,所以还没打开选择器就能看出当前用的是什么 模型、出自谁家。图标取自 @lobehub/icons-static-svg (MIT)发布的正版单色切图,按它自己的 24×24 网格内联成 path,用 currentColor 填充。

  • 标识的是模型厂商,不是本地路由:同一模型可能由多个路由提供,而一行要回答的是「我现在选的是什么」。按路由认标回答的是「从哪条渠道提供」,不能说明模型本身属于哪家。
  • 两条规则,厂商优先。 先看模型自己所属的厂商:取 vendor/model、vendor:model、 vendor_model 中每一段的前缀,路由里带厂商前缀的模型据此认出。只有当模型段里一个 厂商都没认出来时,才由名字里出现的宿主拿标,按整词匹配。
  • 两条规则谁先谁后就是整个设计的要点:grok-4.7@500k · Cursor 是 Grok 的模型经由 Cursor 转售,DeepSeek-V4.1-Flash · Trae CN 是 DeepSeek 的经由 Trae。先看宿主会把这两 行都标成错的厂商——恰恰是图标本来要避免的错误——所以宿主标只会出现在模型段里没有任何厂 商的行上。
  • 认不出厂商的行——Auto · routing group、Space-Bunny、Ling 3.1 Flash Free——保留 带底色的供应商首字母。两种形状表达不同的事:标是一个厂商,带底色的首字母大写是「这个 面板目前还认不出这一行的厂商」。标错厂商比一个面板能兜住的首字母更糟。
  • 回退用的字母由一份固定前缀表匹配,刻意不做通用的「取第一个词」:mimo、hy3 都是 真实模型家族,id 里并不带厂商,所以它们逐个列出;而宽到能猜出它们的规则,同样会把 logo 发给 auto。
  • 左栏的供应商楼层仍然保留带底色的首字母:楼层指的是供应商,因此不同供应商仍可区分。它旁边的模型列表指的是厂商,而厂商有标。
  • 底色是选出来的,不是算出来的。由路由 id 哈希出的色相会在这一个面板里放进十种互 不相干的颜色,让相邻供应商拿到几乎一样的底色,而且新增供应商时整套配色都会漂移。现 在每个供应商按它在已配置供应商顺序里的位置,从一组固定的色相里取一个,所以色相归属 于某个供应商,而不是归属于某一刻。
  • 所有徽标固定在同一个明度与彩度(用 OKLCH,只让色相变化)。没有一个供应商比别的 更抢眼,也不会有谁的徽标像过去那种固定饱和度下的中蓝色一样,直接消失在面板底色里。
  • 两种形状都取周围文字的颜色(color: inherit),所以浅色主题下是深色、深色主题下是 浅色(实测像素对比 12.35:1 与 6.56:1)。曾经给每个色相单独配过字形色,已删除:那种浅色调 在深色主题下清晰,在浅色主题下几乎完全看不见,而浅色正是最容易被漏测的那一套。彩色品牌 标也出于同一个理由被否决:十几种品牌配色会跟面板唯一的强调色打架,而那个强调色是「当前 在用」的对勾。
  • 只有底色带色相;品牌标不带,因为标本身就是形状,在它背后铺一层底色只会让 logo 读起来 像个字母。
  • 触发器的 max-width(300px)在模型名的预算之外单独留出了头像的占位(16px 加间距), 因此戴上它不会让模型名少显示任何字符。宽度不够时截断的是模型名,不是推理强度那一侧:推理 强度用一个短词报告一个状态,而截断后的状态词是另一个字符串(「Hi…」);模型名是自由 文本,截断后仍可读,而且 tooltip 里有完整名字。
  • 头像对无障碍树隐藏:供应商和模型名已经在触发器 tooltip 与行详情里说过了,图标只会 重复一遍。

供应商折叠

若某个供应商的名字等于另一个供应商的名字加上一个括号包裹的能力后缀,它就折叠进 基供应商那一行。modlens 会把视觉能力注册成独立的路由 (DeepSeek (modlens vision)),不折叠的话左栏每个供应商都会多出一行近似重复项。

  • 折叠只影响展示:每一行始终保留「模型 + 真正服务它的路由」的配对,所以选中折 叠行里的模型,提交的仍是该模型自己的供应商 id。
  • 后缀保留在模型名上;折叠模型的路由若与其所在行不同,会额外标注该路由,两者不会 混淆。
  • 带后缀的供应商若没有基供应商,就保留自己独立的一行。

模型信息(上下文长度 / 图片输入)

座位读到的浏览器端目录(ModelCatalogModel)只有 id / name / description / reasoning——没有上下文长度,也没有输入模态。 两者都在 Host 的 llm 服务上,因此由 host 半段(lib/host.js)通过一条回环路由 重新发布:

GET /api/model-picker/models
→ { ok: true, models: [{ provider, id, contextWindow?, input? }] }
  • context: { contextWindow } 的唯一来源是 resolveModel();inputModalities 则来自开销更低的 listModels()。
  • 每个「供应商/模型」组合互相隔离:抛错的供应商或模型会被丢弃,两项信息都没有的行 直接省略。
  • 快照缓存 30 秒——这条路由绝不会按每次按键去扇出请求。
  • 弹窗给每行标注图片图标与上下文长度胶囊,按 provider/modelId 关联。图片图标跟在 模型名后面,因为输入模态是模型自身的属性;上下文胶囊留在行尾那一列,让这些数字 排成可以纵向比较的一列。
  • 视觉能力。 不带斜杠的图标表示模型自己就能读像素。带一道斜杠的图标表示这条路由 之所以接受图片,是因为 modlens 做了桥接:模型其实看不见——modlens 先把图片转写成 文字。这个图标用的是比正文和标题都更弱的一档灰(label-tertiary):它是非文字的状态 图形,门槛是 3:1,实测 3.55:1,明显退到模型名与标题之后。 (modlens vision) 后缀就是区分两者的标志。
  • modlens 后缀不等于通用的视觉能力标志:若模型 id 本身已带视觉标记、其目录已声 明图片输入、或不在 modlens 配置的家族内,modlens 都不会包装它。因此没有后缀的行 也可能原生支持视觉;判断依据是图标,不是名字。modlens 关闭时,折叠和桥接图标都 处于休眠状态——不会有任何后缀。
  • 图像图标用的是比正文与标题都更弱的一档灰(label-tertiary)。它是非文字的状态图形, 门槛是 3:1,实测 3.55:1,所以它能明显退到模型名与标题之后,而且不算不达标。这一档灰只 允许用在图形上:同一个值在本面板上实测 4.49:1,拿来当正文是不及格的。
  • 在 host 半段重启之前,这条路由返回 404,因此上下文胶囊和原生视觉图标暂时缺失。

连通测试(状态分类)

「兜底模型」管理页每行的「测试连通」走另一条回环路由:

POST /api/model-picker/test   { provider, model }
→ { ok: true,  ms }
→ { ok: false, ms, state, error }
  • state 是失败类别,共 quota / rate / auth / timeout / error;ok 时没有 这个字段。浏览器半段只渲染,不做推断。
  • 分类顺序是先码后文:先看宿主失败码(QUOTA、ACCOUNT_QUOTA → quota; RATE_LIMIT → rate;AUTH、INVALID_CREDENTIAL、MISSING_CREDENTIAL → auth), 码认不出时才读上游原文。原文判据与宿主自己的 isQuotaExceededError 逐字一致——写得 更窄会悄悄把宿主已判定的额度失败降级成普通失败——另外补了中文措辞。
  • 额度优先于限流:一句话同时命中两者时按 quota 报,因为「没有余额」是终局结论, 叫读者「等一会儿」是错的。
  • 超时(自身 30 秒上限触发的 abort)单独成类,不参与分类:它是这条路由自己的判据, 不是上游的结论,否则慢通道会被误报成坏了。
  • 流在第一个 chunk 之前抛错时走 catch,同样带上 state;llm.stream() 返回什么 就报什么,第三方适配器可能两者都不给,那时退回 error。

分组列表(电梯)

右栏始终是同一个分组列表:最近使用(历史镜像,有历史时置顶)、收藏(置顶的 镜像副本),然后是每个供应商的分组。左栏是这个列表的目录。

  • 滚动右栏时,左栏会高亮「钉在列表顶边」的那个分组(滚动联动 / 电梯)。分组标题是 position: sticky 的,所以左栏高亮始终和你看得见的标题一致。
  • 分组之间只靠吸顶标题分隔,没有额外的分割线:原先每组一条细线,等于每隔几行就在列表 上横划一道,没有换来任何东西,已移除。
  • 点击左栏的供应商会直接跳到对应分组——是落点,不是一段动画:电梯是目录,路上花 掉的时间没人要。搜索状态下,点击供应商改为缩小结果范围(即搜索分面)。
  • 分组标题只有标签,没有图标。它曾经带一个象形图标(历史用时钟,而所有供应商分组 和收藏分组共用同一个「楼房」图标)。楼房读不出「供应商」的意思,收藏分组里也没有 楼房,而标签本身已经说明了这是哪个分组,所以这些图标已移除。

最近使用

面板一打开,最上面就是最近用过的模型——当前正在用的那条路由排在最前,所以「换回 上一个模型」是打开面板后直接 Enter 的距离。每条最近记录都标注了服务它的供应商,因 为这个分组横跨多个供应商。

  • 记的是目录自己报告的当前路由,不是你在这个弹窗里点过的行:无论切换发生在弹窗 里、官方 /model 弹窗里,还是某个会话重新打开时自带的模型,都算「用过」,历史要回 答的是「我最近在用什么」,不是「我在这个面板点了什么」。
  • 以路由(provider/modelId)为键、去重、定长(6 条):同一条路由再次使用只是被 提到最前,不会重复占位。
  • 数据存在 localStorage 的 dsh.modelPicker.recent 下,和收藏同一套约定。已经不再 配置的路由(供应商被卸掉、模型下架)留在存储里但不渲染:供应商回来时它的记录也 跟着回来,而列表里永远不会出现一个不存在的模型。
  • 历史为空时不显示这个分组和它的电梯楼层——收藏分组空着时会教你「这里能收藏」,历史 没有这个必要,所以第一次打开看到的就是没有历史时的样子。空的收藏分组同样不显示: 一个有标题却没有内容的组,是面板兑现不了的承诺。收藏的电梯楼层也随之消失,因为一个跳 向「并未渲染的分组」的楼层只会把列表滚到空处。收藏任意一个模型,两者都会回来。

收藏

每个模型行在模型名之后都有一个 ☆ / ★ 切换(有图像识别图标时,再排在那之后), 于是整行读起来就是「名称 + 它的标记」:先说明模型能接受什么,再给出你能对它做什么。 整行本身是一个 <button>,所以星标会拦截自己的点击事件,不让它冒泡到行上——收藏一个 模型绝不能顺带选中它——同时星标自己处理 Enter / Space。

  • 星标是悬停才出现,不是常驻。收藏是你对某一行做的事,不是这一行要说的话,所以 在指针悬停到该行、或该行内部拿到键盘焦点之前,星标不绘制。两种情况下它的盒子都保留 原位,因此显现时不会让行发生位移;而且只有真正能悬停的指针才隐藏它——触摸屏上它永远 够不着,所以在那里保持可见。

  • 已收藏的行始终显示星标。那颗星是状态而不是邀请,把它藏起来等于把答案藏起来。

  • 被收藏的模型在列表里出现两次:一次在它所属供应商的分组里,一次在置顶的 「收藏」分组里。收藏行始终标注服务它的供应商。

  • 收藏以路由(provider/modelId)为键,而不是单纯的模型 id:同一个模型 id 常 常由多个已配置的供应商提供,收藏其中一条路由并不代表其他几条也被收藏。

  • 数据存在 localStorage 的 dsh.modelPicker.favorites 下——和官方对话插件存放 自己视图与宽度偏好的地方一致。写入被拒(隐私模式、配额用满)会被吞掉:内存中的 集合仍然跟随当前页面。

在同一模型上重试(失败那一行的旁边)

宿主在对话里画出一行「本轮运行失败」——额度耗尽、密钥失效、流断在半路——然后就没有然后了。 用户这时切进这个已经失败的对话,人还在看那行红字,手下却没有可点的东西:想再试一次,只能在 输入框里手打一句「重试」。这个功能就是那一行下面(输入框上方)的**「重试」**。

它是最窄的一种修复。 不换模型、不动兜底、不改任何用户做过的选择,只是把失败的那一轮用当前 模型重新发一遍。既然什么都没改,它就不需要任何前置设置:没有兜底模型也有,冷启动打开历史对话也有。

冷启动也成立,这才是关键。 会话快照里没有这次失败——它是持久历史,宿主正是从 turn/end 的 reason.error 画出那行红字的。所以面板读同一份事实:最后一次 turn/end 是 kind:'error' 就给出重试;最后一次正常结束(completed)就不给——用户已经越过了那个失败, 再让他重跑一次是往回走。工具提示里带着提供方报的原话(截图里那句 lobsterai: no usable credential; log in first),点不点之前就能看清失败原因。

发什么都分得清。 和「切到兜底再修复」共用同一套判断,只是动作不同:

失败 信号 重试发出去的是
发送失败——请求根本没离开客户端 promptError.op === 'send' 原话原样,从会话日志读回
这一轮死在模型上——宿主画了那行红字 最后一条 turn/end 的 reason.error 原话,前面加一行「本轮运行失败,重试一次。」
Agent 失败——没有可恢复的原话 lastAgentError 续跑指令:先核实已完成的部分,再接着做

第二行那一句是有理由的:失败的那一轮还躺在对话里,原话单独再发一次,模型会把它读成第二条一模 一样的新指令;先说明这一轮失败了,再接原话,才是「同一条请求再试一次」。

它绝不和兜底抢同一个失败。 设了兜底模型时,这个失败归「用兜底模型重试」管(包括自动切换), 这里就不再出现——一个失败两个修复按钮,最后就是一整轮被执行两遍,一遍在旧模型、一遍在新模型。 没设兜底时反过来,只有这个重试。

按下之后不闪烁。 宿主受理 prompt 有一个往返,这期间失败仍然读作「还活着」,按钮会被重新点亮。 所以按下就置灰(行不消失,只是禁用),第二下不会把同一条请求再排一遍;失败真正清掉之后状态自动复位, 下一次失败又是新的问题。

抓图:node scripts/screenshot-retry.mjs(生成 live-retry.png / live-retry-disarmed.png / live-retry-with-fallback.png,这三张走的是和 npm test 同一套座位与 store,用真 Chromium 渲染后截屏)。

故障切换(一键恢复失败的一轮)

场景:某一轮挂了——额度耗尽、被限流,或者 Agent 跑到一半失败——用户得挑一个还能用的模型, 然后再手输一遍「继续」。这个功能去掉的就是那句「继续」。

设计规则是「可见性」。 这是低频事件,所以它绝不能干扰平时的模型切换:会话正常时什么都 不渲染——没有需要预先打开的常驻开关、没有每行都挂的标记、也没有菜单入口。取而代之的是, 只有在一轮确实失败时,输入框旁才出现一个小按钮,并且只有已经指定过兜底模型时才会出现。 两者缺一,这个控件就不存在——所以「没设过兜底模型」等于「没有按钮」,而不是一个点了也没用的 按钮。

一次点击完成两件事。 按钮会切到兜底模型,然后发出修复。切换是先提交并等待完成的,因为 在刚失败的那个模型上发修复,等于在坏掉的地方接着修;切换没成功,就什么都不发。

两种失败形态,两种修复。 它们靠会话自己公开的 snapshot 区分,按钮的文案也随之分岔:

失败 信号 修复动作
发送失败——请求根本没离开客户端 promptError.op === 'send' 原话原样重发,文本从会话日志读回
Agent 失败——已受理,但中途失败或被中断 lastAgentError,或最后一条 assistant/message 的 interrupted === true 续跑指令:先核实已完成的部分,再接着做

把这两种糊成一句「继续」,恰恰会在最要命的那种情况下出错。发送失败什么都没留下,原样重发 同一个请求完全正确;而 Agent 中途失败时,工具可能跑到一半、文件可能写到一半,重发原请求会被 当成一条新指令,有重做已完成工作的风险——所以那种修复改为让模型先核实当前状态。裸的 「继续」是刻意不用的,因为它读起来就是新输入。

指定兜底模型。 弹窗底部「兜底模型」入口打开管理页,可搜索并添加多个模型,拖动行来调整优先级,也可移除。列表里的行同时是切换入口:点一下就把会话切到那个模型并关掉面板(正在用的那一行标「当前正在使用」、不再重复提交);拖动与点击共用一行,指针移动超过 4px 算拖动、不切换。失败时按队列顺序挑选不同于当前模型的首个可用项;自动切换使用同一顺序。旧版单路由设置会自动迁移,设置保存在 localStorage 的 dsh.modelPicker.fallback。

探一次连通,并记住它上次是什么时候坏的。 管理页每一行都有「测试连通」:它通过宿主自己的模型运行时发一条极小的请求,回来就报「连通」并带上耗时。回不来时它不止说「不通」——失败会被分成四类:额度耗尽、限流、认证失败、超时,剩下认不出的才是通用的「不通」。这个区分就是重点:余额用完与一时被限流都会以同样的方式失败,却要你做出相反的反应(充值 / 等一会儿),而「不通」把这两件事糊成一件,逼你打开提示才能分辨。分类由宿主读失败码得出(QUOTA / ACCOUNT_QUOTA / RATE_LIMIT / AUTH),认不出码时再读上游原文——原文的判据与宿主自己的分类器逐字一致,另外补了中文措辞,因为本机渠道常用中文作答。悬停提示给出人话解释这个状态意味着什么,并保留上游原话作为证据。 一条路由一旦失败——无论是这次探测,还是真的有一轮跑在它上面挂了——就带着失败时间、原因与类别记进 localStorage 的 dsh.modelPicker.failures。类别一并存下,所以重新打开页面时它仍然说「额度耗尽」,而不会退化成「不通」。模型列表于是把那个时刻戴在模型名后面:一个告警符号加一个时间,来源与原文放在悬停提示里。因为一条坏掉的路由唯一有用的时刻,正是你挑下一个模型的那一刻。探测通过就清掉这枚标记;超过两周的旧戳在读取时直接丢弃,所以它说的是最近的事实,不是永久案底。与信息符、扳手、星标不同,它不预留位置——没有失败时不留空隙。

探一条渠道,还是探一个模型。 底部那个按钮走的是面板此刻列出来的那张列表,标签直接把量写出来。输入了搜索词(或在那次搜索里点了某家供应商)时,右列就是一组具体的模型,那就逐个探:检测 12 个。没输入时,右列是整个目录——本机是 20 家供应商、1080 条路由——把这一千条全走一遍,回答的却是一个没人问的问题:决定一个模型能不能用的是渠道层的毛病(密钥失效、账号欠费、端点根本不通),那是每家一个答案,不是每个模型一个答案。所以没过滤时,每家只探一个代表模型(检测 20 家),其余一千条不动。两种情形用的是同一条探测,一条接一条,所以每个答案都照常进账本,行上的标记是跑的过程中一条条长出来的。 代表怎么挑:优先你收藏过的那条,其次是你最近用过的——所以"你正在用的这家"由"你正在用的那个模型"来代言——再其次是该家第一个不是免费镜像的模型,因为免费档拒绝你,说明的是免费档的事,不是你在付费的那个账号的事。 并发固定 3 条,这是刻意的:铺得太开会用自己造出的 429 把健康的渠道打死,账本记下的就是这场由检测引起的故障。跑起来时按钮就是停止键(标签变成 12/20,数字用等宽,免得整行左右抖),再点一次结束这一轮,关掉面板也结束它——没人看着的一批请求不该占着通道。已经发出去的探测仍会算进回执,回执写「检测完成 20/20 · 不通 8」(被中断则写「已停止 …」),几秒后自己退场;回执的悬停提示会把不通的是谁列出来,按供应商探时用的就是左列那个名字。记录是那些行上的标记,回执只是回执。

它会自己退场。 失败状态是通过 Session 自己的 subscribe/getSnapshot 读的,所以宿主一报告 失败按钮就出现,prompt() 一清掉错误按钮就消失——没有轮询,也没有额外的簿记去维持两者一致。 点击只修复一次;被拒绝的提交会明说原因并保持可重试,不会记成「已完成」。提交一律用 queue 模式,所以修复永远不会打断正在进行的一轮。

重试用尽后,它会自己接手。 宿主在放弃前会在同一个模型上重试五次,只有这个预算耗尽时 才报出 lastAgentError,所以宿主报出的错误本身就已经等于「再重试这条路由没有意义」。这时面板 会切到已指定兜底里第一个不同于刚失败模型的那条,发出同一种修复,并说明换到了哪个模型。四道闸 让它不至于莽撞:用户主动停止永不触发(中断不是错误,重发等于跟「停下」对着干);没指定兜底 模型时它完全不动——那种情况交给上面那个同模型「重试」,绝不会自动发;面板挂载时日志里已有的失败按 「打开历史」处理,不当成刚炸的一轮;已经就在兜底 模型上、或这个失败已经自动处理过一次的,都交回给用户。那条提示是汇报而不是按钮——修复已经在 跑了——几秒后自己退场。

整条路径都是尽力而为:会话取不到、引用已释放、或兜底路由已不在当前列表里,都只让这次修复 不生效并说明原因,绝不会把活发到不该去的地方。

配置官方模型(底部入口)

弹窗底部固定一行:配置官方模型 → 设置 · 模型。这个选择器只能列出已配置的供应商 提供了什么——它没法新增官方 DeepSeek 路由、没法登录账号、也没法存 API 密钥——而需要 那一页的时刻,恰好就是这个面板开着的时候,所以通往它的入口就放在这里。

  • 这一行是弹窗的直接子节点,在滚动列表之外:会跟着滚走的入口,在模型缺失时正是 找不到的入口。让出约 36px 高度的是列表主体,不是这一行。
  • 「兜底模型」和「官方模型」两个入口并排成组贴着右缘,各是一个图标加一个短标签; 完整说法(如「配置官方模型:设置 · 模型」)放进 tooltip 和读屏名称。左端的供应商列 折叠按钮保持单独的纯图标,不进这一组。它们原本是一条撑满整行的横条,目的地印在最 右侧,读起来像面板的第二行内容,跟它下面的列表抢注意力。
  • 它一步打开设置里的「模型」节,而不是上次停留的那一节。设置弹窗自己持有「开/ 关 + 当前节」的状态,并通过它的 sidebar.settings 座位注册的 store 句柄对外暴露; 选择器就是往这个句柄写 openSection('models')——和点那一行导航完全同一个状态变更。
  • 若某个载体的设置座位不带 store,则退回「执行设置面板自己的 settings.open 命令, 再点亮它的「模型」导航行」;行名取自 settings.section 账本发布的 label,所以兜底 路径跟随界面语言,而不是写死字符串。
  • 两者都没有(该部署根本没有设置界面)时,这一行什么都不做,而不是把选择器关掉、留下 一个空页面:只有设置面板确实打开了,选择器才关闭。
  • 这条交接通过本插件自己的 inject 集读取(slots,以及用 ctx.get 取的选配 shortcuts 服务),不走调用方的 inject,并且绝不会把异常抛进点击处理里。

同一行还多了一个控件:供应商列折叠按钮(空心面板图标),用来收起或展开这个弹窗 自己的左栏。

  • 它折叠的是本弹窗的供应商列表,也就是面板的目录;折叠的不是应用的侧边栏。两 者是不同的对象:应用的侧边栏属于布局插件,这一列属于选择器自己。
  • 折叠后弹窗按让出的这一列收窄(实测 550px → 362px),右边缘锚点不动。模型列表保 持原有宽度,而不是被拉去填补空出来的地方:折叠一列应当去掉空间,而不是把空间重新分 配出去,何况列表的阅读宽度本来就不该变。原本分隔两栏的那条竖线也随着它所属的列一起 消失。
  • 折叠状态是一份视图偏好,记在 localStorage 的 dsh.modelPicker.providersHidden 下,和收藏、历史同一套做法。默认不折叠:这一列是面板的地图,用户没要求弄丢的地图 应该在第一次打开时就在那里。

界面语言

面板说的是应用的语言——你在「设置 → 通用 → 语言」里选的那个——而不是浏览器的语言。 在 DSH 里选了中文,浏览器报 en-US 时这里照样是中文;反过来也一样。

  • 取值链是:locale 服务(真正的偏好,也是其他所有界面读的那个值)→ document.documentElement.lang(外壳对「我正在用什么语言渲染」的声明,locale 插件 每次切换都会改写它)→ navigator.language(只作启动前的默认值)。每一级都有守卫, 所以缺某一级的载体依然能拿到语言。
  • 切换语言会在面板开着的时候立刻重渲染:文案是通过对取值源的订阅读出来的,不用刷新、 也不用等下次打开。
  • 词典故意做得很小——两种语言、一张扁平的 key 表——所以面板从不依赖某个 locale 命名空间 先注册再渲染,也不会在缺它的时候拖垮输入框座位。

搜索语义

查询是同时作用于两栏的分面,而不只是模型列表:

  • 供应商按显示名、路由 id、或「至少有一个模型匹配」来命中。左栏收窄到匹配的供应商, 每个徽章显示该供应商的命中数。
  • 未选中任何供应商时,模型列表横跨所有匹配的供应商,并给每行标注其供应商。
  • 搜索时点击供应商会把列表收窄到该供应商,且不清空查询。输入新查询会清掉上一个供 应商分面。

模型与档位数据走的是和 /model 弹窗同一个按会话的 ModelDirectory,所以在任一面 切换,另一面接下来显示的就是同一个结果。

键盘操作

这个选择器是搜索优先的,焦点始终留在搜索框里:整个弹窗不需要 Tab 穿过每一行就能全 程用键盘操作。焦点也只由光标体现——搜索框在任何状态下都走常态描边,因为它在弹窗的整 个生命周期里都持有焦点,一条被点亮的描边会被读成「需要清掉的状态」。

  • 弹窗打开时光标已经落在第一行上——也就是最近用过的那条路由;空的分组(没有历史 时的「最近使用」、空的「收藏」)不会贡献行,因此也不会被停在那里。光标不等于当前模 型:对勾表示正在使用的模型,光标表示下一次 Enter 会落在哪里。
  • ↑ / ↓ 移动一行,PageUp / PageDown 移动十行,Home / End 跳到两端。光标 在两端停住,不会绕回。
  • Enter 提交光标所在行并关闭弹窗,把焦点交还给触发器。输入新查询会把光标放回第一个 匹配项。
  • 光标就是悬浮底色本身,没有竖条也没有描边;列表会把它滚进视野,scroll-margin-top 保证吸顶的分组标题不会盖住刚滚到的这一行。
  • 搜索框用 aria-activedescendant 指向光标所在行,所以即使 DOM 焦点从不移动,读屏软 件也能播报当前位置。
  • 收藏依然可达:Tab 到某行的星标,按 Enter / Space 切换收藏,而不会选中模型。 星标本身画在文字大小上,但点击热区是 24×24,靠一层伪元素叠出来,因此扩大热区不会 占掉模型名的宽度。

对比度与层级

面板里每一处文字都满足 WCAG AA(4.5:1),而且是拿真实渲染出来的像素量的,不是 拿声明的颜色值算的。面板不带第二种更弱的灰色:元信息直接用宿主的 label-secondary, 层级改由形态承担(模型名 14px/500,行内说明 12px/400,分组标题 11px/600 加 .06em 字距)。

「比正文更弱一点的颜色」有三种更省事的做法,都被实测否掉了:

做法 实测 为什么不行
元素上写 opacity: .6 2.63:1,压到悬浮行上只有 2.25:1 它跟元素背后是什么颜色做合成,同一个值会得到好几个比值;而且它把字形的抗锯齿边缘和字身一起变淡
color-mix(… , transparent) 2.58:1 保留的是 alpha 通道,而不是把颜色调深,所以画出来的每个像素都比它命名的那个颜色更亮
color-mix(… , <面板底色>) 浅色主题的悬浮行上 4.14:1 混合是相对某个底色而言的,而浅色主题下悬浮行比面板更深,方向整个反了

label-secondary 在这个面板可能承载文字的全部四种底面上都过线:深色主题 6.37:1 与 5.04:1,浅色主题 5.50:1 与 4.94:1。

另外两个决定遵循同一条规则,即一个状态不该只靠亮度的轻微差别来承担:

  • 视觉图标用斜杠表示桥接态,而不是调暗(见上文视觉能力)。
  • 左栏选中行和列表的键盘光标用同一块中性底色。整个面板唯一的强调色是「当前在用」那一 行的对勾;给左栏选中行填强调色,会让 --dsw-alias-brand-primary(在这里解析成近白 色)跟那个对勾抢视线。

悬浮底色是「填色的圆角矩形」之后,还有两条规矩保证每一行读得清:

  • 行与行之间留 2px 缝,不能只靠底色区分。底色是齐边相接的,两块相邻的高亮曾经会粘 成一块,看起来像一个控件。
  • 行内第二行说明文字与模型名共用一个左缘,而不是跟图标对齐。图标是排在名字前面的标 记,两行文字应当齐头;缩进量由图标自身的宽度导出,不会跟它走散。

动效只有一个 160ms 的箭头翻转(强 ease-out 曲线),加上按压与悬浮的颜色抬升。开启 prefers-reduced-motion 时翻转被去掉,颜色抬升保留,因为它帮助理解,本身不是运动。

测试

npm test 跑两半:

  • scripts/interaction.mjs 在 jsdom 里通过真实的 loader 入口驱动浏览器半段:打开模 型弹窗、输入供应商名与模型名、断言两栏都被过滤、供应商分面保留查询、弹窗打开时光标 就在第一行并能用 ↑/↓/PageUp/PageDown/Home/End 走动直到 Enter 提交, 以及信息徽章落在信息路由所描述的那些行上。最近使用也被逐条断言:记录正在使用的路由、 再次使用提到最前且不重复、长度封顶、不再配置的路由留在存储里但不渲染。假 context 会 强制 cordis 的 without inject 规则,因此漏声明服务会让测试失败,而不是在真实座位上 才崩。它还断言每个图标都是 SVG,绝不是文字 emoji 或符号字符——在关闭状态的触发 器以及两个弹窗里都是;搜索框也不声明任何聚焦描边——jsdom 的 getComputedStyle 不回 答 :focus,所以这一半检查的是样式表文本。头像两侧都覆盖到了:每一种必须认出厂商的 id 形状(按 id 的 glm-5.3-flash、vendor/ 前缀后的 deepseek/deepseek-v4-flash-vision-exp、厂商段里的 MiniMaxAI/MiniMax-M3、按家族缩 写的 hy3)都认得出;两组锚定对(DeepSeek Chat 与带 vendor 前缀的 id、GLM-4.6 与 glm-5.3-flash)戴同一个标;不同厂商戴不同的标;只有宿主的名字 (Composer 2.5 · Cursor)拿到宿主的标;以及两条规则存在的理由本身——转售的模型 (Grok 4.7 @ 500k · Cursor、DeepSeek-V4.1-Flash · Trae CN)保留厂商的标而不是宿 主的。完全认不出厂商的行保留供应商带底色的首字母,色相是精选的 OKLCH。左栏楼层被断言 保留字母、且各自色相不同,于两种形状不会漂成一种。设置交接同样有覆盖:底部行在滚动区之外、点击它会往设置座位写入 models 并关闭弹窗且不选中任何模型、没有 store 的载体退回 settings.open 命令加账本 自己的导航 label、完全没有设置界面的载体则让选择器保持打开。界面语言是在一个英文浏览器 下断言的:文案必须跟随应用自己的语言声明、开着的面板必须在语言变化时重渲染、locale 服务必须压过文档属性、而完全拿不到语言源的渲染仍要跟随文档。 故障切换正反两面都有断言,而反面权重更大——因为整件事的意义就在于低频故障不能干扰 平时的切换:干净会话完全不渲染按钮(设了兜底和没设兜底都测),有失败但没指定兜底时 同样不渲染。正面则要求一次点击同时完成「切到兜底」和「发出修复」且顺序正确;发送失败重发 原话、Agent 失败改发续跑指令且文案不同;最后一条被中断的消息也算失败;失败清掉后按钮 自己退场——正是这条让它在不增加任何簿记的前提下不挡路。另外还钉住了:兜底路由过期时 只报错不切到空处、被拒绝的提交会说明原因且保持可重试(不会记成已完成)、指定状态可持久化 且再点一次取消、老版本 {route, enabled} 记录仍能取出 route、会话取不到时选择器照常列出模型。 自动切换在同一条轴上断言:亲眼看着失败发生时它会切换、排队一次修复,并把新模型取代按钮 报出来;挂载时日志里已有的失败、用户主动停止、以及没指定兜底模型这三种情况都什么都不做; 失败已经发生在兜底模型上时不切到自己;会话恢复健康后再次失败会再切一次——正是这条让循环 断路器按「每次失败」而不是按「每个会话」生效。 失败账本同样从头到尾有断言:一次不通的探测写下带时间的记录,重开面板它还在、模型行上也还 戴着;一次通过的探测把它清掉;跑在当前模型上的失败会盖在那个模型的行上,而只是从日志 里读到的旧失败什么都不盖;超过两周的旧戳根本不渲染。批量检测沿同一条轴断言:输入搜索词后 只探它命中的那些模型;没有搜索词时每家只探一条而不是整个目录(夹具里的模型数是供应商数 的四倍,所以这个差别看得见);被收藏的那个模型——任何"取第一个"的规则都挑不到它——会代表 它那家出场;每个答案都落账本;按钮跑的时候计数、跑完把自己交还;回执会点出是哪家不通;再点 一次结束这一轮,且回执写「已停止」而不是「检测完成」。
  • scripts/host-route.mjs 用桩 llm 驱动真实的路由处理器:断言行结构、抛错的供应 商/模型被隔离,以及 30 秒缓存命中时不会重新列举。连通测试的路由也这样驱动:流干净结束 报连通,收尾是 error、被中止或 provider 直接抛错都报不通并带上原因;两条路由还被断言在 插件自己的 context 失活之后仍然应答——那正是让每个模型都看起来死掉的场景。

图标对齐。 行内 <svg> 坐在文字基线上,基线下方那段字体降部空间会把图标顶到它 自身行框中心之上——这正是让折叠箭头或对勾偏离文字水平线的原因。 .dsh-mp2-root svg{display:block} 消除了这段空间,每个图标容器再各自居中,于是图标 中心就落在文字的中心线上。

node scripts/probe.mjs 检查真实环境:打开运行中的 GUI(DSH_COOKIE_JAR,默认 /tmp/dsh-cj.txt,里面存着已认证的会话 cookie),断言组件已挂进座位、打开弹窗、测量 查询收窄时面板尺寸不变,并写出 live-model-picker.png。

node scripts/verify-live.mjs 用 Playwright 驱动运行中的 Web GUI(自己伪造宿主签发的 browser-session cookie),检查 jsdom 看不到的东西:光标行的实际底色、搜索框持有焦点时 描边仍是常态色而不是品牌色、点击供应商是「落点」而不是动画,以及底部行的交接确实打开 了设置里的「模型」节。它还会在真实浏览器里量第一行的头像:标必须量到非零且不溢出容器的 盒子、保留 fill-rule="evenodd"(少了它,通义这种描边型的标会把自己的镂空填实)、填充 色是真的颜色而不是透明、背后没有铺底色、旁边也没有多渲染出一个字母。跑完它会自己关掉 面板,不改动你的会话状态。默认打 web 配置的 :3080;用 DSH_LIVE_ORIGIN=http://127.0.0.1:19387 可以指向桌面 App,但桌面 App 跑的 是它自己安装的那份 lib/client.js,改动要先拷进那个配置才看得见。

node scripts/verify-served.mjs 回答的是另一个更烦人的问题:刚覆盖进去的那份代码,运行中的桌面 App 到底发出来了没有?它先比对 profile 里的 lib/client.js 和工作区,再去取 App 真正交给浏览器 的那几个插件 bundle,看里面有没有只有新版本才有的字符串。之所以需要它:App 并不是每次请求都 读这个文件——它打成 bundle,配 rev 的 URL,用 immutable 缓存一年;而那个拼接出来的 bundle 比磁盘慢一个页面加载(本机实测:覆盖后的第一次加载还是旧的一份,第二次才是新的一份)。所以 "刷一下看看"有可能两次都看不到东西。检查说标记不在时,答案是再刷一次或重启 App,绝不是"代码没 生效"。

node scripts/screenshot.mjs 重新生成 README 配图。它会先收藏两个来自不同供应商的 模型——收藏分组是一张静态图必须展示的功能,而全新的浏览器配置下它是空的——并且拒绝 写出一张收藏分组为空的图。可用 SHOT_OUT 覆盖输出路径。

槽位

注入 conversation.input.model(由 @deepseek-ai/dsh-client-ui-conversation 声明)。

注入契约

该座位是单占用槽位,因此本条目会遮蔽官方的 ModelSelect——注册时崩溃会让输入框 完全失去模型控件。cordis 会把服务代理的 this.ctx 重绑到调用方,所以 ctx.modelDirectories 的每个面(directoryFor、load、select)都是通过本插件 的 inject 集去读 remote.session。因此声明是:

const inject = ['slots', 'sessions', 'modelDirectories', 'remote', 'remote.session']

去掉 remote / remote.session 会在座位的 inject 工厂里抛出 cannot get property "remote.session" without inject;条目随即退位,增强选择器永远 不会出现。

第三方素材

模型行可能戴上的品牌标,取自 @lobehub/icons-static-svg(MIT 许可)发布的 单色切图,以 path 数据的形式内联进 lib/client.js。它们是官方正版标而不是重画的近似 版,且只携带已配置路由真的能认到的那些——每个标都在它所在的数据块里注明了来源。界面图 形(星标、对勾、折叠箭头、面板开关、图片、设置、外链)来自 Lucide(ISC 许可),同样内联。没有安装任何图标字体或运行时图标 依赖。

内容来自项目 README(GitHub)↗

链接

同类插件

查看整个分类 →

社区评论

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