dsh-model-rotator
Verifieddsh-model-rotator · v0.2.14 · MIT · Web UI
Model auto-ranking and transparent failover for DeepSeek Harness: ranks discovered models by measured connectivity and speed, switches to the next candidate inside the same turn when the current one is unusable, and ships a grouped model list with per-cha
Install
dsh plugin add dsh-model-rotator Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Readme
dsh-model-rotator · 模型自动排序与故障转移
给 DeepSeek Harness 装上一个「换模型的自动驾驶」:当前模型撞上额度墙、密钥失效、被限流,或者网络刚好抖动时,插件会在同一轮对话之内换到下一个最合适的已配置模型,继续把这轮回答完——用户看到的仍然是一条普通回复,不会中途断掉。
本机实测规模:906 个 provider/model,其中 384 个在轮转池里,跨 48 个渠道(24 个对话渠道 + 24 个识图渠道)。
它到底做了什么
一轮对话失败时,插件依次做这几件事:
- 摸清家底:调
ctx.llm.listProviders()加各适配器的listModels(),枚举本机所有能拨通的provider/model。 - 剔除非对话模型:向量/重排、语音合成与识别、OCR、图像与视频生成、内容审核、评判模型等,一律不进池子。理由很直接——拨它们只可能让本轮失败,还会白占一个本该留给真模型的冷却位。
- 跳过识图孪生:dsh-vision-router 会给每个渠道套一层
<provider>-vision的「识图外壳」,它的作用是给同一个模型加读图能力。也就是说,它是同一个模型的第二条通路,不是第二个模型。把文本轮次切过去,等于让回答绕道图像管线;更糟的是「优先选同款模型」这条规则会把孪生排到最前面。所以这些路由永不作候选(注意:只是名字里带vision的真对话模型不受影响,仍然照常参与)。 - 排序:按实测连通率(拉普拉斯平滑的成功率)、速度(TTFT 中位数与吞吐)、以及当前冷却状态排出一张候选表。你正在用的那个模型、或者它在别的渠道上的近亲,会拿到加权——毕竟你选它自有道理,多半只是这条渠道坏了。
- 先重试,再判死:渠道瞬时抽风很常见(限流毛刺、连接被重置、信道过载),所以单次失败不算故障。同一个模型还会按
retriesPerModel再试几次(默认 2 次,可设 0–5),每次间隔retryBackoffMs(默认 300ms,之后翻倍)。重试不等于切换——它不问你、也不改任何状态。 - 真的换:在
llm/stream瀑布上接管。第一次尝试走宿主原路径;只有当这次失败一个字的正文都没产出时,才放弃这次尝试、关掉它的上游流,用全新的ctx.llm.stream()重新下发下一候选。
两条边界,是整件事的关键
已经出过正文就不再重试。 一旦有任何正文分片吐给用户,这次尝试就锁死了——再试一遍会让用户看到重复的文字。所以「可重试窗口」精确等于「零内容窗口」。
不是所有分片都算内容。 reasoning-delta 不算:重试时宿主会把思考过程连同那一轮一起折叠掉,这正是「思考死循环」「思考强度不被支持」这类失败可以安全重试的原因。标记分片(block-start,或携带思考块的 block-end)也不算。这里踩过坑:早先版本把一个 block-start 当成内容,于是窗口刚开就被关上,紧接着的一个普通错误被当成终局——一个明明活着的模型看起来像死了。
被取消的轮次不折腾。 用户点了停止、超时到点、客户端断开——走链立即停下,不会把候选浪费在没人再看的回复上。
自动调用不当作对话轮次
压缩摘要和会话标题也走同一条 llm/stream 接缝,但它们不是用户轮次。这类调用的拒绝,通常说的是那条专用通道的状况(比如代理回一句「压缩归 bili 管」),此时换一个 provider 去干这段管道活是错误反应:既花掉一个真模型去做没人读的工作,又是虚假切换的头号来源。所以凡是带 purpose: "compaction" 或 purpose: "session-title" 的调用,一律原样透传,错误也透传;没带标记的普通轮次照旧故障转移。
换了模型会告诉你
切换本来是无声的——回复只是换了个模型回来,你根本看不出来。现在它记录在对话顶部的 「模型切换」页签里(与 对话 / 轨迹 / 上下文 并列),逐条列出时间、从哪个模型到哪个模型、以及原因:
14:07:31 modelscope/deepseek-ai/DeepSeek-V4.1-Flash → trae/deepseek-v4.1-flash (quota)
页签的数据来自插件自己的记录(GET /_dsh/model-rotator/switches?session=<会话 id>),不占用对话上下文。页签所在的会话由宿主注入:注册时声明 inject: (sessionId) => ({ sessionId }),组件从 props.sessionId 取——与内置的 对话 / 轨迹 页签同一条契约(0.2.11 前这里读的是 sessions.list 上一个并不存在的字段,页签因此认不出自己在哪个对话里)。记录按会话归属:宿主不向插件路由注入会话,所以会话 id 在切换发生处当场取定(ctx.get('agents').currentInitiator() 返回发起该次调用的 agent),而不是等轮次边界——边界是进程级的,谁能停轮次谁就能把别人的记录整批带走。若某次调用确实没有活动轮次(插件路由、宿主未挂 agents),记录照写、会话留空,空会话只在「全量视图」里可见。
当本对话名下一条记录都没有、而记录总数不为零时,页签会改为展示全部会话的记录,并配一行提示说明这是回退视图——避免「有历史却显示空」。
若你仍想在对话正文里看到那一行(旧行为),把 noticeSwitch 打开即可:
模型已切换:硅基流动/deepseek-4.1 → Loomy/deepseek-4.1 (rate-limit)
已知失败类型时会带上括号,比如 (quota)、(rate-limit)。
| 项目 | 做法 |
|---|---|
| 默认 | 不贴(noticeSwitch: false)。记录在页签里,通知行是可选项 |
| 形态 | 复用宿主自带的通知机制:一条 user/message,source 是 { kind: 'model-rotator/switch', form: 'notice', summary }。对话界面把它渲染成一条上下文行,而不是你发出去的消息 |
| 时机 | 通知行放在轮次边界,经 agent/turn-stopping + agent.steer 投递。切换已经发生、回答已经在流,若在轮次中途插一行,就会插在它所描述的回复正中间(记录本身不在此刻写,见上文) |
| 范围 | 只作用于根对话。子代理记录里的切换属于别人的日志,不打扰你 |
| 打开 | noticeSwitch: true,或设置页里的开关 |
事件类型刻意没自创。持久化读取器会拒绝解释含有未知类型的事件日志(dsh-session 的 KNOWN_SESSION_EVENT_TYPES),自创一个类型会让带它的日志直接加载失败。user/message 是已知类型,notice 是客户端已经理解的形式——宿主自己那条「模型已切换」用的就是这一对。
渠道名优先用宿主发布的 provider displayName(siliconflow 显示成「硅基流动」、modelscope 显示成「魔塔」、buddy 显示成 «CodeBuddy (腾讯)»),没有标签的路由才回落到 id;两者不同时会写成「标签 (id)」的形式。
换之前先问你
故障转移会替换你亲手选的模型,而且可以不声不响——所以默认先问。当前模型零内容失败时,插件经宿主的人机交互接缝(ctx.userQuestions.askTimed())在对话里抛一个问题,给出两个选项:切换 和 保持当前模型。
| 你的回答 | 结果 |
|---|---|
| 切换(或任何不算拒绝的回答) | 下一候选接手本轮 |
| 保持当前模型(或直接打出「不/否/取消/拒绝/no/cancel/stop」之类) | 不再拨任何候选,本轮以原始错误结束 |
| 窗口内没回 | 继续切换——这是一次限时征询,不是否决权 |
提问每轮只问一次:第一次批准即覆盖这一轮剩下的走链,所以一串坏模型不会变成一串一模一样的卡片。默认等 20 秒,可配。
两类失败不问了。 额度耗尽(quota)与提供方不支持(unsupported)是确定性死路:余额不会在 10 秒里回来,不支持的请求下一次仍会被同样拒绝。这种时候「保持当前模型」并不能让模型继续工作,只会让本轮失败——白白多读一张卡片、再等一次超时。所以这两类直接切换,页签里的记录照常写明原因。鉴权(auth)仍然问:403 常常来自突发限速的代理,等一会儿再试是真实的选择而不是形式。
整个机制刻意是「失败即放行」的:接缝不存在、提问抛错、本轮没有活着的根代理(被委托的子代理没人可答)、或者等超时了——走链都会在未确认的情况下继续。无人值守的宿主绝不该卡在一张没人看的卡片上。
失败类型决定后事怎么办
失败不只是一个字符串,它会被归类,而类别决定这个模型要「歇」多久。
额度墙和限流是两码事。 429 insufficient balance 和 429 too many requests 是同一个 HTTP 状态码,但一个几秒后自己就活了,另一个得等额度重置。插件会从消息正文里读语义——insufficient quota、out of credits, please top up、余额不足,请充值、额度已用尽、402 Payment Required 这类——然后区别对待:
| 类型 | 冷却多久 | 要不要先重试 |
|---|---|---|
quota(额度耗尽) |
歇到次日 quotaCooldownHour(默认本地 00:00),且不少于 quotaCooldownMs |
不重试——两次下发之间余额不会自己长回来 |
auth(鉴权失败) |
常规指数退避 | 重试——被突发限速代理解出来的 403 确实会好 |
unsupported(请求形态不被支持) |
常规指数退避 | 不重试——同一个 provider 对同一个请求只会给同样的拒绝 |
rate-limit(限流) |
常规指数退避 | 重试 |
server / network(服务端/网络) |
常规指数退避 | 重试 |
| 未分类 | 常规指数退避 | 重试 |
unsupported 指的是请求形态本身被拒,比如「provider X model Y does not support reasoning effort high」「unsupported model」。它在读请求之前就判定了,重试只会逐字复现同一个拒绝——跳过重试,才能把一次会话级不匹配压成一次切换,而不是三次。
鉴权失败会先于裸 429 被归类,因为等待永远修不好一个密钥:对 429 invalid api key 来说,诚实的名字是 auth 而不是 rate-limit。这个区分只用于记录,不改变处置——两者退避一样;只有 quota 会改变策略。
冷却的下限是「次日 quotaCooldownHour」。23:50 撞上的额度墙仍要付满 quotaCooldownMs,所以它会在次日 11:50 回来,而不是十分钟后的午夜。
model_rotator_status 会把这些都摊开给你看:排名表把这类行标成 QUOTA-REST(并附剩余小时数)而不是 COOLDOWN,type 列写明类别,last failure seen 行列出每个模型最近一次错误及其类型。
实测状态——计数器、最近错误与类型、冷却到期时间——跨进程存活。理由是现实的:「刚被告知余额用完」之后做的那次重启,不该把冷却一并抹掉。它写在 %DSH_HOME%\model-rotator\stats.json(或者 config.json 所在目录),去抖落盘,首次使用时读回一次。延迟采样则刻意只留在内存——它们是 20 个样本的滑动窗口,陈旧的窗口比短的更糟。statsPersistTtlMs 限制读回文件的年龄(默认 24 小时;设 0 表示完全不用这个文件)。
它不碰什么
它从不读、写或改写模型/provider 配置。 不碰 cordis.patch.yml、不碰 agent-default-model、不碰 llm-pi-ai、也不碰任何 provider 注册。它只在运行时观察流式瀑布。如果你的 provider 配置被人动过,那一定不是它干的。
哪些模型会被过滤掉
宿主适配器在交给 listModels() 之前,会把目录自带的 wire: 'chat' 标记剥掉,所以没法从返回行上读能力。名字匹配是唯一可用的信号,而且刻意写得很窄——模式绝不能误伤只是名字里提到某种模态的对话模型(Qwen3-VL-8B-Instruct 是对话模型,保留;Z-Image-Turbo 不是,剔除)。
过滤器覆盖向量与排序、语音、图像生成、文档解析、安全分类器这几大族,普通 provider 与 -vision 变体都算。它在发现阶段就跑,所以插件报的 discovered 已经是过滤后的池子。也因此,一台机器的数字完全取决于它配了哪些 provider——别从本文档抄数字,直接问实际安装:
model_rotator_status # 看 "discovered" 一行,那就是过滤后的候选数
三个工具
| 工具 | 干什么 |
|---|---|
model_rotator_status |
只读:启用状态、配置路径、已发现池、实测排名,附带「当前生效设置来自哪一层」。 |
model_rotator_benchmark |
用最小真实请求探测候选,刷新 TTFT/吞吐与冷却状态(有次数上限,会花掉一点额度)。 |
model_rotator_select |
固定或取消固定转移顺序的队首;队尾仍然由排名决定。 |
状态工具还会报出两个手动清单和识图规则,这样「目录里看得见、排名里看不见」的模型,不必去读代码就能解释清楚:
disabled models : workbuddy/hy3
vision twins : never a candidate (the dsh-vision-router mode wraps a model, it is not a second one)
model list route : /_dsh/model-rotator/models (GET the grouped list; POST {key, enabled} to switch one off or on)
配置
可选,路径 %DSH_HOME%\model-rotator\config.json,改完即热加载。每个字段都有安全默认值,所以完全不建这个文件也能跑。
{
"enabled": true,
"allowImageFailover": false,
"maxCandidates": 4,
"cooldownBaseMs": 60000,
"maxCooldownMs": 1800000,
"quotaCooldownMs": 43200000,
"quotaCooldownHour": 0,
"statsPersistTtlMs": 86400000,
"confirmSwitch": true,
"confirmTimeoutMs": 20000,
"adoptSwitchedModel": true,
"retriesPerModel": 2,
"retryBackoffMs": 300,
"preferSameModel": true,
"weights": { "connectivity": 0.5, "speed": 0.3 },
"excludeProviders": [],
"excludeModels": [],
"excludePatterns": [],
"disabledModels": [],
"enabledModels": [],
"disabledProviders": [],
"pinned": ""
}
enabled—— 总开关。allowImageFailover—— 图像轮次默认跳过。理由:纯文本备选读不了图,在那里转移只会得到「错误答案」而不是「缺失答案」。maxCandidates—— 每轮最多试几个(硬上限 8)。cooldownBaseMs/maxCooldownMs—— 指数退避的上下界(base * 2^(n-1),截断到max)。quotaCooldownMs—— 额度墙之后的最短冷却(默认 12 小时,上限 26 小时)。从失败那一刻起算,不按整点取整。quotaCooldownHour—— 本地小时(0–23),耗尽的模型可以在这时刻再被尝试(默认 0 = 次日零点)。冷却总是走到次日的那个时刻,所以 00:05 撞的墙不会当晚就复活。statsPersistTtlMs—— 实测状态跨重启存活多久(默认 24 小时,上限 30 天;0 = 不持久化)。confirmSwitch—— 离开失败模型前先在对话里问一句(默认true,见上文)。confirmTimeoutMs—— 那个问题等多久之后仍然照切(默认 20000;限 1 秒 … 10 分钟)。adoptSwitchedModel—— 切换之后,把真正作答的模型设成本会话的模型,让会话头部显示实际在用的模型(默认true)。仅限会话内:不动 provider 配置、不写任何默认值;宿主自己那条「模型已切换」通告也会随之报出正确的组合。retriesPerModel—— 认定失败之前同一模型的额外尝试次数(默认 2,硬上限 5)。重试不需要确认,因为它不换模型。retryBackoffMs—— 每次重试前的停顿(默认 300;第 n 次重试等 n 倍,封顶 10 秒)。preferSameModel—— 优先排同款模型在别的渠道上,其次近亲,最后才按实测排名(默认true)。windowGuard—— 上下文窗口装不下「当前对话 + 本轮回复」的故障切换候选直接跳过(默认true)。窗口取自拥有该路由的适配器(resolveModelInfo),不是目录声明:profile 可以声明窗口,但只有实际发起请求的一方知道装不装得下。适配器没公布窗口的候选永不跳过;宿主报不出对话压力的场合整个守卫不生效;与当前路由同窗口的候选也豁免——守卫管的是「换过去」。被扣下的候选在模型列表里标记window,status 工具会报出它依据的压力读数。noticeSwitch—— 是否额外在对话里贴那一行切换通知(默认false;不贴时切换记录仍在「模型切换」页签里)。weights—— 评分里连通率与速度的配比。excludeProviders/excludeModels—— 把指定路由排除出池子。excludePatterns—— 追加到非对话黑名单的正则;写错的正则会被跳过,不会致命。disabledModels—— 在模型列表里逐行关掉的模型。每条是"provider/model",也可以只写裸模型 id(那样会命中所有提供该模型的渠道)。行级条目压过渠道级规则。disabledProviders—— 在渠道表头上点「关闭本渠道」关掉的整条渠道,一条记录一个 provider id。关闭渠道时会一并清掉该渠道内遗留的逐行enabledModels,免得按钮被静默撤销;此后单独开启某一行会写一条新记录,照样生效。enabledModels—— 相反方向。也是当某个 wrapper 命名不符合<provider>-vision约定时,把识图孪生单独放回轮转的唯一方式。由同一个开关写入。pinned—— 转移顺序的优先队首。
设置页与模型列表
插件两半都齐:服务端有给 model-rotator 命名空间用的 Config schema,浏览器端有 client.js(经 dsh.client 与 exports["./client"] 声明),同一个组件渲染到宿主的两个位置:
- 设置 → 模型自动切换:设置导航里的独立入口(
settings.section,idmodel-rotator,order 20)。 - 插件面板里的
model-rotator卡片(plugins.item),卡片正文是同一套表单。
两处都只在宿主真的提供该命名空间时出现。页面上只放「个性化安全」的字段:
| 字段 | 含义 |
|---|---|
enabled |
总开关 |
allowImageFailover |
图像轮次也参与转移 |
maxCandidates |
一轮最多试几个模型 |
cooldownBaseMs |
退避基数 |
maxCooldownMs |
退避上限 |
quotaCooldownMs |
额度墙后的最短冷却 |
quotaCooldownHour |
耗尽模型归来的小时点 |
statsPersistTtlMs |
实测数据跨重启存活多久 |
confirmSwitch |
切换前先问 |
confirmTimeoutMs |
问句等待时长 |
adoptSwitchedModel |
把切换后的模型落到本会话 |
retriesPerModel |
认定失败前的尝试次数 |
retryBackoffMs |
重试之间的停顿 |
preferSameModel |
优先同款模型换渠道 |
weights.connectivity / weights.speed 不在页面上:表单模型一次只承载顶层键,嵌套值留在 config.json。需要手工或工具批量编辑的长清单——excludeProviders、excludeModels、excludePatterns、pinned——也留在那里。
模型列表
表单下方,同一页渲染出模型列表:每一个已发现的 provider/model,带健康状态和自己那一行的开关;另外每条渠道的表头上都有一个开关,一键关掉该 provider 背后的所有模型。两级是刻意设计的:密钥或端点整条死掉时,「关整条渠道」才是你要的那一下;而某一个模型坏了、邻居还好着时,你需要的又是行级开关。
列表分成两段,因为它们回答的是两个不同的问题:
| 分区 | 内容 |
|---|---|
| 对话渠道 | 真正参与自动切换的候选 |
| 识图模式(vision-router 创建) | <provider>-vision 孪生,每条都标注它包裹的是哪个渠道(amd-vision → amd) |
折叠分三级、互不干扰:分区、分区内的渠道、整张表。对话渠道默认展开,识图分区默认收起——因为孪生数量和其余模型一样多(本机最近一次实测:906 行里 453 行是孪生),而且它们永不作候选,收起来才让页面可读。全部展开 / 全部收起 两个按钮一次操作所有分区与渠道。
真实长相(本机实测):
▼ 对话渠道 453 个 · 可用 361 · 冷却 23 · 关闭 69
▼ 硅基流动 (siliconflow) 65 个 · 可用 63 · 冷却 2 · 关闭 0 [关闭本渠道]
● deepseek-v4.1-flash 冷却中 retry in 511m · rate-limit [关闭]
▼ AMD (amd) 8 个 · 可用 5 · 冷却 3 · 关闭 0 [关闭本渠道]
▶ 识图模式(vision-router 创建) 453 个 · 可用 0 · 冷却 0 · 关闭 453
| 列 | 含义 |
|---|---|
| 圆点 + 标签 | 可用(没在冷却、没有失败记录)、未测(还没试过)、冷却中、额度耗尽(歇到次日边界)、异常(上次失败)、已关闭 |
| 明细 | TTFT 与成功/尝试次数,或剩余冷却,或这一行为什么不在轮转里 |
| 开关 | 关闭 / 启用 |
一行「不在轮转」的原因会写明,而不是笼统灰掉:manual(在这里手动关的)、vision(识图孪生,默认排除但开关可以推翻)、provider / excluded(来自 config.json)、non-chat(非对话模型族)、combo(虚拟路由)。只有前两种能在页面上开关,其余要么是结构性的,要么归文件管。
渠道级开关的几条规则(都是实测踩出来的):
- 表头按钮的标签取自渠道自己的开关状态,而不是「有没有某一行在轮转」——整条渠道的行因为行级原因都关着,它依然是开启状态;从表头关掉的渠道,必须给出能重新打开它的动作。
- 已关闭的渠道按钮显示启用本渠道,同时下面的行级开关仍然可用,方便你把某一个模型单独放回,而不必整条渠道重开。
- 关闭渠道会清掉渠道内遗留的逐行覆盖(这样按钮才说到做到);而你在这之后单独打开某一行,那条新记录照样赢(新的、明确的表态压过旧的、笼统的表态)。
- 识图渠道不提供渠道级开关。它的行是被孪生规则挡在外面的,渠道开关撬不动它们——放个按钮在那里,等于写了一笔改不动任何东西的配置却看起来像个承诺。请用行级开关。
列表的数据不走设置表单,走 HTTP:路由 /_dsh/model-rotator/models。
GET返回分组列表:groups是两个分区,channels每个 provider 一条(带kind和孪生所包裹的sourceChannel),models是所有行。分区和渠道带同一套models/ok/resting/off计数器,所以一种行形状就能渲染两级,折叠时也照样报得出状态。POST {key, enabled, scope}翻一个开关并回传刷新后的列表。scope: "provider"用于渠道表头;省略或"model"用于单行。
表单承载你的策略(试几个、等多久),列表是名册——provider 一重新发布它就变;而它的每一行是逐个直接写进 config.json 的,所以刷新不会把你正打到一半的数字抹掉。路由通过 ctx.inject(['webServer'], …) 注册,因为宿主是在插件加载之后才发布 web 服务器的;它和其他所有非公开路径一样,由宿主统一做鉴权。
两层配置合并规则是 默认值 < config.json < 设置页。设置页只在它真正拥有的键上获胜:它没设过的键,值仍然来自文件。model_rotator_select 从不把「页面拥有的键」写回 config.json,所以一个陈旧的文件值永远不会伪装成权威。
命名空间就是插件的 cordis id(model-rotator)。只有服务端一半、没有客户端一半,页面上什么都看不到——这也是两半必须一起发的原因。Config 导出需要 schemastery 在宿主进程内,所以插件在 vendor/node_modules/ 里自带了副本,并在裸导入解析不到时按绝对路径加载它——这样在「manifest link 指向 profile 的 node_modules 之外」的安装形态上,页面也照样出得来。
model_rotator_status 的最后有一块 in force right now,逐行列出每个生效键的合并值与它来自哪一层([default] / [config.json] / [settings page]),不用你自己读两个文件在脑子里做合并。再后面几行给出实时画面:ask channel(切换前的问句到底能不能送达你,不能的话为什么)、adopt on switch、model in use now(从本会话请求头读)、session switch、failure verdict、switch order。
安装
从 npm 安装(推荐):
dsh plugin --profile desktop add dsh-model-rotator
从 GitHub 安装:
dsh plugin --profile desktop add github:chimneyQ/dsh-model-rotator
如果你手上是本地目录,同一条命令换成路径即可:
dsh plugin --profile desktop add <本目录路径>
包内声明了 dsh.bundle.patch,所以它自带的 cordis.patch.yml 会自动接线;它没有任何需要构建的依赖。装完需要完整重启应用才会加载。
确认宿主是否真的把它接上了(首次安装失败可能只写下依赖、却没把插件加入 dsh.profile.bundles):检查 <DSH_HOME>/profiles/desktop/package.json 的 dsh.profile.bundles 里是否列出 dsh-model-rotator。
测试
node test-offline.mjs # 故障转移 + 确认闸门,跑在 stub 宿手上
node test-settings.mjs # 服务端设置契约,对着宿主自己的规则
node test-client.mjs # 浏览器端:加载器形态、卡片注册、字段
node release-check.mjs # 发布自检:manifest / files / exports / 收录条目
四条都不需要网络、也不需要跑着 DSH。离线套件覆盖「失败后重试」「一旦出正文就不再重试」「图像轮次跳过」「所有候选都失败」「冷却记账」「取消信号透传」「经真实瀑布的非对话过滤」「工具注册」「确认闸门」(提问关闭 / 批准 / 按钮拒绝 / 文字拒绝 / 超时 / 每轮一问 / 各条失败即放行路径)。客户端套件用真实的 hooks 运行时渲染名册(状态、副作用、记忆化回调、重渲染)并按下读者会按的按钮,断言返回的东西——两个分区、推导出来的计数、每一级折叠的收放、以及列表只被拉取一次。正则只能证明某个功能被提到过,只有渲染才能证明它真的能用;早先一个重复拉取的循环,正是被这套渲染测出来的。
许可
MIT