dsh-connect-workbuddy 4.13.2 → 4.14.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 +50 -0
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/lib/bin.js +1 -1
- package/lib/client.js +327 -16
- package/lib/{host-heartbeat-BJAL9pEe.js → host-heartbeat-B1I95fKS.js} +307 -52
- package/lib/index.d.ts +214 -21
- package/lib/index.js +6 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.14.0 (2026-10-21)
|
|
4
|
+
|
|
5
|
+
> 版本号是 **minor**:有用户可见的功能与行为变更(模型行新增上游活动徽标;国内区改以 App 身份读产品配置,因此目录与客户端一致)。发往上游的**聊天**请求体、路由 id、凭据、计费与换号规则一律未变——变的只有**读取模型目录**那一次 GET 的 User-Agent。
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- **模型行显示上游自己的活动徽标(促销 / 等级标记)**
|
|
10
|
+
- **上游把它放在同一份目录文档里**:`/v3/config` 的 `data` 下并列着 `data.models`、`data.modelPromotions`、`data.modelTiers`。插件本来就为取目录读这份文档,所以画一枚徽标**不新增任何一次上游请求**,只多一个解析器(`src/promo.ts`)。
|
|
11
|
+
- **徽标是「此刻」的函数,不是解析时的快照**。上游的时段是**某个具名时区里的墙钟时间**(`schedule.daily`,例如 `Asia/Shanghai` 的 `23:00–07:50`,**跨零点**),外加一段**右开**的绝对生命周期(`validFrom` / `validUntil`)。若在解析时就挑定一枚,夜里读到的「夜间折扣」会在第二天早上继续挂着。所以解析层保留**全部候选**,由渲染时刻按当前时钟挑选:先取此刻正在运行且 `priority` 最高的一条;没有正在运行的,再退回常驻型(`display !== 'activeOnly'`)里优先级最高的一条,且它仍受绝对生命周期约束。
|
|
12
|
+
- **必须保留全部候选**,因为上游用「同一模型两条互补条目」表达一个夜间活动:一条带 `discount` 落在夜间时段,另一条不带 `discount` 落在白天的互补时段、只负责把徽标常驻。只留一条必然出错。
|
|
13
|
+
- **徽标只说上游的话**:文案取 `badge.label` / `labelEn`(英文界面优先 `labelEn`),颜色取 `badge.color`(**只接受 `#rgb` / `#rrggbb`**,其余当作没有颜色,避免把任意字符串塞进 CSS),悬停取上游的 `hover.textZh` / `textEn`,并在其后补上**有效期区间**与**每日时段(含上游声明的时区)**,好让「为什么此刻有这枚徽标」一眼可查。插件不翻译、不改写、不编造。
|
|
14
|
+
- **每个模型只画一枚**:模型行只有一行宽,两枚徽标会把模型名与右侧勾选框挤出行外。徽标落在模型名**旁边**而不是里面——模型名是省略号截断的,徽标放进去会被一起裁掉。
|
|
15
|
+
- **污染项必须丢弃**:`modelPromotions` 里混着根本不是促销的条目(`hy-image-3.5`、`kling-video-v3`:只有 `tags` / `videoGeneration`,没有 `modelIds`、没有 `badge`)。判据很干脆:**没有 `modelIds` 或没有 `badge` 的一律丢弃**,`enabled === false` 的同样不参与。
|
|
16
|
+
- **徽标随 `lastCatalog` 持久化**,读回时仍是未解析的全部候选,因此卡片每次打开都能按当刻重新判定;卡片展开时本就每 60s 轮询一次,时窗边界最迟一分钟内被感知,不另设定时器。
|
|
17
|
+
- **只读**:不写上游任何接口、不改账号与积分语义。徽标只是提示,不替用户做选择;上游 `hover.action`(`selectModel` / `upgrade`)**没有**做成可点击动作。取证与判定规则整理在 [`docs/PROMO_BADGE.md`](docs/PROMO_BADGE.md)。
|
|
18
|
+
|
|
19
|
+
- **国内区改以 App 自己的产品身份读产品配置,徽标与客户端一致**
|
|
20
|
+
- **根因是「读错了文档」,不是「上游没下发」**。`/v3/config` 按请求头声明的**客户端通道**裁剪答案,且与主机无关(`www.workbuddy.cn`、`copilot.tencent.com`、`wb.tencentbuddy.com`、`www.codebuddy.cn` 在同一身份下逐字节一致)。实测(同 token、同 URL,**只换 User-Agent**):
|
|
21
|
+
| User-Agent | 国内区 `/v3/config` 的答案 |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `CLI/2.63.2 CodeBuddy/2.63.2`(插件此前用的) | 5 键、35 模型、**无** `modelPromotions` / `modelTiers` |
|
|
24
|
+
| `WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0`(App 身份) | 22 键、62 模型、**8 条促销 + 1 条等级标记** |
|
|
25
|
+
CLI 通道的答案是 App 通道的**严格子集**,恰恰不发布那两个活动数组——所以国内版此前一枚徽标都画不出来,而 WorkBuddy 客户端在同一个账号上是有的。
|
|
26
|
+
- **这个身份不是插件自造的**:桌面端 `app.asar` 的 `buildVlmGatewayHeaders` 拼的就是 `WorkBuddy/<app> WorkBuddy/<app> CLI/<cli>`,其自身日志也记录了这一次请求(`fetch cloud product from url:https://www.workbuddy.cn/v3/config success`)。版本号是**惰性**的(`WorkBuddy/1.0.0 …` 与 `WorkBuddy/5.8.3 …` 答案一致),决定答案形状的是**头里声明的通道**。
|
|
27
|
+
- **聊天仍以 CLI 身份发送**:会话身份三元组(conversation id / request id / message id)绑定在聊天通道上。`PRODUCT_UA` 只出现在 `fetchModels` 的两处目录读取;聊天、探针、token 刷新全部保持 `CLIENT_UA`,计费路径本就不带 User-Agent。**计费归属与凭据暴露面均未变。**
|
|
28
|
+
- **换取名单的变化**:国内区 App 身份的 `cli` 名单相对 CLI 身份只差一项——**多 `auto`、少 `minimax-m2.7`**,其余 19 个模型同名同价。两个模型都实测可对话(`auto` 被上游解析为 `hy4-preview`)。国际区两种身份答案相同,零回归。
|
|
29
|
+
|
|
30
|
+
### Fixes
|
|
31
|
+
|
|
32
|
+
- **换身份后,读名单时带上用户已勾选的模型,不再默默撤下正在用的那个**(issue #32 同一类故障,往前推一步)
|
|
33
|
+
- 运行时目录用用户的勾选过滤上游名单,所以名单少一个 id 就等于无声撤下用户正在用的模型。`minimax-m2.7` 在 App 身份的 `cli` 名单里不见了,但**同一份文档仍然列出它**(`x0.26`、200K in / 48K out),聊天端点也照常应答。
|
|
34
|
+
- 名单读取因此带上用户勾选:**名单 = 上游名单 ∪(用户已勾选、且同一份文档仍然列出的模型)**。上游真正下架的模型(文档里也没有的)不会被复活。仍然零新增请求。
|
|
35
|
+
|
|
36
|
+
- **徽标的时窗判定与文案不再「说多」——三处审查中发现的缺陷**
|
|
37
|
+
- **畸形 `daily` 被降级成「全天有效」**:`parseDaily` 的注释写「丢弃,而不是当作 always」,代码恰好相反——窗口丢弃后 `daily` 整个缺失,判定见「没有时段」就返回运行中。上游任何一次形状微调(写 `25:00`、加秒 `23:00:00`、把 `start`/`end` 改名 `from`/`to`)都会让一条 `activeOnly` 的夜间活动变成 **24 小时常驻徽标**,正午也挂着「夜间折扣」。这是唯一会「说多」的方向。**修法**:新增 `schedule.dailyUnusable`,区分「上游没说时段」(24 小时活动)与「说了但一条都解析不出」(**永不运行**,不画徽标)。同时 `minutesOfClock` 接受 `24:00`(=1440,仅作终点)——上游现在把「全天」写成 `0:00–23:59`,在 23:59 与零点之间留了 1 分钟空洞。
|
|
38
|
+
- **常驻 tier 能顶掉正在跑的促销**:无 `schedule` 的等级标记对「此刻是否运行」恒为 true,被算进「正在运行」候选,于是高 `priority` 的「订阅优先」会盖掉 `priority` 更低、但时窗真的开着的夜间促销——文档承诺「正在运行优先」,实现成了「priority 最高优先」。国际区实测促销与等级标记的 `priority` 都是 200,这个组合会真的发生。**修法**:第一步只考虑**带 `schedule`** 的条目(无 schedule 的只会「常驻」、不会「正在运行」);第二步补上「解析不出时段的条目不得常驻」。tie-break 同时显式写进比较器(同 `priority` 时促销优先),不再依赖两个数组的读取顺序。
|
|
39
|
+
- **tooltip 把结束日显示晚一天**:`validUntil` 是右开(当刻即失效),上游把它写成活动次日的零点、自己的文案说「– 10月31日」;tooltip 直接格式化该时刻,于是同一句里出现「11月1日」。另外日期按读者本地时区渲染,而时间戳是上游按 `Asia/Shanghai` 写的——UTC 以西的读者会看到「7月5日」,与旁边的上游文案自相矛盾。**修法**:右开区间减 1ms 再格式化,按 `schedule.timezone` 渲染。
|
|
40
|
+
|
|
41
|
+
### Docs
|
|
42
|
+
|
|
43
|
+
- 新增 [`docs/PROMO_BADGE.md`](docs/PROMO_BADGE.md):上游活动字段的取证(两区实测矩阵、17 条真实样本、必须丢弃的污染项)、时间语义(左闭右开 / 起点含终点不含 / 跨零点 / `end === start` 为整天 / `24:00` / `dailyUnusable`)、实现取舍与**刻意不做**的边界。
|
|
44
|
+
- `README.md` / `README.en.md`:功能特性各加一条「模型行上的活动徽标」;**更正三处已被本轮实测推翻的说法**——① 原文写「国内版上游目录不下发活动字段、因此国内版不会出现徽标」(换 App 身份后国内区有 8 促销 + 1 等级标记);② 原文写「国内版用 CLI 的 UA、国际版用桌面端 UA」(现两区同用 App 身份);③ 原文引用的 `hy4-preview-f` 免费层已不复存在(两渠道现均挂付费的 `hy4-preview` `x0.29`,免费额度改以活动徽标表达)。
|
|
45
|
+
|
|
46
|
+
### Tests
|
|
47
|
+
|
|
48
|
+
- **`tests/promo.spec.ts`(21 条)**:解析丢弃污染项与 `enabled:false`、读出徽标/文案/生命周期、tier 无 schedule、**候选全保留不冻结**、attach 按模型分组且 priority 降序、生命周期边界(`validUntil` 排他)、`schedule.daily` 按上游时区读取(跨零点、起点含终点不含)、运行中的促销优先、过期即无徽标、运行中的促销优先于常驻 tier、常驻徽标在时段外仍在但过期即消失;以及本轮新增的**七条**:畸形窗口三种形状均判「永不运行」且不常驻、无时长条目不与解析失败混淆、`24:00` 无空洞、`end === start` 为整天、真在跑的促销压过无 schedule 的高 priority tier、tie-break 与输入顺序无关。
|
|
49
|
+
- **`tests/promo-card.spec.tsx`(6 条,jsdom)**:渲染上游文案、颜色经自定义属性下发、悬停含上游文案、无促销不渲染、过期不渲染、多条只画 priority 最高的一条。
|
|
50
|
+
- **`tests/promo-badge.spec.ts`(7 条,新增)**:结束日取最后一天(不为右开边界所骗)、日期按上游时区渲染(三个 locale 逐一核对)、时段按上游时区展示、无时段条目只输出上游原文、语言回退、颜色白名单。
|
|
51
|
+
- **`tests/upstream.spec.ts`(+6 条)**:两区都发 App 身份、国内区换身份后仍以 CLI 身份聊天、活动数组随模型带出、回落路径在两种身份下名单一致、`selectCliModels` 的并集保住用户已勾选模型。
|
|
52
|
+
|
|
3
53
|
## 4.13.2 (2026-10-21)
|
|
4
54
|
|
|
5
55
|
> 版本号是 **patch**:没有接口、配置项或请求结构的变更。发往上游的请求体**一个字节都没变**——4.13.0 的「渠道指纹归一化」照旧在跑,本版只改**网关已经拒绝之后**的那一段:把 11128 从「通用参数拒绝」里拆出来单独归类,并把**被拒的那条 system 提示开头**回显给读者。路由 id、模型 id、凭据、计费与换号规则一律未变(换号规则只有一处「不再多问一次」的减少,见下)。
|
package/README.en.md
CHANGED
|
@@ -25,6 +25,7 @@ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bund
|
|
|
25
25
|
- **Tabbed settings card** — the card's top carries a "Domestic / Global" tab bar; each tab holds its own account pool and model management. Account, directory, selection, and unsaved drafts are fully isolated per tab — switching accounts or refreshing models on one tab never touches the other side's runtime catalog or sessions.
|
|
26
26
|
- **Switch either provider off on its own** — each tab carries an on/off checkbox (both checked by default). Unchecking one **withdraws that provider from DSH's model picker entirely** (it is not merely a hidden tab): its adapter route and its "Settings → Models" directory entry are both pulled, and startup no longer sends pointless requests for it. Account, credits, directory, and selection are all kept, and re-checking restores everything. The typical use is "I have no use for the international side" — switch it off and keep the picker clean. Note: a session that had already selected a model from the switched-off provider will report `NO_ADAPTER` on its next call; the card states this plainly on the switched-off tab instead of failing silently.
|
|
27
27
|
- **Model management** — refresh the full catalog from upstream and enable or disable each model individually; refresh is a draft operation that only takes effect on save. Upstream also reports credit multiplier, context/output limits, and reasoning efforts; **image input is decided by a vendor-verified table**: a refresh pre-checks only models the vendor's own documentation calls natively multimodal, leaves documented text-only models unchecked, and treats anything without a vendor source as unverified and unchecked rather than guessing. Upstream's `supportsImages` is a *platform* declaration, not the model's capability, so it does not drive that decision at all. You can still tick any box by hand, and your selection is kept locally. **Every model row also carries a “Can disable” (`off`) checkbox**: it decides whether that model gets the “turn thinking off entirely” level. The default comes from a reviewed built-in table (the 9 models that claim the capability but answer HTTP 400 for it start unticked), and you can change it per model — **untick** for a model you do not need the level on. **The 9 tabled models cannot be ticked at all** (their row is marked "refuses off" with an explanatory tooltip): the level is not merely an extra option — pi-ai uses it as the DEFAULT for every request that names no tier, and a compaction summary is exactly such a request, so one tick made every `/compact` for that model fail with HTTP 400 / code 11133 (issue #83). Models outside the table are still decided entirely by your selection and their own declaration; your ticks are saved on first save and recomputed no further.
|
|
28
|
+
- **Campaign badges on model rows** — the upstream model catalog carries campaign information (free trials, night discounts, limited-time free use, subscription-only tiers, and the like). The plugin adds **no extra request** (it reuses the catalog configuration it already fetches when refreshing models) and draws a pill badge right on the model row: the upstream supplies both the colour and the wording, and hovering shows the upstream's campaign text, its start/end dates, and its daily window (e.g. “night 23:00–7:50”). **A badge is a function of the current moment**: it shows only inside the window the upstream declares and disappears on its own when that window closes; a window that crosses midnight is judged in the timezone the upstream declared; when several campaigns hit the same model, only the highest-priority one is drawn. A benefit marker without an end date (such as “subscription only”) stays until upstream withdraws it. **A badge is a hint, not behaviour** — it reports the campaign and discount upstream currently advertises, and makes no choice for you. (Both regions' catalogs ship the campaign fields: read as the app, the CN `/v3/config` carries 8 promotions + 1 tier badge and the global one carries 5 + 1; the evidence and the decision rules are written up in [docs/PROMO_BADGE.md](docs/PROMO_BADGE.md).)
|
|
28
29
|
- **Model availability testing, including cooldown recovery times** — working out when a limited model frees up used to mean guessing through repeated failures. Now every model row has a **Test** button that gives you a direct answer for whether the model works right now and, if not, when it comes back. **It sends a real-volume request (about 25k input tokens)**, rather than "ping it and see if it answers" — because the upstream throttles on request **SIZE** (measured: about 20k tokens pass, about 30k are refused), so a small request looks fine while every request in a long conversation may be refused. That is exactly where "the test passes but I am limited" came from. **Where the recovery time comes from**: the upstream's 429 carries **no** `Retry-After` header (and no `X-RateLimit-*`), but it writes the reset time into the response **body** — `{"code":6004,"msg":"…将在 2026-09-30 02:30:30 UTC+8 重置…"}`. The plugin parses that time and converts it using the UTC offset the message states, so the result does not depend on your machine's timezone; the card then shows “✗ Rate limited · usable again after 2026/9/30 02:30:30”. When none of the sources states a time, the plugin **never invents a countdown** and says plainly that no time was given. A rate limit deliberately does **not** borrow the monthly refresh time, which would tell you to wait weeks for a limit that clears in seconds. Probes run in the Host, so **the credential never reaches the page**, and results are not written to settings — your saved selection is untouched.
|
|
29
30
|
|
|
30
31
|
**Two things to know.** ① **It really does spend credits** — one measured probe reported `credit` 0.02 (a refused one 429s and costs almost nothing). So testing is **manual and per-model only**: there is deliberately no “Test selected” batch button, because sweeping the whole list from one click is an uncontrolled spend — and the Host enforces the same rule, **accepting a single model only** and refusing multi-model requests. ② The upstream advertises a 1M context yet throttles far below it, and the plugin advertises each model's **native** window to DSH (no longer clamped to 200K as of 3.5.0), so DSH compacts very late — hitting the throttle in a long session follows directly from that. To make DSH compact sooner, set a “DSH context budget” cap on the model's row (200K / 500K / the model's native window).
|
|
@@ -55,7 +56,7 @@ DSH PiAiAdapter (one stack per provider)
|
|
|
55
56
|
|
|
56
57
|
The CN and international sides each own a complete runtime stack — credential store, model catalog, loopback shim, adapter — with visibility filtered by credential domain (`workbuddy.ai` / `codebuddy.ai` → international, anything else → CN), so **both regions' accounts can be signed in and used by different sessions at the same time**. The international product has two brand domains: the desktop app signs in at `workbuddy.ai`, while the CodeBuddy CLI signs in at `codebuddy.ai` — both are international.
|
|
57
58
|
|
|
58
|
-
The model catalog comes from each region's correct source, and both read the **same** `/v3/config` document the WorkBuddy app itself consumes
|
|
59
|
+
The model catalog comes from each region's correct source, and both read the **same** `/v3/config` document the WorkBuddy app itself consumes, **as the app's own client identity** (`WorkBuddy/<app> WorkBuddy/<app> CLI/<cli>`, the header the desktop's `buildVlmGatewayHeaders` builds). That is required, not cosmetic: `/v3/config` serves a different product configuration per **client channel**, and the CLI channel's answer is a strict subset of the app channel's that publishes **neither `modelPromotions` nor `modelTiers`** — reading as the CLI is what left the CN badge empty. Chat and the availability probe still send as the CLI agent (the conversation-identity triple is bound to the chat channel); only the catalog read uses the app identity. If CN cannot read `/v3/config`, it falls back to the legacy `/v2/enterprises/personal/models` and logs a line, so that path is used only while degraded. The credits overview comes from `https://www.codebuddy.cn/v2/billing/meter/get-user-resource` (international accounts auto-route to the global gateway their credential belongs to, `workbuddy.ai` or `codebuddy.ai` — tokens are not interchangeable across the two brand domains); all are read-only.
|
|
59
60
|
|
|
60
61
|
Credentials are read (read-only) from the WorkBuddy desktop app's own auth file. Refreshed tokens are kept per region in `$DSH_HOME/.workbuddy-auth.cn.json` and `$DSH_HOME/.workbuddy-auth.global.json` (two simultaneously signed-in accounts never overwrite each other; the legacy single file `.workbuddy-auth.json` is still read as a migration source); the desktop app's file is never written.
|
|
61
62
|
|
package/README.md
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
- **插件卡片 tab 切换** —— 设置卡片顶部为「国内版 / 国际版」两个 tab,各含独立的账号池与模型管理;每个 tab 的账号、目录、勾选与未保存草稿完全隔离——在一个 tab 里切账号或刷新模型,不会触碰另一边的运行时目录与会话。
|
|
33
33
|
- **可单独关闭任一版本供应商** —— 每个 tab 右侧有一个勾选框(默认都勾选)。**取消勾选即把该供应商从 DSH 模型选择器里彻底撤掉**(不是只藏起 tab):它的路由与「设置 → 模型」页的目录条目一并摘除,启动时也不再为它发无用请求。账号、积分、目录与勾选**全部保留**,重新勾选即完整恢复。典型用法是「我根本用不到国际版」——关掉它,让选择器干净。注意:若某会话此前选中的正是被关闭供应商的模型,该会话再调用会报 `NO_ADAPTER`,卡片会在关闭态的 tab 上明确提示这一点,不会静默失败。
|
|
34
34
|
- **模型管理** —— 从上游刷新完整模型目录,逐项勾选启用或禁用;刷新是草稿操作,点保存才生效。上游同时给出积分倍率、上下文/输出上限与推理档位;**图片输入按厂商核验表判定**:刷新时只有厂商文档明确写为原生多模态的模型才自动勾选,文档写明纯文本的不勾,**没有厂商资料的一律按「未核实」不猜**(上游的 `supportsImages` 是平台侧声明,不等于模型能看图,不参与判定)。用户仍可随时手动勾选,勾选状态保存在本地。**每个模型行还有「可关闭思考」(`off`)勾选框**:决定要不要为该模型提供「完全关闭思考」这一档位。默认由内置复核名单给出(上游声称可关闭、实测却对它返回 HTTP 400 的 9 个模型默认不勾),你可以按模型改勾/取消——**取消**用于当前不需要该档位的模型;**名单内那 9 个模型不可勾选**(行上标「拒绝 off」,悬停有说明):该档位被勾上后不只是多一个选项,pi-ai 会把它当作**所有未指定档位请求的默认值**,压缩摘要请求正属于这一类,勾一次等于让该模型的每一次 `/compact` 都 400 / 11133(issue #83)。名单外的模型仍完全由你的勾选与自身声明决定;首次保存即写入你的勾选,之后不再重算。
|
|
35
|
+
- **模型行上的活动徽标** —— 上游的模型目录里带着活动信息(免费试用、夜间折扣、限时免费、订阅优先等),插件**不新增任何请求**(复用刷新模型时本来就会拉的那份目录配置),直接在模型行上画一枚胶囊徽标:颜色与文案都由上游给出,悬停能看到上游的活动说明、活动的起止日期与每日生效时段(例如「夜间 23:00–7:50」)。**徽标是「此刻」的函数**:活动只在它自己声明的时间窗内显示,窗口一关徽标就自己消失;跨零点的时段按上游声明的时区判断;同一个模型命中多条活动时只画优先级最高的那条。上游没给结束时间的权益标记(如「订阅优先」)则常驻,直到上游把它下架。**徽标只是提示,不改变模型行为**——它标注的是上游此刻给出的活动与折扣,不替你做选择。(两个区域的上游目录都下发活动字段:国内版按 App 身份读 `/v3/config` 时带 8 条促销 + 1 条等级标记,国际版带 5 条 + 1 条;取证与判定规则见 [docs/PROMO_BADGE.md](docs/PROMO_BADGE.md)。)
|
|
35
36
|
- **模型可用性测试(含冷却恢复时间)** —— 限量模型的冷却时间过去只能靠反复试错去猜。现在每个模型行有一个「测试」按钮,直接告诉你它此刻能否使用、不能用时何时恢复。**它发的是一条真实体积的请求(约 25k 输入 token)**,而不是「发个 ping 看通不通」——因为上游的限流**按请求体积触发**(实测约 20k 能过、约 30k 被拒),小请求看着正常、长对话里却可能每次都被拒,那正是「测试通过、实际被限」的来源。**恢复时间的来源**:上游的 429 **不带** `Retry-After` 响应头(也没有 `X-RateLimit-*`),但它把重置时间写在**响应体**里——`{"code":6004,"msg":"…将在 2026-09-30 02:30:30 UTC+8 重置…"}`。插件从响应体解析该时间(并按其声明的 UTC 偏移换算,不受你本机时区影响),因此卡片能直接显示「✗ 被限流 · 2026/9/30 02:30:30 之后可再用」。三处来源都没说时,插件**不编造倒计时**,如实显示「上游未给出何时恢复」。限流**不会**去借月周期包的刷新时间(那会让人为几秒的限流去等几周)。探测在后台发起,**凭据不会传到页面**;结果不写入设置,也不改变你已保存的勾选。
|
|
36
37
|
|
|
37
38
|
**两点需要知道**:① **它真的花钱**——实测一次报 `credit` 0.02(被限流时直接 429,几乎不花)。因此**只能按单个模型手工点**,没有「测试已勾选」这类批量按钮:一次覆盖整个列表就是一次不可控支出(服务端也据此**只接受单个模型**,多模型请求会被拒)。② 上游虽宣称 1M 上下文,但节流线远低于此;插件按模型的**原生窗口**向 DSH 宣称(3.5.0 起不再默认压到 200K),所以 DSH 很晚才压缩对话——长会话撞限流是这条机制的直接结果。想让 DSH 更早压缩,就在模型行上手动设一个「DSH 上下文预算」上限(可选 200K / 500K / 模型原生窗口)。
|
|
@@ -62,7 +63,7 @@ DSH PiAiAdapter(每个 provider 一套)
|
|
|
62
63
|
|
|
63
64
|
国内版与国际版各持一套完整的运行时栈——凭据 store、模型 catalog、回环 shim、adapter——按凭据域名(`workbuddy.ai` / `codebuddy.ai` → 国际版,其余 → 国内版)隔离可见账号,所以**两个区域的账号可以同时在线、同时被不同会话使用**。国际版有两个品牌域名:桌面端登录在 `workbuddy.ai`,CodeBuddy CLI 登录在 `codebuddy.ai`,两者都是国际版。
|
|
64
65
|
|
|
65
|
-
模型目录按区域取自各自的正确来源,两者都是 **App 自己读的那份** `/v3/config
|
|
66
|
+
模型目录按区域取自各自的正确来源,两者都是 **App 自己读的那份** `/v3/config`,且都以 **App 自己的客户端身份**去读(`WorkBuddy/<app> WorkBuddy/<app> CLI/<cli>`,即桌面端 `buildVlmGatewayHeaders` 拼出的那个头)。这一步是必需的:`/v3/config` 按**客户端通道**下发不同的产品配置,CLI 通道的答复是 App 通道的严格子集,**既不发布 `modelPromotions` 也不发布 `modelTiers`**——用 CLI 身份读,国内版的活动徽标就会一个都画不出来。聊天与探针仍以 CLI 身份发送(会话身份三元组绑在聊天通道上),只有目录读取换成 App 身份。国内版若 `/v3/config` 取不到,会退回旧的 `/v2/enterprises/personal/models` 并记一条日志,所以只在降级时使用。积分概览走 `https://www.codebuddy.cn/v2/billing/meter/get-user-resource`(国际版账号自动路由到其凭据所属的国际网关,`workbuddy.ai` 或 `codebuddy.ai`——两个品牌域名的凭据互不通用),均为只读接口。
|
|
66
67
|
|
|
67
68
|
凭据读取自 WorkBuddy 桌面 App 自身的 auth 文件(只读);刷新得到的 token 按区域存放在 `$DSH_HOME/.workbuddy-auth.cn.json` 与 `$DSH_HOME/.workbuddy-auth.global.json`(双账号同时在线互不覆盖;旧的单文件 `.workbuddy-auth.json` 作为迁移来源保留读取),桌面端文件永不被写入。
|
|
68
69
|
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as WORKBUDDY_APP_EXECUTABLE_ENV, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, mt as hostPeersInfo, o as workbuddyHostHeartbeatPath, ot as findWorkbuddyAppExecutableWithSource, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-
|
|
2
|
+
import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as WORKBUDDY_APP_EXECUTABLE_ENV, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, mt as hostPeersInfo, o as workbuddyHostHeartbeatPath, ot as findWorkbuddyAppExecutableWithSource, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-B1I95fKS.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
|
@@ -8,19 +8,6 @@ window.__ModuleLoader__.load({
|
|
|
8
8
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
9
|
let react_dom = require("react-dom");
|
|
10
10
|
//#region src/status-paths.ts
|
|
11
|
-
/**
|
|
12
|
-
* Node-free constants and types shared by the Host and browser halves.
|
|
13
|
-
*
|
|
14
|
-
* 参考:dingminhua/dsh-connect-trae(MIT,Copyright (c) 2026 LaoDing)
|
|
15
|
-
* — 「3 条同源只读路由(usage / models:refresh / accounts:refresh)+ 一份
|
|
16
|
-
* 与浏览器共享的 node-free 类型定义」的 host↔client 桥梁形态来自该项目
|
|
17
|
-
* (其 `status-paths.ts` 亦如此,并注明沿用
|
|
18
|
-
* corrinehu/dsh-workbuddy-connect 的 status-route 模式)。
|
|
19
|
-
* 改动:路由路径改用本插件 id;类型字段按 WorkBuddy 上游实际给出的能力
|
|
20
|
-
* (积分倍率、多模态、推理档位)调整,不保留 trae 的 1M 变体字段。
|
|
21
|
-
*
|
|
22
|
-
* @module dsh-connect-workbuddy/status-paths
|
|
23
|
-
*/
|
|
24
11
|
/** Plugin-owned usage endpoint consumed by its browser half. */
|
|
25
12
|
const WORKBUDDY_USAGE_PATH = "/plugins/dsh-connect-workbuddy/usage";
|
|
26
13
|
/**
|
|
@@ -1514,6 +1501,162 @@ window.__ModuleLoader__.load({
|
|
|
1514
1501
|
return t("row.poolProbeDaysAgo", { count: Math.round(age / DAY_MS) });
|
|
1515
1502
|
}
|
|
1516
1503
|
//#endregion
|
|
1504
|
+
//#region src/promo.ts
|
|
1505
|
+
/**
|
|
1506
|
+
* `'HH:mm'` → minutes since midnight, or undefined for anything else.
|
|
1507
|
+
*
|
|
1508
|
+
* `24:00` is accepted and means exactly 1440 — the END of a day. The upstream
|
|
1509
|
+
* writes "all day" as `0:00`–`23:59` today, which leaves a one-minute hole
|
|
1510
|
+
* between 23:59 and midnight where a standing campaign reads as not running.
|
|
1511
|
+
* Accepting `24:00` lets such a window be expressed (and lets a future upstream
|
|
1512
|
+
* fix it) without that hole; as a START it is rejected, because a day that
|
|
1513
|
+
* begins at 24:00 is the next day and that is a different statement.
|
|
1514
|
+
*
|
|
1515
|
+
* Any other malformed value is left to the caller to drop — a wrong window
|
|
1516
|
+
* would hide a live promotion, and guessing is not this parser's job.
|
|
1517
|
+
*/
|
|
1518
|
+
function minutesOfClock(value) {
|
|
1519
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
|
|
1520
|
+
if (match === null) return void 0;
|
|
1521
|
+
const hours = Number(match[1]);
|
|
1522
|
+
const minutes = Number(match[2]);
|
|
1523
|
+
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return void 0;
|
|
1524
|
+
if (minutes > 59) return void 0;
|
|
1525
|
+
if (hours > 24) return void 0;
|
|
1526
|
+
if (hours === 24) return minutes === 0 ? 1440 : void 0;
|
|
1527
|
+
return hours * 60 + minutes;
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Minutes since local midnight in `timeZone` at `nowMs`.
|
|
1531
|
+
*
|
|
1532
|
+
* `Intl` does the zone maths, because the upstream's windows are written in
|
|
1533
|
+
* the zone IT names (Asia/Shanghai for the CN feed) and the user's own clock
|
|
1534
|
+
* is not necessarily in that zone. An unusable zone falls back to UTC rather
|
|
1535
|
+
* than throwing: a badge that cannot be placed is a missing badge, not a
|
|
1536
|
+
* broken card.
|
|
1537
|
+
*/
|
|
1538
|
+
function zonedMinutes(nowMs, timeZone) {
|
|
1539
|
+
const zone = timeZone ?? "UTC";
|
|
1540
|
+
try {
|
|
1541
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
1542
|
+
timeZone: zone,
|
|
1543
|
+
hour: "2-digit",
|
|
1544
|
+
minute: "2-digit",
|
|
1545
|
+
hourCycle: "h23"
|
|
1546
|
+
}).formatToParts(new Date(nowMs));
|
|
1547
|
+
const hour = Number(parts.find((part) => part.type === "hour")?.value ?? "0");
|
|
1548
|
+
const minute = Number(parts.find((part) => part.type === "minute")?.value ?? "0");
|
|
1549
|
+
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return 0;
|
|
1550
|
+
return hour * 60 + minute;
|
|
1551
|
+
} catch {
|
|
1552
|
+
return 0;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
/** Whether `nowMinutes` falls inside one of the daily windows. */
|
|
1556
|
+
function withinDailyWindows(windows, nowMinutes) {
|
|
1557
|
+
return windows.some((window) => {
|
|
1558
|
+
const start = minutesOfClock(window.start);
|
|
1559
|
+
const end = minutesOfClock(window.end);
|
|
1560
|
+
if (start === void 0 || end === void 0) return false;
|
|
1561
|
+
if (start === end) return true;
|
|
1562
|
+
if (end > start) return nowMinutes >= start && nowMinutes < end;
|
|
1563
|
+
return nowMinutes >= start || nowMinutes < end;
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Whether the promotion's ABSOLUTE window holds now.
|
|
1568
|
+
*
|
|
1569
|
+
* The absolute window (`validFrom` / `validUntil`) is the promotion's own
|
|
1570
|
+
* lifetime. It gates the badge on its own: a promotion whose lifetime has
|
|
1571
|
+
* ended must not keep advertising itself, whatever its daily hours say.
|
|
1572
|
+
*/
|
|
1573
|
+
function promoWithinAbsoluteWindow(promo, nowMs) {
|
|
1574
|
+
const schedule = promo.schedule;
|
|
1575
|
+
if (schedule === void 0) return true;
|
|
1576
|
+
if (schedule.validFrom !== void 0 && nowMs < schedule.validFrom) return false;
|
|
1577
|
+
if (schedule.validUntil !== void 0 && nowMs >= schedule.validUntil) return false;
|
|
1578
|
+
return true;
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Whether the promotion's DAILY window holds now.
|
|
1582
|
+
*
|
|
1583
|
+
* The daily window is the discount's own hours — it is what makes a
|
|
1584
|
+
* promotion a "night discount" — and a promotion without one is running
|
|
1585
|
+
* whenever its absolute window holds.
|
|
1586
|
+
*
|
|
1587
|
+
* A schedule whose `daily` was published but could not be evaluated
|
|
1588
|
+
* ({@link WorkBuddyPromoSchedule.dailyUnusable}) is NOT running: the upstream
|
|
1589
|
+
* did say the campaign has hours, we just cannot read them, and assuming
|
|
1590
|
+
* "always" is the one guess that overstates the offer.
|
|
1591
|
+
*/
|
|
1592
|
+
function promoWithinDailyWindow(promo, nowMs) {
|
|
1593
|
+
const schedule = promo.schedule;
|
|
1594
|
+
if (schedule === void 0) return true;
|
|
1595
|
+
if (schedule.dailyUnusable === true) return false;
|
|
1596
|
+
if (schedule.daily === void 0 || schedule.daily.length === 0) return true;
|
|
1597
|
+
return withinDailyWindows(schedule.daily, zonedMinutes(nowMs, schedule.timezone));
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* Whether the promotion is running right now: its absolute window AND its
|
|
1601
|
+
* daily hours both hold.
|
|
1602
|
+
*/
|
|
1603
|
+
function promoActiveAt(promo, nowMs) {
|
|
1604
|
+
return promoWithinAbsoluteWindow(promo, nowMs) && promoWithinDailyWindow(promo, nowMs);
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Whether the promotion carries ANY temporal statement of its own.
|
|
1608
|
+
*
|
|
1609
|
+
* A tier badge ("Pro Only") has no `schedule` at all: it describes the model
|
|
1610
|
+
* unconditionally, so `promoActiveAt` is trivially true for it. That is
|
|
1611
|
+
* correct on its own, but it made such a badge eligible for step 1, where it
|
|
1612
|
+
* could shadow a lower-priority campaign whose window really is open.
|
|
1613
|
+
* "Running" should mean "this entry says it runs now", and an entry with no
|
|
1614
|
+
* schedule never does — it only ever "stands". Entries WITH a schedule (a
|
|
1615
|
+
* lifetime, hours, or both) are the ones that can be running.
|
|
1616
|
+
*/
|
|
1617
|
+
function hasSchedule(promo) {
|
|
1618
|
+
return promo.schedule !== void 0;
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* The badge to draw for a model right now, or undefined when there is none.
|
|
1622
|
+
*
|
|
1623
|
+
* The order of the two answers is the whole point:
|
|
1624
|
+
*
|
|
1625
|
+
* 1. A promotion RUNNING NOW wins — highest `priority` first. "Running" means
|
|
1626
|
+
* its absolute window AND its daily hours both hold.
|
|
1627
|
+
* 2. Otherwise a STANDING badge (one the upstream did not mark `activeOnly`)
|
|
1628
|
+
* describes the model for as long as the promotion itself lasts. It is
|
|
1629
|
+
* offered only inside the promotion's absolute window, so an expired
|
|
1630
|
+
* promotion stops advertising itself even though its badge was standing.
|
|
1631
|
+
*
|
|
1632
|
+
* TWO RULES KEEP STEP 2 FROM STATING SOMETHING THE UPSTREAM DID NOT:
|
|
1633
|
+
*
|
|
1634
|
+
* a. An entry that publishes daily hours may stand outside them (that is what
|
|
1635
|
+
* "the hours govern the discount, not the badge" means), but an entry whose
|
|
1636
|
+
* hours could not be parsed cannot stand at all — see `dailyUnusable`.
|
|
1637
|
+
* b. A standing entry with NO schedule at all must not outrank a promotion
|
|
1638
|
+
* that is genuinely running. `promoActiveAt` returns true for a
|
|
1639
|
+
* schedule-less tier, which is correct on its own, but it made a
|
|
1640
|
+
* high-priority tier shadow a lower-priority campaign whose window really
|
|
1641
|
+
* is open. Step 1 is therefore restricted to entries that carry a
|
|
1642
|
+
* `schedule`; the schedule-less badges compete only in step 2, where they
|
|
1643
|
+
* belong.
|
|
1644
|
+
*
|
|
1645
|
+
* The upstream's own `priority` orders entries. Ties are broken toward the
|
|
1646
|
+
* promotion: a price badge ("Free now") is more actionable than an access-tier
|
|
1647
|
+
* badge ("Pro Only"), and the upstream numbers them equally.
|
|
1648
|
+
*
|
|
1649
|
+
* Only ONE badge is drawn per model: a model row is one line wide, and two
|
|
1650
|
+
* badges would push the model name and the image/think checkboxes out of it.
|
|
1651
|
+
*/
|
|
1652
|
+
function selectPromoBadge(promos, nowMs) {
|
|
1653
|
+
if (promos === void 0 || promos.length === 0) return void 0;
|
|
1654
|
+
const sorted = [...promos].sort((a, b) => b.priority - a.priority || (a.source === b.source ? 0 : a.source === "promotion" ? -1 : 1));
|
|
1655
|
+
const running = sorted.find((promo) => hasSchedule(promo) && promoActiveAt(promo, nowMs));
|
|
1656
|
+
if (running !== void 0) return running;
|
|
1657
|
+
return sorted.find((promo) => promo.badge.display !== "activeOnly" && promoWithinAbsoluteWindow(promo, nowMs) && promo.schedule?.dailyUnusable !== true);
|
|
1658
|
+
}
|
|
1659
|
+
//#endregion
|
|
1517
1660
|
//#region src/probe.ts
|
|
1518
1661
|
/**
|
|
1519
1662
|
* The probe sizes the card offers, in tokens.
|
|
@@ -3641,6 +3784,137 @@ window.__ModuleLoader__.load({
|
|
|
3641
3784
|
return NATIVE_MODALITY_BY_MODEL_ID[info.id] === "multimodal";
|
|
3642
3785
|
}
|
|
3643
3786
|
//#endregion
|
|
3787
|
+
//#region src/client/promo-badge.ts
|
|
3788
|
+
/**
|
|
3789
|
+
* The UI's language, as the browser reports it.
|
|
3790
|
+
*
|
|
3791
|
+
* The card already formats dates and numbers with the browser's locale
|
|
3792
|
+
* (`formatDate`), so reading the same signal for the upstream's copy is
|
|
3793
|
+
* consistent: the badge says what the upstream says, in the language the
|
|
3794
|
+
* user reads.
|
|
3795
|
+
*
|
|
3796
|
+
* `navigator` is read defensively — this module is imported by unit tests
|
|
3797
|
+
* that run in a plain Node environment, where it does not exist.
|
|
3798
|
+
*/
|
|
3799
|
+
function uiLanguage() {
|
|
3800
|
+
return typeof navigator === "undefined" ? "" : navigator.language;
|
|
3801
|
+
}
|
|
3802
|
+
/** Whether the given language is an English one. */
|
|
3803
|
+
function isEnglish(language) {
|
|
3804
|
+
return (language ?? "").toLowerCase().startsWith("en");
|
|
3805
|
+
}
|
|
3806
|
+
/**
|
|
3807
|
+
* The badge's own wording.
|
|
3808
|
+
*
|
|
3809
|
+
* The upstream's label is the region's own wording, so it is the default for
|
|
3810
|
+
* every UI. An English UI prefers the English label when the upstream
|
|
3811
|
+
* published one; the global feed publishes none, and there its label is
|
|
3812
|
+
* already English.
|
|
3813
|
+
*/
|
|
3814
|
+
function promoBadgeLabel(promo, language) {
|
|
3815
|
+
const badge = promo.badge;
|
|
3816
|
+
if (isEnglish(language)) return badge.labelEn ?? badge.label;
|
|
3817
|
+
return badge.label;
|
|
3818
|
+
}
|
|
3819
|
+
/**
|
|
3820
|
+
* The chip's colour, when the upstream published a usable one.
|
|
3821
|
+
*
|
|
3822
|
+
* Only a hex colour is accepted: the value reaches an inline style, and the
|
|
3823
|
+
* upstream's feed publishes hex (`#009273`, `#FF0000`). Anything else is
|
|
3824
|
+
* refused rather than passed through, and the card falls back to its own
|
|
3825
|
+
* brand colour.
|
|
3826
|
+
*/
|
|
3827
|
+
function promoBadgeColor(promo) {
|
|
3828
|
+
const color = promo.badge.color;
|
|
3829
|
+
if (color === void 0) return void 0;
|
|
3830
|
+
return /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(color) ? color : void 0;
|
|
3831
|
+
}
|
|
3832
|
+
/**
|
|
3833
|
+
* A calendar day, in the user's own locale — read in the ZONE the upstream
|
|
3834
|
+
* wrote the instant in.
|
|
3835
|
+
*
|
|
3836
|
+
* The zone is part of the statement, exactly as it is for the daily hours. The
|
|
3837
|
+
* upstream publishes `validFrom: "2026-07-06T00:00:00+08:00"` and describes the
|
|
3838
|
+
* same campaign as "7月6日–10月31日". Formatting that instant in the reader's
|
|
3839
|
+
* own zone shows 7月5日 to anyone west of UTC, contradicting the upstream's own
|
|
3840
|
+
* copy in the same tooltip. Keeping the upstream's zone makes the tooltip
|
|
3841
|
+
* self-consistent wherever it is read.
|
|
3842
|
+
*
|
|
3843
|
+
* `medium` rather than the card's numeric `MM/DD` form: this is a promotion's
|
|
3844
|
+
* lifetime, and "Jul 6, 2026" reads as a date while "07/06" reads as a number
|
|
3845
|
+
* the user has to decode.
|
|
3846
|
+
*/
|
|
3847
|
+
function formatDay(ms, language, timeZone) {
|
|
3848
|
+
try {
|
|
3849
|
+
return new Intl.DateTimeFormat(language === void 0 || language === "" ? void 0 : language, {
|
|
3850
|
+
year: "numeric",
|
|
3851
|
+
month: "short",
|
|
3852
|
+
day: "numeric",
|
|
3853
|
+
...timeZone === void 0 ? {} : { timeZone }
|
|
3854
|
+
}).format(new Date(ms));
|
|
3855
|
+
} catch {
|
|
3856
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
3857
|
+
}
|
|
3858
|
+
}
|
|
3859
|
+
/**
|
|
3860
|
+
* The promotion's lifetime, as the upstream's own two ends.
|
|
3861
|
+
*
|
|
3862
|
+
* `validUntil` is EXCLUSIVE — see `promoWithinAbsoluteWindow`: the promotion is
|
|
3863
|
+
* over AT that instant. The upstream writes it as the midnight that opens the
|
|
3864
|
+
* day after the campaign, and says "– Oct 31" in its own copy for a
|
|
3865
|
+
* `validUntil` of `2026-11-01T00:00:00+08:00`. Printing the raw instant would
|
|
3866
|
+
* put "Nov 1, 2026" in the same tooltip and read as if the campaign ran a day
|
|
3867
|
+
* longer than it does. One millisecond before the boundary is the last instant
|
|
3868
|
+
* that still belongs to it, so that is what gets rendered.
|
|
3869
|
+
*/
|
|
3870
|
+
function promoRangeText(promo, language) {
|
|
3871
|
+
const schedule = promo.schedule;
|
|
3872
|
+
if (schedule === void 0) return void 0;
|
|
3873
|
+
const zone = schedule.timezone;
|
|
3874
|
+
const from = schedule.validFrom === void 0 ? void 0 : formatDay(schedule.validFrom, language, zone);
|
|
3875
|
+
const to = schedule.validUntil === void 0 ? void 0 : formatDay(schedule.validUntil - 1, language, zone);
|
|
3876
|
+
if (from === void 0 && to === void 0) return void 0;
|
|
3877
|
+
if (from === void 0) return `– ${to ?? ""}`;
|
|
3878
|
+
if (to === void 0) return `${from} –`;
|
|
3879
|
+
return `${from} – ${to}`;
|
|
3880
|
+
}
|
|
3881
|
+
/**
|
|
3882
|
+
* The hours the discount runs, in the zone the upstream named.
|
|
3883
|
+
*
|
|
3884
|
+
* The zone is part of the statement: "23:00–7:50" means nothing to a reader
|
|
3885
|
+
* in another zone, and the upstream's windows are written in ITS zone
|
|
3886
|
+
* (Asia/Shanghai for the CN feed), not the user's.
|
|
3887
|
+
*/
|
|
3888
|
+
function promoHoursText(promo) {
|
|
3889
|
+
const schedule = promo.schedule;
|
|
3890
|
+
const daily = schedule?.daily;
|
|
3891
|
+
if (daily === void 0 || daily.length === 0) return void 0;
|
|
3892
|
+
const windows = daily.map((window) => `${window.start}–${window.end}`).join(", ");
|
|
3893
|
+
const zone = schedule?.timezone;
|
|
3894
|
+
return zone === void 0 ? windows : `${windows} (${zone})`;
|
|
3895
|
+
}
|
|
3896
|
+
/**
|
|
3897
|
+
* The chip's tooltip: the upstream's own copy, its lifetime, and the hours
|
|
3898
|
+
* the discount runs.
|
|
3899
|
+
*
|
|
3900
|
+
* Empty pieces are left out rather than rendered as blanks, so a promotion
|
|
3901
|
+
* that publishes nothing but a label gets a chip with a label and no hover
|
|
3902
|
+
* text at all.
|
|
3903
|
+
*/
|
|
3904
|
+
function promoBadgeTooltip(promo, language) {
|
|
3905
|
+
const parts = [];
|
|
3906
|
+
const hover = promo.hover;
|
|
3907
|
+
if (hover !== void 0) {
|
|
3908
|
+
const copy = isEnglish(language) ? hover.textEn ?? hover.textZh : hover.textZh ?? hover.textEn;
|
|
3909
|
+
if (copy !== void 0) parts.push(copy);
|
|
3910
|
+
}
|
|
3911
|
+
const range = promoRangeText(promo, language);
|
|
3912
|
+
if (range !== void 0) parts.push(range);
|
|
3913
|
+
const hours = promoHoursText(promo);
|
|
3914
|
+
if (hours !== void 0) parts.push(hours);
|
|
3915
|
+
return parts.join(" · ");
|
|
3916
|
+
}
|
|
3917
|
+
//#endregion
|
|
3644
3918
|
//#region src/client/icon.ts
|
|
3645
3919
|
/**
|
|
3646
3920
|
* Plugin icon (LD brand logo, 64px) shared across the LaoDing plugin family.
|
|
@@ -3894,6 +4168,12 @@ window.__ModuleLoader__.load({
|
|
|
3894
4168
|
.dsm-workbuddy-model-copy{display:flex;align-items:baseline;gap:8px;min-width:0}
|
|
3895
4169
|
.dsm-workbuddy-model-name{display:inline-flex;align-items:baseline;gap:7px;color:var(--dsw-alias-label-primary,#e6e6e6);font-size:13px;font-weight:500;line-height:19px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
3896
4170
|
.dsm-workbuddy-model-name-rate{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;font-weight:400;line-height:16px;flex:none}
|
|
4171
|
+
/* The upstream's own promotion badge ("限时免费" / "Free now" / "Pro Only").
|
|
4172
|
+
The colour is the upstream's own, handed in as a custom property; without
|
|
4173
|
+
one the brand colour stands in. "flex:none" keeps the badge whole while the
|
|
4174
|
+
model name beside it truncates with an ellipsis. The plain background comes
|
|
4175
|
+
first so an engine without color-mix still gets a readable chip. */
|
|
4176
|
+
.dsm-workbuddy-model-promo{flex:none;display:inline-flex;align-items:center;padding:0 7px;border-radius:999px;font-size:10px;line-height:16px;font-weight:600;white-space:nowrap;color:var(--dsm-workbuddy-promo-color,var(--dsw-alias-brand-primary,#5686fe));background:rgba(255,255,255,.08);background:color-mix(in srgb,var(--dsm-workbuddy-promo-color,var(--dsw-alias-brand-primary,#5686fe)) 16%,transparent);border:1px solid transparent;border-color:color-mix(in srgb,var(--dsm-workbuddy-promo-color,var(--dsw-alias-brand-primary,#5686fe)) 40%,transparent)}
|
|
3897
4177
|
.dsm-workbuddy-model-id{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
3898
4178
|
.dsm-workbuddy-model-desc{margin:0;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
|
|
3899
4179
|
.dsm-workbuddy-model-details{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0}
|
|
@@ -4666,6 +4946,37 @@ window.__ModuleLoader__.load({
|
|
|
4666
4946
|
default: return t("row.modelImageUnverified");
|
|
4667
4947
|
}
|
|
4668
4948
|
}
|
|
4949
|
+
/**
|
|
4950
|
+
* The upstream's own promotion badge for one model, or nothing.
|
|
4951
|
+
*
|
|
4952
|
+
* Drawn from the model's UNRESOLVED promo list at RENDER time, because a badge
|
|
4953
|
+
* is a function of the clock: the upstream's promotions run on wall-clock
|
|
4954
|
+
* hours in a named zone, so a badge chosen when the document was read would
|
|
4955
|
+
* keep claiming the night after the night is over. The card re-renders at
|
|
4956
|
+
* least once a minute while it is open (it polls the usage route), so a
|
|
4957
|
+
* window edge is picked up within a minute without a second timer.
|
|
4958
|
+
*
|
|
4959
|
+
* The chip says what the UPSTREAM says — its label, its colour, its hover
|
|
4960
|
+
* copy — and adds nothing of its own: a badge the user cannot trace back to
|
|
4961
|
+
* the upstream's feed is worse than no badge.
|
|
4962
|
+
*
|
|
4963
|
+
* One badge per model: the upstream's `priority` orders the entries that name
|
|
4964
|
+
* a model, and a model row is one line wide — two badges would push the
|
|
4965
|
+
* model name out of the row.
|
|
4966
|
+
*/
|
|
4967
|
+
function promoChip(model) {
|
|
4968
|
+
const promo = selectPromoBadge(model.promos, Date.now());
|
|
4969
|
+
if (promo === void 0) return null;
|
|
4970
|
+
const language = uiLanguage();
|
|
4971
|
+
const color = promoBadgeColor(promo);
|
|
4972
|
+
const tooltip = promoBadgeTooltip(promo, language);
|
|
4973
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
4974
|
+
className: "dsm-workbuddy-model-promo",
|
|
4975
|
+
title: tooltip === "" ? void 0 : tooltip,
|
|
4976
|
+
style: color === void 0 ? void 0 : { "--dsm-workbuddy-promo-color": color },
|
|
4977
|
+
children: promoBadgeLabel(promo, language)
|
|
4978
|
+
});
|
|
4979
|
+
}
|
|
4669
4980
|
/** Inject or refresh the shared card CSS for the current client bundle. */
|
|
4670
4981
|
if (typeof document !== "undefined") {
|
|
4671
4982
|
const cssId = "dsh-connect-workbuddy/client.css";
|
|
@@ -5938,9 +6249,9 @@ window.__ModuleLoader__.load({
|
|
|
5938
6249
|
onChange: () => {
|
|
5939
6250
|
toggleModel(model.id);
|
|
5940
6251
|
}
|
|
5941
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.
|
|
6252
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
5942
6253
|
className: "dsm-workbuddy-model-copy",
|
|
5943
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
6254
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
5944
6255
|
className: "dsm-workbuddy-model-name",
|
|
5945
6256
|
children: [model.name, model.creditMultiplier === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
5946
6257
|
className: "dsm-workbuddy-model-name-rate",
|
|
@@ -5950,7 +6261,7 @@ window.__ModuleLoader__.load({
|
|
|
5950
6261
|
"x)"
|
|
5951
6262
|
]
|
|
5952
6263
|
})]
|
|
5953
|
-
})
|
|
6264
|
+
}), promoChip(model)]
|
|
5954
6265
|
})]
|
|
5955
6266
|
}),
|
|
5956
6267
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|