dsh-connect-workbuddy 4.9.3 → 4.10.0

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,89 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.10.0 (2026-10-19)
4
+
5
+ > 版本号是 **minor**:4.9.5 / 4.9.6 两个 patch 从未单独发布到 npm(npm 上仍是 4.9.4),本次一次性带上它们,并新增福利活动的完整链路。**发往上游的请求体、路由 id、模型 id、凭据、计费与换号规则一律未变。**
6
+
7
+ ### Features
8
+
9
+ - **issue #79:福利活动自动发现 + 单账号领取(4.9.4 → 4.10.0 的主线)**
10
+ - **自动发现**:卡片逐账号标出上游活动(模型服务异常补偿 / 新人首月赠送)里**还没领的那一份**。数据来自上游两个只读状态接口 `/billing/meter/compensation-status` 与 `/billing/meter/check-gift-claimed`(注意**没有 `/v2` 前缀**,探 `/v2/...` 会 404)。
11
+ - **判定规则完全由上游状态决定**:`active && !claimed` 才显示;`claimed` 整行消失;活动过期(上游把 `active` 翻成 `false`)自动消失。**不拿本地时钟去比 `end_time`**,本机时间偏了也不会与服务端打架;`end_time` 只用于 3 天内临期高亮。
12
+ - `active` 单独看**不行**:已领取的账号 `active` 仍为 `true`,只看它会让领过的活动永远挂着待领取。
13
+ - 读取复用签到管线:每账号一次、30 分钟缓存、账号池关闭时不发请求;任一接口失败按「未读」处理,**不会把一次失败缓存成「全都没福利」**。
14
+ - **单账号「领取」按钮**:每个待领取项后一个实心琥珀色胶囊按钮,**一次只领一个账号的一个活动**。没有「全部领取」——一次点击花掉的是一份不可撤销的真实权益。
15
+ - 新路由 `POST /plugins/dsh-connect-workbuddy/benefit/claim?accountId=&kind=`,沿用写路由的 POST + loopback 守卫,并**强制要求 `accountId`**,请求本身无法批量领取。
16
+ - 领取前**重新读取**状态再判定;已领取/已过期一律 409 且不调用上游。领取后用上游**新读数**作答并刷新卡片,不做乐观更新。
17
+ - **领取成功后清掉该账号的福利缓存**:这是本次修掉的最关键 bug。缓存 TTL 30 分钟且此前从不因领取失效,于是「积分涨了、按钮还在」,用户会以为领取失败而重复点击,第二次被 409 拒绝。
18
+ - **每行按钮只联动自己那一行**:此前行的 busy 标志传的是「有任一行动作」,点一行会让五行同时变成「领取中…」,看起来像一次领了整池。
19
+ - 实测:真实账号 `19dea97d` 领取后 `claimed` 由 false 变 true,500 积分到账;同一批次 6 个账号 × 500 积分待领取,截止 10/22。
20
+
21
+ ### Docs
22
+
23
+ - **描述与 README 同步到插件实际能力**:`package.json` 的 `description`、市场提交草稿的 `description.zh` / `description.en`、两个 README 的标题下 tagline 与开头段,统一改写为「国内版 + 国际版接入 / 每次刷新按上游目录自动读取每个模型的积分倍率 / 逐模型图片输入开关 / 多账号切换与账号池自动换号 / 每日签到与只读积分概览」。旧描述只写了「模型接入 + 只读积分概览 + 模型管理」,把 3.x~4.9 长出来的能力全落在了外面。
24
+ - **更正一处不成立的表述**:描述草稿里曾有「费率随错峰时段变化」的说法。全仓(`src/`、`tests/`、`docs/`、两个 README)与 WorkBuddy 桌面端 asar 里都没有任何峰谷/时段折扣逻辑,`git log -S` 也从无此词;插件真正做的是**从上游目录解析每个模型自带的积分倍率**(`x0.79` 这种,解析不出就不显示,绝不猜),且该倍率确实会被上游改动(`deepseek-v4.1-flash` 已从 0 变成 0.11)。会随「时段」变的那个是**免费模型的 token 定额**,不是价格,因此文案按实测口径写「随上游目录刷新更新」,不承诺峰谷计价。
25
+ - **README 补上「福利活动」条目(中英双语)**:4.9.4 的待领取标记与 4.9.6 的单账号「领取」按钮此前在两个 README 里**一次都没提过**。新增条目写清:数据只来自上游两个状态接口、标记阶段只读、三条显示规则由上游状态决定(不用本地时钟比 `end_time`)、一次只领一个账号的一个活动、写路由强制 `accountId` 所以无法批量、领取前重读状态、领取后用上游新读数刷新并清掉该账号的福利缓存。
26
+ - **卡片内说明文案(`src/client/locales.ts` 的 `row.desc`,中英同步)**同步改写,与上面的描述口径一致;两侧键仍严格 1:1。
27
+ - **修 EN README 的编号重复**:自动换号那一节有两个 `7.`,第二条(每次重试写一行 `warn`)改为 `8.`。
28
+
29
+ ### Fixes
30
+
31
+ - **裁剪福利说明不再撑破积分列**:福利文案与按钮曾被挤成竖排「领取」二字并压住右侧目标模型列;胶囊改为 `white-space:nowrap` + 固定 19px 高 + `flex:none`,让列宽只能压缩文字、压不了按钮。
32
+ - **「领取」二字的视觉居中**:无头 Chrome 像素扫描测得 CJK 字形墨迹比行盒中心偏低约 1px,用 `padding-top:1px` 补偿(`transform` 与 `padding-bottom` 两个方向都试过,都会把墨迹推得更低,已弃用)。
33
+ - **CHANGELOG 结构:补回丢失的 `## 4.9.5 (2026-10-19 · 未单独发布,并入 4.10.0)` 标题**。`6bf825a`(feat: 单账号领取福利按钮)把顶部标题改写成 4.9.6 时,把 4.9.5 那一节的正文留在了下面却没有版本标题——于是 4.9.5 的 Features 看起来像是 4.9.6 的一部分,而 `git tag` 与 npm 上它确实是一个独立版本。现按 `cad9ec3`(chore(release): 4.9.5)的原样恢复标题。
34
+
35
+ ## 4.9.6 (2026-10-19 · 未单独发布,并入 4.10.0)
36
+
37
+ > 版本号是 **patch**:新增一个写路由与行内按钮,发往上游的请求体、路由 id、模型 id、凭据、计费与换号规则一律未变。
38
+
39
+ ### Features
40
+
41
+ - **issue #79:账号池卡片提供单账号「领取」按钮**
42
+ - 每个待领取的福利后面带一个按钮,**一次只领一个账号的一个活动**;没有任何「全部领取」,因为一次点击花掉的是一份不可撤销的真实权益。
43
+ - 新路由 `POST /plugins/dsh-connect-workbuddy/benefit/claim?accountId=&kind=`,沿用写路由的 POST + loopback 守卫,并**强制要求 accountId**——请求本身无法批量领取。
44
+ - 领取前**重新读取**状态再判定(窗口内 && 未领取),所以过期的卡片不会触发无谓写入;已领取/已过期一律 409 且不调用上游。
45
+ - 领取后用上游的**新读数**作答并刷新卡片,不做乐观更新:即使上游静默失败,也不会留下「已领取」的假象。
46
+ - 实测:真实账号 `19dea97d` 领取后 `claimed` 由 false 变 true,500 积分到账。
47
+
48
+ ## 4.9.5 (2026-10-19 · 未单独发布,并入 4.10.0)
49
+
50
+ > 版本号是 **patch**:只新增只读查询与界面标记,发往上游的请求体、路由 id、模型 id、凭据、计费与换号规则一律未变。
51
+
52
+ ### Features
53
+
54
+ - **issue #79:账号池卡片标出「有福利还没领」**
55
+ - 数据来自上游自己的两个活动状态接口(`/billing/meter/compensation-status`、`/billing/meter/check-gift-claimed`),**只读**;`claim-compensation` / `claim-gift` 两个写接口从不调用。
56
+ - 三条显示规则都由上游状态直接决定,前端不维护状态:
57
+ - `active && !claimed` → 显示待领取。
58
+ - `claimed` → 整行不出现(领过的不再骚扰)。
59
+ - 活动过期 → `active` 翻 `false`,整行自动消失,**不用本地时钟比 `end_time`**(时钟偏了不会与服务端打架);`end_time` 只用于 3 天内临期高亮。
60
+ - `active` 不能单独判断:已领取的账号 `active` 仍为 `true`,只看它会让领过的账号永远挂着待领取。
61
+ - 读取复用 `checkedInToday` 的管线:每账号一次、TTL 30 分钟、池关闭不发请求;任一接口失败按「未读」处理,不会把一次失败缓存成「全都没福利」。
62
+
63
+ ## 4.9.4 (2026-10-19)
64
+
65
+ > 版本号是 **patch**:只改**错误文案的取材方式**,发往上游的请求体、路由 id、模型 id、凭据、计费与换号规则一律未变(换号规则的变化只有一处「不再多问一次」的减少,见下)。但它修的是一个**会把人送去查错地方**的故障——一个普通的 400 被渲染成「账号池空了」,而上游唯一那句可执行建议被截断丢掉。
66
+
67
+ ### Fixes
68
+
69
+ - **issue #78:400/11133 被渲染成「池中其余账号本次请求都已尝试过」,且 `displayTips` 被 400 字截断丢弃**
70
+ - **原来错在哪**:三个缺陷叠在同一行错误里。
71
+ 1. **描述了一次从未发生的账号遍历**。`isFailoverWorthy()` 明确排除 `client`(同一个请求体换哪个账号都是 400),换号循环因此一次都没跑;但通用分支判断「要不要问池子要解释」用的条件是**回调是否存在**,而真实宿主两个回调都给了。于是 `explainPoolShortfall()` 被问了一次,它看到池里每个成员都健康(400 不记账号故障)、`excluded.length === 0`,命中那条分支返回「池中其余账号本次请求都已尝试过」——**池子其实没被问过任何候选**。句中再加上尾部固定的「池中没有其他可用账号」,整句读起来就是「账号用完了/额度耗尽了」,而真实原因是请求参数。
72
+ 2. **`displayTips` 从未被解析**。`parseUpstreamErrorDetail()` 只取 `code`/`requestId`/`displayMsg`;通用分支把 `result.message.slice(0, 400)` 直接拼进用户可见文本,而这类报文 637–777 字符,前 400 字符全是 `code`/`requestId`/`extError` 的骨架(`param: ""`、`Request: null`),那句「请新建任务、换个模型或稍后重试」必然落在截断之外。报告者看到的回复停在 `Pleas` 中间。
73
+ 3. **状态码印两次**。`failoverReasonText()` 的 `default` 回「不可用(http 400)」,而调用方自己又拼了一次「(http 400)」。
74
+ - **为什么测试没抓住**:既有测试全都用**不带 `displayTips`** 的报文(#56 的 14003、#51 的策略拒绝),所以「可执行建议被截断」这件事在夹具里根本不存在;而「问了没问池子」只被间接覆盖——`shortfall` 回调存在的用例恰好都是有换号的用例,于是「有没有问」和「该不该问」在测试里始终是同一个问题。状态码重复更无从断言:**没有任何测试读过用户看到的那一行**,只断言过 `detail` 字段本身。
75
+ - **现在的形状**:
76
+ - `UpstreamErrorDetail` 增加 `tips?: string`,`parseUpstreamErrorDetail()` 用同一个 `localizedText()` 读 `displayTips` 与 `displayMsg`(字符串与 `{ zh, en }` 两种形态都接受)。两个字段分开存:`displayMsg` 说「错在哪」,`displayTips` 说「该怎么办」——合并会让其中一个失去自己的意思。
77
+ - 通用分支的正文改成 `detail.tips ?? detail.displayMsg ?? clippedUpstreamText(message, 400)`,`code`/`requestId` 收进紧凑后缀「(code 11133,requestId 33221fe5-…)」(support 要的就是这两个,此前只能从被切碎的 JSON 里自己找)。`clippedUpstreamText()` 压平空白、超长时优先在句末断开并补 `…`——它**只**接住解析不出来的报文(非 JSON 网关页),有 `detail` 的报文永远走不到它。
78
+ - `failoverReasonText()` 增加 `case 'client': return '请求被上游拒绝'`,`default` 改为 `'不可用'`(不再自带状态码)。另两个分支(`policy_reject` / `model_busy`)里同样的 `slice(0, 200)` 也换成 `clippedUpstreamText()`,形状统一。
79
+ - **问池子的条件改为「真的搜过」**:`failoverShortfall !== undefined && failoverAccount !== undefined && isFailoverWorthy(result.kind)`。`client` 与 `model_busy` 两类不再触发那次排序(它们本来就不换号,问出来的答案必然是假的);`client` 另给一句正确的建议——「该请求被上游按参数拒绝:与网络、账号、额度都无关,换账号也不会改变结果」。
80
+ - **同一份报文现在渲染为**(177 字符,原来是 490):
81
+ ```
82
+ workbuddy 上游失败:请求被上游拒绝(http 400)(code 11133,requestId 33221fe5-f6d5-4ea1-8882-669411e07a5b)。这次对话的内容这个模型不接受,与网络无关。请新建任务、换个模型或稍后重试。该请求被上游按参数拒绝:与网络、账号、额度都无关,换账号也不会改变结果,请按上面的提示调整后重试
83
+ ```
84
+ - **测试**:新增 `tests/upstream-error-copy.spec.ts`(12 例)。它**不测 helper 而测渲染出来的那句话**——helper 只值它换来的用户可见结果,且 `detail` 由真实的 `parseUpstreamErrorDetail()` 产出,所以解析与渲染是一起被覆盖的(手搓 `detail` 的夹具可以在解析坏掉时仍然是绿的)。用例含:报告者那份 11133 报文逐字节渲染出上游建议、`message` 里**没有** `extError`/`"requestId"` 等骨架、长度 < 300、`(http 400)` 恰好出现**一次**(数出来的,因为「含有 http 400」对坏文案也成立)、`client` 失败时 shortfall 回调**调用次数为 0**且不出现两句池子术语、429 这类**会**换号的失败仍然既问池子又打印池子的结论并保留上游的重置时间、超长非 JSON 页补 `…` 且长度有界、短的非 JSON 页原样显示且不加 `…`、以及 `policy_reject`/`model_busy` 两个分支的文案不回归。删掉 `tips` 的解析、或把「问池子」的条件改回看回调是否存在,这组里都有用例会失败——已实测确认判别力。
85
+ - **刻意没做的**:没有改 `explainPoolShortfall()` 本身(它那句「池中其余账号本次请求都已尝试过」在**真的**搜过池子之后是诚实的,问题在于以前没搜过也会被问);没有把 `triedAccountIds` 传进 shortfall 回调(`tests/pool-e2e.spec.ts` 有源码文本守卫固定 `explainPoolShortfall(region)` 这个调用形状,且现有签名「不从 shim 接收参数」是 `src/index.ts:837-844` 写明的设计);没有改 400 的换号策略本身——它本来就是对的,错的是文案在描述一次没发生的换号。
86
+
3
87
  ## 4.9.3 (2026-10-19)
4
88
 
5
89
  > 版本号是 **patch**:新增一个**可选**的查询参数(`detail=fast`)和一条**只读**的本地取数路径;不带参数的老请求逐字节不变,发往上游的请求体、路由 id、模型 id、凭据与配置项一律未改。但**它修的是一个用户可见的等待**——打开设置页后要等半分钟到一分钟才出现账号列表。
package/README.en.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1 align="center">dsh-connect-workbuddy</h1>
6
6
 
7
- <p align="center"><b>Connect locally signed-in WorkBuddy models to DeepSeek Harness, with an account pool and model management.</b></p>
7
+ <p align="center"><b>Connect locally signed-in WorkBuddy models — CN and international — to DeepSeek Harness: read each model's credit multiplier automatically, toggle image input per model, switch between multiple accounts, and keep daily check-in with a read-only credits overview in capsule readouts on the card and in the composer.</b></p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/dsh-connect-workbuddy"><img src="https://img.shields.io/npm/v/dsh-connect-workbuddy?style=flat-square&label=npm&color=cb3837" alt="npm version"></a>
@@ -17,7 +17,7 @@
17
17
 
18
18
  [English](README.en.md) | 中文
19
19
 
20
- A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bundle plugin that connects locally signed-in WorkBuddy models to the DSH model picker — **both the CN app and the international WorkBuddy AI app are supported** — with an account pool and selectable model management. **The CN and international sides are two parallel providers (`workbuddy` / `workbuddy-global`) that can be used at the same time**; the settings card separates them with tabs for convenient management.
20
+ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bundle plugin that connects locally signed-in WorkBuddy models to the DSH model picker — **both the CN app and the international WorkBuddy AI app are supported** — with an account pool, selectable model management, and a set of read-only capabilities built around credits: **every catalog refresh reads each model's credit multiplier from upstream** (the `x0.79` figure comes entirely from the upstream catalog; the plugin neither hard-codes it nor guesses), image input is toggled per model, multiple accounts can be switched or fail over automatically through the pool, and daily check-in plus read-only credit overviews shown as compact pills on the card and in the composer. **The CN and international sides are two parallel providers (`workbuddy` / `workbuddy-global`) that can be used at the same time**; the settings card separates them with tabs for convenient management. All it needs is a WorkBuddy sign-in on this machine — or **one sign-in from the browser**.
21
21
 
22
22
  ## Features
23
23
 
@@ -33,6 +33,7 @@ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bund
33
33
 
34
34
  <img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="Composer credit panel: a per-account balance table for the domestic region, with a refresh button and a last-refreshed time" />
35
35
  - **Account pool: batch actions across several accounts, plus automatic failover** — tick the accounts you want into a pool and you get **one-click check-in for every account**, **one-click testing of every account against a model you choose** (defaulting to the region's zero-multiplier free model), and with **Enable the account pool** switched on, **automatic failover**: a request that fails upstream is retried against the pool's other usable accounts, in order, until one serves it or none is left. Membership starts **empty and is an explicit opt-in**: "signed in on this machine" is not the same as "spend this account's credits and claim its rewards". The pool's **ranking decides who serves every request** (usable first, then expiring-credits-first, higher credits, freshest credential), and on a failure the next usable member is tried. It **never writes back your saved selection** — switch the pool off and your own account applies again immediately. **The switch only decides whether the PLUGIN chooses** — with it off, the plugin stops picking and switching accounts for you, but check-in and testing stay available (they are manual actions). Testing reuses the model rows' real-volume probe, so it **does cost real credits**; **automatic check-in is deliberately not offered** — a check-in claims real rewards, so it only ever runs when you press the button. See [Account pool](#account-pool).
36
+ - **Benefits: a marker for what is unclaimed, plus a per-account Claim button** — the card marks, account by account, anything in the upstream's benefit campaigns (compensation / gifts) that **has not been claimed yet**. The data comes from the upstream's own two status endpoints; in the marking stage the plugin is **read-only** and never calls the two write endpoints. Three display rules all follow the upstream's state: `active && !claimed` shows the row, `claimed` removes it entirely, and an expired campaign (upstream flips `active` to `false`) disappears by itself — the plugin **never compares `end_time` against the local clock**, so a skewed machine cannot argue with the server; `end_time` only drives the "three days left" highlight. `active` alone is not enough to decide: an already-claimed account still reports `active: true`, and reading it alone would leave a claimed campaign hanging there forever. The read reuses the check-in pipeline (once per account, cached for 30 minutes, no request while the account pool is off), and a failing endpoint counts as "unread" rather than caching "nobody has benefits". Each row ends in a solid amber **Claim pill**, and one click claims **one campaign of one account**: there is no "claim everything", because a click spends one irrevocable real entitlement — and the write route **requires `accountId`**, so the request itself cannot batch. Before writing it **re-reads** the status and decides again; anything already claimed or expired returns 409 without touching upstream. After a successful claim it answers with the upstream's **fresh reading** and refreshes the card, with no optimistic update — even a silent upstream failure cannot leave a false "claimed" behind — and it also drops that account's cached benefit state, so the pill cannot linger on a stale status.
36
37
  - **Actionable advice when a credential is refused** — when the upstream rejects the selected account's token, the plugin actually **probes** the other local sign-ins: if one still answers, it tells you to switch to it (with a one-click switch) instead of vaguely asking you to sign in again — re-authenticating fixes nothing while you are pinned to a revoked backup. Only when there is genuinely no other local account does it ask you to sign in again.
37
38
  - **Add accounts by QR sign-in, import/export them in bulk, test or delete them one by one** — the WorkBuddy desktop app is no longer a prerequisite: the card's **Add account by QR code** (labelled **Sign in on the web** for the international build — that region uses a browser login page, there is no QR) opens a dialog and takes you **straight to the authorization page** in your browser; if the browser blocks that, the dialog's link, copy and **Open page again** are the fallback. Every credential lands in the plugin's own vault (`$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`, one file per account), and the desktop app's auth files stay **read-only and are never modified**. Each row of the account table gains **Test** and **Remove**: a row test reuses the same probe as the batch run, so the two are comparable; removing deletes only the copy the plugin stored and **hides** the account: it disappears at once and the automatic poll cannot bring it back, while **Detect accounts again** restores it (signing in or importing the same account clears the hide too). **The desktop app's sign-in is never touched** — removing means "this plugin no longer lists it", not "sign out of the app". **Export / import accounts** moves credentials to another machine: pick accounts to export a JSON file → choose a file and read a **redacted preview** (field names and whether a token is present; the token itself never enters the preview) → tick what to import. An import overwrites per account rather than duplicating, and **only a record positively identified as belonging to that region is stored** — a domain the plugin cannot classify is skipped and counted as skipped. See [Account pool](#account-pool).
38
39
  - **Credits live in the account pool** — the member table shows each account's remaining credits and nearest expiry, and credits are the ranking's second key (more credits serves first). The queries are **read-only** and consume no credits.
@@ -96,7 +97,7 @@ This plugin's standing rule is that it **never rewrites who pays** — your save
96
97
  5. **Which do not:** a malformed request (HTTP 400). Every account answers the same 400, so walking the pool would only multiply the wait before the user sees an error they must act on anyway. A stream that dies **mid-flight** is not retried either — bytes are already on the wire, and replaying would splice two answers together.
97
98
  6. **Members already known to be unusable are skipped** — a rate-limited account still inside its stated cooldown, or one whose credential the upstream rejected. **An account that was never tested counts as usable**: a freshly discovered sign-in must not be invisible just because nobody has measured it yet.
98
99
  7. **A live failure is RECORDED as a measurement** (with the reset time the upstream stated), so the NEXT request starts from an available account instead of re-hitting the account that just refused it. Before this, only the manual batch test wrote measurements — so a just-limited account still read as untested and kept being picked first. HTTP 400 is the exception: that is the REQUEST being rejected, which says nothing about the account, and recording it would sideline a good one. Accounts not checked into the pool are not measured either, since the tick is what grants "you may spend this account's credits".
99
- 7. Every retry leaves a `warn` line in the host log naming the failure and the account that follows.
100
+ 8. Every retry leaves a `warn` line in the host log naming the failure and the account that follows.
100
101
 
101
102
  ### What the switch actually controls
102
103
 
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1 align="center">dsh-connect-workbuddy</h1>
6
6
 
7
- <p align="center"><b>把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供账号池与模型管理。</b></p>
7
+ <p align="center"><b>把本机登录的 WorkBuddy 模型(国内版与国际版)接入 DeepSeek Harness —— 每次刷新自动读取每个模型的积分倍率,逐模型开关图片输入,多账号可切换,并带每日签到与只读积分概览(卡片与输入框里的胶囊读数)。</b></p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="README.en.md">English</a> ·
@@ -24,7 +24,7 @@
24
24
  <a href="https://dshfind.com/plugins/dingminhua/dsh-connect-workbuddy"><img src="https://dshfind.com/api/badge/dingminhua/dsh-connect-workbuddy" alt="dshfind plugin"></a>
25
25
  </p>
26
26
 
27
- 一个独立的 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bundle 插件。它把本机已登录的 WorkBuddy 账号(**国内版与国际版 WorkBuddy AI 均支持**)接到 DSH 的模型选择器,同时提供账号池与可勾选的模型管理界面。**国内版与国际版是两个并行的供应商(`workbuddy` / `workbuddy-global`),可同时使用**;插件设置卡片以 tab 区分两者,方便统一管理。
27
+ 一个独立的 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bundle 插件。它把本机已登录的 WorkBuddy 账号(**国内版与国际版 WorkBuddy AI 均支持**)接到 DSH 的模型选择器,同时提供账号池、可勾选的模型管理,以及一套围绕积分的只读能力:**每次刷新按上游目录自动读取每个模型的积分倍率**(`x0.79` 这种,倍率完全由上游给出,插件不写死也不猜)、逐模型开关图片输入、多账号切换与账号池自动换号、每日签到、以及卡片与输入框里胶囊式的只读积分读数。**国内版与国际版是两个并行的供应商(`workbuddy` / `workbuddy-global`),可同时使用**;插件设置卡片以 tab 区分两者,方便统一管理。只需本机 WorkBuddy 的登录态,**或在浏览器里登录一次**即可。
28
28
 
29
29
  ## 功能特性
30
30
 
@@ -40,6 +40,7 @@
40
40
 
41
41
  <img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="对话输入框积分面板:国内版账号余额表格、刷新积分按钮与最近刷新时间" />
42
42
  - **账号池:多账号批量操作与自动换号** —— 把要用的账号勾进池子,即可**一键签到所有账号**、**一键测试所有账号 × 你指定的模型**(默认挑该区域倍率为 0 的免费模型);开启**启用账号池**即同时打开**自动换号**——某个请求在上游失败时,插件会依次用池内其他可用账号重试,直到有一个接手、或全部不可用为止,然后才把错误报给你。成员**默认一个都不含**、必须显式勾选(「本机有登录」不等于「要拿它去签到和花积分」);**开启后由池子的排序决定每一次请求用谁**(可用优先,其次快过期先花、积分高、凭据更新的);失败时依次用池内其他可用账号重试。排序**从不写回你保存的选择**——关掉开关,你选定的账号立刻完全生效。只有请求本身不合法(HTTP 400)不换号:同一个请求体换谁都是 400,重试只会白等。**这个开关只管「插件要不要自己决定」**:关掉它,插件不再替你选账号、也不再替你换号,但**签到与测试仍然可用**(它们是手动动作)。测试走的是与模型行同一套真实体积探测,因此**有真实成本**;**不提供自动签到**——签到会真实领取奖励,只在你点按钮时执行。详见[账号池](#账号池)。
43
+ - **福利活动:待领取标记 + 单账号「领取」按钮** —— 卡片会逐账号标出上游活动(补偿 / 赠礼)里**还没领的那一份**,数据来自上游自己的两个状态接口,标记阶段**只读**、两个写接口一次都不调用。显示规则完全由上游状态决定:`active && !claimed` 才显示,`claimed` 整行消失,活动过期(`active` 翻 `false`)自动消失——**不用本地时钟去比 `end_time`**,本机时间偏了也不会与服务端打架;`end_time` 只用于 3 天内临期高亮。`active` 单独看不行:已领取的账号 `active` 仍为 `true`,只看它会让领过的账号永远挂着待领取。读取复用签到的管线(每账号一次、30 分钟缓存、账号池关闭时不发请求),任一接口失败按「未读」处理,不会把一次失败缓存成「全都没福利」。行尾是一个实心琥珀色的**胶囊按钮「领取」**,一次只领**一个账号的一个活动**:没有「全部领取」,因为一次点击花掉的是一份不可撤销的真实权益,写路由也**强制要求 `accountId`**,请求本身无法批量领取。领取前**重新读取**状态再判定,已领取或已过期一律 409 且不调用上游;领取后用上游的**新读数**作答并刷新卡片,不做乐观更新——即使上游静默失败也不会留下「已领取」的假象,成功后同时清掉该账号的福利缓存,按钮不会顶着旧状态赖着不走。
43
44
  - **凭据失效时给出可执行的建议** —— 上游拒绝当前账号的 token 时,插件会**实际探测**本机其他账号:若有账号仍然可用,就直接告诉你「切换到它即可」(并附一键切换),而不是笼统地让你重新登录——被钉在一个作废的旧备份上时,重新登录并不能解决问题。只有本机确实没有别的账号可切换时,才会提示重新登录。
44
45
  - **扫码添加账号、批量导入导出、逐账号测试/删除** —— 不必先装 WorkBuddy 桌面 App:卡片里的「扫码添加账号」(国际版为「网页登录」——那边是浏览器登录页,没有二维码)点开即弹出对话框,并在浏览器里**自动打开**授权页;被浏览器拦截时对话框内提供链接、复制与「重新打开页面」兜底。所有凭据统一落到插件自己的凭据库(`$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`,一账号一文件),**桌面 App 的认证文件始终只读、永不被改动**。账号表格每一行右侧有「测试」与「删除」:测试与批量测试走同一套探测,结果可比;删除只删插件自己存的那份,并把这个账号**暂时隐藏**:删掉后立刻消失,也不会被自动轮询带回来;点「重新检测账号」即可找回(重新登录或导入同一账号也会清除隐藏记录)。**桌面 App 的登录始终不动**——删除是「本插件不再列出它」,不是退出 App 登录。「导出 / 导入账号」可把凭据搬到另一台机器:多选导出 JSON → 选文件后先看**脱敏预览**(只显示字段与是否有 token,明文不进预览)→ 勾选导入。导入按账号去重覆盖,**只有确认属于该区域**的记录才入库,认不出的域名会被跳过并如实计入「已跳过」。详见[账号池](#账号池)。
45
46
  - **积分信息在账号池里** —— 成员表逐账号显示剩余积分与最近到期时间,排序的第二键就是积分(谁多谁优先)。查询是**只读**的、不消耗积分。
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-BHaBzzOr.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-BQHO4oiB.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
@@ -44,6 +44,23 @@ window.__ModuleLoader__.load({
44
44
  * costs nothing on the Host side either.
45
45
  */
46
46
  const WORKBUDDY_GITHUB_URL = "https://github.com/dingminhua/dsh-connect-workbuddy";
47
+ /**
48
+ * The page where a human claims a benefit: 「套餐与积分」.
49
+ *
50
+ * Named here rather than inline in the card because it is the page the benefit
51
+ * line points at when the plugin itself declines to claim (an unknown campaign
52
+ * kind). One constant keeps that pointer honest if the page moves.
53
+ */
54
+ const WORKBUDDY_PLAN_URL = "https://www.workbuddy.cn/profile/plans-usage";
55
+ /**
56
+ * Plugin-owned benefit claim endpoint: POST + loopback, ONE account per call.
57
+ *
58
+ * A credit grant is a write on a real account, so it carries the same guards as
59
+ * check-in, plus `?accountId=` because the pool can hold accounts this plugin
60
+ * did not pick. There is deliberately no batch action here — see
61
+ * `claimBenefit` and the route handler.
62
+ */
63
+ const WORKBUDDY_BENEFIT_CLAIM_PATH = "/plugins/dsh-connect-workbuddy/benefit/claim";
47
64
  /** Plugin-owned live model refresh endpoint. */
48
65
  const WORKBUDDY_MODELS_REFRESH_PATH = "/plugins/dsh-connect-workbuddy/models/refresh";
49
66
  /** Plugin-owned local account rescan endpoint. */
@@ -2243,6 +2260,65 @@ window.__ModuleLoader__.load({
2243
2260
  t
2244
2261
  ]);
2245
2262
  /**
2263
+ * Claim ONE benefit on ONE account, from its row (issue #79).
2264
+ *
2265
+ * Always a single account and a single campaign: there is no batch entry
2266
+ * point, because a claim spends a real entitlement that cannot be undone. The
2267
+ * row is the unit of consent, so the row is the only way to ask for it.
2268
+ *
2269
+ * The Host re-reads the campaign before AND after the write, so the message
2270
+ * here reports what the gateway says now rather than what was requested. The
2271
+ * refresh afterwards is what actually removes the 待领取 line: the card has no
2272
+ * optimistic update, so a claim that silently did nothing cannot leave a stale
2273
+ * line claiming credits were taken.
2274
+ */
2275
+ const runRowClaim = (0, react.useCallback)(async (accountId, accountName, kind, title, creditNum) => {
2276
+ const name = accountName === "" ? t("row.accountUnnamed") : accountName;
2277
+ setRowBusyAccountId(accountId);
2278
+ setRowBusyAction("claim");
2279
+ setActionError(void 0);
2280
+ const url = withWorkBuddyRegion(WORKBUDDY_BENEFIT_CLAIM_PATH, region) + "&accountId=" + encodeURIComponent(accountId) + "&kind=" + encodeURIComponent(kind);
2281
+ try {
2282
+ const response = await fetch(url, {
2283
+ method: "POST",
2284
+ headers: { accept: "application/json" },
2285
+ credentials: "same-origin"
2286
+ });
2287
+ const body = await response.json().catch(() => void 0);
2288
+ if (!response.ok) throw new Error(poolFailureText(t, {
2289
+ status: response.status,
2290
+ reason: body?.reason,
2291
+ error: body?.error
2292
+ }));
2293
+ if (!mounted.current) return;
2294
+ appendLog(t("row.poolLogBenefitClaimed", {
2295
+ accountName: name,
2296
+ title,
2297
+ count: formatNumber$1(creditNum)
2298
+ }), "ok");
2299
+ await onRefresh?.();
2300
+ } catch (error) {
2301
+ const message = error instanceof Error ? error.message : t("row.requestFailed");
2302
+ if (mounted.current) {
2303
+ setActionError(message);
2304
+ appendLog(t("row.poolLogBenefitFailed", {
2305
+ accountName: name,
2306
+ message
2307
+ }), "error");
2308
+ }
2309
+ } finally {
2310
+ if (mounted.current) {
2311
+ setRowBusyAccountId(void 0);
2312
+ setRowBusyAction(void 0);
2313
+ }
2314
+ }
2315
+ }, [
2316
+ appendLog,
2317
+ onRefresh,
2318
+ region,
2319
+ t
2320
+ ]);
2321
+ /**
2246
2322
  * Remove ONE account from its row, via the Host.
2247
2323
  *
2248
2324
  * The Host dismisses the account (and deletes its vault entry when it has
@@ -2393,7 +2469,11 @@ window.__ModuleLoader__.load({
2393
2469
  canEdit: canEditPool,
2394
2470
  checkinSupported: pool.checkinSupported,
2395
2471
  onToggle: (next) => toggleMember(account.accountId, next),
2396
- busy: rowBusyAccountId !== void 0,
2472
+ busy: rowBusyAccountId === account.accountId,
2473
+ rowBusy: rowBusyAccountId !== void 0,
2474
+ rowBusyAction,
2475
+ /** The pill also needs to know WHICH row is busy, not just that one is. */
2476
+ claimBusy: rowBusyAccountId === account.accountId,
2397
2477
  testDisabled: pool.targetModelSource === "none" || pool.targetModelSource === "stale",
2398
2478
  targetHint: pool.targetModelSource === "none" ? t("row.poolTargetNone") : pool.targetModelSource === "stale" ? t("row.poolTargetStale", { model: pool.staleTargetModelId ?? "" }) : void 0,
2399
2479
  onTest: () => {
@@ -2402,6 +2482,9 @@ window.__ModuleLoader__.load({
2402
2482
  onRemove: () => {
2403
2483
  runRowRemove(account.accountId, account.accountName);
2404
2484
  },
2485
+ onClaim: (kind, title, creditNum) => {
2486
+ runRowClaim(account.accountId, account.accountName, kind, title, creditNum);
2487
+ },
2405
2488
  orderIndex: isMember && orderIsManual ? left + 1 : void 0,
2406
2489
  orderIsManual,
2407
2490
  moveEnabled: pool.enabled === true && isMember && canEditPool,
@@ -2481,7 +2564,7 @@ window.__ModuleLoader__.load({
2481
2564
  }
2482
2565
  /** One account's row. */
2483
2566
  function renderAccountRow(input) {
2484
- const { t, account, checked, canEdit, checkinSupported, onToggle, busy, testDisabled, targetHint, onTest, onRemove, orderIndex, orderIsManual, moveEnabled, onMoveUp, onMoveDown, moveUpDisabled, moveDownDisabled } = input;
2567
+ const { t, account, checked, canEdit, checkinSupported, onToggle, busy, rowBusy, testDisabled, targetHint, onTest, onRemove, onClaim, rowBusyAction, claimBusy, orderIndex, orderIsManual, moveEnabled, onMoveUp, onMoveDown, moveUpDisabled, moveDownDisabled } = input;
2485
2568
  const name = account.accountName === "" ? t("row.accountUnnamed") : account.accountName;
2486
2569
  const excluded = exclusionText(t, account.excludedBy);
2487
2570
  const probe = account.probe;
@@ -2531,7 +2614,30 @@ window.__ModuleLoader__.load({
2531
2614
  "aria-label": t("row.poolMoveDownAria", { accountName: name }),
2532
2615
  disabled: moveDownDisabled,
2533
2616
  onClick: onMoveDown
2534
- }, "↓")) : null) : null, account.current ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-tag" }, t("row.poolCurrentBadge")) : null, (0, react.createElement)("span", { className: checked ? "dsm-workbuddy-pool-member" : "dsm-workbuddy-pool-not-member" }, checked ? t("row.poolMember") : t("row.poolNotMember")), excluded === void 0 ? probe === void 0 ? null : (0, react.createElement)("span", null, t("row.poolTestedAt", { at: formatShortClock(probe.atMs) })) : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-excluded" }, excluded))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-credits" }, account.credits === void 0 ? (0, react.createElement)("span", null, "—") : (0, react.createElement)("b", null, formatNumber$1(account.credits)), account.expiringSoon !== void 0 && account.expiringSoon > 0 ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon" }, t("row.poolCreditSoon", { count: formatNumber$1(account.expiringSoon) })) : account.nearestExpiryMs === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon-plain" }, t("row.poolCreditNearest", { at: formatShortClock(account.nearestExpiryMs) }))), (0, react.createElement)("span", {
2617
+ }, "↓")) : null) : null, account.current ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-tag" }, t("row.poolCurrentBadge")) : null, (0, react.createElement)("span", { className: checked ? "dsm-workbuddy-pool-member" : "dsm-workbuddy-pool-not-member" }, checked ? t("row.poolMember") : t("row.poolNotMember")), excluded === void 0 ? probe === void 0 ? null : (0, react.createElement)("span", null, t("row.poolTestedAt", { at: formatShortClock(probe.atMs) })) : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-excluded" }, excluded))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-credits" }, account.credits === void 0 ? (0, react.createElement)("span", null, "—") : (0, react.createElement)("b", null, formatNumber$1(account.credits)), account.expiringSoon !== void 0 && account.expiringSoon > 0 ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon" }, t("row.poolCreditSoon", { count: formatNumber$1(account.expiringSoon) })) : account.nearestExpiryMs === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon-plain" }, t("row.poolCreditNearest", { at: formatShortClock(account.nearestExpiryMs) })), account.benefits === void 0 || account.benefits.length === 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-benefits" }, account.benefits.map((benefit, index) => (0, react.createElement)("span", {
2618
+ key: benefit.kind,
2619
+ className: benefit.urgent ? "dsm-workbuddy-pool-benefit dsm-workbuddy-pool-benefit-urgent" : "dsm-workbuddy-pool-benefit"
2620
+ }, index === 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-sep" }, "·"), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-benefit-tag" }, benefit.urgent ? benefit.daysLeft === void 0 || benefit.daysLeft === 0 ? t("row.poolBenefitDueToday", {
2621
+ title: benefit.title,
2622
+ count: formatNumber$1(benefit.creditNum)
2623
+ }) : t("row.poolBenefitUrgent", {
2624
+ title: benefit.title,
2625
+ count: formatNumber$1(benefit.creditNum),
2626
+ days: formatNumber$1(benefit.daysLeft)
2627
+ }) : t("row.poolBenefitClaimable", {
2628
+ title: benefit.title,
2629
+ count: formatNumber$1(benefit.creditNum)
2630
+ })), (0, react.createElement)("button", {
2631
+ type: "button",
2632
+ className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn dsm-workbuddy-pool-benefit-btn",
2633
+ disabled: rowBusy,
2634
+ title: t("row.poolBenefitHint", { url: WORKBUDDY_PLAN_URL }),
2635
+ "aria-label": t("row.poolBenefitClaimAria", {
2636
+ title: benefit.title,
2637
+ count: formatNumber$1(benefit.creditNum)
2638
+ }),
2639
+ onClick: () => onClaim(benefit.kind, benefit.title, benefit.creditNum)
2640
+ }, (0, react.createElement)("span", null, claimBusy && rowBusyAction === "claim" ? t("row.poolBenefitClaiming") : t("row.poolBenefitClaim"))))))), (0, react.createElement)("span", {
2535
2641
  className: "dsm-workbuddy-pool-probe",
2536
2642
  ...probe?.message === void 0 ? {} : { title: probe.message }
2537
2643
  }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-main" }, probeLine), probeLabel === void 0 && age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-prov" }, probeLabel === void 0 ? null : (0, react.createElement)("span", { className: probeLabel.className }, probeLabel.text), probeLabel === void 0 || age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-sep" }, "·"), age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-age" }, age), probe === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-model" }, probe.modelId === void 0 ? t("row.poolProbeModelUnknown") : t("row.poolProbeModel", { model: probe.modelId })))), checkinSupported ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checkin" }, account.checkedInToday === void 0 ? (0, react.createElement)("span", {
@@ -2540,13 +2646,13 @@ window.__ModuleLoader__.load({
2540
2646
  }, t("row.poolCheckinUnknown")) : account.checkedInToday ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checked" }, t("row.poolCheckedIn")) : t("row.poolNotCheckedIn")) : null, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-row-actions" }, (0, react.createElement)("button", {
2541
2647
  type: "button",
2542
2648
  className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn",
2543
- disabled: busy || testDisabled,
2649
+ disabled: rowBusy || testDisabled,
2544
2650
  title: testDisabled ? targetHint : void 0,
2545
2651
  onClick: onTest
2546
2652
  }, t("row.poolRowTest")), (0, react.createElement)("button", {
2547
2653
  type: "button",
2548
2654
  className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn",
2549
- disabled: busy,
2655
+ disabled: rowBusy,
2550
2656
  title: t("row.poolRowRemoveHint"),
2551
2657
  "aria-label": t("row.poolRowRemoveAria", { accountName: name }),
2552
2658
  onClick: onRemove
@@ -3566,11 +3672,11 @@ window.__ModuleLoader__.load({
3566
3672
  * Client styles for the WorkBuddy plugin card.
3567
3673
  *
3568
3674
  * 参考:dingminhua/dsh-connect-trae(MIT,Copyright (c) 2026 LaoDing)
3569
- * — 整套 `dsm-*` 卡片样式系统(卡片外壳、按钮原语、`--dsw-alias-*`
3675
+ * — 整套 dsm-* 卡片样式系统(卡片外壳、按钮原语、--dsw-alias-*
3570
3676
  * 主题变量与十六进制回退值)逐字沿用自该项目,其又复制自
3571
3677
  * dingminhua/dsh-subagent-default-model(MIT)的 SETTINGS_CSS。
3572
3678
  * 沿用目的是让两个插件共享同一套外部表现语言。
3573
- * 改动:类名由 `dsm-trae-*` 改为 `dsm-workbuddy-*`;
3679
+ * 改动:类名由 dsm-trae-* 改为 dsm-workbuddy-*;
3574
3680
  * 移除 trae 特有的 1M 变体样式,新增按套餐聚合的积分行样式;
3575
3681
  * 双 provider 化后新增国内版/国际版 tab 栏样式。
3576
3682
  *
@@ -3960,6 +4066,114 @@ window.__ModuleLoader__.load({
3960
4066
  .dsm-workbuddy-pool-credits b{color:var(--dsw-alias-label-primary,#e6e6e6);font-weight:600}
3961
4067
  .dsm-workbuddy-pool-soon{font-size:11px;line-height:16px;color:var(--dsw-alias-state-warn-primary,#f59e0b)}
3962
4068
  .dsm-workbuddy-pool-soon-plain{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#999)}
4069
+ /* Claimable benefits (issue #79). A quiet line by default — it is an opportunity, not a fault — and loud only when the window is about to close. */
4070
+ .dsm-workbuddy-pool-benefits{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#999);min-width:0}
4071
+ /* One entry = text + pill, and the text wraps rather than being cut.
4072
+ The tag used to be nowrap + ellipsis, which truncated the label to
4073
+ 「模型服务异常补偿…」 — dropping the amount, i.e. the only part the user is
4074
+ deciding on. Measured in headless Chrome at the card's real width, the entry
4075
+ line has room for two lines, so the tag clamps to two and shows the whole
4076
+ label; the pill wraps to the line below and never shrinks.
4077
+ line-clamp, not plain wrapping: an unexpectedly long upstream title must not
4078
+ push the row taller than the pill can travel. */
4079
+ .dsm-workbuddy-pool-benefit-tag{min-width:0;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
4080
+ /* min-width:0 IS the whole fix (issue #79).
4081
+ The pill made this entry's min-content 229px (full line + button) inside a
4082
+ 148px column, so it painted over the probe column beside it.
4083
+ A flex ITEM defaults to min-width:auto and refuses to shrink below its
4084
+ content, which is what pinned it at 229; min-width:0 lets the tag ellipsize
4085
+ and the pill keep its size.
4086
+
4087
+ Measured in headless Chrome against the real rendered row, as an A/B:
4088
+ min-width:0 -> 0 overflowing children, entry 148px
4089
+ without it -> 3 overflowing children, entry 229px
4090
+ Changing the row's grid tracks to minmax(0,...) was tried too and made NO
4091
+ difference (the entry overflows its CELL, so the CELL was never the
4092
+ constraint) — so the tracks are left as they were. */
4093
+ .dsm-workbuddy-pool-benefit{display:flex;flex-wrap:wrap;align-items:center;gap:2px 5px;min-width:0;max-width:100%}
4094
+ .dsm-workbuddy-pool-benefit-urgent{color:var(--dsw-alias-state-warn-primary,#f59e0b);font-weight:600}
4095
+ /* The claim pill: a solid amber call to action.
4096
+ Orange, not the brand blue, because this is the ONE control in the pool that
4097
+ hands the user something (500 credits) instead of spending their attention on
4098
+ a read-only figure. It should read as an opportunity, and amber is the hue
4099
+ that says "take this" without shouting.
4100
+
4101
+ Solid fill rather than a tinted outline: at 19px tall and 46px wide, a tinted
4102
+ outline sat next to the 1,294 credit figure above it and read as DISABLED.
4103
+ The solid fill also survives the row's grey text without competing for the
4104
+ eye. The card's own 已签到 green is the only other saturated element here.
4105
+
4106
+ Load-bearing details, each measured in headless Chrome:
4107
+ - white-space:nowrap: without it the flex row squeezed 领取 into 领/取 on
4108
+ two lines and the pill grew into a 32px-tall blob;
4109
+ - a fixed 19px height, so it matches the 11px text beside it instead of
4110
+ inheriting .dsm-btn's padding;
4111
+ - flex:none, so the shrinking column eats the TEXT and never the pill. */
4112
+ /* Order matters: this rule must come AFTER .dsm-btn-outline (line ~36) so its
4113
+ amber background wins. Two-class specificity turned out to be the wrong lever
4114
+ and was REMOVED after measuring: pairing the classes also beat the
4115
+ dsm-workbuddy-pool-small-btn padding, and without dsm-btn-outline on the
4116
+ element the button fell back to dsm-btn's default background — rendered white
4117
+ on a dark row. Source order is what was already working; keep it. */
4118
+ .dsm-workbuddy-pool-benefit-btn{
4119
+ /* Flex centring plus a 1px nudge is what actually centres 领取.
4120
+ Measured on the rendered pill in headless Chrome (OCR-free pixel scan of
4121
+ the glyph mask inside the 46x19 box):
4122
+ display:inline-flex; align-items:center -> above 3.5px, below 4.5px,
4123
+ glyph sat 1.0px low
4124
+ plus translateY(-0.5px) below -> above 4.0px, below 4.0px
4125
+ Flex centring centres the LINE BOX, and a CJK glyph's ink sits below the
4126
+ centre of its em box, so centring the box leaves the ink low. The nudge
4127
+ compensates for the ink, not for the box. It is half a pixel because the
4128
+ label renders at 2x on a retina display and 1px would overshoot there. */
4129
+ display:inline-flex;
4130
+ align-items:center;
4131
+ justify-content:center;
4132
+ flex:none;
4133
+ padding:0 9px;
4134
+ height:19px;
4135
+ font-size:11px;
4136
+ line-height:1;
4137
+ font-weight:600;
4138
+ border-radius:999px;
4139
+ white-space:nowrap;
4140
+ letter-spacing:.01em;
4141
+ border-color:var(--dsw-alias-state-warn-primary,#f59e0b);
4142
+ color:#2a1d05;
4143
+ background:var(--dsw-alias-state-warn-primary,#f59e0b);
4144
+ /* The only shadow in the pool table: it lifts the pill off the row so the
4145
+ control reads as clickable rather than as part of the label. */
4146
+ box-shadow:0 1px 2px rgba(0,0,0,.28);
4147
+ transition:filter .12s ease;
4148
+ }
4149
+ /* Optical centring for the label (issue #79).
4150
+ Flex centring centres the LINE BOX, but a CJK glyph's ink sits below the
4151
+ centre of that box, so 领取 measured 3.5px above / 4.5px below — visibly low.
4152
+ padding-top pushes the ink down by shifting the line box within the centred
4153
+ flex item. Measured on the rendered pill in headless Chrome, as a sweep:
4154
+ (none) -> above 3.5 / below 4.5 (+1.0 low)
4155
+ padding-top:1px -> above 4.0 / below 4.0 (centred)
4156
+ padding-top:2px -> above 4.5 / below 3.5 (-1.0 high)
4157
+ transform and padding-bottom were both tried first and moved the ink the
4158
+ WRONG way, because both grow the flex item, which re-centres the line box
4159
+ downward. Padding-top is the one that shifts the ink up. */
4160
+ .dsm-workbuddy-pool-benefit-btn > span{display:block;padding-top:1px}
4161
+ .dsm-workbuddy-pool-benefit-btn:hover:not(:disabled){
4162
+ /* Brighten, do not invert: an inverted pill loses its "take this" reading,
4163
+ and the dark-on-amber swap is the part that looked cheap in the draft. */
4164
+ filter:brightness(1.08);
4165
+ border-color:var(--dsw-alias-state-warn-primary,#f59e0b);
4166
+ color:#2a1d05;
4167
+ background:var(--dsw-alias-state-warn-primary,#f59e0b);
4168
+ }
4169
+ .dsm-workbuddy-pool-benefit-btn:active:not(:disabled){filter:brightness(.94)}
4170
+ /* A row action is in flight: this row, or another one. The pill goes flat so it
4171
+ cannot be clicked twice while the first claim is still landing. */
4172
+ .dsm-workbuddy-pool-benefit-btn:disabled{
4173
+ opacity:.45;
4174
+ cursor:default;
4175
+ box-shadow:none;
4176
+ }
3963
4177
  .dsm-workbuddy-pool-probe,.dsm-workbuddy-pool-checkin{font-size:12px;line-height:17px;color:var(--dsw-alias-label-tertiary,#999);min-width:0;overflow-wrap:anywhere}
3964
4178
  .dsm-workbuddy-pool-probe{display:flex;flex-direction:column;gap:4px}
3965
4179
  .dsm-workbuddy-pool-probe-main{color:var(--dsw-alias-label-secondary,#c6c9d0)}
@@ -3977,6 +4191,12 @@ window.__ModuleLoader__.load({
3977
4191
  .dsm-workbuddy-pool-members-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
3978
4192
  .dsm-workbuddy-pool-members-title{margin:0 0 3px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,#e6e6e6)}
3979
4193
  .dsm-workbuddy-pool-members-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
4194
+ /* The 测试/删除 and 全选/全不选 buttons. Shared with the benefit pill, which
4195
+ * OVERRIDES both properties at higher specificity (see its rule above): a pill is
4196
+ * a capsule whose height is fixed at 19px, so inheriting this 3px/10px rhythm
4197
+ * pushed its own padding/line-height out and changed how the label sat.
4198
+ * Kept as-is rather than narrowed to the parents that use it, so the row-action
4199
+ * and select-all sizing stays in one place. */
3980
4200
  .dsm-workbuddy-pool-small-btn{padding:3px 10px;font-size:12px}
3981
4201
  .dsm-workbuddy-pool-check{display:inline-flex;align-items:center;flex:none;cursor:pointer}
3982
4202
  .dsm-workbuddy-pool-check input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
@@ -4137,7 +4357,7 @@ window.__ModuleLoader__.load({
4137
4357
  * Composer credit readout styles, injected by the readout component itself.
4138
4358
  *
4139
4359
  * 参考:dingminhua/dsh-connect-trae(MIT,Copyright (c) 2026 LaoDing)
4140
- * — 该项目的 `COMPOSER_POINTS_CSS`,类名改为 `dsm-workbuddy-*`,
4360
+ * — 该项目的 COMPOSER_POINTS_CSS,类名改为 dsm-workbuddy-*,
4141
4361
  * 其余(尺寸、节奏、主题变量与回退值、表格对齐)逐条沿用。
4142
4362
  *
4143
4363
  * Separate from the card bundle because the readout lives in the session's
@@ -6898,7 +7118,7 @@ window.__ModuleLoader__.load({
6898
7118
  */
6899
7119
  const en = {
6900
7120
  "row.title": "WorkBuddy credits & models (dsh-connect-workbuddy)",
6901
- "row.desc": "Use WorkBuddy models in DSH and see your remaining credits; the domestic and international sides are two independent providers, each with its own account — use both at the same time.",
7121
+ "row.desc": "Use WorkBuddy models in DSH: each refresh reads every model's credit multiplier from the upstream catalog, image input is toggled per model, several accounts can be switched or failed over automatically, and daily check-in plus your remaining credits are one click away. The domestic and international sides are two independent providers, each with its own account — use both at the same time.",
6902
7122
  "row.expand": "Expand",
6903
7123
  "row.collapse": "Collapse",
6904
7124
  "row.tabCn": "Domestic",
@@ -7068,6 +7288,15 @@ window.__ModuleLoader__.load({
7068
7288
  "row.poolCheckedIn": "Checked in",
7069
7289
  "row.poolNotCheckedIn": "Not checked in",
7070
7290
  "row.poolCheckinUnknown": "—",
7291
+ "row.poolBenefitClaimable": "{title}: {count} credits",
7292
+ "row.poolBenefitUrgent": "{title}: {count} credits · {days}d left",
7293
+ "row.poolBenefitDueToday": "{title}: {count} credits · last day",
7294
+ "row.poolBenefitClaim": "Claim",
7295
+ "row.poolBenefitClaiming": "Claiming…",
7296
+ "row.poolBenefitClaimAria": "Claim {title} ({count} credits) for this account",
7297
+ "row.poolLogBenefitClaimed": "{accountName} claimed {title}: {count} credits",
7298
+ "row.poolLogBenefitFailed": "{accountName} claim failed: {message}",
7299
+ "row.poolBenefitHint": "Claim it on the plans page: {url}",
7071
7300
  "row.poolCreditSoon": "{count} expiring within 3 days",
7072
7301
  "row.poolCreditNearest": "Nearest package expires {at}",
7073
7302
  "row.poolExcludedRateLimited": "Rate limited",
@@ -7211,7 +7440,7 @@ window.__ModuleLoader__.load({
7211
7440
  };
7212
7441
  const zh = {
7213
7442
  "row.title": "接入使用 WorkBuddy 积分与模型(dsh-connect-workbuddy)",
7214
- "row.desc": "在 DSH 中使用 WorkBuddy 模型并随时查看剩余积分;国内版与国际版是两个独立供应商,各有自己的账号,可同时使用。",
7443
+ "row.desc": "在 DSH 中使用 WorkBuddy 模型:每次刷新都按上游目录读取每个模型的积分倍率,图片输入可逐模型开关,多个账号可切换或由账号池自动换号,每日签到与剩余积分一键可查。国内版与国际版是两个独立供应商,各有自己的账号,可同时使用。",
7215
7444
  "row.expand": "展开",
7216
7445
  "row.collapse": "收起",
7217
7446
  "row.tabCn": "国内版",
@@ -7381,6 +7610,15 @@ window.__ModuleLoader__.load({
7381
7610
  "row.poolCheckedIn": "已签到",
7382
7611
  "row.poolNotCheckedIn": "未签到",
7383
7612
  "row.poolCheckinUnknown": "—",
7613
+ "row.poolBenefitClaimable": "{title}:{count} 积分",
7614
+ "row.poolBenefitUrgent": "{title}:{count} 积分 · 剩 {days} 天",
7615
+ "row.poolBenefitDueToday": "{title}:{count} 积分 · 今天截止",
7616
+ "row.poolBenefitClaim": "领取",
7617
+ "row.poolBenefitClaiming": "领取中…",
7618
+ "row.poolBenefitClaimAria": "为该账号领取{title}({count} 积分)",
7619
+ "row.poolLogBenefitClaimed": "{accountName} 已领取{title}:{count} 积分",
7620
+ "row.poolLogBenefitFailed": "{accountName} 领取失败:{message}",
7621
+ "row.poolBenefitHint": "去套餐与积分页领取:{url}",
7384
7622
  "row.poolCreditSoon": "{count} 积分 3 天内过期",
7385
7623
  "row.poolCreditNearest": "最近积分包 {at} 过期",
7386
7624
  "row.poolExcludedRateLimited": "被限流",