dsh-connect-workbuddy 4.5.2 → 4.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,75 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.6.1 (2026-10-16)
4
+
5
+ > 版本号是 **patch**:只加了一行提示文案,没有行为、接口或配置变更。
6
+
7
+ ### Fixes
8
+
9
+ - **模型区补一句「免费模型有时间段内定额 Token 上限,并非无限制使用。」(加粗)**
10
+ - **为什么单独一句、而不是接在上面那句说明后面**:目录里有一批 `creditMultiplier: 0` 的模型,卡片把它们显示为免费;而上游仍会在**某一时段内的 token 定额**用完时用 `6004` / `14003` 拒掉 —— **那个定额不是账单余额,充钱也解决不了**。把「免费=无限」当成事实的人,会把 `6004` 读成「插件坏了」进而去找一个没问题的账号(#56 的报告者就为这个误读花掉过时间),或者去充值(`#63` 报的正是这个读法)。上面那句是描述**事实**、灰度的说明文字,这一句纠正的是**预期**,所以它独立成行并加粗。
11
+ - **颜色刻意不变**:仍是三级灰,不换成主色也不换成错误色 —— 它陈述的是正常产品行为,不是故障;换成红色会读成报错横幅,而真正该被当成错误的信息在别处。
12
+ - **中英 1:1**:`row.tabFreeQuotaWarn`(`免费模型有时间段内定额 Token 上限,并非无限制使用。` / `Free models have a per-window token quota — they are not unlimited.`)。
13
+ - **守卫**:`tests/pool-card-render.spec.tsx` 新增 2 例 —— ① 提示会渲染、且带的是**加粗类**而不是又一条灰度提示;② 字重必须写在样式表里(断言 `.dsm-workbuddy-free-quota-warn{font-weight:600}`),防止有人改成内联 `style={{fontWeight:600}}` 之后这条规则在 CSS 里查不到。**变异验证**:① 删掉那段提示 → 红;② 从 CSS 删掉加粗规则 → 红。
14
+ - **顺带踩到并记录一个老坑**:这行的样式注释里写了反引号(引用 locale 键),而 `styles.ts` 是**模板字面量** —— 反引号会直接截断字符串,表现为 `tsc` 报 `',' expected`。同一个错在本次发布周期里犯了两次,已改为不带反引号的写法。
15
+
16
+ ## 4.6.0 (2026-10-15)
17
+
18
+ > 版本号是 **minor** 而不是 patch:这一版新增了**用户可见的界面结论与控件**(池级「模型整体繁忙」判断、积分弹窗的打开期自动刷新、额度提示的呈现位置调整),按语义化版本属于特性。主线是**三处「界面上说了错话」的修正** —— 窗口左上角那个本来没有的方框、池卡片把模型问题说成账号问题、以及同一个账号在两个界面上状态不一致。三处都不会让测试或 CI 变红,只有人看界面才会发现,这也是它们能一路发出去的原因。
19
+
20
+ ### Fixes
21
+
22
+ - **「状态不合拍」的真正根因:同一个账号,积分弹窗和账号池卡片说出了两个不同的状态**(维护者第二次报同一句话,这次带了三张**同一秒**的截图)
23
+ - **先证明这不是「先后两个时刻」的错觉**:维护者这次附的两张卡片截图,文件写入时间都是 **17:12:47**,同一个面板上的两个视图在**同一秒**画出了不同的世界 —— 池卡片说 `dingxiyue` 是「模型繁忙 · 10/15, 17:13 之后可再用 · 约 2 分钟后」,积分弹窗对**同一个账号**一个字都没画。同一份 store、同一条 `exclusionOf` 规则,两边唯一能不同的只有**时间**。
24
+ - **三个各自独立的缺陷叠成了这一张图**,逐个修:
25
+ - **① 弹窗的表格是一次性快照,开完就不动了。**`fetchAccountCredits` 只在 `[open, activeRegion]` 变化时跑一次,而池卡片是每 60 秒重新排一次(`POLL_INTERVAL_MS`)。`dingxiyue` 那条 `live-request` 失败发生在 **17:11:47**,弹窗的读取早就结束了,所以它画不出这个标记 —— 两边对**各自手上那份数据**都没说错,这正是它难读的原因。改为在面板打开期间按 **60 秒**(卡片同一节奏)重读,最后**一次读完之后**才起定时器(避免与打开时那次读重叠)。**为什么不用读数自己的 5 分钟**:这张表要渲染的最短冷却就是 `MODEL_BUSY_COOLDOWN_MS`(2 分钟),5 分钟的周期意味着它能在卡片已经改口之后**继续谎报「模型繁忙」最多 4 分钟**。关闭面板即停(关闭后不再读,成本规则不变)。
26
+ - **② 写着「刷新」的按钮刷新的不是它所在的那张表。**它只调 `fetchUsage`,于是按下去只动了触发器上的余额,**表格每一行(连标记一起)原封不动**。按钮就在账号表的表头里,用户按它的意思是「刷新这张表」;一个刷新别处的东西比没有这个控件更糟。改为 `refresh()` 并发刷新两半(读数 + 表格),因为 `fetchUsage` 自己吞掉失败、不会抛,所以一半出错也不会让另一半被跳过。
27
+ - **③ 上游明明说了时间,却被丢掉了。**`dingxiaoman2016` 存下来的报文是 code 6004 的**英文**写法:`usage exceeds frequency limit, but don't worry, your usage will reset at 2026-10-16 11:34:04 UTC+8, …`。而 `UPSTREAM_RESET_PATTERN` 要求中文关键词 `重置`,于是 `parseUpstreamResetAt` 返回 `undefined` → `cooldownOf` 返回 `{}` → 记录**没有** `retryAtMs`,尽管它自己的 `message` 就写着那一刻。卡片于是渲染「被限流 · **上游未给出何时恢复**」,而 `retryDue` 退回 30 分钟的 `POOL_UNKNOWN_COOLDOWN_MS`,把账号提前约 **18 小时**放回候选池,再花一次请求重新撞上同一堵墙。这是「诚实缺席」契约坏在了**更糟的那一侧**:不是编了一个时间,而是**把真的时间扔了**。
28
+ - **英文写法不是个例**:issue #63 的正文里就带着同一句式(`will reset at 2026-10-14 23:28:38 UTC+8`),所以这是这项服务**真的在用**的第二种措辞。两种措辞的**标记位置相反**(中文 `重置` 在时间戳**后面**,英文 `reset at` 在**前面**),所以不是一个正则放宽就能覆盖的 —— 拆成 `UPSTREAM_RESET_PATTERN_ZH` / `_EN` 两条,共用一个 `RESET_STAMP` 片段,避免两份时间戳形状各自漂移。英文那条仍然**锚定在时间戳紧前面**,与中文条一样严格。
29
+ - **存量记录在读取时被治好**(`retryAtMsOf`,`src/account-pool-store.ts`):光修解析器只能救**以后**的写入,而维护者屏幕上那条记录已经躺在文件里了。`readPoolProbes` 是所有消费者拿到 probe 的**唯一入口**(`accountExclusions` 弹窗 / `poolMembersOf` 卡片**和故障转移路由** / `memberNames`),在这里治一次就全部生效;分散到三个消费点重推就是同一规则的三份拷贝,而且**以后新加的消费者会静默漏掉**。这不是永久的第二套推导:`writePoolProbes` 是「读→合并→写」,所以**下一次写入就把治好值落盘**。
30
+ - **只治 `rate-limited` / `out-of-credit` 两种结局**,因为只有这两种的语义**存在**「上游说的时间」。放宽它会出事:`AccountPool.tsx` 会渲染任何存在的 `retryAtMs`,给一个 `ok` 的账号补上时间就会显示「可用 · <某时刻> 之后可再用」—— 在一个**什么都没在等**的健康账号上。`model-busy` 同理被排除:它的冷却是一个本地量级,永远不是报文里的时间戳。
31
+ - **守卫**:新增 `tests/state-mismatch-surfaces.spec.ts`(10 例,用**逐字的真实英文 6004 报文**,裁剪过的 fixture 证明不了任何事)与 `tests/composer-panel-freshness.spec.tsx`(8 例,**执行真实组件** + 假定时器,因为这两个缺陷都是「哪段代码在哪个时刻跑」,正则守卫会被改写绕过)。**变异验证**(逐个改回、各自变红):① 解析器去掉英文分支 → 红 **5** 例;② 关掉读取时治疗 → 红 **3** 例;③ 去掉结局范围限制 → 红 1 例(这条是安全属性);④ 恢复「只在打开时读一次」→ 红 1 例;⑤ 刷新按钮改回只调 `fetchUsage` → 红 1 例;⑥ 关闭面板不清定时器 → 红 1 例;⑦ 把在途守卫改成无条件(**连用户的手动刷新一起吞掉**)→ 红 1 例;⑧ 彻底删掉在途守卫 → 红 1 例。**注意**:新测试用 `vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })` —— 默认连 `setTimeout` 一起假掉会**死锁共用 harness 的 `settle()`**(它靠真实 `setTimeout(…, 0)` 排空 React)。
32
+ - **一个「定时器守卫」的分寸**:表格的重读分两条路 —— **定时器**的问句是「有变化吗」,所以一次在途的读取已经回答了它,**跳过**这次 tick;而**用户按下的动作**(刷新按钮、`switchTo` 写完之后的重读)**必须真的发生**,所以带 `force` 并且认领最新的 generation。这个区分不是洁癖:定时器读得慢时用户按下刷新,如果被「已有读取在跑」吞掉,控件就是在**没有问过上游**的情况下宣称「表是最新的」—— 与「刷新按钮刷新的是别的地方」属于同一类缺陷。**这一条是被变异验证逼出来的**:先写的版本守卫是无条件的,第 ⑦ 项变异(把 `!force &&` 去掉)暴露出它会吞掉手动刷新;第 ⑧ 项变异(彻底删掉守卫)又暴露出**「跳过 tick」那个测试本身是假的** —— 定时器只在打开时那次读**结束之后**才装上,所以把打开时那次的 promise 挂住,根本没有 tick 会来,那个用例无论如何都通过。改成挂住**第一次 tick** 的读取之后,第 ⑧ 项才真正变红。
33
+ - **顺带一条**:弹窗页脚的时间戳现在跟着**表格**那次读取走(`fetchAccountCredits` 里也写 `lastRefresh`)。原来它只反映读数那次读取,于是「刚刷过的表」可能顶着**一个没动过的时刻** —— 与那些冻住的标记是同一个「这数据还新吗」的问题。
34
+
35
+ - **数据损坏:账号池的 `modelId` 写入后被读丢,并被后续每次写入抹掉**(维护者报「账号池表和积分弹窗状态没对齐」时查出来的)
36
+ - **根因不是显示,是读写不对称**:`readPoolProbes` 的白名单里没有 `modelId`,而 `writePoolProbes` 是**读→合并→写回**。所以这个字段不是"看不见"——它会被**下一次写入永久抹掉**,之后再写也回不来。已复现:写入 `modelId` 后读回是 `undefined`。
37
+ - **为什么危险**:这不是少显示一个字段。#61 加这个字段是为了让「哪个模型的测量」可核对(`6004` 是「账号 × 模型」条件,同一时刻 A 账号在 `hy4-preview-f` 可用、`step-5-preview` 却返回 429/6004)。字段一丢,池子就把一次**单模型**的 `ok` 当成了**账号级**结论——正是这个字段要防的误读。卡片上的「未记录模型」只是它的可见症状。
38
+ - **改了什么**:`readPoolProbes` 补读 `modelId`;`isProbe` 补校验(非字符串丢弃,与 `accountName` 同理由:它作为标签进 UI)。
39
+ - **守卫**:`tests/account-pool-store.spec.ts` 新增 2 例。**关键点**:单次写入的往返测试**抓不到**这个 bug(第一次写入是完整的)——必须**第二次写入**才能暴露,所以第二例专门写「无关账号的写入不得破坏已存的 modelId」。**变异验证**:删掉补读那行 → 两例都变红。
40
+
41
+ ### 改进
42
+
43
+ - **池级状态:所有成员都卡在同一个模型上时,卡片直接说「这是模型的问题,不是你的账号」(issue #64 的第 ① 条)**
44
+ - **报告者的问题**:池内 4 个账号全被同一模型 14003 挡住,卡片上**每一行都写着「模型繁忙」——每行都对,加起来却是错的**。用户读到的是「我的池子坏了」,于是去找一个不存在的坏账号(正是 #56 报告的那场搜寻),而换账号恰好是唯一救不了的动作 —— 限制跟随模型。
45
+ - **改了什么**:新增 `allMembersBlockedByModel()`(`src/client/pool-state.ts`)与两条文案(`row.poolModelWideBusy` / `Hint`)。命中时卡片渲染「所有成员都卡在同一个模型上 —— 这是模型的问题,不是你的账号」并建议**换模型**;未命中时仍是原来那条「账号池里目前没有可用的账号」。两支**刻意分开**:那条说的是**账号**不行,这条说的是**账号没问题、模型才是那堵墙**。模型级分支先判、优先胜出。
46
+ - **判据是 `every` 而不是 `some`**:必须有成员、且每个被排除的成员原因**都是** `model-busy`。一个账号被限流、其余是模型繁忙的池子**不算**模型级结论 —— 那个限流确实说的是那个账号,此时说「不是你的账号」就是同一类虚假安慰。
47
+ - **一条被证伪的注释与随之删掉的死代码**:最初把「非空」与「无可用成员」写成两个并列条件,但**后者不可达** —— 可用成员的 `excludedBy` 是 `undefined`,所以 `every(... === 'model-busy')` 在任何成员可用时已经为假。(已按「三个成员以内所有组合」穷举验证:`every` 成立且存在可用成员的状态数为 0。)留着它就等于留一段测不住的代码,所以删掉,把这条性质写在文档注释里而不是写成跑不到的分支。
48
+ - **守卫**:`tests/pool-state.spec.ts` 新增 6 例。**变异验证**:① `every`→`some` → 红 3 例(这条最关键,防止模型级结论在混合池上误报);② 删掉非空判断 → 红 1 例;③ 判定整张表而非成员 → 红 2 例。
49
+
50
+ - **额度失败的两条提示都改成可行动的中文**(维护者要求:「换号成功要显示切换了账号,换号失败要给原因并建议换模型」)
51
+ - **换号成功**:`failoverNoticeText` 由 `「xxx」was rate limited — this answer came from another account` 改为 `「xxx」被限流,已改用池中另一个账号回答`;`failoverReasonText` 整条改为中文(`被限流` / `积分已耗尽` / `登录已失效` / `该模型当前繁忙(不是账号问题)` 等)。**按维护者的选择,不点名接管账号**——代码原本就有一条明确注释反对点名(改名会诱导用户以为要去管理那个账号),这条理由保留。
52
+ - **失败计数**:`(after trying 3 accounts)` → `(已尝试 3 个账号)`。
53
+ - **换号失败时补「为什么没号可换」**:新增可选回调 `failoverShortfall`(shim 侧)与 `explainPoolShortfall`(宿主侧)。宿主用**与路由同一个** `exclusionOf` 读每个成员的去向,按原因归并计数,输出如 `池中已无可用账号(2 个账号被限流)`,并接一句 `建议换一个模型继续,或稍后等待该账号的额度恢复`。
54
+ - **为什么用「不要再换账号」的口径**:池子刚刚搜过所有成员,用户的下一个动作应该是**搜索替不了他做的那一个**。原本文案只说"请求失败",把四种补救(等额度/加账号/重新登录/换模型)全留给用户猜,而其中三种插件已经排除过了。
55
+ - **热路径代价为零**:`failoverShortfall` **只在请求已经失败之后**才问一次,成功路径完全不碰(有专门用例断言:成功时回调调用次数为 0)。回调抛错时**降级为不提原因**,绝不把真实的 429 换成插件自己的错误。
56
+ - **守卫**:`tests/shim-failover.spec.ts` 新增 5 例(原因入正文、失败类别不丢、宿主不给原因时保持原样、回调抛错时不污染、成功时不问)。`tests/pool-e2e.spec.ts` 新增 1 例源码守卫:确认接线存在、受池子开关门控、复用 `exclusionOf`(不得自己重推)、且失败路径不发起网络调用。**变异验证**(逐个改回、各自变红):① 回调异常泄漏进回复 → 红;② 删掉「建议换模型」 → 红;③ 成功路径也去问解释器 → 红 9 例;④ 解释器去掉池子开关门控 → 红;⑤ 解释器改用原始 `probe.outcome` 而非 `exclusionOf` → 红。
57
+
58
+ - **额度提示:把占用的 `shell.quota-notice` 座位还回去 —— 左上角那个「本来没有」的方框就是这个插件画的**(issue #63 的第二次后续;维护者看截图后的判定)
59
+ - **维护者看到的**:窗口**左上角**出现一个带边框的方框,内容正是宿主那句「当前请求的额度已用尽」加一句括号,而且不走。维护者说「这个本来没有的啊」—— **判断是对的,那确实是本插件画的。**
60
+ - **它是怎么来的(根因)**:上一版为了「接着宿主那句话补一句」,注册进了 `shell.quota-notice` 这条链。链的契约写得很清楚:**认领该 code 的条目是「接管整个界面」(take over the surface),不是给宿主界面加装饰** —— 全拒时宿主才渲染它自带的 `Toast`。于是:宿主的 `Toast`(`position:fixed; top:40px; left:50%`,会自动淡出)**根本没被渲染**,顶替它的却是个 `display:inline` 的 `span` —— 没有定位、没有居中、没有背景、不会淡出。它落在 `shell.overlay`(`AppFrame` 里一个 `absolute; inset:0` 的层)的**左上角**,一直留着。**「本来没有」是真的:那个坐标上原本是宿主的 Toast,被换掉之后就成了一个没人见过的盒子。**
61
+ - **改了什么**:`select` 由「认领 `QUOTA`」改为**一律返回 `null`**(新增导出 `selectQuotaNoticeClaim`,规则只有一处、只有一个测试)。全拒让链回到宿主自己的 `Toast`,**插件不再往 `shell.overlay` 里画任何东西**。组件本身只留那句括号:不再重绘宿主那句(上一版重绘它,才在对话里造出**第二份**同一句话),`-head` 类与 `quotaNotice.fallbackHead` 的渲染一并去掉 —— 宿主自己那句仍然一字未改地由宿主画。
62
+ - **为什么不改成「只加样式、不接管」**:做不到。链只有「接管」与「不接管」两种结果,没有「叠加」;想让那条括号跟着宿主的 Toast 走,就必须接管,接管就必然失去 Toast 的定位与淡出。所以选择归还座位,括号去**对话里**补 —— 那本来就是用户失败时真正在看的地方。
63
+ - **文案与样式**:`quotaNotice.supplement` 不变(`(部分模型另有每日额度,与积分无关)`);`.dsm-workbuddy-quota-notice` 仍是 `display:inline` 且**不许画盒子**;`.dsm-workbuddy-quota-notice-head` 删除(它存在的唯一理由是给被接管的那句话上色)。
64
+ - **守卫**:`tests/issue63-quota-notice.spec.tsx` 重写为 6 例,其中**两例是形态/决策守卫**:`selectQuotaNoticeClaim` 对任何 owner 都必须返回 `null`(**这条是本次修复的核心**),根节点必须是 `span`、**一个 `button` 都没有**、**恰好一个子节点**(第二个子节点就是宿主那句话又溜回来了)、且**不存在** `.dsm-workbuddy-quota-notice-head`。**变异验证**(逐个改回、各自变红):① 让 `select` 重新认领 `QUOTA` → 红;② 把宿主句子重新渲染进组件 → 红(连红 3 例);③ 加回 `-head` 类 → 红。**注意**:上一版的文案守卫**全部**在出问题的版本上照样绿 —— 文案对、形态错,这就是那个方框能发出去的原因。
65
+ - **产物**:`docs/assets/quota-notice-inline-mock.html` + 同名 PNG 重做,新增「v2 占用 overlay 后的真实落点」一栏,用 `absolute; inset:0` 的容器**复刻**出维护者截图里那个左上角落点,并并排给出「归还座位后宿主 Toast 回来」的样子。
66
+
67
+ ### 已核对(不是本次改动,是维护者的第二个问题)
68
+
69
+ - **「额度已用尽时自动换池子里的账号继续」这条链路是通的 —— 逐环节复核并补了**端到端**守卫**(`tests/quota-auto-switch-chain.spec.ts`,6 例)
70
+ - **这条链的五环各有自己的测试**(分类 `upstream.spec.ts`、结果 `outcomeOfFailure`、冷却 `cooldownOf`/`quotaRefreshOf`、排名 `rankPool`、失败转移 `shim-failover.spec.ts`),但**没有一条测试把它们连起来跑**:换掉分类器,失败转移的用例照样绿(它自己造 `kind`);换掉 `exclusionOf`,分类用例照样绿(它不问池子拿这个结论做什么)。新文件把**上游真实报文**一路喂到「下一个请求会发给谁」,覆盖 `6004`(带 `将在 … UTC+8 重置` 的 429)与 `14018`(积分耗尽)两种拼写,并反向钉住 `14003` 是**模型级**冷却(`upstream-tips`,2 分钟)而非账号判决。**结论:通。** 两个前提同时成立才会不换号 —— **账号池开关打开**、且**池子里有第二个可用成员**;只有这一个账号时 `rankPool` 仍返回该行(卡片要能解释它为什么出局),但它不是候选,`failoverAccountFor` 因此返回 `undefined`,用户看到的就是带括号的那句失败提示。这一点也由新用例钉住。
71
+ - **顺带记一条容易误读的事实**:`14003` 的账号在排名里**确实**被记为 `model-busy` 排到候选之后(排在 `fresh` 之后,不是我之前以为的「仍排第一」)。真正阻止它换号的是另一道闸 —— `isFailoverWorthy` 按 `kind` 拒绝。两处是同一决定的两道防线,别只看排名就以为它会换号。
72
+
3
73
  ## 4.5.2 (2026-10-14)
4
74
 
5
75
  > 本节含三件事,**一次发布**:先修了「每行下拉框挤坏布局 + 命名看不懂」,随后维护者判定**把这个下拉框连同逐账号设置整体撤掉**;并**新增两个一键遍历按钮**(对齐同族 trae 插件)——撤掉的是「逐账号指定测试哪一个模型」,加回的是「一次把模型测一遍」,两者不冲突。4.4.6 之后没有任何以 4.5.2 发布过的产物,所以合并为一版。
@@ -0,0 +1,96 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN"><head><meta charset="utf-8">
3
+ <title>额度提示 · 撤掉 overlay 占用,只在对话里补充</title>
4
+ <style>
5
+ :root{
6
+ --bg:#141519; --layer:#1b1c21; --line:#2c2d31;
7
+ --label-primary:#e6e6e6; --label-secondary:#c6c9d0; --label-tertiary:#8b8f98;
8
+ --error:#e5484d; --warn:#f5a524;
9
+ --font:-apple-system,"PingFang SC","Microsoft YaHei",system-ui,sans-serif;
10
+ --mono:ui-monospace,SFMono-Regular,Menlo,monospace;
11
+ }
12
+ *{box-sizing:border-box}
13
+ body{margin:0;padding:28px 32px 48px;background:var(--bg);color:var(--label-primary);font-family:var(--font);font-size:14px;line-height:22px}
14
+ .wrap{max-width:880px;margin:0 auto}
15
+ h1{font-size:15px;font-weight:600;margin:0 0 4px}
16
+ .sub{color:var(--label-tertiary);font-size:12px;line-height:18px;margin:0 0 22px}
17
+ .block{border:1px solid var(--line);border-radius:10px;background:var(--layer);padding:16px 18px 18px;margin:0 0 16px}
18
+ .block > .cap{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--label-tertiary);margin:0 0 12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
19
+ .cap .tag{font-family:var(--mono);font-size:10px;letter-spacing:0;text-transform:none;padding:1px 6px;border:1px solid var(--line);border-radius:20px;color:var(--label-secondary)}
20
+ .old{border-color:#5a2327;background:#1d1416}
21
+ .old .cap{color:#e78387}
22
+ .strike{text-decoration:line-through;opacity:.9}
23
+
24
+ /* 宿主当前渲染:本轮运行失败 行(复刻,未改动) */
25
+ .turnErrorRow{display:grid;grid-template-columns:10px minmax(0,1fr) auto;align-items:start;gap:8px;padding:2px 0;font-size:13px;line-height:20px}
26
+ .turnErrorDot{width:6px;height:6px;border-radius:50%;background:var(--error);margin-top:7px;justify-self:center}
27
+ .turnErrorCopy{min-width:0;overflow-wrap:anywhere}
28
+ .turnErrorTitle{color:var(--error);font-weight:600;margin-right:6px}
29
+ .turnErrorMessage{color:var(--label-secondary)}
30
+ .turnErrorCode{color:var(--label-tertiary);font-family:var(--mono);font-size:11px}
31
+
32
+ /* ---- 本插件现在真实渲染的样式(与 src/client/styles.ts 一致) ---- */
33
+ .dsm-workbuddy-quota-notice{display:inline}
34
+ .dsm-workbuddy-quota-notice-body{color:var(--dsw-alias-label-tertiary,var(--label-tertiary));font-size:12px}
35
+
36
+ /* 被否决的旧对话框(仅作对照) */
37
+ .modal{max-width:420px;padding:10px 12px;border-radius:8px;background:#23242a;border:1px solid var(--line);font-size:12px;line-height:17px}
38
+ .modal .head{font-weight:600;color:var(--label-primary)}
39
+ .modal .body{color:var(--label-secondary);margin-top:4px}
40
+ .modal .btn{margin-top:8px;padding:2px 10px;border:1px solid var(--line);background:transparent;color:var(--label-secondary);border-radius:6px;font:inherit;font-size:12px}
41
+
42
+ /* 宿主的通用 Toast(本轮改动后它回来了)。定位取自 Toast.module.css。 */
43
+ .hostToast{position:relative;width:max-content;max-width:min(640px,100%);display:flex;align-items:center;gap:10px;padding:12px 16px;border-radius:12px;background:#2b3038;color:#e6e6e6;font-size:14px;line-height:22px;box-shadow:0 8px 28px rgba(0,0,0,.45)}
44
+ .hostToast .ic{display:grid;place-items:center;flex:none;color:var(--warn)}
45
+
46
+ /* overlay 层的复刻(AppFrame:absolute + inset:0),用来展示被占用时的落点 */
47
+ .overlaySim{position:relative;height:96px;border:1px dashed var(--line);border-radius:8px;background:#17181c;overflow:hidden}
48
+ .overlaySim .seatLabel{position:absolute;right:8px;bottom:6px;font-family:var(--mono);font-size:10px;color:var(--label-tertiary)}
49
+ </style>
50
+ </head><body><div class="wrap">
51
+
52
+ <h1>额度提示 · 撤掉 overlay 占用,只在对话里补充</h1>
53
+ <p class="sub">宿主那句「当前请求的额度已用尽」由宿主自己画,插件不再抢它的座位。插件只保留那句括号。<b>注意标题里的「本轮运行失败」和它后面那句,都是宿主画的,不是本插件的输出。</b></p>
54
+
55
+ <div class="block old">
56
+ <p class="cap"><span class="strike">已撤除</span><span class="tag">v1 · shell.quota-notice 卡片</span></p>
57
+ <div class="modal">
58
+ <div class="head">当前请求的额度已用尽</div>
59
+ <div class="body">部分模型另有每日额度限制,与积分余额无关 —— 也可能确实是本账号积分耗尽。账号池那一行会写明上游给出的恢复时刻;也可以先换个账号或换个模型继续。</div>
60
+ <button class="btn">知道了</button>
61
+ </div>
62
+ </div>
63
+
64
+ <div class="block old">
65
+ <p class="cap"><span class="strike">已撤除</span><span class="tag">v2 · 占用 overlay 后的真实落点</span></p>
66
+ <div class="overlaySim">
67
+ <span class="dsm-workbuddy-quota-notice" role="status"><span class="dsm-workbuddy-quota-notice-head">当前请求的额度已用尽</span><span class="dsm-workbuddy-quota-notice-body">(部分模型另有每日额度,与积分无关)</span></span>
68
+ <span class="seatLabel">shell.overlay(absolute, inset:0)</span>
69
+ </div>
70
+ <p class="sub" style="margin:10px 0 0">这就是维护者截图里的左上角方框:占用链座位后,宿主的 Toast 不再渲染,而这个 <code>span</code> 既没有 <code>position:fixed</code> 也没有居中、没有背景、不会淡出,于是停在 overlay 的左上角不走。</p>
71
+ </div>
72
+
73
+ <div class="block">
74
+ <p class="cap">现在 · 宿主的 Toast 回来了<span class="tag">链座位已归还</span></p>
75
+ <div style="display:flex;justify-content:center">
76
+ <div class="hostToast"><span class="ic">⚠</span><span>当前请求的额度已用尽</span></div>
77
+ </div>
78
+ </div>
79
+
80
+ <div class="block">
81
+ <p class="cap">现在 · 对话里的失败行<span class="tag">宿主画行,插件只补括号</span></p>
82
+ <div class="turnErrorRow">
83
+ <span class="turnErrorDot"></span>
84
+ <span class="turnErrorCopy">
85
+ <span class="turnErrorTitle">本轮运行失败</span><span class="turnErrorMessage">当前请求的额度已用尽<span class="dsm-workbuddy-quota-notice" role="status"><span class="dsm-workbuddy-quota-notice-body">(部分模型另有每日额度,与积分无关)</span></span></span>
86
+ </span>
87
+ <code class="turnErrorCode">QUOTA</code>
88
+ </div>
89
+ </div>
90
+
91
+ <div class="block">
92
+ <p class="cap">英文<span class="tag">en locale</span></p>
93
+ <p style="margin:0">Request quota exhausted.<span class="dsm-workbuddy-quota-notice" role="status"><span class="dsm-workbuddy-quota-notice-body"> (some models also have a daily limit that is unrelated to your credits)</span></span></p>
94
+ </div>
95
+
96
+ </div></body></html>
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-DcUM0cVn.js";
2
+ import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-BI7_lfoi.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
package/lib/client.js CHANGED
@@ -981,6 +981,17 @@ window.__ModuleLoader__.load({
981
981
  5e4,
982
982
  1e5
983
983
  ];
984
+ /**
985
+ * The `<timestamp> UTC±N` core of the upstream's reset sentence.
986
+ *
987
+ * Shared by both spellings below so the two can never drift into accepting
988
+ * different timestamps: the offset handling and the calendar round-trip check in
989
+ * {@link parseUpstreamResetAt} are what make a parsed value trustworthy, and two
990
+ * copies of this shape would be two chances to loosen one of them.
991
+ */
992
+ const RESET_STAMP = String.raw`(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})\s*UTC\s*([+-])(\d{1,2})(?::?(\d{2}))?`;
993
+ new RegExp(`将在?\\s*${RESET_STAMP}[\\s\\S]*?重置`, "u");
994
+ new RegExp(`(?:reset|resets|refresh|refreshes)\\s+(?:at|on)\\s+${RESET_STAMP}`, "iu");
984
995
  //#endregion
985
996
  //#region src/client/remaining.ts
986
997
  const MINUTE_MS = 6e4;
@@ -1244,6 +1255,43 @@ window.__ModuleLoader__.load({
1244
1255
  const wanted = /* @__PURE__ */ new Set([...effectiveMembers, ...draftMembers]);
1245
1256
  return accounts.filter((account) => wanted.has(account.accountId) && account.excludedBy === void 0).map((account) => account.accountId);
1246
1257
  }
1258
+ /**
1259
+ * Whether EVERY member is blocked by the same MODEL — issue #64's pool-level
1260
+ * verdict.
1261
+ *
1262
+ * The reporter's case: four members, all measured `model-busy`, and the card
1263
+ * only showed four rows each saying "模型繁忙". Summed up by the reader that
1264
+ * reads as "my pool is broken", and the natural next move — switch accounts —
1265
+ * is the one action that cannot help, because the limit follows the model. The
1266
+ * per-row labels are each true and the aggregate conclusion is false, which is
1267
+ * why this needs its own decision rather than another row label.
1268
+ *
1269
+ * The conditions, and where each is enforced — NOT just "some member is
1270
+ * model-busy":
1271
+ *
1272
+ * - **at least one member.** Enforced by the length check: an empty pool has
1273
+ * no model-wide story to tell, and the plain "no usable account" sentence is
1274
+ * the honest one there (nothing is checked).
1275
+ * - **no member usable, and every blocked member's reason IS `model-busy`.**
1276
+ * Both fall out of the single `every` predicate, because a usable member has
1277
+ * `excludedBy === undefined`. See the note at the return statement: they are
1278
+ * genuinely one condition in code, and writing them as two produced an
1279
+ * unreachable branch that no test could hold.
1280
+ *
1281
+ * A pool where one account is rate-limited and the rest are model-busy is NOT a
1282
+ * model verdict: the rate limit really is about that account, and saying "it is
1283
+ * the model, not your accounts" would be the same class of false comfort this
1284
+ * helper exists to remove.
1285
+ *
1286
+ * Returned as a boolean rather than a prebuilt sentence so the caller keeps
1287
+ * every user-visible string in the locale table.
1288
+ */
1289
+ function allMembersBlockedByModel(accounts, effectiveMembers, draftMembers) {
1290
+ const wanted = /* @__PURE__ */ new Set([...effectiveMembers, ...draftMembers]);
1291
+ const members = accounts.filter((account) => wanted.has(account.accountId));
1292
+ if (members.length === 0) return false;
1293
+ return members.every((member) => member.excludedBy === "model-busy");
1294
+ }
1247
1295
  //#endregion
1248
1296
  //#region src/client/AccountPool.tsx
1249
1297
  /**
@@ -1848,7 +1896,10 @@ window.__ModuleLoader__.load({
1848
1896
  className: "dsm-btn dsm-btn-outline",
1849
1897
  disabled: rescanning === true,
1850
1898
  onClick: onRescan
1851
- }, rescanning === true ? t("row.accountsScanning") : t("row.accountsRescan")))), !poolRunning ? (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolEnabledHint")) : null, poolRunning && usable.length === 0 ? (0, react.createElement)("p", {
1899
+ }, rescanning === true ? t("row.accountsScanning") : t("row.accountsRescan")))), !poolRunning ? (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolEnabledHint")) : null, poolRunning && usable.length === 0 ? allMembersBlockedByModel(pool?.accounts ?? [], effectiveMembers, active.memberAccountIds) ? (0, react.createElement)("p", {
1900
+ className: "dsm-workbuddy-pool-warn",
1901
+ role: "status"
1902
+ }, `${t("row.poolModelWideBusy")} ${t("row.poolModelWideBusyHint")}`) : (0, react.createElement)("p", {
1852
1903
  className: "dsm-workbuddy-pool-warn",
1853
1904
  role: "status"
1854
1905
  }, `${t("row.poolNoCandidate")} ${t("row.poolNoCandidateHint")}`) : null, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-members" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-members-head" }, (0, react.createElement)("div", null, (0, react.createElement)("h4", { className: "dsm-workbuddy-pool-members-title" }, t("row.poolMembersTitle")), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolMembersHint"))), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-members-actions" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-hint" }, t("row.poolSelectedCount", {
@@ -3023,13 +3074,16 @@ window.__ModuleLoader__.load({
3023
3074
  /* Its OWN label class, deliberately not the switch's: a query for "the
3024
3075
  sidebar-credit switch" must not be able to pick this control up, and the two
3025
3076
  are different widgets (a checkbox and a two-way picker). */
3026
- /* The supplement to the host's fixed quota sentence (issue #63). It sits in the
3027
- frame-wide overlay seat, so it must read on its own and stay narrow: a column
3028
- with the host's sentence first (verbatim) and the supplement under it. */
3029
- .dsm-workbuddy-quota-notice{display:flex;flex-direction:column;gap:4px;max-width:420px;padding:10px 12px;border-radius:8px;background:var(--dsw-alias-bg-layer-2,#23242a);border:1px solid var(--dsw-alias-border-l1,#2c2d31);font-size:12px;line-height:17px}
3030
- .dsm-workbuddy-quota-notice-head{font-weight:600;color:var(--dsw-alias-label-primary,#e6e6e6)}
3031
- .dsm-workbuddy-quota-notice-body{color:var(--dsw-alias-label-secondary,#c6c9d0)}
3032
- .dsm-workbuddy-quota-notice-dismiss{align-self:flex-start;margin-top:2px;padding:2px 10px;font-size:12px}
3077
+ /* The supplement to the host's fixed quota sentence (issue #63). The plugin no
3078
+ longer claims the host's shell.quota-notice chain seat, so this no longer
3079
+ paints into shell.overlay — that seat is the reason the previous build's
3080
+ inline span parked at the overlay's TOP-LEFT as a box the maintainer had
3081
+ never seen before. It is kept inline and box-free for the surface that
3082
+ renders it INSIDE a line of conversation: the moment it paints a box it stops
3083
+ being a parenthetical and becomes the dialog that was already rejected once.
3084
+ The body is one step quieter than the text it follows. */
3085
+ .dsm-workbuddy-quota-notice{display:inline}
3086
+ .dsm-workbuddy-quota-notice-body{color:var(--dsw-alias-label-tertiary,#8b8f98);font-size:12px}
3033
3087
  .dsm-workbuddy-credit-position-switch{display:inline-flex;align-items:center;gap:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
3034
3088
  .dsm-workbuddy-credit-position{max-width:150px;padding:2px 6px;font-size:12px}
3035
3089
  .dsm-workbuddy-credits-switch-row .dsm-workbuddy-credit-position-switch{margin-left:12px}
@@ -3075,6 +3129,12 @@ window.__ModuleLoader__.load({
3075
3129
  scrolling to the end of a long model list. */
3076
3130
  .dsm-workbuddy-models-dirty{margin-left:8px;padding:1px 6px;border-radius:6px;background:var(--dsw-alias-state-warn-primary,#f59e0b);color:#1b1d22;font-size:11px;font-weight:600;line-height:16px;vertical-align:1px;white-space:nowrap}
3077
3131
  .dsm-workbuddy-models-summary{margin:2px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:18px}
3132
+ /* The free-model quota warning (see the tabFreeQuotaWarn locale key). Bold on
3133
+ purpose, and it keeps the summary's own grey rather than the primary label
3134
+ colour: it has to stand out from the three lines around it WITHOUT reading as
3135
+ an error banner, because the fact it states is normal product behaviour
3136
+ rather than a failure. Weight is the only signal it needs. */
3137
+ .dsm-workbuddy-free-quota-warn{font-weight:600}
3078
3138
  .dsm-workbuddy-model-list{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2,#36373b);border-radius:10px;overflow:hidden}
3079
3139
  .dsm-workbuddy-model{display:grid;grid-template-columns:minmax(0,1fr);gap:7px;padding:10px 12px;background:var(--dsw-alias-bg-layer-2,#232529);transition:opacity .16s}
3080
3140
  .dsm-workbuddy-model-disabled{opacity:.55}
@@ -4696,6 +4756,10 @@ window.__ModuleLoader__.load({
4696
4756
  className: "dsm-workbuddy-models-summary",
4697
4757
  children: t("row.tabHint")
4698
4758
  }),
4759
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4760
+ className: "dsm-workbuddy-models-summary dsm-workbuddy-free-quota-warn",
4761
+ children: t("row.tabFreeQuotaWarn")
4762
+ }),
4699
4763
  !activeRegionOn ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4700
4764
  className: "dsm-workbuddy-tab-off-notice",
4701
4765
  children: t("row.tabOffNotice")
@@ -5215,12 +5279,34 @@ window.__ModuleLoader__.load({
5215
5279
  }
5216
5280
  }
5217
5281
  /**
5218
- * Automatic refresh period. Five minutes, matching a credit balance's actual
5219
- * rate of change: it moves on the scale of model calls, not seconds, and each
5220
- * read is a read-only upstream call.
5282
+ * Automatic refresh period for the READOUT. Five minutes, matching a credit
5283
+ * balance's actual rate of change: it moves on the scale of model calls, not
5284
+ * seconds, and each read is a read-only upstream call.
5221
5285
  */
5222
5286
  const COMPOSER_CREDITS_REFRESH_INTERVAL_MS = 3e5;
5223
5287
  /**
5288
+ * Refresh period for the panel's per-account table, while it stays open.
5289
+ *
5290
+ * Deliberately NOT the readout's five minutes. The table carries two things,
5291
+ * and the faster-moving one decides the cadence:
5292
+ *
5293
+ * - the balance, which barely moves, and
5294
+ * - each account's `excludedBy` ("被限流" / "模型繁忙"), which is derived from
5295
+ * the pool's own cooldowns — and the SHORTEST of those is the model-scoped
5296
+ * two-minute throttle (`MODEL_BUSY_COOLDOWN_MS`).
5297
+ *
5298
+ * At five minutes the table could state an account is 模型繁忙 long after the
5299
+ * upstream let it serve again, and the pool card — which polls every 60 s
5300
+ * (`POLL_INTERVAL_MS` in `WorkBuddyCard.tsx`) — would say it is fine at the same
5301
+ * moment. Two surfaces disagreeing about one account is exactly the complaint
5302
+ * this constant answers, so the table now converges on the card's cadence
5303
+ * instead of its own.
5304
+ *
5305
+ * The cost is bounded and paid only while the panel is open: one `fetchCredits`
5306
+ * per account per minute, the same read the card already makes when it is open.
5307
+ */
5308
+ const COMPOSER_ACCOUNT_REFRESH_INTERVAL_MS = 6e4;
5309
+ /**
5224
5310
  * Provider routes whose credit balance this readout can show, mapped to the
5225
5311
  * region to read it from.
5226
5312
  *
@@ -5269,6 +5355,10 @@ window.__ModuleLoader__.load({
5269
5355
  const [lastRefresh, setLastRefresh] = (0, react.useState)(void 0);
5270
5356
  const [open, setOpen] = (0, react.useState)(false);
5271
5357
  const inFlight = (0, react.useRef)(false);
5358
+ /** Account-table reads in flight; see `fetchAccountCredits`. */
5359
+ const accountsInFlight = (0, react.useRef)(0);
5360
+ /** Which account-table read is the newest; see `fetchAccountCredits`. */
5361
+ const accountsReadGeneration = (0, react.useRef)(0);
5272
5362
  const mounted = (0, react.useRef)(true);
5273
5363
  const rootRef = (0, react.useRef)(null);
5274
5364
  const panelRef = (0, react.useRef)(null);
@@ -5317,9 +5407,27 @@ window.__ModuleLoader__.load({
5317
5407
  * A failure keeps whatever we already have rather than clearing it: the table
5318
5408
  * falls back to the usage document's account list, so a failed figure must not
5319
5409
  * remove the switch.
5410
+ *
5411
+ * `force` distinguishes the TWO callers, and the distinction is the point:
5412
+ *
5413
+ * - the panel's timer passes nothing, so a tick that arrives while a read is
5414
+ * still in flight is SKIPPED. The panel is asking "has anything changed?",
5415
+ * and a read already on its way answers that.
5416
+ * - a deliberate action (the refresh button, or `switchTo` re-reading after a
5417
+ * write) passes `force: true`, because it must actually happen. Skipping it
5418
+ * would be the same defect this file is fixing: a control that reports "the
5419
+ * table is current" without having asked. It also claims the newest
5420
+ * generation, so its answer is the one that lands.
5421
+ *
5422
+ * Latest-wins rather than a queue: responses can resolve out of order, and a
5423
+ * slow tick started earlier must not overwrite the press that superseded it —
5424
+ * which would silently re-draw a mark the press had just cleared.
5320
5425
  */
5321
- const fetchAccountCredits = (0, react.useCallback)(async (signal) => {
5426
+ const fetchAccountCredits = (0, react.useCallback)(async (signal, force) => {
5322
5427
  if (activeRegion === void 0) return;
5428
+ if (!force && accountsInFlight.current > 0) return;
5429
+ const generation = ++accountsReadGeneration.current;
5430
+ accountsInFlight.current += 1;
5323
5431
  try {
5324
5432
  const response = await fetch(withWorkBuddyRegion(WORKBUDDY_ACCOUNT_CREDITS_PATH, activeRegion), {
5325
5433
  headers: { accept: "application/json" },
@@ -5329,8 +5437,14 @@ window.__ModuleLoader__.load({
5329
5437
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
5330
5438
  const data = await response.json();
5331
5439
  if (!mounted.current || signal?.aborted === true) return;
5332
- if (Array.isArray(data.accounts)) setAccountCredits(data.accounts);
5333
- } catch {}
5440
+ if (generation !== accountsReadGeneration.current) return;
5441
+ if (Array.isArray(data.accounts)) {
5442
+ setAccountCredits(data.accounts);
5443
+ setLastRefresh(Date.now());
5444
+ }
5445
+ } catch {} finally {
5446
+ accountsInFlight.current -= 1;
5447
+ }
5334
5448
  }, [activeRegion]);
5335
5449
  const hostHidden = hostCreditsShownOf(usage) === false;
5336
5450
  (0, react.useEffect)(() => {
@@ -5354,11 +5468,37 @@ window.__ModuleLoader__.load({
5354
5468
  hostHidden,
5355
5469
  fetchUsage
5356
5470
  ]);
5471
+ /**
5472
+ * Per-account figures are read on OPEN, then kept fresh while the panel stays
5473
+ * open.
5474
+ *
5475
+ * The on-open read is what makes the table useful immediately. The TIMER is
5476
+ * the fix for the complaint that started this: with an open-only read, a panel
5477
+ * left open showed a frozen snapshot of every account's `excludedBy`, while
5478
+ * the pool card behind it re-ranked every 60 s. The two then disagreed about
5479
+ * the same account — the panel drawing no mark where the card said 模型繁忙 —
5480
+ * and neither surface was wrong on its own data, which is what made it so hard
5481
+ * to read.
5482
+ *
5483
+ * The timer starts only AFTER the first read settles, so a slow upstream
5484
+ * cannot stack overlapping reads (the same shape the readout's own effect
5485
+ * uses). Aborted on close: the panel pays one read per minute only while it is
5486
+ * actually on screen.
5487
+ */
5357
5488
  (0, react.useEffect)(() => {
5358
5489
  if (!open || activeRegion === void 0) return void 0;
5490
+ let cancelled = false;
5491
+ let timer;
5359
5492
  const controller = new AbortController();
5360
- fetchAccountCredits(controller.signal);
5493
+ fetchAccountCredits(controller.signal).finally(() => {
5494
+ if (cancelled) return;
5495
+ timer = window.setInterval(() => {
5496
+ fetchAccountCredits(controller.signal);
5497
+ }, COMPOSER_ACCOUNT_REFRESH_INTERVAL_MS);
5498
+ });
5361
5499
  return () => {
5500
+ cancelled = true;
5501
+ if (timer !== void 0) window.clearInterval(timer);
5362
5502
  controller.abort();
5363
5503
  };
5364
5504
  }, [
@@ -5394,13 +5534,34 @@ window.__ModuleLoader__.load({
5394
5534
  try {
5395
5535
  await writeAccountSlot(settingsScope, activeRegion, accountId);
5396
5536
  await fetchUsage();
5397
- await fetchAccountCredits();
5537
+ await fetchAccountCredits(void 0, true);
5398
5538
  } catch {
5399
5539
  if (mounted.current) setFailed(true);
5400
5540
  } finally {
5401
5541
  if (mounted.current) setSwitchingId(void 0);
5402
5542
  }
5403
5543
  };
5544
+ /**
5545
+ * One manual refresh, of both things the panel shows.
5546
+ *
5547
+ * It used to call `fetchUsage` alone, so pressing 刷新 on the account table
5548
+ * refreshed the trigger's balance and left every ROW exactly as it was —
5549
+ * including its 被限流 / 模型繁忙 mark. The button sits inside the table's own
5550
+ * header, so the user reads it as "refresh this table"; a control that
5551
+ * refreshes something else is worse than no control, and it is one of the
5552
+ * reasons the panel could keep claiming a state the card had already moved
5553
+ * past.
5554
+ *
5555
+ * Both reads run concurrently rather than in sequence: neither depends on the
5556
+ * other, and awaiting the usage document first would delay the table by one
5557
+ * round trip for no benefit. `refreshUsage` resolves rather than throws on
5558
+ * failure (it records `failed` itself), so the table's read is never skipped
5559
+ * because the other half went wrong.
5560
+ */
5561
+ const refresh = (0, react.useCallback)(async () => {
5562
+ setFailed(false);
5563
+ await Promise.all([fetchUsage(), fetchAccountCredits(void 0, true)]);
5564
+ }, [fetchUsage, fetchAccountCredits]);
5404
5565
  if (owned === void 0) return null;
5405
5566
  /**
5406
5567
  * The Host's own answer for this region, when the document carries one.
@@ -5481,7 +5642,7 @@ window.__ModuleLoader__.load({
5481
5642
  className: "dsm-workbuddy-composer-panel-refresh",
5482
5643
  disabled: busy || switchingId !== void 0,
5483
5644
  onClick: () => {
5484
- fetchUsage();
5645
+ refresh();
5485
5646
  },
5486
5647
  children: busy ? t("composer.refreshing") : t("composer.refresh")
5487
5648
  })]
@@ -5667,7 +5828,14 @@ window.__ModuleLoader__.load({
5667
5828
  //#endregion
5668
5829
  //#region src/client/WorkBuddyQuotaNotice.tsx
5669
5830
  /**
5670
- * The supplement this plugin adds to the host's quota notice (issue #63).
5831
+ * The supplement this plugin adds to a quota failure (issue #63).
5832
+ *
5833
+ * 参考:`dsh-connect-trae`(MIT)— 「以插件身份向宿主的通知链注册一个条目」的
5834
+ * 席位用法与自建 DOM(不依赖 ui-primitives)的渲染约定,见 docs/DESIGN.md §5.3。
5835
+ * 改动:本模块是原创实现,但其中两处决定**与本文件早期版本相反**,且都是被真机
5836
+ * 截图推翻的:(1) 曾认领 `shell.quota-notice` 链并在其中重绘宿主句子,后判定该
5837
+ * 链的语义是「接管整个界面」而非加装饰,遂改为**一律拒收**;(2) 曾是带按钮的
5838
+ * 卡片,后改为只读的行内括号。两处的原因各自写在下文对应位置,删改前请先读。
5671
5839
  *
5672
5840
  * WHY THIS EXISTS. When an upstream failure is classified `QUOTA`, the host
5673
5841
  * renders ONE fixed sentence — 「当前请求的额度已用尽」 — and (measured on the
@@ -5688,19 +5856,29 @@ window.__ModuleLoader__.load({
5688
5856
  * resets hours later. Staying out of the retry set is the behaviour we keep
5689
5857
  * (the maintainer's call on issue #63); the sentence is what we supplement.
5690
5858
  *
5691
- * HOW. The host ships a CHAIN seat for exactly this: `shell.quota-notice`, a
5692
- * frame-wide overlay whose own comment says "a `shell.quota-notice` entry may
5693
- * replace the generic Toast for codes it claims". The chain hands a claiming
5694
- * entry the host's own owner object — `{ code, message, dismiss, keepOpen }` —
5695
- * so this component can keep the host's sentence VERBATIM and append the one
5696
- * fact the sentence is missing. Claiming only `QUOTA` leaves every other code on
5697
- * the host's fallback Toast.
5698
- *
5699
- * WHY THE HOST'S SENTENCE IS KEPT, not rewritten: it is the string the shell
5700
- * shows everywhere else for this code, and a plugin that reworded it would
5701
- * contradict the shell's own vocabulary. The supplement is additive, and it is
5702
- * written to be TRUE FOR BOTH CASES the code covers — a model's own daily quota
5703
- * (not credits) and a genuinely empty balance — because the plugin cannot always
5859
+ * HOW (SUPERSEDED — see the DECLINE note below). The host ships a CHAIN seat
5860
+ * for exactly this: `shell.quota-notice`, a frame-wide surface whose own
5861
+ * comment says "a `shell.quota-notice` entry may replace the generic Toast for
5862
+ * codes it claims". An earlier build of this plugin claimed `QUOTA` there and
5863
+ * re-rendered the sentence plus the supplement.
5864
+ *
5865
+ * THE DECLINE (the maintainer's call, on a screenshot). Claiming a chain entry
5866
+ * does not decorate the host's surface, it TAKES IT OVER: the host's generic
5867
+ * warning Toast is then not rendered at all, and what replaced it was an inline
5868
+ * `<span>` — no fixed positioning, no centring, no background, no fade-out. It
5869
+ * landed at the top-left of `shell.overlay` and stayed. That is the box the
5870
+ * maintainer reported as "not being there before", and it was correct: the
5871
+ * plugin had painted it. The registration now declines every owner, so the
5872
+ * host's own Toast is what shows — a surface whose look this plugin does not
5873
+ * get to decide — and the supplement lives in the conversation instead, beside
5874
+ * the host's failure row, which is where the user is actually looking when a
5875
+ * turn fails.
5876
+ *
5877
+ * WHY THE HOST'S SENTENCE IS LEFT ALONE: it is the string the shell shows
5878
+ * everywhere else for this code, and a plugin that reworded it would contradict
5879
+ * the shell's own vocabulary. The supplement is additive, and it is written to
5880
+ * be TRUE FOR BOTH CASES the code covers — a model's own daily quota (not
5881
+ * credits) and a genuinely empty balance — because the plugin cannot always
5704
5882
  * tell which one it is: the upstream states it in Chinese prose the plugin is
5705
5883
  * not allowed to parse for policy.
5706
5884
  *
@@ -5711,29 +5889,35 @@ window.__ModuleLoader__.load({
5711
5889
  * notice is built from the same primitives every other component here uses.
5712
5890
  */
5713
5891
  /**
5714
- * Which notice codes this plugin claims.
5892
+ * The chain selector: which owner (if any) this plugin takes over.
5715
5893
  *
5716
- * `QUOTA` only — it is what the shipped classifier produces for BOTH failures
5717
- * this plugin raises (`6004` account rate limit and `14018` exhausted credits),
5718
- * and claiming anything else would take over a notice this plugin knows nothing
5719
- * about. Exported so the claim is unit-testable without a host.
5894
+ * ALWAYS `null`, and that is the fix — see the DECLINE note in the file header.
5895
+ * Exported so the decline is unit-testable without a host: the spec asserts
5896
+ * this returns null for every owner, which is what keeps the host's own Toast
5897
+ * on screen. A function rather than an inline lambda in the registration so the
5898
+ * rule has one home and one test, instead of being restated at the call site.
5720
5899
  */
5721
- const WORKBUDDY_QUOTA_NOTICE_CODES = ["QUOTA"];
5722
- /** Whether this plugin should take over the notice for one owner object. */
5723
- function claimsQuotaNotice(code) {
5724
- return typeof code === "string" && WORKBUDDY_QUOTA_NOTICE_CODES.includes(code);
5900
+ function selectQuotaNoticeClaim(_owner) {
5901
+ return null;
5725
5902
  }
5726
- /** The frame-wide supplement shown under the host's sentence. */
5903
+ /**
5904
+ * The supplement this plugin adds to a quota failure.
5905
+ *
5906
+ * It is what the `shell.quota-notice` chain WOULD render if the registration
5907
+ * claimed an owner — which it no longer does. Kept as the rendering seat for
5908
+ * the supplement so the copy, the locale keys and their guards survive the
5909
+ * decline: the next surface that wants this sentence (a failure row, a
5910
+ * transcript notice) mounts this component rather than re-inventing the markup.
5911
+ *
5912
+ * ONE line, no body, no button, no host sentence of its own — the takeover that
5913
+ * used to prepend the host's sentence is exactly what was removed.
5914
+ */
5727
5915
  function WorkBuddyQuotaNotice(props) {
5728
- const { matched, t } = props;
5729
- return (0, react.createElement)("div", {
5916
+ const { t } = props;
5917
+ return (0, react.createElement)("span", {
5730
5918
  className: "dsm-workbuddy-quota-notice",
5731
5919
  role: "status"
5732
- }, (0, react.createElement)("span", { className: "dsm-workbuddy-quota-notice-head" }, matched.message ?? t("quotaNotice.fallbackHead")), (0, react.createElement)("span", { className: "dsm-workbuddy-quota-notice-body" }, t("quotaNotice.supplement")), matched.dismiss === void 0 ? null : (0, react.createElement)("button", {
5733
- type: "button",
5734
- className: "dsm-btn dsm-btn-outline dsm-workbuddy-quota-notice-dismiss",
5735
- onClick: () => matched.dismiss?.()
5736
- }, t("quotaNotice.dismiss")));
5920
+ }, (0, react.createElement)("span", { className: "dsm-workbuddy-quota-notice-body" }, t("quotaNotice.supplement")));
5737
5921
  }
5738
5922
  //#endregion
5739
5923
  //#region src/client/locales.ts
@@ -5756,6 +5940,7 @@ window.__ModuleLoader__.load({
5756
5940
  "row.tabCn": "Domestic",
5757
5941
  "row.tabGlobal": "Global",
5758
5942
  "row.tabHint": "Each tab is a separate provider (workbuddy / workbuddy-global) with its own account, credits, and models. Both sides are live at once: different sessions can pick from either side, and changes on one tab never touch the other.",
5943
+ "row.tabFreeQuotaWarn": "Free models have a per-window token quota — they are not unlimited.",
5759
5944
  "row.tabSwitchHint": "Uncheck to switch this provider off. Its models disappear from the DSH model picker; your account, credits, and model settings are kept and return when you check it again.",
5760
5945
  "row.tabSwitchAria": "Offer the {region} provider to DSH",
5761
5946
  "row.tabOffNotice": "This provider is switched off: none of its models are offered to DSH. Your account, credits, and model settings are kept — check the box above the tab to bring it back.",
@@ -5889,6 +6074,8 @@ window.__ModuleLoader__.load({
5889
6074
  "row.poolTitle": "Account pool",
5890
6075
  "row.poolNoCandidate": "No account in this pool can be used right now.",
5891
6076
  "row.poolNoCandidateHint": "Members are rate-limited, out of credits, rejected, or unreachable. Rate limits, drained quotas and transient failures return to the pool on their own — at the time the upstream states, or after a 30-minute fallback. Only a rejected credential needs a new sign-in.",
6077
+ "row.poolModelWideBusy": "Every member is blocked on the same model — this is the model, not your accounts.",
6078
+ "row.poolModelWideBusyHint": "Switching accounts cannot help: the limit follows the model, so a different account hits the same wall. Wait about a minute, or switch to a less busy model in this session.",
5892
6079
  "row.poolCheckinAll": "Check in all accounts",
5893
6080
  "row.poolCheckingIn": "Checking in…",
5894
6081
  "row.poolTestAll": "Test all accounts",
@@ -6005,8 +6192,7 @@ window.__ModuleLoader__.load({
6005
6192
  "row.creditPositionStats": "below the input box",
6006
6193
  "row.creditPositionAria": "Credit readout position for {region}",
6007
6194
  "quotaNotice.fallbackHead": "The quota for this request is exhausted",
6008
- "quotaNotice.supplement": "Some models have their own daily limit that is unrelated to your credit balance — check the account pool row for the stated reset time, or switch account or model to continue.",
6009
- "quotaNotice.dismiss": "Got it",
6195
+ "quotaNotice.supplement": " (some models also have a daily limit that is unrelated to your credits)",
6010
6196
  "row.creditPositionHint": "Where the credit readout sits. \"composer toolbar\" is the default (the row with the model picker). Choose \"below the input box\" — the row carrying the round / step / token statistics — if another plugin occupies the composer toolbar and the readout does not appear.",
6011
6197
  "row.showCreditsGlobalHint": "Show the international (WB AI) credit balance in the composer toolbar, but only while this session uses a WB AI model. Click it for per-account balances and a manual refresh (refreshes by itself every 5 minutes).",
6012
6198
  "composer.pointsCn": "WB CN",
@@ -6039,6 +6225,7 @@ window.__ModuleLoader__.load({
6039
6225
  "row.tabCn": "国内版",
6040
6226
  "row.tabGlobal": "国际版",
6041
6227
  "row.tabHint": "每个 tab 是一个独立供应商(workbuddy / workbuddy-global),各有自己的账号、积分与模型。两边同时生效:不同会话可各选一边,一侧的改动不影响另一侧。",
6228
+ "row.tabFreeQuotaWarn": "免费模型有时间段内定额 Token 上限,并非无限制使用。",
6042
6229
  "row.tabSwitchHint": "取消勾选即关闭该供应商:它的模型不再出现在 DSH 模型选择器里;账号、积分与模型设置都会保留,重新勾选即恢复。",
6043
6230
  "row.tabSwitchAria": "是否向 DSH 提供{region}供应商",
6044
6231
  "row.tabOffNotice": "该供应商已关闭:它的模型不会提供给 DSH。账号、积分与模型设置均已保留——勾选上方复选框即可恢复。",
@@ -6172,6 +6359,8 @@ window.__ModuleLoader__.load({
6172
6359
  "row.poolTitle": "账号池",
6173
6360
  "row.poolNoCandidate": "账号池里目前没有可用的账号。",
6174
6361
  "row.poolNoCandidateHint": "成员要么在限流冷却中、要么积分耗尽、要么凭据被拒、要么上游无响应。限流、积分耗尽与瞬时故障都会自动回池——按上游给出的恢复时间,没给时间就按 30 分钟兜底,不需要你操作;只有「凭据被拒」需要重新登录。",
6362
+ "row.poolModelWideBusy": "所有成员都卡在同一个模型上 —— 这是模型的问题,不是你的账号。",
6363
+ "row.poolModelWideBusyHint": "换账号解决不了:限制跟随模型,换一个账号撞的是同一堵墙。等大约 1 分钟,或把这个会话切到负载较低的模型。",
6175
6364
  "row.poolCheckinAll": "一键签到所有账号",
6176
6365
  "row.poolCheckingIn": "签到中…",
6177
6366
  "row.poolTestAll": "一键测试所有账号",
@@ -6288,8 +6477,7 @@ window.__ModuleLoader__.load({
6288
6477
  "row.creditPositionStats": "输入框下方",
6289
6478
  "row.creditPositionAria": "{region} 的积分显示位置",
6290
6479
  "quotaNotice.fallbackHead": "当前请求的额度已用尽",
6291
- "quotaNotice.supplement": "部分模型另有每日额度限制,与积分余额无关 —— 也可能确实是本账号积分耗尽。账号池那一行会写明上游给出的恢复时刻;也可以先换个账号或换个模型继续。",
6292
- "quotaNotice.dismiss": "知道了",
6480
+ "quotaNotice.supplement": "(部分模型另有每日额度,与积分无关)",
6293
6481
  "row.creditPositionHint": "积分显示在哪里。「输入框工具栏」是默认位置(与模型选择同一行)。如果装了别的插件后读数不显示,改成「输入框下方」—— 那是「轮次 / 步数 / token」统计所在的那一行。",
6294
6482
  "row.showCreditsGlobalHint": "在对话输入框工具栏显示国际版(WB AI)剩余积分,仅当本会话使用 WB AI 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",
6295
6483
  "composer.pointsCn": "WB CN",
@@ -6469,32 +6657,42 @@ window.__ModuleLoader__.load({
6469
6657
  }
6470
6658
  };
6471
6659
  /**
6472
- * The frame-wide quota notice, claimed so the supplement can be shown
6473
- * (issue #63).
6660
+ * The frame-wide quota notice, DECLINED so the host keeps its own Toast
6661
+ * (issue #63 follow-up, the maintainer's call on a screenshot).
6662
+ *
6663
+ * WHAT WAS WRONG. The previous build claimed `QUOTA` and rendered the
6664
+ * supplement as an inline `<span>` in this seat. The chain's own contract
6665
+ * says a claiming entry "takes over the surface" — so claiming REPLACED the
6666
+ * host's generic warning Toast, and the replacement was not a Toast: an
6667
+ * inline span has no `position: fixed`, no centring, no background and no
6668
+ * fade, so it parked at the TOP-LEFT of `shell.overlay` (an `inset: 0`
6669
+ * layer) and stayed there until the next notice. The maintainer read it, on
6670
+ * screen, as a box that had not been there before — and it had not.
6474
6671
  *
6475
- * The host renders ONE fixed sentence for a `QUOTA` failure — 「当前请求的
6476
- * 额度已用尽」 — and discards the provider's message while doing it. For
6477
- * `6004` that sentence is wrong (the upstream's own words say 「频率限制」,
6478
- * with a reset time, and the reporting account still held thousands of
6479
- * credits), but re-classifying is NOT the fix: measured against the shipped
6480
- * classifier, every spelling that stops reading as `QUOTA` lands in
6481
- * `RATE_LIMIT`, which is IN the host's retry set — 5 in-place retries and
6482
- * ~15.5s of backoff on a limit that resets hours later. So the behaviour
6483
- * (no retry) stays and the sentence gets a SUPPLEMENT.
6672
+ * WHY DECLINING IS THE FIX, not a worse one. The host's own sentence is
6673
+ * already correct and already appears in the turn's failure row inside the
6674
+ * conversation (`message.failure.quota`, via `failureMessage` in
6675
+ * `ui-chat`'s MessageItem) — the place the user is actually reading when a
6676
+ * turn fails. Taking over the frame-wide surface bought this plugin nothing
6677
+ * except a second, uglier copy of a sentence the host already draws where it
6678
+ * matters. Declining the claim returns the seat to the host: the Toast is
6679
+ * the host's own design, and this plugin stops imposing a look on a surface
6680
+ * it does not own.
6484
6681
  *
6485
- * `select` claims `QUOTA` only, and the chain is the host's own designed
6486
- * extension point for this ("a shell.quota-notice entry may replace the
6487
- * generic Toast for codes it claims") — every other code keeps the host's
6488
- * fallback Toast untouched.
6682
+ * The supplement itself is NOT lost — it rides in the conversation, which is
6683
+ * the only surface that is actually in the user's eyeline (see
6684
+ * `registerStatsCredits` and the failure row). The `select` below is
6685
+ * therefore an explicit, documented DECLINE of every code rather than a
6686
+ * deletion of the registration: the seat stays claimed in one place with its
6687
+ * rationale attached, so a future reader does not re-add the takeover
6688
+ * without re-reading why it was removed.
6489
6689
  */
6490
6690
  const registerQuotaNotice = () => {
6491
6691
  try {
6492
6692
  return ctx.slots.inject("shell.quota-notice", () => ctx.slots.register({
6493
6693
  name: "shell.quota-notice",
6494
6694
  locale: namespace,
6495
- select: (owner) => {
6496
- return claimsQuotaNotice(typeof owner === "object" && owner !== null ? owner.code : void 0) ? owner : null;
6497
- },
6695
+ select: selectQuotaNoticeClaim,
6498
6696
  inject: () => ({ t })
6499
6697
  }, WorkBuddyQuotaNotice));
6500
6698
  } catch (error) {
@@ -3825,7 +3825,7 @@ function generationOf(version) {
3825
3825
  //#endregion
3826
3826
  //#region src/version.ts
3827
3827
  /** The npm package version this build was produced from. */
3828
- const WORKBUDDY_CONNECT_VERSION = "4.5.2";
3828
+ const WORKBUDDY_CONNECT_VERSION = "4.6.1";
3829
3829
  //#endregion
3830
3830
  //#region src/host-heartbeat.ts
3831
3831
  /**
package/lib/index.d.ts CHANGED
@@ -1074,6 +1074,20 @@ interface WorkBuddyShim {
1074
1074
  * retry may happen before anything has been written to the client.
1075
1075
  */
1076
1076
  type WorkBuddyFailoverAccount = (triedAccountIds: readonly string[]) => Promise<WorkBuddyCredential | undefined>;
1077
+ /**
1078
+ * Why the pool had no candidate, asked only after failover came up empty.
1079
+ *
1080
+ * A separate callback rather than a richer return type on
1081
+ * {@link WorkBuddyFailoverAccount}: the loop calls that one on the HOT PATH of
1082
+ * every retry, and a policy that had to explain its refusal on every call would
1083
+ * make the common case pay for the rare one. This runs at most once per failed
1084
+ * request, and only when the answer is already going to be an error.
1085
+ *
1086
+ * Returns a short Chinese clause naming the reason (e.g. "池中其余账号都在冷却"),
1087
+ * or undefined when the host has nothing to add — in which case the reply says
1088
+ * only that no other account was available.
1089
+ */
1090
+ type WorkBuddyFailoverShortfall = () => Promise<string | undefined>;
1077
1091
  /** Constructor dependencies. */
1078
1092
  interface WorkBuddyShimOptions {
1079
1093
  store: WorkBuddyCredentialStore;
@@ -1086,6 +1100,19 @@ interface WorkBuddyShimOptions {
1086
1100
  * reported as-is.
1087
1101
  */
1088
1102
  failoverAccount?: WorkBuddyFailoverAccount;
1103
+ /**
1104
+ * Why the pool had no usable candidate, asked after failover found none.
1105
+ *
1106
+ * The failure path uses this to answer the question the user is actually left
1107
+ * with — "why did nothing else work?" — instead of only reporting that every
1108
+ * attempt failed. Without it the pool's own reasoning is invisible at exactly
1109
+ * the moment it matters, and "换个模型" reads as a guess rather than as the
1110
+ * conclusion of a search the plugin already performed.
1111
+ *
1112
+ * Optional, and its absence is a supported state: a host that does not supply
1113
+ * it produces the same reply as before this option existed.
1114
+ */
1115
+ failoverShortfall?: WorkBuddyFailoverShortfall;
1089
1116
  /**
1090
1117
  * Called before the FIRST attempt of every chat request, so the plugin can
1091
1118
  * point the store at the account the pool's ranking says should serve.
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-DcUM0cVn.js";
1
+ import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-BI7_lfoi.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, readFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -1719,20 +1719,25 @@ function describeAccount(credential) {
1719
1719
  * One short phrase for why an account was abandoned, for the in-reply notice.
1720
1720
  *
1721
1721
  * Kept to the distinction the user can act on rather than the internal kind
1722
- * name: "rate limited" says wait or add an account, "rejected" says the sign-in
1722
+ * name: "账号被限流" says wait or add an account, "登录已失效" says the sign-in
1723
1723
  * is the problem. The numeric kind would only send them to the log.
1724
+ *
1725
+ * The wording is what `failoverNoticeText` interpolates into a Chinese sentence,
1726
+ * so these are Chinese phrases rather than English ones — the reader of a failed
1727
+ * turn in this product reads Chinese, and an English clause inside Chinese prose
1728
+ * was the previous build's most visible rough edge.
1724
1729
  */
1725
1730
  function failoverReasonText(failure) {
1726
- if (failure.ok) return "unusable";
1731
+ if (failure.ok) return "不可用";
1727
1732
  switch (failure.kind) {
1728
- case "soft_rate": return "rate limited";
1729
- case "hard_credit": return "out of credits";
1730
- case "model_busy": return "throttled on this model (not the account)";
1731
- case "session_dead": return "signed out";
1732
- case "policy_reject": return "rejected by policy";
1733
- case "not_found": return "model unavailable";
1734
- case "server": return "erroring upstream";
1735
- default: return `unusable (http ${failure.status})`;
1733
+ case "soft_rate": return "被限流";
1734
+ case "hard_credit": return "积分已耗尽";
1735
+ case "model_busy": return "该模型当前繁忙(不是账号问题)";
1736
+ case "session_dead": return "登录已失效";
1737
+ case "policy_reject": return "被服务端策略拒绝";
1738
+ case "not_found": return "模型不可用";
1739
+ case "server": return "上游异常";
1740
+ default: return `不可用(http ${failure.status})`;
1736
1741
  }
1737
1742
  }
1738
1743
  /**
@@ -1747,9 +1752,13 @@ function failoverReasonText(failure) {
1747
1752
  * over is the one the user is about to keep talking to, and naming it invites
1748
1753
  * the reading that they must now manage it. What they need is to know the one
1749
1754
  * they chose did not answer, and why.
1755
+ *
1756
+ * Chinese, matching the rest of a failed turn's copy: the host renders its own
1757
+ * quota sentence in Chinese, the pool rows are Chinese, and an English clause
1758
+ * here was the one line of the reply a Chinese reader had to decode.
1750
1759
  */
1751
1760
  function failoverNoticeText(from, reason) {
1752
- return `\n\n> [dsh-connect-workbuddy] 「${from}」was ${reason} — this answer came from another account\n\n`;
1761
+ return `\n\n> [dsh-connect-workbuddy] 「${from}」${reason},已改用池中另一个账号回答\n\n`;
1753
1762
  }
1754
1763
  /** One frame carrying replacement text for a frame whose content was consumed. */
1755
1764
  function contentFrame(chunk, choice, content) {
@@ -1774,6 +1783,7 @@ function createWorkBuddyShim(options) {
1774
1783
  const { store, client, catalog } = options;
1775
1784
  const logger = options.logger;
1776
1785
  const failoverAccount = options.failoverAccount;
1786
+ const failoverShortfall = options.failoverShortfall;
1777
1787
  const prepareAccount = options.prepareAccount;
1778
1788
  const onAccountFailure = options.onAccountFailure;
1779
1789
  const onRequestBody = options.onRequestBody;
@@ -1971,7 +1981,9 @@ function createWorkBuddyShim(options) {
1971
1981
  reportFailures();
1972
1982
  if (!result.ok) {
1973
1983
  const attempts = triedAccountIds.length;
1974
- const note = attempts > 1 ? ` (after trying ${attempts} accounts)` : "";
1984
+ const note = attempts > 1 ? `(已尝试 ${attempts} 个账号)` : "";
1985
+ const shortfall = failoverShortfall === void 0 ? void 0 : await failoverShortfall().catch(() => void 0);
1986
+ const why = shortfall === void 0 ? "" : `:${shortfall}`;
1975
1987
  if (result.kind === "policy_reject") {
1976
1988
  const detail = result.detail;
1977
1989
  const lead = detail?.displayMsg ?? result.message.slice(0, 200);
@@ -1992,7 +2004,7 @@ function createWorkBuddyShim(options) {
1992
2004
  writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, message, hostErrorCode(result.kind));
1993
2005
  return;
1994
2006
  }
1995
- writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy upstream ${result.kind} (http ${result.status})${note}: ${result.message.slice(0, 400)}`, hostErrorCode(result.kind));
2007
+ writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy 上游失败:${failoverReasonText(result)}(http ${result.status})${note}${why}。${result.message.slice(0, 400)}` + (failoverShortfall === void 0 ? "" : "。池中没有其他可用账号,建议换一个模型继续,或稍后等待该账号的额度恢复"), hostErrorCode(result.kind));
1996
2008
  return;
1997
2009
  }
1998
2010
  res.writeHead(200, {
@@ -2448,25 +2460,54 @@ function parseRetryAfter(value, nowMs) {
2448
2460
  return parsed;
2449
2461
  }
2450
2462
  /**
2451
- * The upstream's reset sentence, as its Chinese gateway writes it.
2463
+ * The `<timestamp> UTC±N` core of the upstream's reset sentence.
2464
+ *
2465
+ * Shared by both spellings below so the two can never drift into accepting
2466
+ * different timestamps: the offset handling and the calendar round-trip check in
2467
+ * {@link parseUpstreamResetAt} are what make a parsed value trustworthy, and two
2468
+ * copies of this shape would be two chances to loosen one of them.
2469
+ */
2470
+ const RESET_STAMP = String.raw`(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})\s*UTC\s*([+-])(\d{1,2})(?::?(\d{2}))?`;
2471
+ /**
2472
+ * The upstream's reset sentence, in BOTH spellings this service uses.
2452
2473
  *
2453
- * Measured on a live 429 (code 6004), verbatim:
2474
+ * Chinese gateway, measured on a live 429 (code 6004), verbatim:
2454
2475
  *
2455
2476
  * `您的使用量已超出频率限制,将在 2026-09-30 02:30:30 UTC+8 重置,您也可以切换其他模型继续使用。`
2456
2477
  *
2457
- * The two halves are captured separately because the second one is the whole
2458
- * reason this parser exists: the upstream DOES name a time, and the plugin was
2459
- * showing "the upstream gave no time" while the answer was sitting right there
2460
- * in the body. The earlier note in this module ("the upstream provides no
2461
- * rate-limit metadata") was drawn from the RESPONSE HEADERS alone — true as far
2462
- * as it went, and wrong as a conclusion, because the time is in the body text.
2478
+ * International gateway, measured on the same code over the same account pool,
2479
+ * verbatim:
2463
2480
  *
2464
- * Deliberately loose about the surrounding wording (`[\s\S]*?` on both sides)
2465
- * and strict about the parts that must not be guessed: the keyword 重置, the
2466
- * timestamp shape, and an explicit UTC offset. A message that merely mentions
2467
- * 重置 without a parsable offset yields no time, which is the honest answer.
2481
+ * `usage exceeds frequency limit, but don't worry, your usage will reset at
2482
+ * 2026-10-16 11:34:04 UTC+8, alternatively, you can switch to the other
2483
+ * models to continue using it.`
2484
+ *
2485
+ * WHY THERE ARE TWO. Each gateway states the time in its own language, and the
2486
+ * marker sits on a DIFFERENT SIDE of the timestamp: 重置 follows it in Chinese,
2487
+ * while `reset at` precedes it in English — so one pattern cannot cover both by
2488
+ * loosening the surrounding prose alone. The original pattern demanded the
2489
+ * Chinese keyword, so an English body parsed to nothing and the card said
2490
+ * 「上游未给出何时恢复」 about a message that had just named the exact minute.
2491
+ *
2492
+ * That is the honest-absence contract broken in the OTHER direction — not a
2493
+ * made-up time, but a real time discarded — and it is the worse of the two
2494
+ * failures, because the account stays excluded on the 30-minute fallback instead
2495
+ * of the ~18 hours upstream asked for. The pool then returns the account to
2496
+ * candidacy early and spends a request rediscovering the same 6004.
2497
+ *
2498
+ * Both patterns stay deliberately loose about the surrounding prose and strict
2499
+ * about the parts that must not be guessed: a keyword, the timestamp shape, and
2500
+ * an explicit UTC offset. A message that merely mentions a reset without a
2501
+ * parsable offset still yields no time, which remains the honest answer.
2468
2502
  */
2469
- const UPSTREAM_RESET_PATTERN = /将在?\s*(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})\s*UTC\s*([+-])(\d{1,2})(?::?(\d{2}))?[\s\S]*?重置/u;
2503
+ const UPSTREAM_RESET_PATTERN_ZH = new RegExp(`将在?\\s*${RESET_STAMP}[\\s\\S]*?重置`, "u");
2504
+ /**
2505
+ * The English spelling, anchored on `reset at` / `refresh on` IMMEDIATELY before
2506
+ * the timestamp — the same strictness as the Chinese pattern, which requires its
2507
+ * marker immediately after it. A message that mentions a reset somewhere else
2508
+ * entirely therefore still yields no time.
2509
+ */
2510
+ const UPSTREAM_RESET_PATTERN_EN = new RegExp(`(?:reset|resets|refresh|refreshes)\\s+(?:at|on)\\s+${RESET_STAMP}`, "iu");
2470
2511
  /**
2471
2512
  * Parse the reset time out of an upstream failure message.
2472
2513
  *
@@ -2484,7 +2525,7 @@ const UPSTREAM_RESET_PATTERN = /将在?\s*(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d
2484
2525
  * would be fiction in the other direction.
2485
2526
  */
2486
2527
  function parseUpstreamResetAt(message) {
2487
- const match = UPSTREAM_RESET_PATTERN.exec(message);
2528
+ const match = UPSTREAM_RESET_PATTERN_ZH.exec(message) ?? UPSTREAM_RESET_PATTERN_EN.exec(message);
2488
2529
  if (match === null) return void 0;
2489
2530
  const [, year, month, day, hour, minute, second, sign, offsetHours, offsetMinutes] = match;
2490
2531
  const offsetTotalMinutes = (sign === "-" ? -1 : 1) * (Number(offsetHours) * 60 + Number(offsetMinutes ?? "0"));
@@ -3567,7 +3608,7 @@ const POOL_STORE_PREFIX = ".workbuddy-pool";
3567
3608
  function isProbe(value) {
3568
3609
  if (typeof value !== "object" || value === null) return false;
3569
3610
  const candidate = value;
3570
- return typeof candidate["outcome"] === "string" && typeof candidate["atMs"] === "number" && (candidate["retryAtMs"] === void 0 || typeof candidate["retryAtMs"] === "number") && (candidate["message"] === void 0 || typeof candidate["message"] === "string") && (candidate["accountName"] === void 0 || typeof candidate["accountName"] === "string") && (candidate["source"] === void 0 || typeof candidate["source"] === "string");
3611
+ return typeof candidate["outcome"] === "string" && typeof candidate["atMs"] === "number" && (candidate["retryAtMs"] === void 0 || typeof candidate["retryAtMs"] === "number") && (candidate["message"] === void 0 || typeof candidate["message"] === "string") && (candidate["accountName"] === void 0 || typeof candidate["accountName"] === "string") && (candidate["modelId"] === void 0 || typeof candidate["modelId"] === "string") && (candidate["source"] === void 0 || typeof candidate["source"] === "string");
3571
3612
  }
3572
3613
  /**
3573
3614
  * One region's path for measured pool facts.
@@ -3579,6 +3620,49 @@ function workbuddyPoolStorePath(region) {
3579
3620
  return join(resolveDshHome(), `${POOL_STORE_PREFIX}.${region}.json`);
3580
3621
  }
3581
3622
  /**
3623
+ * The recovery time a stored record implies — declared, or recoverable from its
3624
+ * own body.
3625
+ *
3626
+ * The field itself wins when present: it is what the writer computed with the
3627
+ * full input set (`Retry-After` header, quota-refresh point, body), and
3628
+ * re-deriving it could only lose information.
3629
+ *
3630
+ * The fallback exists because a record can state a time in its `message` and
3631
+ * still have no `retryAtMs`. That is not a hypothetical — it is what the English
3632
+ * `6004` body produced, since the parser that ran at write time matched only the
3633
+ * Chinese 「… 重置」 sentence:
3634
+ *
3635
+ * `{"code":6004,"msg":"usage exceeds frequency limit, but don't worry, your
3636
+ * usage will reset at 2026-10-16 11:34:04 UTC+8, …"}`
3637
+ *
3638
+ * leaving a record that names the exact minute it will serve again while
3639
+ * `retryDue` falls back to {@link POOL_UNKNOWN_COOLDOWN_MS}. The row then told the
3640
+ * user 「上游未给出何时恢复」 about a body that had just said when, and the pool
3641
+ * put the account back ~18 hours early to rediscover the same limit.
3642
+ *
3643
+ * Read-time healing is the only place this can be repaired for EXISTING records:
3644
+ * every consumer receives probes through {@link readPoolProbes} —
3645
+ * `accountExclusions` (the composer panel), `poolMembersOf` /
3646
+ * `localPoolMembers` (the card AND failover routing), `memberNames` — so healing
3647
+ * here fixes all of them at once. Re-deriving at each consumer instead would be
3648
+ * three copies of one rule, and the next consumer added would silently miss it.
3649
+ *
3650
+ * Not a permanent second derivation: `writePoolProbes` reads through this
3651
+ * function before it writes, so the very next write persists the healed value.
3652
+ *
3653
+ * Scoped to the two outcomes whose vocabulary HAS a stated time, matching
3654
+ * {@link WorkBuddyPoolProbe.retryAtMs}. Widening it would let an incidental
3655
+ * sentence in an `ok` body attach a countdown to a healthy account —
3656
+ * `AccountPool.tsx` prints any present `retryAtMs` beside the outcome, so that
3657
+ * would render a waiting time on an account that is not waiting for anything.
3658
+ */
3659
+ function retryAtMsOf(probe) {
3660
+ if (probe.retryAtMs !== void 0) return probe.retryAtMs;
3661
+ if (probe.outcome !== "rate-limited" && probe.outcome !== "out-of-credit") return void 0;
3662
+ if (probe.message === void 0) return void 0;
3663
+ return parseUpstreamResetAt(probe.message);
3664
+ }
3665
+ /**
3582
3666
  * Read one region's measured facts.
3583
3667
  *
3584
3668
  * Contains NO credentials. The one free-text field is `message`, and it is
@@ -3611,13 +3695,15 @@ async function readPoolProbes(region) {
3611
3695
  const probes = {};
3612
3696
  for (const [accountId, probe] of Object.entries(document.probes)) {
3613
3697
  if (accountId === "" || !isProbe(probe)) continue;
3698
+ const retryAtMs = retryAtMsOf(probe);
3614
3699
  probes[accountId] = {
3615
3700
  outcome: probe.outcome,
3616
3701
  atMs: probe.atMs,
3617
3702
  ...knownProbeSource(probe.source) === void 0 ? {} : { source: knownProbeSource(probe.source) },
3618
- ...probe.retryAtMs === void 0 ? {} : { retryAtMs: probe.retryAtMs },
3703
+ ...retryAtMs === void 0 ? {} : { retryAtMs },
3619
3704
  ...probe.message === void 0 ? {} : { message: probe.message },
3620
- ...probe.accountName === void 0 ? {} : { accountName: probe.accountName }
3705
+ ...probe.accountName === void 0 ? {} : { accountName: probe.accountName },
3706
+ ...probe.modelId === void 0 ? {} : { modelId: probe.modelId }
3621
3707
  };
3622
3708
  }
3623
3709
  return probes;
@@ -5206,6 +5292,7 @@ function apply(ctx, config) {
5206
5292
  },
5207
5293
  prepareAccount: () => applyPoolSelection(region),
5208
5294
  failoverAccount: (triedAccountIds) => failoverAccountFor(region, triedAccountIds),
5295
+ failoverShortfall: () => explainPoolShortfall(region),
5209
5296
  onAccountFailure: (accountId, failure) => {
5210
5297
  recordAccountFailure(region, accountId, failure);
5211
5298
  }
@@ -5719,6 +5806,57 @@ function apply(ctx, config) {
5719
5806
  }
5720
5807
  };
5721
5808
  /**
5809
+ * Why the pool left a failed request with nowhere to go, in one short clause.
5810
+ *
5811
+ * Runs ONLY after a request has already failed, so it may afford work the hot
5812
+ * path cannot — but it still reads local state only (the probe store and the
5813
+ * member list), because spending a network call to explain a failure would add
5814
+ * a second delay to a request the user is already waiting on.
5815
+ *
5816
+ * It counts rather than lists. The reply is one line inside an error message,
5817
+ * and enumerating accounts there would bury the advice; the card is where a
5818
+ * per-account breakdown belongs, and it already shows one.
5819
+ *
5820
+ * Returns `undefined` when the pool is OFF or has no members — in which case
5821
+ * there is no pool story to tell, and the shim renders its own sentence
5822
+ * without one. That is a real state, not a failure: the plugin supports
5823
+ * running with no pool at all.
5824
+ */
5825
+ const explainPoolShortfall = async (region) => {
5826
+ if (!poolPreferencesOf(current(), region).enabled) return void 0;
5827
+ const members = await localPoolMembers(region);
5828
+ if (members.length === 0) return void 0;
5829
+ const nowMs = Date.now();
5830
+ const excluded = members.map((member) => ({
5831
+ id: member.account.id,
5832
+ excludedBy: exclusionOf(member.probe, nowMs)
5833
+ })).filter((entry) => entry.excludedBy !== void 0);
5834
+ if (excluded.length === 0) return "池中其余账号本次请求都已尝试过";
5835
+ const counts = /* @__PURE__ */ new Map();
5836
+ for (const entry of excluded) counts.set(entry.excludedBy, (counts.get(entry.excludedBy) ?? 0) + 1);
5837
+ return `池中已无可用账号(${[...counts.entries()].map(([reason, count]) => `${String(count)} 个${poolExclusionClause(reason)}`).join("、")})`;
5838
+ };
5839
+ /**
5840
+ * One exclusion reason as a short Chinese clause for
5841
+ * {@link explainPoolShortfall}.
5842
+ *
5843
+ * Separate from the card's own labels (`exclusion-text.ts` in the client half)
5844
+ * on purpose: those are standalone table cells ("被限流"), where these are
5845
+ * glued into a sentence and must read after a count ("1 个账号被限流"). Sharing
5846
+ * one string would force one of the two to read wrong.
5847
+ */
5848
+ function poolExclusionClause(reason) {
5849
+ switch (reason) {
5850
+ case "rate-limited": return "账号被限流";
5851
+ case "out-of-credit": return "账号积分耗尽";
5852
+ case "model-busy": return "账号撞上模型繁忙";
5853
+ case "credential-rejected": return "账号登录已失效";
5854
+ case "not-found": return "账号已不存在";
5855
+ case "unusable": return "账号不可用";
5856
+ default: return "账号不可用";
5857
+ }
5858
+ }
5859
+ /**
5722
5860
  * The last CREDITS reading per region, so a request can rank by balance
5723
5861
  * without fetching one.
5724
5862
  *
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
5
- "version": "4.5.2",
5
+ "version": "4.6.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",