dsh-connect-workbuddy 4.13.1 → 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 +84 -0
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/lib/bin.js +1 -1
- package/lib/client.js +336 -16
- package/lib/{host-heartbeat-CwdR6teZ.js → host-heartbeat-B1I95fKS.js} +413 -53
- package/lib/index.d.ts +258 -26
- package/lib/index.js +33 -20
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,89 @@
|
|
|
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
|
+
|
|
53
|
+
## 4.13.2 (2026-10-21)
|
|
54
|
+
|
|
55
|
+
> 版本号是 **patch**:没有接口、配置项或请求结构的变更。发往上游的请求体**一个字节都没变**——4.13.0 的「渠道指纹归一化」照旧在跑,本版只改**网关已经拒绝之后**的那一段:把 11128 从「通用参数拒绝」里拆出来单独归类,并把**被拒的那条 system 提示开头**回显给读者。路由 id、模型 id、凭据、计费与换号规则一律未变(换号规则只有一处「不再多问一次」的减少,见下)。
|
|
56
|
+
|
|
57
|
+
### Fixes
|
|
58
|
+
|
|
59
|
+
- **issue #87 / #88 / #89 / #90 的后半段:11128 仍被归进「通用参数拒绝」,读者拿到的还是那句「请按上面的提示调整后重试」,而「上面的提示」是网关的误导文案**
|
|
60
|
+
- **报告**:4.13.0 把「为什么被拒」查清了(网关按 system 提示**开头**匹配第三方 agent 清单),并让这类请求不再发出去。但**清单是实测快照**:网关新增词条时,请求照样被拒,而插件此时给出的仍然是那句通用回复——`workbuddy 上游失败:请求被上游拒绝(http 400)(code 11128,requestId …)。这次调用没通过渠道校验,可能不是官方客户端发出的。请用官方版本重新发送。该请求被上游按参数拒绝:与网络、账号、额度都无关,换账号也不会改变结果,请按上面的提示调整后重试`。四份报告里的一共五位用户(@huaizhuanghub、@ShiningYangYXN、@yuhang0529、@haohui421 等)在这句上花了大量时间:换账号、换区域、装官方客户端、改 UA。
|
|
61
|
+
- **真实原因:`11128` 这一族里有三道门共用一个业务码,而插件只认其中一道。** 网关的 `displayTips` 对**每一道**都写成同一句「这次调用没通过渠道校验,可能不是官方客户端发出的」,所以那句话从来不是判据,只是一句放之四海皆准的托词。本版要处理的那一道是**提示开头命中**(4.13.0 已定位),而另外两道是**请求头**缺 conversation id(issue #66 已修)与**首条消息不是 system**。三道门共用 `11128`,**只有 body 能区分**。
|
|
62
|
+
- **插件此前的三个错处,逐条对应**:
|
|
63
|
+
1. **归类错了**:11128 落在 `client` 兜底类,读者拿到的是「按参数拒绝,换账号也没用」——这句本身没错,但**它没说是什么参数被拒**,于是提示词从来不在嫌疑名单上;而「不是官方客户端发出的」那句托词被原样转述,用户信了它。
|
|
64
|
+
2. **证据没有回显**:被拒的证据在**我们刚发出去的请求**里,不在上游的回复里。插件手上明明有那几行字,却只转述了上游的托词。
|
|
65
|
+
3. **探针那一侧也退化成通用失败**:探针用的是中性 system 提示(`probeRequestBody`),所以「测试能过、聊天被拒」正是这个闸的特征,而卡片当时把同一种拒绝印成「✗ 测试失败」——那一句在账号旁边,读者只会去换账号。
|
|
66
|
+
- **修法一:把「提示开头命中」这一道单独归类(`channel_reject`)**。判据是**两半都要**:body 里同时有 `"code":11128` **和** msg 的措辞(`unapproved channel` / `illegal api invocation`,大小写不敏感)。只看 code 会连另外两道门一起认领——那会让读者去改写一条根本没问题的 system 提示,比通用回复更糟;所以另外两道门(`conversation id missing`、`first message is not system prompt`)被**显式排除**。**只认 `msg`、绝不认 `displayTips`**:那句托词是全族共用的,认它等于把三道门混成一道。判据放在 `classifyUpstreamError()` 里**策略拒绝之后、429/14003 之前**,所以真正的内容拒绝(`11140`)仍走它更具体的回复。
|
|
67
|
+
- **修法二:回显被拒的那一行**。新增 `systemOpeningsOfBody()`:从**实际发出去的 body**(`prepareChatBody` 归一化之后的那一份)里取每条 system 消息的**第一行非空内容**,最多回显 3 条、每条截到 120 字符,取的是**上真实发送的字节**(所以命中已知指纹时回显的是插件加的那行占位语,不是宿主原文——这正是「证据」二字的含义)。读不出 body 时不编造,直接省略这段,但结论句仍然成立。
|
|
68
|
+
- **修法三:三条行为跟着这个归类走**,因为它们都属于「上游拒的是提示词,不是账号」这一件事:
|
|
69
|
+
- **不再换号**(`isFailoverWorthy`):网关读的是**每条 account 都会发的那同一句提示**,换到下一个账号只会晚一轮拿到同一个 11128,而每换一次就烧一次真实额度、还往一个从没出问题的凭据上记一笔假账。
|
|
70
|
+
- **不把账号踢出池**(`exclusionOf`):与 `policy-rejected` 同处理——测量照记(卡片要能解释发生了什么),但账号仍是候选。把它 bench 掉既浪费一个好账号,又把真正的解法(改提示词)藏起来。
|
|
71
|
+
- **探针与账号表用各自的说法**(`channel-rejected` 这个 outcome):卡片那一行的文案是「网关拦的是提示词本身,不是这个账号——某条 system 消息的开头命中了它的第三方 agent 过滤,换账号或换区域都会得到同一个答案(测试能过、聊天被拒,正是这个原因)」,语气用 `warn` 而不是 `bad`:账号本身没问题,一行 `bad` 会把读者送回「换账号」。
|
|
72
|
+
- **回复文本长什么样**(形状与其它失败一致,报告者会原样粘进 issue,所以外框不变):`workbuddy 上游失败:被网关的渠道校验拦截(不是账号、额度或网络问题)(http 400)(code 11128,requestId …)。命中的 system 提示开头:「You are an AI agent powered by DeepSeek Harness.」。该请求被 WorkBuddy 网关的渠道校验拦截,上游把它描述成「不是官方客户端发出的」,但那是误导性文案:网关匹配的是 system 提示的开头,与账号、积分、额度、网络都无关,换账号或换区域也不会改变结果。若这段开头来自宿主或其他插件,请修改该提示;插件已对已知的第三方 agent 指纹做归一化,新的指纹可反馈到 issue 里`
|
|
73
|
+
- **宿主那一侧也核过了(本版刻意**不**改的地方)**:这个失败写给宿主的 `code` 就是 `channel_reject`(**不翻译**),状态 400 ⇒ 宿主分类为 `INVALID_REQUEST`。这一类**不在**宿主的原地重试集合(`RATE_LIMIT` / `SERVER` / `TIMEOUT` / `TRANSPORT` / `EMPTY_RESPONSE`)里——这是对的:闸的判决对同一段提示是确定的,重试只是把同一串字节再发一遍。宿主 `failureMessage()` 只对 `QUOTA` / `ACCOUNT_QUOTA` / `AUTH` 等少数几类**丢掉插件自己的文案**,本类不在其中,所以上面那句原样到达读者。
|
|
74
|
+
- **代价与边界(如实登记)**:① 回显的是**提示开头**,不是整个提示——闸只看开头,其余内容无辜,不该被抄进 issue;② 只对**body 能区分**的那一道门生效,另外两道门(请求头缺 id、首条不是 system)维持原分类,它们各有各的修法;③ 提示开头若本身就是敏感内容,读者粘进 issue 时要注意(插件不做脱敏,**上游原文回显与 issue 传播是使用者自己的决定**);④ 4.13.0 的「已知指纹归一化」清单仍是实测快照,本版**不扩大**它——归一化是修复,单独归类是**兜底**,两者不互相替代。
|
|
75
|
+
|
|
76
|
+
### Tests
|
|
77
|
+
|
|
78
|
+
- **`tests/issue90-channel-reject.spec.ts` 新增 24 条用例**(含 3 组):把「认得这道门」与「**认错**这道门」分开钉,后者才是本版的真正难点:
|
|
79
|
+
- **认门(6 条)**:真实 CN 网关的 11128 报文逐字节捕获 → 归为 `channel_reject`(修复前是 `client`);只认 `msg` 不认 `displayTips`(三道门共用的那句托词不足以认领任何一道);另外两道 11128 门**保持原分类**(认错会让读者去改一条没问题的提示);code 与措辞**两半都要**(换个业务码引同样的英文短语不是这道门);真正的内容拒绝(`11140`)仍走它更具体的回复;请求 id 里恰好含 `11128` 不被误认。
|
|
80
|
+
- **探针与账号池(2 条)**:探针把这个拒绝报成 `channel-rejected` 而不是 `failed`(后者在卡片上是「✗ 测试失败」,是唯一会引人去换账号的标签);`exclusionOf()` 对它返回 `undefined`——账号不被踢出池。
|
|
81
|
+
- **回显取什么(6 条)**:每条 system 消息各取第一行;**取的是归一化之后真正上线的字节**(已知指纹回显的是占位行,不是宿主原文);病态长行截到 120 字符而不是把整个提示倒出来;最多 3 条;读不出来的 body 一律返回 `[]` 且**不抛**(丢掉测量不是可以接受的代价);`content` 是数组而非字符串的消息被跳过。
|
|
82
|
+
- **渲染出来的回复(6 条)**:真的挂起 **shim**(真 body 准备 + 真失败渲染),只把上游应答换成固定报文:回复里有被拒的那一行、有 `code 11128`、有 `requestId`、有「换账号或换区域也不会改变结果」、有「误导性文案」,并且**没有**旧那句「换账号也不会改变结果,请按上面的提示调整后重试」;回显的是**归一化后**的内容;宿主没给 system 消息时回显插件补的那行占位语(`ensureSystemHead` 保证有头,所以这是离「没有提示可引」最近的一种);**没有把一次换号花掉**(failover 回调被调用 0 次,且回复不声称「已尝试 N 个账号」——那是 issue #78 的老坑);宿主面拿到的是 400 + `channel_reject`。
|
|
83
|
+
- **卡片文案(2 条)**:中英文都与「策略拒绝」「通用失败」不同(共用字符串会让「测试能过、聊天被拒」读成内容拒绝,而那有完全相反的下一步);**所有 `row.probe*` 文案里不含 `**` 粗体标记**——探针结论是当作纯文本渲染进 `<p>` 的,带标记就会在屏幕上显示成字面的星号。
|
|
84
|
+
- **宿主那一侧(2 条,issue #84 的做法)**:用**真实 pi-ai 客户端**打**真实 shim**,再问**宿主自己的判定函数**——`isQuotaExceededError()` 必须为假(为真则宿主会用自己的「额度已用尽」顶掉插件的句子,正是 issue #84 的那个坑),且文本里必须有「命中的 system 提示开头」与「换账号或换区域也不会改变结果」,**且不能有**「请用官方版本重新发送」那句托词。
|
|
85
|
+
- **变异验证**:`git checkout src/upstream.ts src/shim.ts` 回退修复后,本文件 **17 条**失败(归类回 `client`、回显消失、换号又发生、探针退回 `failed`、宿主侧被判成额度失败);仍然通过的 7 条正是护栏(另外两道 11128 门的原分类、`11140` 的原分类、账号不被踢出池、不浪费换号、卡片文案互不相同、文案不带 `**`)。
|
|
86
|
+
|
|
3
87
|
## 4.13.1 (2026-10-21)
|
|
4
88
|
|
|
5
89
|
> 版本号是 **patch**:没有接口、配置项或请求结构的变更。发往上游的请求体**一个字节都没变**——修的是「请求根本没被发出去」这条本地失败路径:目录里没有 reasoning 元数据的模型,此前在组装请求时就被本地判定失败(`UNSUPPORTED_REASONING_EFFORT`),网络一个字节都没出。路由 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 {
|
|
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
|
/**
|
|
@@ -902,6 +889,7 @@ window.__ModuleLoader__.load({
|
|
|
902
889
|
case "model-busy": return retryDue(probe, nowMs, 12e4) ? void 0 : "model-busy";
|
|
903
890
|
case "out-of-credit": return retryDue(probe, nowMs) ? void 0 : "out-of-credit";
|
|
904
891
|
case "policy-rejected": return;
|
|
892
|
+
case "channel-rejected": return;
|
|
905
893
|
default: return "unusable";
|
|
906
894
|
}
|
|
907
895
|
}
|
|
@@ -1016,6 +1004,7 @@ window.__ModuleLoader__.load({
|
|
|
1016
1004
|
case "model-busy": return t("row.poolExcludedModelBusy");
|
|
1017
1005
|
case "out-of-credit": return t("row.poolExcludedOutOfCredit");
|
|
1018
1006
|
case "credential-rejected": return t("row.probeCredentialRejected");
|
|
1007
|
+
case "channel-rejected": return t("row.probeChannelRejected");
|
|
1019
1008
|
case "not-found": return t("row.probeNotFound");
|
|
1020
1009
|
case "unavailable": return t("row.probeUnavailable");
|
|
1021
1010
|
default: return t("row.probeFailed");
|
|
@@ -1204,6 +1193,7 @@ window.__ModuleLoader__.load({
|
|
|
1204
1193
|
"out-of-credit",
|
|
1205
1194
|
"credential-rejected",
|
|
1206
1195
|
"policy-rejected",
|
|
1196
|
+
"channel-rejected",
|
|
1207
1197
|
"unavailable",
|
|
1208
1198
|
"not-found",
|
|
1209
1199
|
"failed"
|
|
@@ -1511,6 +1501,162 @@ window.__ModuleLoader__.load({
|
|
|
1511
1501
|
return t("row.poolProbeDaysAgo", { count: Math.round(age / DAY_MS) });
|
|
1512
1502
|
}
|
|
1513
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
|
|
1514
1660
|
//#region src/probe.ts
|
|
1515
1661
|
/**
|
|
1516
1662
|
* The probe sizes the card offers, in tokens.
|
|
@@ -3638,6 +3784,137 @@ window.__ModuleLoader__.load({
|
|
|
3638
3784
|
return NATIVE_MODALITY_BY_MODEL_ID[info.id] === "multimodal";
|
|
3639
3785
|
}
|
|
3640
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
|
|
3641
3918
|
//#region src/client/icon.ts
|
|
3642
3919
|
/**
|
|
3643
3920
|
* Plugin icon (LD brand logo, 64px) shared across the LaoDing plugin family.
|
|
@@ -3891,6 +4168,12 @@ window.__ModuleLoader__.load({
|
|
|
3891
4168
|
.dsm-workbuddy-model-copy{display:flex;align-items:baseline;gap:8px;min-width:0}
|
|
3892
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}
|
|
3893
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)}
|
|
3894
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}
|
|
3895
4178
|
.dsm-workbuddy-model-desc{margin:0;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
|
|
3896
4179
|
.dsm-workbuddy-model-details{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0}
|
|
@@ -4663,6 +4946,37 @@ window.__ModuleLoader__.load({
|
|
|
4663
4946
|
default: return t("row.modelImageUnverified");
|
|
4664
4947
|
}
|
|
4665
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
|
+
}
|
|
4666
4980
|
/** Inject or refresh the shared card CSS for the current client bundle. */
|
|
4667
4981
|
if (typeof document !== "undefined") {
|
|
4668
4982
|
const cssId = "dsh-connect-workbuddy/client.css";
|
|
@@ -4823,6 +5137,10 @@ window.__ModuleLoader__.load({
|
|
|
4823
5137
|
text: t("row.probePolicyRejected"),
|
|
4824
5138
|
tone: "bad"
|
|
4825
5139
|
};
|
|
5140
|
+
case "channel-rejected": return {
|
|
5141
|
+
text: t("row.probeChannelRejected"),
|
|
5142
|
+
tone: "warn"
|
|
5143
|
+
};
|
|
4826
5144
|
case "not-found": return {
|
|
4827
5145
|
text: t("row.probeNotFound"),
|
|
4828
5146
|
tone: "bad"
|
|
@@ -5931,9 +6249,9 @@ window.__ModuleLoader__.load({
|
|
|
5931
6249
|
onChange: () => {
|
|
5932
6250
|
toggleModel(model.id);
|
|
5933
6251
|
}
|
|
5934
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.
|
|
6252
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
5935
6253
|
className: "dsm-workbuddy-model-copy",
|
|
5936
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
6254
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
5937
6255
|
className: "dsm-workbuddy-model-name",
|
|
5938
6256
|
children: [model.name, model.creditMultiplier === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
5939
6257
|
className: "dsm-workbuddy-model-name-rate",
|
|
@@ -5943,7 +6261,7 @@ window.__ModuleLoader__.load({
|
|
|
5943
6261
|
"x)"
|
|
5944
6262
|
]
|
|
5945
6263
|
})]
|
|
5946
|
-
})
|
|
6264
|
+
}), promoChip(model)]
|
|
5947
6265
|
})]
|
|
5948
6266
|
}),
|
|
5949
6267
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
@@ -7211,6 +7529,7 @@ window.__ModuleLoader__.load({
|
|
|
7211
7529
|
"row.probeOutOfCreditAt": "✗ Not enough credits · quota refreshes {at}",
|
|
7212
7530
|
"row.probeCredentialRejected": "✗ WorkBuddy refused this account's token — sign in again or pick another account",
|
|
7213
7531
|
"row.probePolicyRejected": "✗ WorkBuddy's server refused this request under its content policy — re-signing in won't help; try the desktop app or another region/account",
|
|
7532
|
+
"row.probeChannelRejected": "✗ The gateway refused the PROMPT itself, not this account — the opening of a system message matched its third-party-agent filter, so another account or region answers the same way",
|
|
7214
7533
|
"row.probeNotFound": "✗ The upstream does not know this model",
|
|
7215
7534
|
"row.probeUnavailable": "✗ The upstream could not be reached — no usable answer came back",
|
|
7216
7535
|
"row.probeFailed": "✗ Test failed",
|
|
@@ -7536,6 +7855,7 @@ window.__ModuleLoader__.load({
|
|
|
7536
7855
|
"row.probeOutOfCreditAt": "✗ 积分不足 · 积分包 {at} 刷新",
|
|
7537
7856
|
"row.probeCredentialRejected": "✗ WorkBuddy 拒绝了这个账号的 token——请重新登录或换一个账号",
|
|
7538
7857
|
"row.probePolicyRejected": "✗ WorkBuddy 服务端按内容策略拒绝了这次请求——重新登录无效;可在桌面端验证或切换区域/账号",
|
|
7858
|
+
"row.probeChannelRejected": "✗ 网关拦的是提示词本身,不是这个账号——某条 system 消息的开头命中了它的第三方 agent 过滤,换账号或换区域都会得到同一个答案(测试能过、聊天被拒,正是这个原因)",
|
|
7539
7859
|
"row.probeNotFound": "✗ 上游不认识这个模型",
|
|
7540
7860
|
"row.probeUnavailable": "✗ 连不上上游——上游没有给出可用答复",
|
|
7541
7861
|
"row.probeFailed": "✗ 测试失败",
|