dsh-connect-trae 2.7.2 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,165 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.10.0 (2026-10-07)
4
+
5
+ > **新增:在 DSH 主界面侧边栏常驻一行「Trae通用积分」**([#26](https://github.com/dingminhua/dsh-connect-trae/issues/26),**实现由报告人 JiewiW 贡献**)。
6
+ >
7
+ > 看积分原先必须打开插件设置卡片、展开、找到额度区。现在侧边栏底部(工作区列表下方、设置上方)常驻一行「Trae通用积分:<数值>」与刷新按钮,**默认显示**,每 5 分钟自动更新,也可手动刷新。
8
+
9
+ > 这是 2.9.0 起五个版本合并后的首个公开版本:npm 上的 `latest` 此前仍是 2.8.0,因此**从 2.8.0 升级会一次拿到下面全部内容**。
10
+
11
+ ### Features
12
+
13
+ - **侧边栏积分行**:在 `sidebar.footer.action` 槽位渲染「Trae通用积分:<数值>」+ 「刷新」按钮。
14
+ - **默认显示**(opt-out):`showPointsInMainUi` 默认 `true`。
15
+ - **关闭即彻底消失**:行内的 `×` 或卡片里的复选框都能关闭,关闭后**侧边栏不留任何 DOM**,轮询一并停止。
16
+ - 开启后**先立即抓取一次,完成后再启动 5 分钟周期**的定时器,不叠加;关闭或卸载即清除定时器并中止在途请求。
17
+ - 抓取失败保留上一次数值并提示;刷新进行中忽略重复点击。
18
+ - 显示的是**通用积分**(`credits.generalAvailable`,SOLO 聊天实际消耗的那个池子),与卡片上的数字口径一致。
19
+ - 侧边栏收起(56px 导轨)时整行不渲染;国际版为订阅制、无可比数字,因此不显示。
20
+ - **开关在三处,写同一个字段**(`showPointsInMainUi`),永不冲突:侧边栏那一行的 `×`、卡片顶部、卡片「模型」区标题旁(在「从 Trae 刷新」「一键测试启用模型」左侧)。
21
+
22
+ ### 为什么不只用插件卡片里的复选框
23
+
24
+ 第一版把开关**只**放在插件卡片里,结果在装了 **dshmarket** 的宿主上等于不存在:市场插件接管了插件页,而它的 UI **完全不渲染插件自带的配置卡片**(其客户端 bundle 里 `plugins.row.config` / `plugins.bundle.config` **0 处引用**)。于是积分行被挡住、开关又在打不开的页面里。现在把开关同时放在**模型区**(用户管理模型时视线所在)与**积分行本身**,并默认显示——不再依赖任何一个可能不可达的页面。
25
+
26
+ ### 修复(本版合并的 2.9.1–2.9.4)
27
+
28
+ - **2.9.1**:积分行默认显示,关闭入口移到行内(原为默认关闭 + 开关藏在插件卡片)。
29
+ - **2.9.2**:标签由「通用积分」改为「**Trae通用积分**」,避免与侧边栏里其他插件的数字混淆。
30
+ - **2.9.3**:**补上复选框缺失的 CSS**——移植贡献者实现时只搬了侧边栏积分行的样式,漏掉了卡片里复选框的样式,导致它一直在渲染但**没有布局与配色、等同于不可见**;同时在「模型」区标题旁加了同一个复选框。
31
+ - **2.9.4**:关闭后**彻底不显示**,不再残留「积分」小按钮(回到开关的路已在卡片两处)。
32
+
33
+ ### Tests
34
+
35
+ - 全仓 423 → **435**:开关默认即显示;关闭后容器内**无任何 DOM**(`container.innerHTML === ''`)且轮询停止;行内 `×` 与卡片两处复选框**写同一字段、互相同步**;`preferStrongerRow` 等既有用例不变。
36
+ - **变异验证多次全部被抓**:关闭时残留元素 → 2 例失败;去掉模型区的复选框 → 2 例失败;「缺失即开启」改回「必须显式 true」→ 1 例失败;收起态不退出 → 1 例失败。
37
+
38
+ ### 致谢
39
+
40
+ 报告人 **JiewiW** 独立完成了实现、430 个测试(含假定时器),并在独立 DSH 实例上做了端到端验证——包括「关闭开关后跨越两个应触发周期点无任何请求」这类定时器泄漏检查,还明确声明未改动版本号与无关代码。他当初在说明里提醒的「该 rc.2 宿主未内联挂载插件配置面板」后来被证明是真实的坑,本版据此把开关放到了可达位置。
41
+
42
+ ## 2.9.4 (2026-10-07)
43
+
44
+ ### 变更
45
+
46
+ - **关闭积分显示后,侧边栏彻底不留任何东西**(`×` 或取消勾选都是如此)。之前关闭后会在原位置留一个「积分」小按钮,理由是可以随时找回开关;但 2.9.3 已经把同一个复选框放进卡片**两处**(卡片顶部 + 模型区),回到开关的路本来就在配置它的地方。**用户要的是清干净,残留一个按钮仍然占着他们想腾空的位置。**
47
+ - 随之删除不再使用的样式(`.dsm-trae-sidebar-points-off` / `-show`)与文案键(`sidebar.show` / `sidebar.showHint`)。
48
+
49
+ ### Tests
50
+
51
+ - `sidebar-points` 的用例改为断言**关闭后容器里没有任何 DOM**(`container.innerHTML === ''`),而不是「还剩一个可恢复的入口」;并断言轮询也随之停止。行内 `×` 的用例同样断言完全清空。
52
+ - **变异验证被抓**:关闭时改为残留一个元素 → 2 例失败。
53
+
54
+ ## 2.9.3 (2026-10-07)
55
+
56
+ > **修复:「显示积分」复选框实际上看不见**——它一直在卡片里渲染,但**没有任何 CSS**,所以读起来像是不存在。
57
+
58
+ ### 问题
59
+
60
+ 2.9.0 移植贡献者的实现时,我只搬了侧边栏积分行的样式,**漏掉了卡片里那个复选框的样式**(`.dsm-trae-mainui-switch*`)。复选框本身渲染了,但没有布局、没有间距、没有配色——在卡片里等同于不可见。用户因此反复找不到「在DSH主界面显示积分状态」这个开关。
61
+
62
+ ### 修复
63
+
64
+ - **补上缺失的样式**(`.dsm-trae-mainui-switch` / `-label` / `input`),复选框现在正常显示、可点、有品牌色。
65
+ - **在「模型」区标题旁再加一个同样的复选框**(就在「从 Trae 刷新」「一键测试启用模型」两个按钮的左侧)。理由:管理模型时视线就在那里,而**需要用户去找的设置等于没有设置**——这正是这个开关此前丢失的原因。两处写**同一个字段**,永不冲突;顶部的那个也保留。
66
+
67
+ ### Tests
68
+
69
+ - 全仓 435(用例数不变,但 `card-mainui-switch` 的两条被**加强**):现在断言卡片里**两处都有**复选框、默认都未勾选、且**勾任意一处另一处同步**(共享字段);「配置已开启」的用例也改为断言**两处都是勾选态**。
70
+ - **变异验证被抓**:删掉模型区那一个复选框 → 2 例失败。
71
+
72
+ ## 2.9.2 (2026-10-07)
73
+
74
+ ### 变更
75
+
76
+ - **侧边栏积分行的标签改为「Trae通用积分」**(英文 `Trae credits`)。原先只写「通用积分」,而侧边栏底部可能同时出现其他插件的数字,读者无法确定这个余额属于谁。加上产品名前缀后一眼可辨。
77
+
78
+ ## 2.9.1 (2026-10-07)
79
+
80
+ > **修复:积分行默认不显示,而它的开关在一个部分宿主上根本打不开。**
81
+
82
+ ### 问题
83
+
84
+ 2.9.0 把积分行做成 **opt-in**(`showPointsInMainUi` 默认 `false`),开关只放在插件卡片里。这在部分宿主上等于**功能不存在**:
85
+
86
+ - 用户装了 **dshmarket**,插件页默认停在市场的 tab,而市场 UI **完全不渲染插件自带的配置卡片**(我在它的客户端 bundle 里搜 `plugins.row.config` / `plugins.bundle.config`,**0 处引用**;它只注册自己的卡片,key 是 `dshmarket`)。
87
+ - 于是积分行被 Gate 挡住不渲染,而唯一能打开它的开关在用户到不了的页面里 —— 既看不到功能,也找不到开关。
88
+
89
+ **根因是我把「可关闭」实现成了「默认关闭」**:用户的原话是「**如果要加这个显示**,再加一个开关让不想显示的用户可以关闭」——那本该是**默认显示 + 可关闭**。
90
+
91
+ ### 修复
92
+
93
+ - **`showPointsInMainUi` 默认改为 `true`**(opt-out)。侧边栏底部直接显示通用积分。
94
+ - **关闭开关移到积分行自己身上**:积分行右侧多一个 `×`,点一下即隐藏;隐藏后**同一位置留一个极小的「积分」按钮**,一点即恢复。
95
+ - 两个控件写的是**同一个字段**(`showPointsInMainUi`),因此与插件卡片里的复选框永不冲突。
96
+ - 关键点:**控制入口和功能渲染在同一处**,所以「宿主能画这一行」就等于「用户能控制它」,不再依赖任何设置页。
97
+ - 侧边栏收起(56px 导轨)时仍然整行不渲染:收起态塞不下标签+数值+按钮,会变成「积…」。
98
+
99
+ ### 兼容性说明
100
+
101
+ - 之前**明确关闭过**该开关的用户不受影响(显式 `false` 仍是关闭);「字段缺失」现在按**开启**处理(原先按关闭),所以升级后会直接看到积分行。
102
+ - 插件卡片里的复选框**保留**:在能渲染卡片的宿主上(如内置插件管理器),它依然可用。
103
+
104
+ ### Tests
105
+
106
+ - 全仓 433 → **435**:新增「默认即显示」「关闭后留下可恢复的入口」「行内关闭按钮写入共享字段」;并把原「关闭时不渲染任何 DOM」的用例改写为**「关闭时仍留一个可操作入口」**——那条旧断言正是把这次的问题固化下来的地方。
107
+ - **变异验证 2 次全部被抓**:把「缺失即开启」改回「必须显式 true」→ 1 例失败;关闭时改为不渲染入口 → 1 例失败。
108
+
109
+ ## 2.9.0 (2026-10-06)
110
+
111
+ > **可选:在 DSH 主界面侧边栏常驻一行通用积分**([#26](https://github.com/dingminhua/dsh-connect-trae/issues/26),**由报告人 JiewiW 提交实现**)。
112
+ >
113
+ > 看积分原先必须打开插件设置卡片、展开、找到额度区;现在勾一个开关,侧边栏底部(工作区列表下方、设置上方)常驻一行「通用积分:<数值>」与刷新按钮。
114
+
115
+ ### Features
116
+
117
+ - **侧边栏积分行**(开关 `showPointsInMainUi`,**默认关闭**):在 `sidebar.footer.action` 槽位渲染「通用积分:<数值>」+ 「刷新」按钮;`title` 显示最近刷新时间;抓取失败保留上一次数值并提示;刷新进行中忽略重复点击。
118
+ - 开关关闭时**不渲染任何 DOM**(不是隐藏),因此不产生任何请求;关闭开关或插件卸载即清除定时器并中止在途请求。
119
+ - 开启后**先立即抓取一次,完成后再启动 5 分钟周期**的定时器,不叠加。
120
+
121
+ ### 合入时相对贡献者实现的三处调整
122
+
123
+ 1. **数字口径:`credits.generalAvailable`(通用积分),不是 `credits.available`。** `available` 是上游汇总(`summary.totalAmount - consumedAmount`),而卡片展示的是按额度包分出的 Work / 通用两桶;汇总**不保证**等于任一桶。一个用户在侧边栏找不到对应数字的余额,比没有数字更糟。通用桶也正是 SOLO 聊天路径消耗的那个。
124
+ 2. **轮询 60s → 5 分钟。** 卡片的轮询本身就是 60s,而每次 usage 读取会打**两个**上游请求;积分以「模型调用」而非秒为单位变化,60s 的边际价值很低。
125
+ 3. **侧边栏收起时整行不渲染**(`wide === false` → `null`)。宿主 CSS 并**不会**在收起时隐藏底部区域,而是把它压成 56px 居中窄条(`SidebarRoot.module.css` 的 `.collapsed .footerActions`);标签 + 余额 + 按钮塞进 56px 会被裁成「积…」。而**收起正是长时间工作的常态**——这个功能最需要成立的时刻不能是它最坏看的时刻。提前退出也意味着收起期间不启动抓取循环。
126
+
127
+ ### Tests
128
+
129
+ - 全仓 423 → **433**:移植贡献者的 7 例(开关默认关闭/持久化/读回/回显;挂载即抓取、首次抓取后启定时器、卸载即清除;失败保留上次数值;进行中点击被忽略;Gate 关闭时零 DOM),并新增 3 例锁定上述调整。
130
+ - **测试加固**:贡献者的 fixture 让 `available` 与 `generalAvailable` **恰好相等**,所以换成哪个字段测试都过——这正是能悄悄退回汇总口径的盲区。现已让两者**不相等**,字段选错会立刻失败。
131
+ - 同时把 `card-region-switch` 的取元素方式从「按下标取前两个 checkbox」改为**按 aria-label 精确匹配区域开关**:新开关让卡片多了第三个 checkbox,按下标会开始断言到错误的控件。
132
+ - **变异验证 3 次全部被抓**:收起态不再退出 → 1 例失败;取字段改回 `available` → 3 例失败;轮询改回 60s → 1 例失败。
133
+
134
+ ### 致谢
135
+
136
+ 报告人 **JiewiW** 独立完成了实现、430 个测试(含假定时器)、并在独立 DSH 实例上做了端到端验证(含「关闭开关后跨越两个应触发周期点无任何请求」这类定时器泄漏检查),还明确声明未改动版本号与无关代码。
137
+
138
+ ## 2.8.0 (2026-10-06)
139
+
140
+ > **绑定账号额度耗尽时不再是一块没有解释的空面板**([#25](https://github.com/dingminhua/dsh-connect-trae/issues/25),报告人 JiewiW)。
141
+ >
142
+ > 账号选择是**持久化**的:你一旦把某个账号绑上去,它就长期生效。于是「绑定的那个账号额度用尽」在卡片上表现为**一个永远为 0 的面板,没有任何解释**;而「刷新账号与 Token」会返回 200 和同一份账号列表,看起来和失败一模一样。报告人遇到的正是这个:TRAE SOLO CN 卸载后其凭据仍留在 `%APPDATA%`,CN 区一直绑着那个已耗尽的残留账号,而他以为绑的是自己正在用的 Trae CN。
143
+
144
+ ### Features
145
+
146
+ - **「刷新账号与 Token」现在会报告结果**(`rescanAccounts`):成功后显示「找到 N 个账号 · 仍绑定 X」,失败时显示原因并保留原错误。用户从此能区分「刷新成功但无变化」与「刷新失败」。
147
+ - **绑定账号额度耗尽且本机另有可用账号时,卡片给出明确提示并支持一键切换**(仅国内版,额度接口只存在于 CN):
148
+ - 仅当**已选账号自身额度为 0** 时才去查其他账号,账号健康时**不产生任何额外请求**;
149
+ - 提示里带上可用账号的名字、产品与可用积分数,一键写入绑定并立即刷新;
150
+ - 其他账号同样为空、或来自另一区域(国际版)的,一律不提示——避免给出「切过去也用不了」或「数字不适用本区」的建议。
151
+ - **账号下拉显示可读的产品名**:`Trae` / `Trae 国际版` / `TRAE SOLO` / `TRAE SOLO 国际版`,取代原先裸露的 `cn` / `sg` / `solo` 标记——正是这个标记让报告人分不清哪个凭据来自已卸载的产品。
152
+
153
+ ### Implementation notes
154
+
155
+ - 新增只读的 `TraeCredentialStore.credentialOf(accountId)`:查某个**指定**账号的凭据而不改绑。原先只能靠 `selectAccount` 切换来读,而那会与共用同一 store 的在途聊天请求竞争,并持久化一次用户并未做出的选择。这个方法是纯查询:只读、无刷新副作用、`accountId` 不变。
156
+ - 探测其他账号的额度用一个**临时** usage client,并复用与绑定账号**完全相同**的 `toCredits`(Work/general 分桶),避免出现「提示里的数字和旁边显示的数字算法不一致」。
157
+
158
+ ### Tests
159
+
160
+ - 全仓 419 → **423**(+4):绑定账号为空时才出现替代账号;账号健康时**一次都不探测**(断言探测次数为 0);同样为空、以及**跨区域**的账号既不被提示也不被探测;单个账号探测抛错时安全降级。
161
+ - **变异验证 3 次全部被抓**:永不触发探测 → 1 例失败;去掉「无额度」过滤(空账号也会被当作出路)→ 1 例失败;去掉区域限制(会去探测国际版账号)→ 1 例失败。
162
+
3
163
  ## 2.7.2 (2026-10-05)
4
164
 
5
165
  > **补完 #23 的确定性**:2.7.1 修好了 Max 档随机丢失,但**在 max 相等时仍保留了先到者**,于是 **dev 基础窗口**还会随货架顺序漂移。报告人 JiewiW 在 Windows 复测时发现并给了修法(`glm-5.2`:`chat_v3` 的 dev=116000 与 `solo_agent_remote` 的 dev=200000 都带 max=1000000,谁先到就取谁)。
package/README.en.md CHANGED
@@ -40,10 +40,11 @@ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bund
40
40
  - **Dual-region detection (CN / international)** — detects local sign-ins across Trae CN, TRAE SOLO CN, Trae, and TRAE SOLO installs. The region is derived from the credential's own `userRegion` claim (host suffix and edition label as fallbacks), with no manual switch.
41
41
  - **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.
42
42
  - **Vision follows Trae's own declaration** — when Trae marks a model `multimodal`, the plugin advertises it as such with no manual tick per model (issue #16). The rule is "**models you tick ∪ models Trae marks multimodal**": models Trae marks `false`, or does not mark at all, stay text-only and can still be turned on by hand. A row enabled automatically is shown ticked and read-only, with the reason. See "Image input" below.
43
- - **Account switching** — refreshes the token list and lets users select an account without storing tokens in DSH settings.
43
+ - **Account switching** — refreshes the token list and lets users select an account without storing tokens in DSH settings. **Your choice is remembered and stays in effect**: the plugin will never switch accounts on its own just because the bound one ran out of credit or another usable one was found (switching bills a different person). So when the bound account is exhausted the card **says so explicitly** and lists the other accounts on this machine that still have credit, with their balances, for you to switch to in one click — instead of showing an empty panel or quietly rebinding you. "Refresh accounts & tokens" now always reports what happened (how many accounts were found, who is still bound) instead of appearing to do nothing. The dropdown names the product (`Trae` / `TRAE SOLO` / international) so a credential left behind by an uninstalled app is recognisable.
44
44
  - **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.
45
45
  - **One-click test of the enabled models** — sends each **enabled** model one minimal message (a single `ping`, no tools) and reports whether it actually answers: **works** / **needs a higher plan** (Trae `1005`; your plan does not cover it) / **not served** (`4001`/`4011`; no SOLO function serves it) / **throttled** / **sign-in rejected** / **not determined**. The last one is a first-class outcome: a timeout, a dropped connection, a proxy or a 5xx is **never** reported as unusable — only an explicit upstream refusal is a negative verdict. This is the plugin's **only** operation that spends quota (check-in changes account state but spends nothing), it runs only when you click, and its results live in the current session rather than in your settings.
46
46
  - **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 that account has been paid). This is the plugin's **only** operation that changes account state, and it runs only when you click. Trae allows **one check-in per device per day**: after switching accounts, if this machine's slot is already spent (by any account), the selected account cannot claim here that day — the card explains that rather than pretending the account was paid. The international side has no check-in activity, so no button is shown there.
47
+ - **Sidebar credits** — a permanent "Trae credits: <n>" line with a refresh button sits at the bottom of the left sidebar (below the workspace list, above Settings), refreshing every 5 minutes and on demand. **Shown by default**; the `×` on its right hides it completely (to bring it back, tick the switch in the plugin card — the same switch appears at the top of the card and beside the model actions). It shows the **general** credit pool — the one SOLO chat actually spends — so the number matches the card, and the line disappears when the sidebar is collapsed. The international region is subscription-based and has no comparable number, so nothing is shown there.
47
48
  - **Secure loopback shim** — one random port + in-process random secret per region; the real Trae token is never handed to pi-ai.
48
49
 
49
50
  ## How it works
package/README.md CHANGED
@@ -40,10 +40,11 @@
40
40
  - **国内国际双区域自动识别** —— 自动发现 Trae CN / TRAE SOLO CN / Trae / TRAE SOLO 四个本地安装的登录账号;区域由凭证自带的 `userRegion` 声明自动判定(host 后缀与 edition 标签兜底),无需手动指定。
41
41
  - **目录与勾选按区域隔离** —— 国内版与国际版各一套模型目录、勾选、图片开关与上下文预算,两个供应商各自读各自的槽位。
42
42
  - **识图能力跟随 Trae 自身的标注** —— Trae 把某个模型标为 `multimodal` 时,插件直接按多模态对外声明,无需你在卡片上逐一手勾(issue #16)。判据是「**你手动勾选的 ∪ Trae 标注多模态的**」:Trae 明确标 `false` 或未标注的模型保持纯文本,你可以照旧手动为它们打开图片输入。卡片上被自动开启的那一行显示为已勾选且只读,并说明原因。详见下方「图片输入(识图)」。
43
- - **多账号切换** —— 支持重新读取 Token 列表并选择账号;Token 不写入 DSH 设置。
43
+ - **多账号切换** —— 支持重新读取 Token 列表并选择账号;Token 不写入 DSH 设置。**你的选择会被记住并长期生效**:插件不会因为某个账号额度用尽、或检测到另一个可用账号就自作主张替你切换(换账号意味着换人计费)。所以当绑定的账号额度耗尽时,卡片会**明确告诉你**,并把本机其他还有额度的账号连同其可用积分数列出来,由你一键切换——而不是让面板一直空着、也不自动把你切走。「刷新账号与 Token」每次都会回报结果(找到几个账号、当前仍绑定谁),不会再「点了没反应」。下拉里显示的是产品名(`Trae` / `TRAE SOLO` / 国际版),便于分辨凭据来自哪个安装。
44
44
  - **只读用量与模型管理** —— 国内版查看 Work 积分与通用积分,国际版查看订阅/试用状态;刷新/启用 Trae 模型;只读查询不消耗额度。
45
45
  - **一键测试启用模型** —— 给**已启用**的每个模型发一条最小消息(一句 `ping`、不带工具),逐个告诉你能不能真的应答:**可用** / **需要更高档位**(Trae `1005`,你的套餐不覆盖)/ **上游不提供**(`4001`/`4011`,SOLO 通道没有这个模型)/ **被限流** / **登录凭据被拒** / **本次未测出**。最后一项是一等公民:超时、断连、代理、5xx **一律不算「不可用」**——只有上游明确的拒绝才算负面结论。**这是插件里唯一消耗额度的操作**(签到改变的是账号状态,不消耗),只在你点击时运行,结果只留在当前会话里不写入设置。
46
46
  - **每日签到领取** —— 国内版卡片上一键领取当日签到积分(按钮显示每日奖励,该账号已领取则显示「今日已领取」并禁用);这是插件里**唯一会改变账号状态**的操作,由你主动点击触发。Trae 的签到按「**每台设备每天一次**」限制:切换账号后如果本机今天的名额已经用掉(无论是哪个账号用的),当前账号在本机当天无法再签,卡片会说明原因而不是谎报「已领取」。国际版没有签到活动,因此不显示该按钮。
47
+ - **侧边栏积分** —— DSH 左侧边栏底部(工作区列表下方、设置上方)常驻一行「Trae通用积分:<数值>」与刷新按钮,每 5 分钟自动更新,也可手动刷新。**默认显示**;右侧的 `×` 可隐藏,关闭后**侧边栏不再留任何东西**(要重新显示就回到插件卡片里勾选,卡片顶部与模型区各有一个同一开关)。显示的是**通用积分**(SOLO 聊天实际消耗的那个额度池),与卡片上的数字一致;侧边栏收起时该行自动消失。国际版为订阅制、没有可比数字,因此不显示。
47
48
  - **安全 loopback shim** —— 每区域一个随机端口 + 进程内随机 secret,真实 Trae token 不交给 pi-ai。
48
49
 
49
50
  ## 工作原理
package/lib/client.js CHANGED
@@ -263,7 +263,22 @@ window.__ModuleLoader__.load({
263
263
  .dsm-trae-model-name-rate{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;font-weight:400;line-height:16px;flex:none}
264
264
  /* The two head actions sit together; the probe one SPENDS quota, so it carries
265
265
  the hint tooltip rather than looking like its read-only sibling. */
266
+ /* The sidebar-credit switch. Two instances share this style: the card's own
267
+ row at the top and the copy beside the model actions (2.9.3). Without it the
268
+ checkbox rendered unstyled and read as absent — the whole reason the switch
269
+ looked missing. */
270
+ .dsm-trae-mainui-switch{padding:2px 0 0}
271
+ .dsm-trae-mainui-switch-label{display:flex;align-items:center;gap:9px;font-size:13px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0);cursor:pointer;white-space:nowrap}
272
+ .dsm-trae-mainui-switch-label input{margin:0;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
273
+ .dsm-trae-mainui-switch-label:has(input:disabled){opacity:.6;cursor:default}
266
274
  .dsm-trae-models-actions{display:flex;align-items:center;gap:8px;flex:none}
275
+ }
276
+ .dsm-trae-usage-alternative{display:flex;align-items:flex-start;gap:10px;margin-top:10px;padding:10px 12px;border:1px solid var(--dsw-alias-state-warning-border,#7a5b12);border-radius:10px;background:var(--dsw-alias-state-warning-bg,#2a2313)}
277
+ .dsm-trae-usage-alternative-text{margin:0;flex:1 1 auto;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#e6e6e6)}
278
+ .dsm-trae-usage-switch-button{flex:0 0 auto;padding:5px 10px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:8px;background:var(--dsw-alias-bg-layer-3,#2c2f36);color:var(--dsw-alias-label-primary,#e6e6e6);font-size:12px;line-height:16px;cursor:pointer}
279
+ .dsm-trae-usage-switch-button:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4,#333740)}
280
+ .dsm-trae-usage-switch-button:disabled{opacity:.5;cursor:not-allowed}
281
+ .dsm-trae-usage-note{margin:8px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px}
267
282
  .dsm-trae-models-hint{margin:4px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px}
268
283
  /* Verdict badge. Only a real refusal is coloured as a warning; an unknown
269
284
  verdict stays neutral because an inconclusive probe is not evidence against
@@ -303,6 +318,24 @@ window.__ModuleLoader__.load({
303
318
  an error: it is Trae's one-per-device-per-day rule, and the day is simply
304
319
  spent. Rendering it in the error colour would read as a plugin fault. */
305
320
  .dsm-trae-checkin-note{margin:8px 0 0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
321
+ `;
322
+ /**
323
+ * Sidebar credit line styles, injected by the sidebar component itself.
324
+ *
325
+ * Separate from the card bundle because the line lives in the shell's sidebar,
326
+ * not inside the plugin card; a card that is closed still shows this.
327
+ */
328
+ const TRAE_SIDEBAR_CSS = `
329
+ .dsm-trae-sidebar-points{display:flex;align-items:center;gap:8px;padding:4px 2px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
330
+ .dsm-trae-sidebar-points-line{display:flex;align-items:center;gap:6px;min-width:0;flex:1 1 auto}
331
+ .dsm-trae-sidebar-points-text{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
332
+ .dsm-trae-sidebar-points-failed{color:var(--dsw-alias-state-error-primary,#ef4444);font-size:11px;line-height:16px;white-space:nowrap}
333
+ .dsm-trae-sidebar-points-hide{appearance:none;flex:none;font:inherit;font-size:14px;line-height:1;padding:2px 6px;border:0;border-radius:6px;color:var(--dsw-alias-label-tertiary,#999);background:transparent;cursor:pointer}
334
+ .dsm-trae-sidebar-points-hide:hover{color:var(--dsw-alias-label-primary,#e6e6e6);background:var(--dsw-alias-bg-layer-3,#2c2f36)}
335
+ .dsm-trae-sidebar-points-refresh{appearance:none;flex:none;font:inherit;font-size:11px;line-height:16px;padding:2px 8px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:7px;color:var(--dsw-alias-label-secondary,#c6c9d0);background:transparent;cursor:pointer}
336
+ .dsm-trae-sidebar-points-refresh:hover:not(:disabled){color:var(--dsw-alias-label-primary,#e6e6e6);border-color:var(--dsw-alias-label-dimmed,#777)}
337
+ .dsm-trae-sidebar-points-refresh:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
338
+ .dsm-trae-sidebar-points-refresh:disabled{opacity:.5;cursor:default}
306
339
  `;
307
340
  //#endregion
308
341
  //#region src/client/TraeUsageCard.tsx
@@ -368,7 +401,7 @@ window.__ModuleLoader__.load({
368
401
  document.head.appendChild(styleTag);
369
402
  }
370
403
  }
371
- function formatNumber(value) {
404
+ function formatNumber$1(value) {
372
405
  return new Intl.NumberFormat(void 0, {
373
406
  minimumFractionDigits: 0,
374
407
  maximumFractionDigits: 0
@@ -387,7 +420,7 @@ window.__ModuleLoader__.load({
387
420
  if (value === void 0) return unknown;
388
421
  if (value >= 1e6 && value % 1e6 === 0) return `${value / 1e6}M`;
389
422
  if (value >= 1e3 && value % 1e3 === 0) return `${value / 1e3}K`;
390
- return formatNumber(value);
423
+ return formatNumber$1(value);
391
424
  }
392
425
  /**
393
426
  * The subscription row's verdict.
@@ -479,8 +512,37 @@ window.__ModuleLoader__.load({
479
512
  */
480
513
  const [claiming, setClaiming] = (0, react.useState)(false);
481
514
  /** Last claim refusal, shown until the next successful refresh. */
515
+ /**
516
+ * Toggle the global "show credits at the DSH sidebar foot" switch. Unlike the
517
+ * per-region provider switch this is one top-level boolean shared by both
518
+ * regions; only that field is written, and the read-back must show the new
519
+ * value before success is reported.
520
+ */
521
+ const toggleShowPointsInMainUi = async (enabled) => {
522
+ if (settingsScope === void 0 || settingsScope.getSnapshot().writable !== true) return;
523
+ setTogglingMainUi(true);
524
+ setWriteError(void 0);
525
+ try {
526
+ await writeSettingsField(settingsScope, "showPointsInMainUi", enabled, (readBack) => unwrapVolatileDeep(readBack)?.showPointsInMainUi === enabled);
527
+ } catch (error) {
528
+ if (mounted.current) setWriteError(error instanceof Error ? error.message : t("row.requestFailed"));
529
+ } finally {
530
+ if (mounted.current) setTogglingMainUi(false);
531
+ }
532
+ };
482
533
  const [claimError, setClaimError] = (0, react.useState)(void 0);
483
534
  /**
535
+ * Outcome of the last "refresh accounts" press (issue #25).
536
+ *
537
+ * The rescan answers 200 with a fresh account list whether or not anything
538
+ * changed, and the selection is PERSISTED, so a rescan of a machine whose
539
+ * bound account is still the one it was yesterday looks identical to a
540
+ * failure. Reporting the outcome is the difference between "it did nothing"
541
+ * and "it found the same 2 accounts, still on X".
542
+ */
543
+ const [togglingMainUi, setTogglingMainUi] = (0, react.useState)(false);
544
+ const [rescanNote, setRescanNote] = (0, react.useState)(void 0);
545
+ /**
484
546
  * Whether this MACHINE has spent today's check-in while the selected account
485
547
  * has not been paid — the "switched account" state. Kept separate from
486
548
  * `claimError` because it is not a failure: it is the upstream's one-per-
@@ -599,15 +661,24 @@ window.__ModuleLoader__.load({
599
661
  });
600
662
  const body = await response.json();
601
663
  if (!response.ok || !Array.isArray(body.accounts)) throw new Error(`HTTP ${response.status}`);
602
- const selected = body.accounts.find((account) => account.selected)?.id;
664
+ const selected = body.accounts.find((account) => account.selected);
603
665
  const configured = configuredAccountsOf(settingsScope?.getSnapshot().value);
604
- if (selected !== void 0 && selected !== configured[activeRegion] && settingsScope !== void 0 && settingsScope.getSnapshot().writable === true) await writeSettingsField(settingsScope, "accounts", {
666
+ const followed = selected !== void 0 && selected.id !== configured[activeRegion] && settingsScope !== void 0 && settingsScope.getSnapshot().writable === true;
667
+ if (followed && settingsScope !== void 0) await writeSettingsField(settingsScope, "accounts", {
605
668
  ...configured,
606
- [activeRegion]: selected
607
- }, (readBack) => configuredAccountsOf(readBack)[activeRegion] === selected);
669
+ [activeRegion]: selected.id
670
+ }, (readBack) => configuredAccountsOf(readBack)[activeRegion] === selected.id);
608
671
  await refreshUsage(activeRegion);
672
+ if (mounted.current) setRescanNote(body.accounts.length === 0 ? t("row.accountsRefreshedNone") : t("row.accountsRefreshed", {
673
+ count: String(body.accounts.length),
674
+ selected: followed ? "" : ` · ${t("row.accountsKept")} ${selected?.accountName ?? selected?.id ?? "—"}`
675
+ }));
609
676
  } catch (error) {
610
- if (mounted.current) setWriteError(error instanceof Error ? error.message : t("row.requestFailed"));
677
+ if (mounted.current) {
678
+ const message = error instanceof Error ? error.message : t("row.requestFailed");
679
+ setRescanNote(t("row.accountsRescanFailed", { message }));
680
+ setWriteError(message);
681
+ }
611
682
  } finally {
612
683
  if (mounted.current) setBusy(false);
613
684
  }
@@ -968,6 +1039,21 @@ window.__ModuleLoader__.load({
968
1039
  children: open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
969
1040
  className: "dsm-trae-usage",
970
1041
  children: [
1042
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1043
+ className: "dsm-trae-mainui-switch",
1044
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1045
+ className: "dsm-trae-mainui-switch-label",
1046
+ title: t("row.showPointsInMainUiHint"),
1047
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1048
+ type: "checkbox",
1049
+ checked: settingsValue?.showPointsInMainUi === true,
1050
+ disabled: settingsScope?.getSnapshot().writable !== true || togglingMainUi,
1051
+ onChange: (event) => {
1052
+ toggleShowPointsInMainUi(event.currentTarget.checked);
1053
+ }
1054
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.showPointsInMainUi") })]
1055
+ })
1056
+ }),
971
1057
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
972
1058
  className: "dsm-trae-tabs",
973
1059
  role: "tablist",
@@ -1047,30 +1133,58 @@ window.__ModuleLoader__.load({
1047
1133
  children: busy ? t("row.accountsScanning") : t("row.refreshTokens")
1048
1134
  })]
1049
1135
  }),
1050
- status.status !== "error" && status.accounts.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
1136
+ status.status !== "error" && status.accounts.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1051
1137
  className: "dsm-trae-account-picker",
1052
1138
  "aria-label": t("row.accountsTitle"),
1053
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1054
- className: "dsm-trae-usage-select-wrap",
1055
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1056
- className: "dsm-trae-usage-select",
1057
- value: status.status === "signed-in" ? status.accountId : "",
1058
- disabled: switchingAccount || settingsScope?.getSnapshot().writable !== true,
1059
- onChange: (event) => {
1060
- switchAccount(event.currentTarget.value);
1061
- },
1062
- children: status.accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
1063
- value: account.id,
1139
+ children: [
1140
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1141
+ className: "dsm-trae-usage-select-wrap",
1142
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1143
+ className: "dsm-trae-usage-select",
1144
+ value: status.status === "signed-in" ? status.accountId : "",
1145
+ disabled: switchingAccount || settingsScope?.getSnapshot().writable !== true,
1146
+ onChange: (event) => {
1147
+ switchAccount(event.currentTarget.value);
1148
+ },
1149
+ children: status.accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
1150
+ value: account.id,
1151
+ children: [
1152
+ account.accountName,
1153
+ " · ",
1154
+ account.region === "ai" ? t("row.regionAi") : t("row.regionCn"),
1155
+ " · ",
1156
+ t(`row.edition${account.edition === "cn" ? "Cn" : account.edition === "sg" ? "Sg" : account.edition === "solo" ? "Solo" : "SoloSg"}`)
1157
+ ]
1158
+ }, account.id))
1159
+ })
1160
+ }),
1161
+ status.status === "signed-in" && status.alternatives !== void 0 && status.alternatives.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1162
+ className: "dsm-trae-usage-alternative",
1163
+ role: "status",
1164
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1165
+ className: "dsm-trae-usage-alternative-text",
1064
1166
  children: [
1065
- account.accountName,
1066
- " · ",
1067
- account.region === "ai" ? t("row.regionAi") : t("row.regionCn"),
1068
- " · ",
1069
- account.edition
1167
+ t("row.creditsExhausted", { account: status.accountName }),
1168
+ " ",
1169
+ t("row.creditsAlternative", { accounts: status.alternatives.map((alt) => `${alt.accountName}(${t(`row.edition${alt.edition === "cn" ? "Cn" : alt.edition === "sg" ? "Sg" : alt.edition === "solo" ? "Solo" : "SoloSg"}`)}:${formatNumber$1(alt.workAvailable + alt.generalAvailable)})`).join("、") })
1070
1170
  ]
1071
- }, account.id))
1171
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1172
+ type: "button",
1173
+ className: "dsm-trae-usage-switch-button",
1174
+ disabled: switchingAccount || settingsScope?.getSnapshot()?.writable !== true,
1175
+ onClick: () => {
1176
+ const target = status.alternatives?.[0];
1177
+ if (target !== void 0) switchAccount(target.id);
1178
+ },
1179
+ children: t("row.creditsAlternativeSwitch")
1180
+ })]
1181
+ }) : null,
1182
+ rescanNote === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1183
+ className: "dsm-trae-usage-note",
1184
+ role: "status",
1185
+ children: rescanNote
1072
1186
  })
1073
- })
1187
+ ]
1074
1188
  }) : null,
1075
1189
  status.status === "signed-in" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1076
1190
  status.payStatus === void 0 || payPlan === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -1130,7 +1244,7 @@ window.__ModuleLoader__.load({
1130
1244
  }),
1131
1245
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1132
1246
  className: "dsm-trae-usage-stat-value dsm-trae-usage-stat-value-work",
1133
- children: formatNumber(status.credits.workAvailable)
1247
+ children: formatNumber$1(status.credits.workAvailable)
1134
1248
  }),
1135
1249
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1136
1250
  className: "dsm-trae-usage-stat-hint",
@@ -1152,7 +1266,7 @@ window.__ModuleLoader__.load({
1152
1266
  }),
1153
1267
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1154
1268
  className: "dsm-trae-usage-stat-value dsm-trae-usage-stat-value-general",
1155
- children: formatNumber(status.credits.generalAvailable)
1269
+ children: formatNumber$1(status.credits.generalAvailable)
1156
1270
  }),
1157
1271
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1158
1272
  className: "dsm-trae-usage-stat-hint",
@@ -1169,8 +1283,8 @@ window.__ModuleLoader__.load({
1169
1283
  status.checkin === void 0 ? null : (() => {
1170
1284
  const accountClaimed = status.checkin.checkedIn;
1171
1285
  const deviceSpent = !accountClaimed && (status.checkin.didCheckedIn || deviceSpentNotice);
1172
- const reward = formatNumber(status.checkin.credits);
1173
- const bonus = status.checkin.extraCredits === void 0 ? "" : ` + ${formatNumber(status.checkin.extraCredits)}`;
1286
+ const reward = formatNumber$1(status.checkin.credits);
1287
+ const bonus = status.checkin.extraCredits === void 0 ? "" : ` + ${formatNumber$1(status.checkin.extraCredits)}`;
1174
1288
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1175
1289
  className: "dsm-trae-checkin",
1176
1290
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
@@ -1224,27 +1338,42 @@ window.__ModuleLoader__.load({
1224
1338
  children: t("row.modelsSummary", { count: activeEnabledIds.size })
1225
1339
  })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1226
1340
  className: "dsm-trae-models-actions",
1227
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1228
- type: "button",
1229
- className: "dsm-btn dsm-btn-outline",
1230
- disabled: busy,
1231
- onClick: () => {
1232
- refreshModels();
1233
- },
1234
- children: busy ? t("row.modelsRefreshing") : t("row.modelsRefresh")
1235
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1236
- type: "button",
1237
- className: "dsm-btn dsm-btn-outline",
1238
- disabled: busy || testing !== void 0 || activeEnabledIds.size === 0,
1239
- title: t("row.modelsTestHint"),
1240
- onClick: () => {
1241
- testEnabledModels();
1242
- },
1243
- children: testing === void 0 ? t("row.modelsTestEnabled") : t("row.modelsTesting", {
1244
- done: testing.done,
1245
- total: testing.total
1341
+ children: [
1342
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1343
+ className: "dsm-trae-mainui-switch-label",
1344
+ title: t("row.showPointsInMainUiHint"),
1345
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1346
+ type: "checkbox",
1347
+ checked: settingsValue?.showPointsInMainUi === true,
1348
+ disabled: settingsScope?.getSnapshot().writable !== true || togglingMainUi,
1349
+ onChange: (event) => {
1350
+ toggleShowPointsInMainUi(event.currentTarget.checked);
1351
+ }
1352
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.showPointsInMainUi") })]
1353
+ }),
1354
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1355
+ type: "button",
1356
+ className: "dsm-btn dsm-btn-outline",
1357
+ disabled: busy,
1358
+ onClick: () => {
1359
+ refreshModels();
1360
+ },
1361
+ children: busy ? t("row.modelsRefreshing") : t("row.modelsRefresh")
1362
+ }),
1363
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1364
+ type: "button",
1365
+ className: "dsm-btn dsm-btn-outline",
1366
+ disabled: busy || testing !== void 0 || activeEnabledIds.size === 0,
1367
+ title: t("row.modelsTestHint"),
1368
+ onClick: () => {
1369
+ testEnabledModels();
1370
+ },
1371
+ children: testing === void 0 ? t("row.modelsTestEnabled") : t("row.modelsTesting", {
1372
+ done: testing.done,
1373
+ total: testing.total
1374
+ })
1246
1375
  })
1247
- })]
1376
+ ]
1248
1377
  })]
1249
1378
  }),
1250
1379
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -1411,6 +1540,236 @@ window.__ModuleLoader__.load({
1411
1540
  });
1412
1541
  }
1413
1542
  //#endregion
1543
+ //#region src/client/SidebarPoints.tsx
1544
+ /**
1545
+ * Compact credit line contributed to the DSH main sidebar foot
1546
+ * (`sidebar.footer.action`).
1547
+ *
1548
+ * Mounted only while the plugin-card switch `showPointsInMainUi` is on, so the
1549
+ * slot renders nothing when the feature is off. On mount it fetches the bound
1550
+ * CN account's remaining credits ONCE and only then starts the periodic
1551
+ * refresh timer; the timer is cleared on unmount (switch off / plugin unload),
1552
+ * so timers never stack.
1553
+ *
1554
+ * A failed refresh keeps the last known value and surfaces a short failure
1555
+ * note; an in-flight refresh ignores further clicks (debounce).
1556
+ */
1557
+ /**
1558
+ * Automatic refresh period. The first fetch happens immediately on enable;
1559
+ * this is the gap between subsequent automatic fetches.
1560
+ *
1561
+ * Five minutes, not the card's 60s: a credit balance moves on the scale of
1562
+ * model calls, not seconds, and this line is a glanceable reminder rather than
1563
+ * a live meter. Each poll is two read-only upstream calls, so a tighter period
1564
+ * would buy nothing a user can act on.
1565
+ */
1566
+ const SIDEBAR_POINTS_REFRESH_INTERVAL_MS = 3e5;
1567
+ /** Inject the sidebar-line CSS once. */
1568
+ if (typeof document !== "undefined") {
1569
+ const cssId = "dsh-connect-trae/sidebar.css";
1570
+ if (!document.querySelector(`style[data-plugin-css="${cssId}"]`)) {
1571
+ const styleTag = document.createElement("style");
1572
+ styleTag.dataset.plugin = "dsh-connect-trae";
1573
+ styleTag.dataset.pluginCss = cssId;
1574
+ styleTag.textContent = TRAE_SIDEBAR_CSS;
1575
+ document.head.appendChild(styleTag);
1576
+ }
1577
+ }
1578
+ function formatNumber(value) {
1579
+ return new Intl.NumberFormat(void 0, { maximumFractionDigits: 0 }).format(value);
1580
+ }
1581
+ function formatClock(value) {
1582
+ return new Intl.DateTimeFormat(void 0, {
1583
+ hour: "2-digit",
1584
+ minute: "2-digit",
1585
+ second: "2-digit"
1586
+ }).format(new Date(value));
1587
+ }
1588
+ /**
1589
+ * The GENERAL credit balance out of one usage-route document.
1590
+ *
1591
+ * Deliberately `generalAvailable`, NOT `credits.available`. The latter is the
1592
+ * upstream aggregate (`summary.totalAmount - consumedAmount`), while the card
1593
+ * shows two buckets derived from the entitlement packs; the aggregate is not
1594
+ * guaranteed to equal the buckets, and a sidebar number the user cannot match
1595
+ * against the card is worse than no number at all. The general bucket is the
1596
+ * one the SOLO chat path spends, so it is the one worth watching.
1597
+ */
1598
+ function generalCreditsOf(usage) {
1599
+ if (usage.status !== "signed-in") return void 0;
1600
+ return usage.credits?.generalAvailable;
1601
+ }
1602
+ /** One fetch+render cycle. Returns false when the request failed. */
1603
+ function SidebarPoints({ t, onHide }) {
1604
+ if (t === void 0) throw new Error("Sidebar points line requires its translation function");
1605
+ const [points, setPoints] = (0, react.useState)(void 0);
1606
+ const [signedIn, setSignedIn] = (0, react.useState)(true);
1607
+ const [busy, setBusy] = (0, react.useState)(false);
1608
+ const [failed, setFailed] = (0, react.useState)(false);
1609
+ const [lastRefresh, setLastRefresh] = (0, react.useState)(void 0);
1610
+ /** True while a request is in flight; debounces repeated refresh clicks. */
1611
+ const inFlight = (0, react.useRef)(false);
1612
+ const mounted = (0, react.useRef)(true);
1613
+ (0, react.useEffect)(() => {
1614
+ mounted.current = true;
1615
+ return () => {
1616
+ mounted.current = false;
1617
+ };
1618
+ }, []);
1619
+ const fetchPoints = (0, react.useCallback)(async (signal) => {
1620
+ if (inFlight.current) return false;
1621
+ inFlight.current = true;
1622
+ if (mounted.current) setBusy(true);
1623
+ try {
1624
+ const response = await fetch(withTraeRegion(TRAE_USAGE_PATH, "cn"), {
1625
+ headers: { accept: "application/json" },
1626
+ credentials: "same-origin",
1627
+ ...signal === void 0 ? {} : { signal }
1628
+ });
1629
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1630
+ const value = await response.json();
1631
+ if (!mounted.current || signal?.aborted === true) return true;
1632
+ const general = generalCreditsOf(value);
1633
+ if (general !== void 0) {
1634
+ setPoints(general);
1635
+ setSignedIn(true);
1636
+ } else setSignedIn(value.status === "signed-in");
1637
+ setFailed(false);
1638
+ setLastRefresh(Date.now());
1639
+ return true;
1640
+ } catch {
1641
+ if (mounted.current && signal?.aborted !== true) setFailed(true);
1642
+ return false;
1643
+ } finally {
1644
+ inFlight.current = false;
1645
+ if (mounted.current) setBusy(false);
1646
+ }
1647
+ }, []);
1648
+ (0, react.useEffect)(() => {
1649
+ let cancelled = false;
1650
+ let timer;
1651
+ const controller = new AbortController();
1652
+ fetchPoints(controller.signal).finally(() => {
1653
+ if (cancelled) return;
1654
+ timer = window.setInterval(() => {
1655
+ fetchPoints(controller.signal);
1656
+ }, SIDEBAR_POINTS_REFRESH_INTERVAL_MS);
1657
+ });
1658
+ return () => {
1659
+ cancelled = true;
1660
+ if (timer !== void 0) window.clearInterval(timer);
1661
+ controller.abort();
1662
+ };
1663
+ }, [fetchPoints]);
1664
+ const titleText = lastRefresh === void 0 ? void 0 : t("sidebar.lastRefresh", { time: formatClock(lastRefresh) });
1665
+ const valueText = !signedIn ? t("sidebar.signedOut") : points === void 0 ? "—" : formatNumber(points);
1666
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1667
+ className: "dsm-trae-sidebar-points",
1668
+ title: titleText,
1669
+ children: [
1670
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1671
+ className: "dsm-trae-sidebar-points-line",
1672
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1673
+ className: "dsm-trae-sidebar-points-text",
1674
+ "aria-live": "polite",
1675
+ children: [
1676
+ t("sidebar.points"),
1677
+ ":",
1678
+ valueText
1679
+ ]
1680
+ }), failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1681
+ className: "dsm-trae-sidebar-points-failed",
1682
+ role: "status",
1683
+ children: t("sidebar.refreshFailed")
1684
+ }) : null]
1685
+ }),
1686
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1687
+ type: "button",
1688
+ className: "dsm-trae-sidebar-points-refresh",
1689
+ disabled: busy,
1690
+ "aria-label": busy ? t("sidebar.refreshing") : t("sidebar.refresh"),
1691
+ title: busy ? t("sidebar.refreshing") : t("sidebar.refresh"),
1692
+ onClick: () => {
1693
+ fetchPoints();
1694
+ },
1695
+ children: busy ? t("sidebar.refreshing") : t("sidebar.refresh")
1696
+ }),
1697
+ onHide === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1698
+ type: "button",
1699
+ className: "dsm-trae-sidebar-points-hide",
1700
+ "aria-label": t("sidebar.hide"),
1701
+ title: t("sidebar.hide"),
1702
+ onClick: onHide,
1703
+ children: "×"
1704
+ })
1705
+ ]
1706
+ });
1707
+ }
1708
+ //#endregion
1709
+ //#region src/client/SidebarPointsGate.tsx
1710
+ /**
1711
+ * Gate between the `sidebar.footer.action` slot and the compact credit line.
1712
+ *
1713
+ * Kept in its own module (rather than inside the browser-plugin entry) so it
1714
+ * can be imported from the Node/jsdom test suite: the entry imports
1715
+ * browser-only DSH packages the test environment cannot load, but this gate
1716
+ * depends only on React, the card's settings-scope shape and the line
1717
+ * component.
1718
+ *
1719
+ * TWO states:
1720
+ *
1721
+ * - on → {@link SidebarPoints}, which owns the fetch loop and its timer;
1722
+ * - off → NOTHING. The row is absent from the DOM, not hidden.
1723
+ *
1724
+ * Off used to leave a small "积分" chip behind, on the theory that a switch you
1725
+ * cannot find is no switch at all (2.9.1). That safety net is no longer needed:
1726
+ * the card carries the same checkbox twice — at the top of the card and beside
1727
+ * the model actions — so the way back is always where the feature is
1728
+ * configured. What the user asked for is for the row to be gone, and a leftover
1729
+ * chip still occupies the footer they wanted cleared.
1730
+ *
1731
+ * The collapsed rail also renders nothing: the shell squeezes the footer to a
1732
+ * 56px strip rather than hiding it, and the line does not read well there — see
1733
+ * the bail-out below.
1734
+ */
1735
+ /**
1736
+ * Read the sidebar-credit switch out of a committed settings snapshot.
1737
+ *
1738
+ * Absent means ON: the field defaults to true on the host, and a settings
1739
+ * scope that has not populated yet must not blink the line away. Only an
1740
+ * explicit `false` (the user turned it off, from the line or the card) hides
1741
+ * the balance.
1742
+ */
1743
+ function sidebarPointsEnabledOf(scope) {
1744
+ if (scope === void 0) return true;
1745
+ return unwrapVolatileDeep(scope.getSnapshot().value)?.showPointsInMainUi !== false;
1746
+ }
1747
+ function SidebarPointsGate(props) {
1748
+ const { t, settingsScope, wide } = props;
1749
+ const [enabled, setEnabled] = (0, react.useState)(() => sidebarPointsEnabledOf(settingsScope));
1750
+ (0, react.useEffect)(() => settingsScope?.subscribe(() => {
1751
+ setEnabled(sidebarPointsEnabledOf(settingsScope));
1752
+ }), [settingsScope]);
1753
+ /**
1754
+ * Write the switch back through the same field the card writes, so the two
1755
+ * controls can never disagree. The read-back is not awaited for its value:
1756
+ * the subscription above re-renders when the committed snapshot changes, and
1757
+ * an unwritable scope simply leaves the current state alone.
1758
+ */
1759
+ const setShown = (0, react.useCallback)((next) => {
1760
+ if (settingsScope === void 0 || settingsScope.getSnapshot().writable !== true) return;
1761
+ settingsScope.set("showPointsInMainUi", next).catch(() => {});
1762
+ }, [settingsScope]);
1763
+ if (wide === false) return null;
1764
+ if (!enabled) return null;
1765
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SidebarPoints, {
1766
+ t,
1767
+ onHide: () => {
1768
+ setShown(false);
1769
+ }
1770
+ });
1771
+ }
1772
+ //#endregion
1414
1773
  //#region src/client/locales.ts
1415
1774
  /**
1416
1775
  * Plugin-card copy registered under the settings.trae locale namespace.
@@ -1484,6 +1843,26 @@ window.__ModuleLoader__.load({
1484
1843
  "row.modelsTestEnabled": "Test enabled models",
1485
1844
  "row.modelsTesting": "Testing {done}/{total}…",
1486
1845
  "row.modelsTestHint": "Sends one minimal message per enabled model to see which ones actually answer. Costs a small amount of quota.",
1846
+ "row.showPointsInMainUi": "Show credits in the DSH main UI",
1847
+ "row.showPointsInMainUiHint": "Keep a general-credit line with a refresh button at the bottom of the DSH sidebar (hidden while the sidebar is collapsed; refreshes every 5 minutes).",
1848
+ "sidebar.points": "Trae credits",
1849
+ "sidebar.refresh": "Refresh",
1850
+ "sidebar.refreshing": "Refreshing…",
1851
+ "sidebar.lastRefresh": "Last refreshed {time}",
1852
+ "sidebar.refreshFailed": "Refresh failed",
1853
+ "sidebar.signedOut": "Not signed in",
1854
+ "sidebar.hide": "Hide credits",
1855
+ "row.accountsRefreshed": "Accounts refreshed: {count} found{selected}",
1856
+ "row.accountsKept": "still bound to",
1857
+ "row.accountsRefreshedNone": "Accounts refreshed: no usable Trae account found",
1858
+ "row.accountsRescanFailed": "Could not refresh accounts: {message}",
1859
+ "row.editionCn": "Trae",
1860
+ "row.editionSg": "Trae (international)",
1861
+ "row.editionSolo": "TRAE SOLO",
1862
+ "row.editionSoloSg": "TRAE SOLO (international)",
1863
+ "row.creditsExhausted": "The bound account ({account}) has run out of credit",
1864
+ "row.creditsAlternative": "Another account on this machine still has credit: {accounts}",
1865
+ "row.creditsAlternativeSwitch": "Switch to it",
1487
1866
  "row.modelsTestEmpty": "Enable at least one model first.",
1488
1867
  "row.modelsTestError": "Test failed: {message}",
1489
1868
  "row.modelsTestDone": "{available} of {total} answered.",
@@ -1576,6 +1955,26 @@ window.__ModuleLoader__.load({
1576
1955
  "row.modelsTestEnabled": "一键测试启用模型",
1577
1956
  "row.modelsTesting": "正在测试 {done}/{total}…",
1578
1957
  "row.modelsTestHint": "给每个已启用模型发一条最小消息,看哪些真的能应答。会消耗少量额度。",
1958
+ "row.showPointsInMainUi": "在DSH主界面显示积分状态",
1959
+ "row.showPointsInMainUiHint": "在 DSH 侧边栏底部常驻一行通用积分与刷新按钮(收起侧边栏时不显示,每 5 分钟自动刷新)。",
1960
+ "sidebar.points": "Trae通用积分",
1961
+ "sidebar.refresh": "刷新",
1962
+ "sidebar.refreshing": "刷新中…",
1963
+ "sidebar.lastRefresh": "最近刷新 {time}",
1964
+ "sidebar.refreshFailed": "刷新失败",
1965
+ "sidebar.signedOut": "未登录",
1966
+ "sidebar.hide": "隐藏积分",
1967
+ "row.accountsRefreshed": "已刷新账号:找到 {count} 个账号{selected}",
1968
+ "row.accountsKept": "仍绑定",
1969
+ "row.accountsRefreshedNone": "已刷新账号:未找到可用的 Trae 账号",
1970
+ "row.accountsRescanFailed": "刷新账号失败:{message}",
1971
+ "row.editionCn": "Trae",
1972
+ "row.editionSg": "Trae 国际版",
1973
+ "row.editionSolo": "TRAE SOLO",
1974
+ "row.editionSoloSg": "TRAE SOLO 国际版",
1975
+ "row.creditsExhausted": "当前绑定账号({account})额度已用尽",
1976
+ "row.creditsAlternative": "本机还有可用账号:{accounts}",
1977
+ "row.creditsAlternativeSwitch": "切换到它",
1579
1978
  "row.modelsTestEmpty": "请先启用至少一个模型。",
1580
1979
  "row.modelsTestError": "测试失败:{message}",
1581
1980
  "row.modelsTestDone": "{total} 个里有 {available} 个正常应答。",
@@ -1725,6 +2124,32 @@ window.__ModuleLoader__.load({
1725
2124
  };
1726
2125
  registerCard("plugins.bundle.config", "dsh-connect-trae");
1727
2126
  registerCard("plugins.row.config", "dsh-connect-trae#dsh-connect-trae");
2127
+ /**
2128
+ * General-credit line at the sidebar foot (`sidebar.footer.action`, a
2129
+ * root-scope list slot), contributed by JiewiW (issue #26).
2130
+ *
2131
+ * Registration is unconditional for the slot's lifetime; the component
2132
+ * renders nothing until the card switch is on, and nothing again while the
2133
+ * sidebar is collapsed — so an off switch (or a collapsed rail) leaves zero
2134
+ * DOM and no fetch loop.
2135
+ */
2136
+ const registerSidebarPoints = () => {
2137
+ try {
2138
+ ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
2139
+ name: "sidebar.footer.action",
2140
+ id: "dsh-connect-trae-points",
2141
+ order: 4,
2142
+ locale: namespace,
2143
+ inject: () => ({
2144
+ t,
2145
+ settingsScope
2146
+ })
2147
+ }, SidebarPointsGate));
2148
+ } catch (error) {
2149
+ console.error("[dsh-connect-trae] sidebar points slot failed to register (host provider unaffected):", error);
2150
+ }
2151
+ };
2152
+ registerSidebarPoints();
1728
2153
  } catch (error) {
1729
2154
  console.error("[dsh-connect-trae] client card failed to load (host provider unaffected):", error);
1730
2155
  }
package/lib/index.d.ts CHANGED
@@ -483,6 +483,22 @@ declare class TraeCredentialStore {
483
483
  private preferred;
484
484
  accounts(): Promise<TraeAccountChoice[]>;
485
485
  current(): Promise<TraeCredential | undefined>;
486
+ /**
487
+ * The stored credential of one specific account, without changing which
488
+ * account this store is bound to (issue #25).
489
+ *
490
+ * The card needs to answer "the account I am bound to has run out of credits
491
+ * — is another account on this machine usable?" and {@link resolve} cannot
492
+ * help: it always returns the BOUND account, and switching the bound account
493
+ * just to read one number would race every in-flight chat request sharing
494
+ * this store, and would persist a selection the user never made.
495
+ *
496
+ * So this is a pure lookup: read-only, no refresh side effects (a stale token
497
+ * is returned as-is and the caller's request then fails honestly), and
498
+ * `accountId` is left untouched. An unknown id yields undefined rather than a
499
+ * fallback, for the same reason {@link current} refuses to fall back.
500
+ */
501
+ credentialOf(accountId: string): Promise<TraeCredential | undefined>;
486
502
  resolve(): Promise<TraeCredential>;
487
503
  status(): Promise<{
488
504
  state: 'signed-in' | 'signed-out';
@@ -1673,6 +1689,22 @@ interface TraeWebAccount {
1673
1689
  tokenExpiresAtMs: number;
1674
1690
  selected: boolean;
1675
1691
  }
1692
+ /**
1693
+ * Another account on this machine that still has credit, surfaced only while
1694
+ * the BOUND account is exhausted (issue #25).
1695
+ *
1696
+ * The account switch is persisted, so a user whose bound account ran out sees
1697
+ * a permanently zeroed card with no explanation. This carries just enough to
1698
+ * name the alternative in the UI — identity plus the two CN credit buckets —
1699
+ * and is only ever produced by a read-only upstream call.
1700
+ */
1701
+ interface TraeWebCreditAlternative {
1702
+ id: string;
1703
+ accountName: string;
1704
+ edition: TraeWebAccount['edition'];
1705
+ workAvailable: number;
1706
+ generalAvailable: number;
1707
+ }
1676
1708
  /** One probed candidate path and why it did not yield an account. */
1677
1709
  interface TraeWebSearchPath {
1678
1710
  path: string;
@@ -1789,6 +1821,13 @@ type TraeWebUsage = {
1789
1821
  };
1790
1822
  credits?: TraeWebCredits;
1791
1823
  creditsError?: string;
1824
+ /**
1825
+ * Present only when the bound account is out of credit AND another account
1826
+ * on this machine has some (issue #25). The card then offers a one-click
1827
+ * switch instead of leaving a permanently empty panel. Absent in every
1828
+ * other case so a healthy account never pays for the extra read.
1829
+ */
1830
+ alternatives?: readonly TraeWebCreditAlternative[];
1792
1831
  /**
1793
1832
  * Daily check-in state (CN only). Absent on the international region,
1794
1833
  * whose check-in surface does not exist — probed 2026-09-24: the
@@ -1817,6 +1856,12 @@ interface TraeUsageRouteOptions {
1817
1856
  store(region: TraeRegion): TraeCredentialStore;
1818
1857
  /** The region-scoped usage client (CN work credits / international pay status). */
1819
1858
  client(region: TraeRegion): TraeUsageClient;
1859
+ /**
1860
+ * CN credit balances of an account OTHER than the bound one, read without
1861
+ * changing which account is bound (issue #25). Optional: when absent the
1862
+ * exhausted-account hint is simply not offered.
1863
+ */
1864
+ creditsOfAccount?(region: TraeRegion, accountId: string): Promise<TraeWebCredits | undefined>;
1820
1865
  /** The requested region's last-refreshed raw directory (one entry per upstream model). */
1821
1866
  displayModels(region: TraeRegion): readonly TraeModelInfo[];
1822
1867
  /** The user's model selection in the requested region, stored as model id (= Trae name). */
@@ -1977,6 +2022,22 @@ interface Config {
1977
2022
  * its own slot; tokens remain outside settings.
1978
2023
  */
1979
2024
  accounts?: Partial<Record<TraeRegion, string>>;
2025
+ /**
2026
+ * Show the CN account's general credit balance as a permanent line at the
2027
+ * bottom of the DSH sidebar (issue #26, contributed by JiewiW).
2028
+ *
2029
+ * ON by default and opt-out since 2.9.1. It was opt-in first, on the theory
2030
+ * that a user who never asked for a periodic usage read should not get one —
2031
+ * but the switch lived only in the plugin card, and the card is not reachable
2032
+ * in every host: a market plugin can own the Plugins tab and never dispatch
2033
+ * `plugins.bundle.config`, so the line was permanently off with no discoverable
2034
+ * way to turn it on. The line now carries its own on/off control in the
2035
+ * sidebar, which is reachable exactly where the feature renders.
2036
+ *
2037
+ * Declared volatile alongside `accounts`/`regions` so the settings form can
2038
+ * both read and write it.
2039
+ */
2040
+ showPointsInMainUi?: boolean;
1980
2041
  /**
1981
2042
  * Per-region model state, keyed `cn` | `ai`. The CN and international apps
1982
2043
  * expose different rosters, so each keeps its own directory and selection
package/lib/index.js CHANGED
@@ -1198,6 +1198,25 @@ var TraeCredentialStore = class {
1198
1198
  if (this.accountId === void 0) return this.preferred(credentials);
1199
1199
  return credentials.find((credential) => traeAccountId(credential) === this.accountId);
1200
1200
  }
1201
+ /**
1202
+ * The stored credential of one specific account, without changing which
1203
+ * account this store is bound to (issue #25).
1204
+ *
1205
+ * The card needs to answer "the account I am bound to has run out of credits
1206
+ * — is another account on this machine usable?" and {@link resolve} cannot
1207
+ * help: it always returns the BOUND account, and switching the bound account
1208
+ * just to read one number would race every in-flight chat request sharing
1209
+ * this store, and would persist a selection the user never made.
1210
+ *
1211
+ * So this is a pure lookup: read-only, no refresh side effects (a stale token
1212
+ * is returned as-is and the caller's request then fails honestly), and
1213
+ * `accountId` is left untouched. An unknown id yields undefined rather than a
1214
+ * fallback, for the same reason {@link current} refuses to fall back.
1215
+ */
1216
+ async credentialOf(accountId) {
1217
+ if (accountId === "") return void 0;
1218
+ return (await this.readAll()).find((credential) => traeAccountId(credential) === accountId);
1219
+ }
1201
1220
  async resolve() {
1202
1221
  const credential = await this.current();
1203
1222
  if (credential === void 0) throw new Error(`trae: no signed-in account found (${this.candidates().map((item) => item.path).join(" or ")})`);
@@ -4081,6 +4100,8 @@ function json(res, status, body) {
4081
4100
  });
4082
4101
  res.end(payload);
4083
4102
  }
4103
+ /** Bound on how many OTHER accounts one status read may probe (issue #25). */
4104
+ const TRAE_CREDIT_ALTERNATIVE_LIMIT = 8;
4084
4105
  /**
4085
4106
  * Read the ids to test from a POST body.
4086
4107
  *
@@ -4125,7 +4146,14 @@ function loopbackOrigin(req) {
4125
4146
  return false;
4126
4147
  }
4127
4148
  }
4128
- /** Map the credit snapshot to the card's compact credit document. */
4149
+ /**
4150
+ * Map the credit snapshot to the card's compact credit document.
4151
+ *
4152
+ * Exported so the "is another account usable?" probe (issue #25) derives its
4153
+ * two buckets through the SAME endpoint classification the bound account uses
4154
+ * — a second implementation of the Work/general split would be free to disagree
4155
+ * with the number shown next to it.
4156
+ */
4129
4157
  function toCredits(snapshot) {
4130
4158
  const { totalAmount, consumedAmount } = snapshot.summary;
4131
4159
  const credit = (value) => Math.round(value * 1e4) / 1e4;
@@ -4221,14 +4249,51 @@ async function traeWebUsage(deps, region) {
4221
4249
  };
4222
4250
  }
4223
4251
  const [snapshotResult, checkinResult] = await Promise.allSettled([client.snapshot(), client.checkinStatus()]);
4252
+ const credits = snapshotResult.status === "fulfilled" ? toCredits(snapshotResult.value) : void 0;
4253
+ const alternatives = credits !== void 0 && credits.workAvailable <= 0 && credits.generalAvailable <= 0 ? await creditAlternatives(deps, region, accounts) : void 0;
4224
4254
  return {
4225
4255
  status: "signed-in",
4226
4256
  ...account,
4227
- ...snapshotResult.status === "fulfilled" ? { credits: toCredits(snapshotResult.value) } : { creditsError: safeMessage(snapshotResult.reason) },
4228
- ...checkinResult.status === "fulfilled" ? { checkin: toCheckin(checkinResult.value) } : { checkinError: safeMessage(checkinResult.reason) }
4257
+ ...credits === void 0 ? { creditsError: snapshotResult.status === "rejected" ? safeMessage(snapshotResult.reason) : "unknown" } : { credits },
4258
+ ...checkinResult.status === "fulfilled" ? { checkin: toCheckin(checkinResult.value) } : { checkinError: safeMessage(checkinResult.reason) },
4259
+ ...alternatives === void 0 ? {} : { alternatives }
4229
4260
  };
4230
4261
  }
4231
4262
  /**
4263
+ * Other accounts on this machine that still have credit, when the bound one has
4264
+ * none (issue #25).
4265
+ *
4266
+ * Only ever called for a bound account whose own credits came back at zero, so
4267
+ * a healthy setup pays nothing for it. Each candidate is read through
4268
+ * `creditsOfAccount`, which must not disturb the bound account; a candidate
4269
+ * that fails is simply absent from the answer rather than reported as a broken
4270
+ * account, because a stale token on an unrelated login is not this card's
4271
+ * problem to explain. The list is capped so a machine with many stale
4272
+ * credentials cannot turn one status read into a burst of upstream calls.
4273
+ */
4274
+ async function creditAlternatives(deps, region, accounts) {
4275
+ if (deps.creditsOfAccount === void 0) return void 0;
4276
+ const candidates = accounts.filter((account) => !account.selected && account.region === region).slice(0, TRAE_CREDIT_ALTERNATIVE_LIMIT);
4277
+ if (candidates.length === 0) return void 0;
4278
+ const alternatives = (await Promise.all(candidates.map(async (account) => {
4279
+ try {
4280
+ const credits = await deps.creditsOfAccount(region, account.id);
4281
+ if (credits === void 0) return void 0;
4282
+ if (credits.workAvailable <= 0 && credits.generalAvailable <= 0) return void 0;
4283
+ return {
4284
+ id: account.id,
4285
+ accountName: account.accountName,
4286
+ edition: account.edition,
4287
+ workAvailable: credits.workAvailable,
4288
+ generalAvailable: credits.generalAvailable
4289
+ };
4290
+ } catch {
4291
+ return;
4292
+ }
4293
+ }))).filter((entry) => entry !== void 0);
4294
+ return alternatives.length === 0 ? void 0 : alternatives;
4295
+ }
4296
+ /**
4232
4297
  * The region a request addresses, or a 400 answer. Absent parameter means the
4233
4298
  * domestic tab; an unknown value is refused rather than guessed.
4234
4299
  */
@@ -4704,6 +4769,7 @@ const Config = z.object({
4704
4769
  "solo-sg"
4705
4770
  ]).default("auto").description("Trae edition hint")),
4706
4771
  accountId: z.string().description("Deprecated: pre-split account selector, attributed to its own region"),
4772
+ showPointsInMainUi: asVolatile(z.boolean().default(true).description("在DSH主界面显示积分状态(可在侧边栏那一行上随时关闭)")),
4707
4773
  accounts: asVolatile(accountSelectionConfig.description("Per-region account selections, keyed cn | ai")),
4708
4774
  regions: asVolatile(z.dict(regionStateConfig).default({}).description("Per-region model directory and selection, keyed cn | ai")),
4709
4775
  lastCatalog: z.array(modelConfig).description("Deprecated: pre-region-split CN model directory"),
@@ -4893,6 +4959,22 @@ function apply(ctx, config) {
4893
4959
  credential: () => store.resolve(),
4894
4960
  deviceId: async () => (await identity()).deviceId
4895
4961
  }),
4962
+ /**
4963
+ * Credit balance of one named account, WITHOUT rebinding the store
4964
+ * (issue #25). The card asks this only after it saw the bound account
4965
+ * come back empty, so it can point at a login that still works.
4966
+ *
4967
+ * A throwaway client is used on purpose: the shared `usageClient` reads
4968
+ * `store.resolve()`, which is bound to the selected account, and calling
4969
+ * `selectAccount` to look at a neighbour would both race concurrent chat
4970
+ * requests and persist a switch the user never made. A stale token simply
4971
+ * fails here and the account is left out of the answer.
4972
+ */
4973
+ creditsOfAccount: async (accountId) => {
4974
+ const credential = await store.credentialOf(accountId);
4975
+ if (credential === void 0) return void 0;
4976
+ return toCredits(await new TraeUsageClient({ credential: async () => credential }).snapshot());
4977
+ },
4896
4978
  discoverModels: async (signal) => {
4897
4979
  const merged = mergeTraeModelSources(await withDirectoryRetry(() => remoteCatalog.fetchModels(signal)), await solo.fetchModels(signal));
4898
4980
  wire.callableKeys.clear();
@@ -4951,7 +5033,8 @@ function apply(ctx, config) {
4951
5033
  regionEnabled: (region) => regionEnabled(current(), region),
4952
5034
  discoverModels: (region, signal) => stacks[region].discoverModels(signal),
4953
5035
  probeModels: (region, ids) => stacks[region].probeModels(ids),
4954
- rawDiagnostic: (region) => stacks[region].rawDiagnostic()
5036
+ rawDiagnostic: (region) => stacks[region].rawDiagnostic(),
5037
+ creditsOfAccount: (region, accountId) => stacks[region].creditsOfAccount(accountId)
4955
5038
  }));
4956
5039
  /**
4957
5040
  * Live registration handles per region, filled once the shim is listening.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-trae",
3
3
  "displayName": "DSH Connect Trae",
4
4
  "description": "把本机登录的 Trae 模型接入 DeepSeek Harness:国内版与国际版双供应商并行,提供用量/积分概览与每日签到领取。",
5
- "version": "2.7.2",
5
+ "version": "2.10.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",