dsh-connect-trae 2.0.5 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.0 (2026-09-24)
4
+
5
+ ### Features
6
+
7
+ - **新增「每日签到领取」**(对齐 `dsh-connect-workbuddy` 的签到能力):
8
+ - **交互**:国内版卡片在积分面板下方多一行签到:显示每日奖励(基础 + 额外,本机实测 `150 + 50`)与一个「立即领取」按钮;领取中显示「领取中…」,当日已领取显示「今日已领取」并禁用。国际版没有签到活动,因此**不显示该行**。
9
+ - **这是插件里唯一的写操作**,也是唯一会改变账号状态的调用(`POST /trae/api/v2/ug/checkin_credits/claim`);其余查询全部只读。README 顶部与特性列表都按此措辞改写,不再把整个用量面板称为「只读」。
10
+ - **根因级发现:缺 `x-device-id` 会被静默拒绝**。同一份 Authorization,只加这一个头,结果从「HTTP 200 + 业务码 `9004`(The submitted order parameters are incorrect)」变成「`code: 0`,奖励到账」——**HTTP 状态始终是 200**,只看状态码会把它误判为成功。设备号取自本机 `iCubeAuthInfo://icube-dc:<id>`,即 `identity.ts` 里聊天通道同源的那个 `deviceId`(官方客户端在 `main.js` 的 `fb(headers)` 里给每个 ug 请求都补上它)。`deviceId` 由 `index.ts` 惰性注入;读不到时降级为不发该头——**只影响领取,不影响状态查询**(状态接口两种情况下都正常返回)。
11
+ - **领取前的守卫比上游更强**:路由 `POST /plugins/dsh-connect-trae/checkin` 仅接受 POST、仅接受回环来源、仅服务 `cn` 区域,并且**先读状态**——已领取(`checked_in` 或 `did_checked_in` 任一为真)就直接返回 `alreadyCheckedIn` 且**根本不发领取请求**;活动关闭答 409。国际版直接答 404 并说明原因,而不是把上游的 HTML 404 当成网络故障抛给用户。
12
+ - **已实测上游按北京自然日幂等**:重复领取仍回 `code: 0`,而权益总额(`total=1600, consumed=390.8`)逐字节不变,**不会重复发放**。插件仍然自行守卫——「不会重复发放」是上游的行为,不是插件可以依赖的保证。
13
+ - **业务拒绝不当成故障**:`claimed: false` + 原始业务码与 message 原样回给卡片并显示出来,而不是包成 500。否则用户只会看到「领取失败」,而丢掉唯一能解释原因的 `9004`。
14
+ - **对齐官方客户端的状态判定**:`checked_in` 与 `did_checked_in` 是**两个独立字段**,官方在「今日已领取」上同时看后者(即使状态读回 `checked_in: false` 也保持按钮禁用)。插件在**卡片与路由两侧都**按同一规则判定,`extra_credits` 也一并展示,避免把当天实际到账的 200 说成 150。
15
+ - 卡片新增 8 条真实点击测试(`tests/card-checkin.spec.tsx`,渲染**真组件**、`fireEvent.click` 真按钮、断言真发出去的请求),`usage.spec.ts` 补 6 条、`web-status.spec.ts` 补 7 条,共 21 条新测试(全仓 261 → 282)。做过**变异验证**:去掉路由的 `did_checked_in` 守卫 → 2 条失败;去掉卡片的同款判定 → 1 条失败;把卡片的 `POST` 改成 `GET` → 1 条失败;删掉「已领取则不发请求」的守卫 → 2 条失败。
16
+ - 取证与接口契约:`docs/USAGE_API_RESEARCH.md` 新增「每日签到领取」一节(含决定性请求头对照表、幂等性实测表、区域限制与官方字段语义)。
17
+
18
+ ## 2.0.6 (2026-09-22)
19
+
20
+ ### Features
21
+
22
+ - **新增「可单独关闭任一版本的供应商」**(issue #11:用户问「能禁用国际版或国内版吗,有没有配置的地方」——「比如说我根本用不到国际版」):
23
+ - **此前确实无解,而且最容易被误用的那条路走不通**:`edition` 配置只收窄**凭据扫描范围**(找账号用),不参与 provider 注册;`src/index.ts` 的注册循环对 `REGION_KEYS = ['cn','ai']` 是无条件注册的,所以设 `edition: "cn"` 之后 `trae-global` 依旧出现在模型选择器里。卸载国际版 App 同理——无账号时该区域回落到**插件内置的兜底目录**(Gemini / GPT / MiniMax / Kimi 共 7 条),这些模型一个都调不通,却和可用模型并排显示(这正是 issue #8 的成因)。DSH 侧也没有「停用某供应商」的入口。
24
+ - **交互**:设置卡片两个 tab 各带一个勾选,**默认都勾选**(老用户零感知);取消勾选即关闭该供应商,重新勾选即恢复。
25
+ - **关闭是「真撤掉」,不是「藏 tab」**:抽掉 adapter 路由(`AdapterRegistrationHandle.replace([])`)——这是让模型**从 DSH 模型选择器消失**的那一步;同时抽掉供应商目录条目(`DirectoryRegistrationHandle.replace([])`),否则「设置 → 模型」仍会列出它。DSH 的类型契约明确允许持零路由而保持注册("An empty array is legal here ... unlike an empty initial registration"),因此实现是**先正常注册、再 `replace([])`**,两步在同一同步段内完成,无可观测的路由空窗,且开关**立即生效、无需重启**。
26
+ - **顺带消掉一个失败面**:关闭的区域连启动时的模型目录拉取都跳过,不再发任何 Remote/SOLO 请求——「没装国际版每次启动报一次错」那类噪音一并消失。
27
+ - **状态不丢**:开关写入只改目标槽位的 `enabled` 一个字段,该区域的目录 / 勾选 / 图片开关 / 上下文预算与另一区域的整个槽位原样带过,所以「关掉 → 再打开」能完整恢复。配套地在卡片保存模型目录时**回写 `enabled`**——那次写入会替换整个槽位,漏掉它就会「保存一次模型列表 = 悄悄重新打开已关闭的供应商」。
28
+ - **已知代价(明确提示而非静默失败)**:若某会话此前选中了被关闭供应商的模型,路由撤掉后再调用会报 `NO_ADAPTER`;这是「彻底移除」的必然结果,卡片在该 tab 上给出明确文案说明设置均已保留、勾选即可恢复。
29
+ - **两侧读同一条规则**:Host 的 `regionEnabled()` 与卡片的 `regionEnabledOf()` 共用「仅显式 `false` 关闭」的判定,回归测试用 8 种正常/畸形形状交叉比对二者,锁定永不分歧;卡片另读 Host 随文档下发的 `enabled`(signed-in / signed-out 两个分支都带),因为**未登录的区域恰恰是用户最想关掉的那一个**。
30
+ - **修复开发期发现的「勾选点了没反应」**:卡片把**整个 settings 段**传给了只接受 **`regions` 子对象**的判定函数,于是它去查 `section['cn']`(不存在)并**永远返回 `true`**——复选框恒定显示为勾选、点击看起来毫无变化;而**写入其实已经成功**(设置里确实落下了 `enabled: false`),Host 侧路由也确实撤掉了,问题只在卡片的读取。修复方式不是「把参数改对」了事,而是让 `regionsMapOf()` 统一收口、**同时接受整段与子对象两种入参**,使传错一半在结构上不再可能静默返回 `true`;并补 2 条回归测试覆盖「整段入参」与「返回值必须是 `regions` 子对象」(把拆包逻辑改回原样,这 2 条立刻失败,已实测)。
31
+ - **补齐「真实点击」测试,堵住让该 bug 溜过的结构性缺口**:此前 `vitest.config.ts` 只收 `tests/**/*.spec.ts` 且 `environment: 'node'`,**根本无法渲染 React 组件**——仓库里唯一的客户端测试还是自己标注了 DRIFT WARNING 的「手工镜像副本」,不是产品代码,所以「点一下复选框」从来没有被自动化验证过(这正是 bug 能发布出去的原因)。现在 `tests/card-region-switch.spec.tsx` 在 jsdom 里渲染**真组件**、`fireEvent.click` 真实 `<input type="checkbox">`,断言 settings 落值与勾选态翻转,8 条用例覆盖空配置/已关闭/点击写入/重新启用保留选择/点第二个框/不切 tab/关闭态文案/不可写禁用。**变异验证**:把拆包逻辑改回出 bug 的样子,4 条立刻失败(含「存了 false 却显示已勾选」这一条,与用户症状一致)。配套:放开 `.spec.tsx`、`tsconfig` 分派、新增 `jsdom` / `@testing-library/react` / `@testing-library/dom` / `react-dom@18` 四个 devDependencies;`dsh-client-ui-primitives` 因带浏览器专用 CSS modules 在测试中整体 mock(产品依赖契约不变)。
32
+ - 新增 10 条回归测试(区域开关判定与两侧一致性、整段/子对象双入参、开关往返不丢状态、关闭后 `listProviders()` 与目录条目同步消失且不影响另一侧、**模型选择器分组也消失**、重启后仍生效、两个都关仍可恢复、旧配置两边都开、`enabled` 在两种文档分支都下发)。开发中做过**变异验证**:把「抽掉路由」改成「照常注册」(即只隐藏 tab 的假实现),集成测试立刻失败;把目录 `replace` 改回按区域调用,4 条测试失败;把整段入参的拆包逻辑去掉,2 条测试失败——测试确实咬得住。
33
+ - 取证与回复草稿:`docs/ISSUE11_DIAGNOSIS.md`、`docs/ISSUE11_REPLY_DRAFT.md`。
34
+
3
35
  ## 2.0.5 (2026-09-22)
4
36
 
5
37
  ### Fixes
package/README.en.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1 align="center">dsh-connect-trae</h1>
6
6
 
7
- <p align="center"><b>Connect locally signed-in Trae models to DeepSeek Harness with local DSH tools and a read-only credits overview.</b></p>
7
+ <p align="center"><b>Connect locally signed-in Trae models to DeepSeek Harness with local DSH tools, a credits overview, and daily check-in claiming.</b></p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="README.md">中文</a> ·
@@ -24,11 +24,14 @@
24
24
  <a href="https://dshfind.com/plugins/dingminhua/dsh-connect-trae"><img src="https://dshfind.com/api/badge/dingminhua/dsh-connect-trae" alt="dshfind plugin"></a>
25
25
  </p>
26
26
 
27
- A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bundle plugin that connects locally signed-in Trae models (**both CN and international installs**) to the DSH model picker. Trae generates structured tool calls while DSH executes its own local tools, with a read-only usage overview (Work/general credits on CN, subscription status on international) and model-management panel. **The CN and international sides are two parallel providers (`trae` / `trae-global`) that can be used at the same time**; the settings card separates them with tabs for convenient management.
27
+ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bundle plugin that connects locally signed-in Trae models (**both CN and international installs**) to the DSH model picker. Trae generates structured tool calls while DSH executes its own local tools, with a usage overview (Work/general credits on CN, subscription status on international), daily check-in claiming, and a model-management panel. **The CN and international sides are two parallel providers (`trae` / `trae-global`) that can be used at the same time**; the settings card separates them with tabs for convenient management.
28
+
29
+ > Apart from the daily check-in claim, every query this plugin makes is read-only and consumes nothing; the claim only happens when you click the button.
28
30
 
29
31
  ## Features
30
32
 
31
33
  - **Dual parallel providers** — the CN side registers as the `trae` provider (e.g. `DeepSeek-V4-Flash`, `DeepSeek-V4-Pro`), the international side as `trae-global` (the Gemini/GPT/MiniMax roster), and **both rosters appear in the DSH model picker simultaneously**: different sessions can each pick a side without interfering.
34
+ - **Either side can be switched off** — each tab carries its own checkbox (both checked by default): unchecking **withdraws that provider from the DSH model picker entirely** (not merely hiding the tab) and stops all of its requests; checking it again restores it with the account, directory, picks, and context budgets intact. If you never use the international side, just uncheck it.
32
35
  - **Tabbed settings card** — the card's top carries a "Domestic / Global" tab bar; each tab holds its own account picker, usage overview, 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.
33
36
  - **Multiplier in the model name** — model names show the credit multiplier in Trae's own menu format (e.g. `GLM-5.2 · x0.79`), updated with each directory refresh.
34
37
  - **DSH local tool loop** — gets pending structured `tool_calls` from Trae `llm_utils_chat`, lets DSH execute its own local tools, then returns tool results to the model.
@@ -36,6 +39,7 @@ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bund
36
39
  - **Region-isolated directories and selections** — CN and international each keep their own model directory, enabled picks, image opt-ins, and context budgets, and each provider reads only its own slot.
37
40
  - **Account switching** — refreshes the token list and lets users select an account without storing tokens in DSH settings.
38
41
  - **Read-only usage and model management** — Work/general credits on CN accounts, subscription/trial status on international ones; enable Trae models freely. Read-only queries consume nothing.
42
+ - **Daily check-in claiming** — a one-click claim on the CN card (the button shows the daily reward, and reads "Claimed today" and disables itself once done). This is the plugin's **only** operation that changes account state, and it runs only when you click. The international side has no check-in activity, so no button is shown there.
39
43
  - **Secure loopback shim** — one random port + in-process random secret per region; the real Trae token is never handed to pi-ai.
40
44
 
41
45
  ## How it works
@@ -53,7 +57,7 @@ DSH PiAiAdapter (one stack per provider)
53
57
 
54
58
  The CN and international sides each own a complete runtime stack — credential store, model catalog, wire map, upstream clients, loopback shim, adapter — with visibility filtered by the credential's own region claim, so **both regions' accounts can be signed in and used by different sessions at the same time**.
55
59
 
56
- Usage overview hits the read-only `https://api.trae.cn/trae/api/v2/pay/*` and `/trae/api/v2/ug/*` endpoints (international accounts read their own gateway's subscription status). Refreshed tokens are kept per region in `$DSH_HOME/.trae-auth.cn.json` and `$DSH_HOME/.trae-auth.ai.json` (two simultaneously signed-in accounts never overwrite each other; the legacy single file `.trae-auth.json` is still read as a migration source).
60
+ Usage overview hits the read-only `https://api.trae.cn/trae/api/v2/pay/*` and `/trae/api/v2/ug/*` endpoints (international accounts read their own gateway's subscription status); the daily check-in claim is the one write among them, going to `POST /trae/api/v2/ug/checkin_credits/claim` with this installation's device id (`x-device-id`, the same identity the chat path sends). Refreshed tokens are kept per region in `$DSH_HOME/.trae-auth.cn.json` and `$DSH_HOME/.trae-auth.ai.json` (two simultaneously signed-in accounts never overwrite each other; the legacy single file `.trae-auth.json` is still read as a migration source).
57
61
 
58
62
  > See `docs/IMPLEMENTATION_PLAN.md`, `docs/SOLO_ROUTE_DECISION.md`, `docs/USAGE_API_RESEARCH.md`.
59
63
 
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1 align="center">dsh-connect-trae</h1>
6
6
 
7
- <p align="center"><b>把本机登录的 Trae 模型接入 DeepSeek Harness,并提供只读的用量/积分概览。</b></p>
7
+ <p align="center"><b>把本机登录的 Trae 模型接入 DeepSeek Harness,并提供用量/积分概览与每日签到领取。</b></p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="README.en.md">English</a> ·
@@ -24,11 +24,14 @@
24
24
  <a href="https://dshfind.com/plugins/dingminhua/dsh-connect-trae"><img src="https://dshfind.com/api/badge/dingminhua/dsh-connect-trae" alt="dshfind plugin"></a>
25
25
  </p>
26
26
 
27
- 一个独立的 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bundle 插件。它把本机已登录的 Trae 账号(**国内版与国际版均支持**)接到 DSH 的模型选择器:模型负责生成结构化工具调用,`bash` / `read` / `write` / `edit` 等工具由 DSH 本地执行;同时提供**只读**的用量概览(国内版 Work/通用积分、国际版订阅状态)与模型管理界面。**国内版与国际版是两个并行的供应商(`trae` / `trae-global`),可同时使用**;插件设置卡片以 tab 区分两者,方便统一管理。
27
+ 一个独立的 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bundle 插件。它把本机已登录的 Trae 账号(**国内版与国际版均支持**)接到 DSH 的模型选择器:模型负责生成结构化工具调用,`bash` / `read` / `write` / `edit` 等工具由 DSH 本地执行;同时提供用量概览(国内版 Work/通用积分、国际版订阅状态)、每日签到领取与模型管理界面。**国内版与国际版是两个并行的供应商(`trae` / `trae-global`),可同时使用**;插件设置卡片以 tab 区分两者,方便统一管理。
28
+
29
+ > 除每日签到领取外,插件的所有查询都是只读的、不消耗额度;签到领取也只在你点击按钮时才发生。
28
30
 
29
31
  ## 功能特性
30
32
 
31
33
  - **双供应商并行接入** —— 国内版注册为 DSH 的 `trae` provider(`DeepSeek-V4-Flash`、`DeepSeek-V4-Pro` 等),国际版注册为 `trae-global`(Gemini / GPT / MiniMax 阵容),**两边模型同时出现在 DSH 模型选择器里**:不同会话可以各选一边,互不干扰。
34
+ - **可单独关闭任一版本** —— 两个 tab 各带一个勾选(默认都勾选):取消勾选即把该版本的供应商**整个从 DSH 模型选择器撤掉**(不是只藏起 tab),并且不再发起任何请求;重新勾选即恢复,账号、目录、勾选与上下文预算全部保留。用不到国际版就把国际版关掉即可。
32
35
  - **插件卡片 tab 切换** —— 设置卡片顶部为「国内版 / 国际版」两个 tab,各含独立的账号选择、用量概览与模型管理;每个 tab 的账号、目录、勾选与未保存草稿完全隔离——在一个 tab 里切账号或刷新模型,不会触碰另一边的运行时目录与会话。
33
36
  - **倍率内嵌模型名** —— 模型名称按 Trae 自身菜单的格式显示积分倍率(如 `GLM-5.2 · x0.79`),倍率随目录刷新更新。
34
37
  - **DSH 本地工具循环** —— 通过 Trae `llm_utils_chat` 获取待执行的结构化 `tool_calls`,交由 DSH 自带的本地工具执行,再将工具结果回传模型。
@@ -36,6 +39,7 @@
36
39
  - **目录与勾选按区域隔离** —— 国内版与国际版各一套模型目录、勾选、图片开关与上下文预算,两个供应商各自读各自的槽位。
37
40
  - **多账号切换** —— 支持重新读取 Token 列表并选择账号;Token 不写入 DSH 设置。
38
41
  - **只读用量与模型管理** —— 国内版查看 Work 积分与通用积分,国际版查看订阅/试用状态;刷新/启用 Trae 模型;只读查询不消耗额度。
42
+ - **每日签到领取** —— 国内版卡片上一键领取当日签到积分(按钮显示每日奖励,已领取则显示「今日已领取」并禁用);这是插件里**唯一会改变账号状态**的操作,由你主动点击触发。国际版没有签到活动,因此不显示该按钮。
39
43
  - **安全 loopback shim** —— 每区域一个随机端口 + 进程内随机 secret,真实 Trae token 不交给 pi-ai。
40
44
 
41
45
  ## 工作原理
@@ -53,7 +57,7 @@ DSH PiAiAdapter(每个 provider 一套)
53
57
 
54
58
  国内版与国际版各持一套完整的运行时栈——凭据 store、模型 catalog、wire 映射、上游客户端、回环 shim、adapter——按凭证自带的区域声明隔离可见账号,所以**两个区域的账号可以同时在线、同时被不同会话使用**。
55
59
 
56
- 用量概览走 `https://api.trae.cn/trae/api/v2/pay/*` 与 `/trae/api/v2/ug/*` 只读接口(国际账号走其自有网关的订阅状态端点)。刷新得到的 token 按区域存放在 `$DSH_HOME/.trae-auth.cn.json` 与 `$DSH_HOME/.trae-auth.ai.json`(两个账号同时在线互不覆盖;旧的单文件 `.trae-auth.json` 作为迁移来源保留读取)。
60
+ 用量概览走 `https://api.trae.cn/trae/api/v2/pay/*` 与 `/trae/api/v2/ug/*` 只读接口(国际账号走其自有网关的订阅状态端点);每日签到领取是其中唯一的写操作,走 `POST /trae/api/v2/ug/checkin_credits/claim`,需要携带本机安装的设备号(`x-device-id`,与聊天通道同源)。刷新得到的 token 按区域存放在 `$DSH_HOME/.trae-auth.cn.json` 与 `$DSH_HOME/.trae-auth.ai.json`(两个账号同时在线互不覆盖;旧的单文件 `.trae-auth.json` 作为迁移来源保留读取)。
57
61
 
58
62
  > 详见 `docs/IMPLEMENTATION_PLAN.md`、`docs/SOLO_ROUTE_DECISION.md`、`docs/USAGE_API_RESEARCH.md`。
59
63
 
package/lib/client.js CHANGED
@@ -14,6 +14,11 @@ window.__ModuleLoader__.load({
14
14
  const TRAE_MODELS_REFRESH_PATH = "/plugins/dsh-connect-trae/models/refresh";
15
15
  /** Plugin-owned local account rescan endpoint. */
16
16
  const TRAE_ACCOUNTS_REFRESH_PATH = "/plugins/dsh-connect-trae/accounts/refresh";
17
+ /**
18
+ * Plugin-owned daily check-in claim endpoint. POST, loopback-only, and the only
19
+ * route in this plugin that changes upstream account state.
20
+ */
21
+ const TRAE_CHECKIN_PATH = "/plugins/dsh-connect-trae/checkin";
17
22
  /** Query parameter naming the region a card request addresses. */
18
23
  const TRAE_REGION_PARAM = "region";
19
24
  /** Every region, in card tab order. */
@@ -38,6 +43,61 @@ window.__ModuleLoader__.load({
38
43
  [region]: slot
39
44
  };
40
45
  }
46
+ /**
47
+ * Narrow a settings value to the `regions` map. Accepts EITHER the whole
48
+ * settings section (`{ regions: {...}, ... }`) or the `regions` map itself, and
49
+ * unwraps the former. This tolerance is deliberate: passing the whole section
50
+ * where the map was expected was a real shipped bug — the lookup then read
51
+ * `section['cn']` (absent), so the card's checkbox reported `true` forever and
52
+ * clicking it appeared to do nothing even though the write succeeded.
53
+ */
54
+ function regionsMapOf(value) {
55
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
56
+ const record = value;
57
+ const nested = record["regions"];
58
+ if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) return nested;
59
+ return record;
60
+ }
61
+ /** One region's stored slot as a plain object; any other shape reads as empty. */
62
+ function regionSlotOf(value, region) {
63
+ const slot = regionsMapOf(value)[region];
64
+ return typeof slot === "object" && slot !== null && !Array.isArray(slot) ? slot : {};
65
+ }
66
+ /**
67
+ * Whether one region's provider is switched on. Opt-out semantics: only an
68
+ * explicit `false` disables it, so a config written before this switch existed
69
+ * (and the pre-region-split flat fields, which never carry `enabled`) keep both
70
+ * providers running exactly as before. The Host reads the same rule through
71
+ * `regionStateOf`, so card and Host can never disagree about a region's state.
72
+ *
73
+ * `value` may be the whole settings section or the `regions` map (see
74
+ * {@link regionsMapOf}).
75
+ */
76
+ function regionEnabledOf(value, region) {
77
+ return regionSlotOf(value, region)["enabled"] !== false;
78
+ }
79
+ /**
80
+ * Build the next `regions` settings value for a provider on/off toggle. ONLY
81
+ * the target region's `enabled` flag changes: every other field of that slot
82
+ * (its directory, selection, image opt-ins, context budgets) and every other
83
+ * region's slot are carried over verbatim, so switching a provider off never
84
+ * discards the user's model picks and switching it back on restores them.
85
+ *
86
+ * This is deliberately separate from {@link nextRegionSlots}: that helper
87
+ * writes a whole slot from a signed-in tab's draft, while this one must work
88
+ * for a region that is signed OUT — which is precisely the region a user wants
89
+ * to switch off (no international install, no international account).
90
+ *
91
+ * `value` may be the whole settings section or the `regions` map; the RETURN
92
+ * value is always the `regions` map, i.e. exactly what `settingsScope.set(
93
+ * 'regions', ...)` needs.
94
+ */
95
+ function nextRegionEnabled(value, region, enabled) {
96
+ return nextRegionSlots(regionsMapOf(value), region, {
97
+ ...regionSlotOf(value, region),
98
+ enabled
99
+ });
100
+ }
41
101
  //#endregion
42
102
  //#region src/client/icon.ts
43
103
  /**
@@ -77,12 +137,18 @@ window.__ModuleLoader__.load({
77
137
  .dsm-btn-primary:hover:not(:disabled){opacity:.9}
78
138
  .dsm-trae-usage{display:flex;flex-direction:column;gap:16px;margin:0;padding:16px 0 4px}
79
139
  .dsm-trae-tabs{display:flex;gap:6px;padding:4px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:10px;background:var(--dsw-alias-bg-layer-3,#2a2c33)}
80
- .dsm-trae-tab{appearance:none;font:inherit;cursor:pointer;flex:1;border:0;border-radius:7px;padding:7px 10px;color:var(--dsw-alias-label-tertiary,#999);font-size:13px;font-weight:500;line-height:18px;background:transparent;transition:color .15s,background .15s;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
140
+ .dsm-trae-tab-cell{display:flex;align-items:center;gap:2px;flex:1;min-width:0}
141
+ .dsm-trae-tab{appearance:none;font:inherit;cursor:pointer;flex:1;min-width:0;border:0;border-radius:7px;padding:7px 10px;color:var(--dsw-alias-label-tertiary,#999);font-size:13px;font-weight:500;line-height:18px;background:transparent;transition:color .15s,background .15s;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
81
142
  .dsm-trae-tab:hover:not(:disabled):not(.dsm-trae-tab-active){color:var(--dsw-alias-label-primary,#e6e6e6)}
82
143
  .dsm-trae-tab:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
83
144
  .dsm-trae-tab-active{color:var(--dsw-alias-label-primary,#e6e6e6);background:var(--dsw-alias-bg-layer-2,#232529);box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2,#3a3d45)}
145
+ .dsm-trae-tab-off{opacity:.55}
146
+ .dsm-trae-tab-switch{display:inline-flex;align-items:center;flex:none;padding:0 8px 0 2px;cursor:pointer}
147
+ .dsm-trae-tab-switch input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
148
+ .dsm-trae-tab-switch input:disabled{cursor:not-allowed}
84
149
  .dsm-trae-tab-dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;vertical-align:baseline}
85
150
  .dsm-trae-tab-hint{margin:0;color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:18px}
151
+ .dsm-trae-tab-off-notice{margin:0;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:10px;color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:12px;line-height:18px;background:var(--dsw-alias-bg-layer-3,#2a2c33)}
86
152
  .dsm-trae-usage-account{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:14px;background:var(--dsw-alias-bg-layer-2,#24262c)}
87
153
  .dsm-trae-usage-account-copy{display:flex;flex-direction:column;gap:3px;min-width:0}
88
154
  .dsm-trae-usage-expiry{padding-left:19px;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
@@ -148,6 +214,11 @@ window.__ModuleLoader__.load({
148
214
  .dsm-trae-searched-list code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,#b8b8b8);word-break:break-all}
149
215
  .dsm-trae-searched-reason{color:var(--dsw-alias-label-tertiary,#999);font-size:11px}
150
216
  .dsm-trae-usage-cheer:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:2px}
217
+ .dsm-trae-checkin{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 16px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:14px;background:var(--dsw-alias-bg-layer-2,#24262c)}
218
+ .dsm-trae-checkin-copy{display:flex;flex-direction:column;gap:2px;min-width:0}
219
+ .dsm-trae-checkin-label{color:var(--dsw-alias-label-primary,#e6e6e6);font-size:13px;line-height:18px;font-weight:600}
220
+ .dsm-trae-checkin-hint{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:17px}
221
+ .dsm-trae-checkin-button{flex:none}
151
222
  `;
152
223
  //#endregion
153
224
  //#region src/client/TraeUsageCard.tsx
@@ -223,6 +294,16 @@ window.__ModuleLoader__.load({
223
294
  const [drafts, setDrafts] = (0, react.useState)({});
224
295
  const [saving, setSaving] = (0, react.useState)(false);
225
296
  const [switchingAccount, setSwitchingAccount] = (0, react.useState)(false);
297
+ /** Region whose on/off checkbox write is in flight, so its box can't race. */
298
+ const [togglingRegion, setTogglingRegion] = (0, react.useState)(void 0);
299
+ /**
300
+ * Whether a check-in claim is in flight. A single flag rather than a
301
+ * per-region map: the claim exists only on the CN tab, and one shared flag
302
+ * keeps a tab switch from stranding a second concurrent claim.
303
+ */
304
+ const [claiming, setClaiming] = (0, react.useState)(false);
305
+ /** Last claim refusal, shown until the next successful refresh. */
306
+ const [claimError, setClaimError] = (0, react.useState)(void 0);
226
307
  const mounted = (0, react.useRef)(true);
227
308
  (0, react.useEffect)(() => {
228
309
  mounted.current = true;
@@ -233,6 +314,21 @@ window.__ModuleLoader__.load({
233
314
  (0, react.useEffect)(() => settingsScope?.subscribe(() => {
234
315
  setSettingsRevision((value) => value + 1);
235
316
  }), [settingsScope]);
317
+ /**
318
+ * Whether one region's provider is switched on, read off the SAME committed
319
+ * settings document the Host reads (`regionEnabledOf` mirrors the Host's
320
+ * `regionStateOf` opt-out rule: only an explicit `false` disables). Reading
321
+ * the stored value rather than echoing local state means a rejected write,
322
+ * another window's change, or a restart all converge on the truth.
323
+ *
324
+ * The whole settings section is passed deliberately: `regionEnabledOf`
325
+ * accepts either it or the bare `regions` map, because passing the section
326
+ * where the map was expected was a shipped bug (the lookup read
327
+ * `section['cn']`, found nothing, and reported `true` forever — the checkbox
328
+ * stayed checked and clicking it appeared dead while the write succeeded).
329
+ */
330
+ const regionOn = (item) => regionEnabledOf(settingsScope?.getSnapshot().value, item);
331
+ const activeRegionOn = regionOn(activeRegion);
236
332
  const refreshUsage = (0, react.useCallback)(async (region, signal) => {
237
333
  try {
238
334
  const response = await fetch(withTraeRegion(TRAE_USAGE_PATH, region), {
@@ -260,7 +356,7 @@ window.__ModuleLoader__.load({
260
356
  }
261
357
  }, [t]);
262
358
  (0, react.useEffect)(() => {
263
- if (!open) return;
359
+ if (!open || !activeRegionOn) return;
264
360
  const controller = new AbortController();
265
361
  refreshUsage(activeRegion, controller.signal);
266
362
  return () => {
@@ -269,14 +365,23 @@ window.__ModuleLoader__.load({
269
365
  }, [
270
366
  open,
271
367
  activeRegion,
368
+ activeRegionOn,
272
369
  refreshUsage
273
370
  ]);
274
371
  const status = statusByRegion[activeRegion] ?? {
275
372
  status: "signed-out",
276
373
  accounts: []
277
374
  };
375
+ /**
376
+ * Drop a claim refusal when the tab changes. The error belongs to the region
377
+ * it came from — carrying it onto the other tab would attribute an
378
+ * international account's problem to the CN one (or the reverse).
379
+ */
278
380
  (0, react.useEffect)(() => {
279
- if (!open || status.status !== "signed-in") return;
381
+ setClaimError(void 0);
382
+ }, [activeRegion]);
383
+ (0, react.useEffect)(() => {
384
+ if (!open || !activeRegionOn || status.status !== "signed-in") return;
280
385
  const controller = new AbortController();
281
386
  const timer = window.setInterval(() => {
282
387
  refreshUsage(activeRegion, controller.signal);
@@ -288,6 +393,7 @@ window.__ModuleLoader__.load({
288
393
  }, [
289
394
  open,
290
395
  activeRegion,
396
+ activeRegionOn,
291
397
  refreshUsage,
292
398
  status.status
293
399
  ]);
@@ -326,6 +432,54 @@ window.__ModuleLoader__.load({
326
432
  if (mounted.current) setSwitchingAccount(false);
327
433
  }
328
434
  };
435
+ /**
436
+ * Claim today's check-in reward for the active tab's region.
437
+ *
438
+ * The Host route owns every guard that matters — POST only, loopback origin
439
+ * only, CN only, and a status read before the claim — because a browser-side
440
+ * check is not a guard. The button's own `disabled` is therefore a courtesy
441
+ * (it stops an obviously pointless click), not the protection.
442
+ *
443
+ * A refusal is not thrown away: the route answers `claimed: false` with the
444
+ * upstream's business code and message, and the card shows it. The upstream
445
+ * also reports success for an already-claimed day, so `alreadyCheckedIn`
446
+ * refreshes the state without pretending a new reward landed.
447
+ */
448
+ const claimCheckin = async () => {
449
+ setClaiming(true);
450
+ setClaimError(void 0);
451
+ try {
452
+ const response = await fetch(withTraeRegion(TRAE_CHECKIN_PATH, activeRegion), {
453
+ method: "POST",
454
+ headers: { accept: "application/json" },
455
+ credentials: "same-origin"
456
+ });
457
+ const body = await response.json().catch(() => void 0);
458
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`);
459
+ if (body?.claimed === false && body.alreadyCheckedIn !== true) throw new Error(body.message === void 0 || body.message === "" ? t("row.checkinRefused", { code: String(body.code ?? "") }) : `${t("row.checkinRefused", { code: String(body.code ?? "") })}: ${body.message}`);
460
+ await refreshUsage(activeRegion);
461
+ } catch (error) {
462
+ if (mounted.current) setClaimError(error instanceof Error ? error.message : t("row.requestFailed"));
463
+ } finally {
464
+ if (mounted.current) setClaiming(false);
465
+ }
466
+ };
467
+ /**
468
+ * Switch one region's provider off or on (issue #11). The write carries the
469
+ * region's whole slot through untouched — only `enabled` changes — so the
470
+ * user's directory, model picks, image opt-ins and budgets survive a
471
+ * round trip. The Host withdraws or restores the provider route on the next
472
+ * `onChange`, which is what actually removes it from DSH's model picker.
473
+ */
474
+ const toggleRegion = async (item, enabled) => {
475
+ if (settingsScope === void 0 || settingsScope.getSnapshot().writable !== true) return;
476
+ setTogglingRegion(item);
477
+ try {
478
+ await settingsScope.set("regions", nextRegionEnabled(settingsScope.getSnapshot().value, item, enabled));
479
+ } finally {
480
+ if (mounted.current) setTogglingRegion(void 0);
481
+ }
482
+ };
329
483
  const refreshModels = async () => {
330
484
  setBusy(true);
331
485
  try {
@@ -443,6 +597,7 @@ window.__ModuleLoader__.load({
443
597
  try {
444
598
  if (status.status !== "signed-in") return;
445
599
  await settingsScope.set("regions", nextRegionSlots(configuredRegions, activeRegion, {
600
+ enabled: regionOn(activeRegion),
446
601
  lastCatalog: visibleModels.map((model) => ({
447
602
  ...model,
448
603
  input: ["text"]
@@ -503,19 +658,36 @@ window.__ModuleLoader__.load({
503
658
  "aria-label": title,
504
659
  children: TRAE_REGIONS.map((item) => {
505
660
  const regionStatus = statusByRegion[item];
506
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
507
- type: "button",
508
- role: "tab",
509
- "aria-selected": item === activeRegion,
510
- className: `dsm-trae-tab${item === activeRegion ? " dsm-trae-tab-active" : ""}`,
511
- onClick: () => {
512
- setActiveRegion(item);
513
- },
514
- children: [regionStatus === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
515
- "aria-hidden": "true",
516
- className: "dsm-trae-tab-dot",
517
- style: dotStyle(regionStatus.status)
518
- }), item === "cn" ? t("row.tabCn") : t("row.tabAi")]
661
+ const on = regionOn(item);
662
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
663
+ className: "dsm-trae-tab-cell",
664
+ role: "presentation",
665
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
666
+ type: "button",
667
+ role: "tab",
668
+ "aria-selected": item === activeRegion,
669
+ className: `dsm-trae-tab${item === activeRegion ? " dsm-trae-tab-active" : ""}${on ? "" : " dsm-trae-tab-off"}`,
670
+ onClick: () => {
671
+ setActiveRegion(item);
672
+ },
673
+ children: [regionStatus === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
674
+ "aria-hidden": "true",
675
+ className: "dsm-trae-tab-dot",
676
+ style: dotStyle(regionStatus.status)
677
+ }), item === "cn" ? t("row.tabCn") : t("row.tabAi")]
678
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
679
+ className: "dsm-trae-tab-switch",
680
+ title: t("row.tabSwitchHint"),
681
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
682
+ type: "checkbox",
683
+ checked: on,
684
+ disabled: settingsScope?.getSnapshot().writable !== true || togglingRegion !== void 0,
685
+ "aria-label": t("row.tabSwitchAria", { region: item === "cn" ? t("row.tabCn") : t("row.tabAi") }),
686
+ onChange: (event) => {
687
+ toggleRegion(item, event.currentTarget.checked);
688
+ }
689
+ })
690
+ })]
519
691
  }, item);
520
692
  })
521
693
  }),
@@ -523,6 +695,11 @@ window.__ModuleLoader__.load({
523
695
  className: "dsm-trae-tab-hint",
524
696
  children: t("row.tabHint")
525
697
  }),
698
+ regionOn(activeRegion) ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
699
+ className: "dsm-trae-tab-off-notice",
700
+ role: "status",
701
+ children: t("row.tabOffNotice")
702
+ }),
526
703
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
527
704
  className: "dsm-trae-usage-account",
528
705
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -668,6 +845,45 @@ window.__ModuleLoader__.load({
668
845
  className: "dsm-trae-usage-error",
669
846
  children: t("row.creditsError", { message: status.creditsError })
670
847
  }),
848
+ status.checkin === void 0 ? null : (() => {
849
+ const done = status.checkin.checkedIn || status.checkin.didCheckedIn;
850
+ const reward = formatNumber(status.checkin.credits);
851
+ const bonus = status.checkin.extraCredits === void 0 ? "" : ` + ${formatNumber(status.checkin.extraCredits)}`;
852
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
853
+ className: "dsm-trae-checkin",
854
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
855
+ className: "dsm-trae-checkin-copy",
856
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
857
+ className: "dsm-trae-checkin-label",
858
+ children: t("row.checkinLabel")
859
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
860
+ className: "dsm-trae-checkin-hint",
861
+ children: [t("row.checkinReward", { reward: `${reward}${bonus}` }), done ? ` · ${t("row.checkinDoneHint")}` : ""]
862
+ })]
863
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
864
+ type: "button",
865
+ className: "dsm-btn dsm-btn-primary dsm-trae-checkin-button",
866
+ disabled: !status.checkin.enabled || done || claiming,
867
+ onClick: () => {
868
+ claimCheckin();
869
+ },
870
+ children: claiming ? t("row.checkinClaiming") : done ? t("row.checkinClaimed") : t("row.checkinClaim")
871
+ })]
872
+ });
873
+ })(),
874
+ status.checkin !== void 0 && !status.checkin.enabled ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
875
+ className: "dsm-trae-usage-error",
876
+ children: t("row.checkinDisabled")
877
+ }) : null,
878
+ status.checkinError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
879
+ className: "dsm-trae-usage-error",
880
+ children: t("row.checkinError", { message: status.checkinError })
881
+ }),
882
+ claimError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
883
+ className: "dsm-trae-usage-error",
884
+ role: "alert",
885
+ children: t("row.checkinError", { message: claimError })
886
+ }),
671
887
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
672
888
  className: "dsm-trae-models",
673
889
  "aria-label": t("row.modelsTitle"),
@@ -850,6 +1066,9 @@ window.__ModuleLoader__.load({
850
1066
  "row.tabCn": "Domestic",
851
1067
  "row.tabAi": "Global",
852
1068
  "row.tabHint": "Each tab is a separate provider (trae / trae-global) with its own account, usage, and models. Both sides are live at once: different sessions can pick from either side, and changes on one tab never touch the other.",
1069
+ "row.tabSwitchHint": "Uncheck to switch this provider off. Its models disappear from the DSH model picker; its account, usage, and model settings are kept and return when you check it again.",
1070
+ "row.tabSwitchAria": "Offer the {region} provider to DSH",
1071
+ "row.tabOffNotice": "This provider is switched off: none of its models are offered to DSH. Your account, usage, and model settings are kept — check the box above the tab to bring it back.",
853
1072
  "row.signedOut": "Not signed in",
854
1073
  "row.signedOutHint": "Sign in once in the Trae desktop app; this plugin follows that sign-in automatically.",
855
1074
  "row.searchedTitle": "Paths checked",
@@ -873,6 +1092,15 @@ window.__ModuleLoader__.load({
873
1092
  "row.creditsConsumedLabel": "Consumed",
874
1093
  "row.creditsTotalLabel": "Total",
875
1094
  "row.creditsError": "Usage unavailable: {message}",
1095
+ "row.checkinLabel": "Daily check-in",
1096
+ "row.checkinReward": "{reward} credits per day",
1097
+ "row.checkinClaim": "Claim today",
1098
+ "row.checkinClaiming": "Claiming…",
1099
+ "row.checkinClaimed": "Claimed today",
1100
+ "row.checkinDoneHint": "come back tomorrow",
1101
+ "row.checkinRefused": "Trae refused the check-in (code {code})",
1102
+ "row.checkinError": "Check-in failed: {message}",
1103
+ "row.checkinDisabled": "This account has no check-in activity available.",
876
1104
  "row.regionCn": "CN",
877
1105
  "row.regionAi": "International",
878
1106
  "row.subscriptionLabel": "Subscription",
@@ -915,6 +1143,9 @@ window.__ModuleLoader__.load({
915
1143
  "row.tabCn": "国内版",
916
1144
  "row.tabAi": "国际版",
917
1145
  "row.tabHint": "每个 tab 是一个独立供应商(trae / trae-global),各有自己的账号、用量与模型。两边同时生效:不同会话可各选一边,一侧的改动不影响另一侧。",
1146
+ "row.tabSwitchHint": "取消勾选即关闭该供应商:它的模型不再出现在 DSH 模型选择器里;账号、用量与模型设置都会保留,重新勾选即恢复。",
1147
+ "row.tabSwitchAria": "是否向 DSH 提供{region}供应商",
1148
+ "row.tabOffNotice": "该供应商已关闭:它的模型不会提供给 DSH。账号、用量与模型设置均已保留——勾选上方复选框即可恢复。",
918
1149
  "row.signedOut": "未登录",
919
1150
  "row.signedOutHint": "在 Trae 桌面 App 里登录一次即可,插件会自动跟随当前登录的账号。",
920
1151
  "row.searchedTitle": "已检查的路径",
@@ -938,6 +1169,15 @@ window.__ModuleLoader__.load({
938
1169
  "row.creditsConsumedLabel": "消耗",
939
1170
  "row.creditsTotalLabel": "合计",
940
1171
  "row.creditsError": "用量查询失败:{message}",
1172
+ "row.checkinLabel": "每日签到",
1173
+ "row.checkinReward": "每日 {reward} 积分",
1174
+ "row.checkinClaim": "立即领取",
1175
+ "row.checkinClaiming": "领取中…",
1176
+ "row.checkinClaimed": "今日已领取",
1177
+ "row.checkinDoneHint": "明天再来",
1178
+ "row.checkinRefused": "Trae 拒绝了本次签到(业务码 {code})",
1179
+ "row.checkinError": "签到失败:{message}",
1180
+ "row.checkinDisabled": "该账号当前没有可用的签到活动。",
941
1181
  "row.regionCn": "国内版",
942
1182
  "row.regionAi": "国际版",
943
1183
  "row.subscriptionLabel": "订阅状态",