dsh-connect-trae 4.15.0 → 4.17.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,98 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.17.0 (2026-11-09)
4
+
5
+ ### 自动刷新改为可选项:整个插件一个节奏,默认 5 分钟
6
+
7
+ 改动前,插件对**同两条只读上游路由**用了**两个硬编码节奏**:卡片每 **60 秒**重读一次用量
8
+ 文档,输入框读数每 **5 分钟**一次。取哪个取决于你正好在看哪个界面,两个都改不了。而每次
9
+ 用量读取是**两次上游请求**,所以「正在看卡片」这件事本身就悄悄把后台流量翻了几倍。
10
+
11
+ 现在是一个字段 `refreshIntervalMinutes`,一处 UI(「设置 → Trae → **能力**」里的
12
+ 「自动刷新」),可选 **1 / 5 / 10 / 30 / 60 分钟**,**默认 5**。默认值选 5 是因为它正是
13
+ 输入框读数原本的节奏——升级不改变任何人的观感,而卡片的 60 秒被拉到同一个值上。
14
+
15
+ **为什么是「整个插件一个值」而不是每面板一个。** 它配的是插件的后台噪声,不是某个区域的
16
+ 数据:两处打的是同两条路由、同样的代价。按面板存会让同一块屏幕出现两个节奏,而这正是这次
17
+ 要消灭的东西。
18
+
19
+ **写读链路,以及为什么不是简单的 `setInterval` 换个数。**
20
+
21
+ | 环节 | 做法 | 不做会怎样 |
22
+ | --- | --- | --- |
23
+ | schema | `z.union([1,5,10,30,60])`,默认 5 | 手改文件写个 `7` 会被接受,得到一个没人选过的节奏 |
24
+ | 读取 | `refreshIntervalMinutesOf()`,非集合内一律回落默认 | 认不出的值让刷新**静默停止**,症状只是数字悄悄变旧 |
25
+ | 写入 | `writeSettingsField` 读回校验,比的是**解析后的分钟数** | 写入被接受但没落地时,选择器显示新值而定时器还是旧的 |
26
+ | 定时器 | 间隔进 effect 依赖,cleanup 先清旧定时器、中止在途请求 | 改一次间隔就多一个循环并跑,上游请求翻倍且界面看不出来 |
27
+ | 共享 | 卡片与输入框读数读**同一个** reader | 两个界面漂回两个节奏,正是本版要修的缺陷 |
28
+
29
+ 选项列表本身是**共享常量** `TRAE_REFRESH_INTERVAL_MINUTES`,schema 与选择器都从它渲染:
30
+ 两处各写一份字面量,就会出现「选择器给得出、写入拒绝」的选项。
31
+
32
+ **输入框读数补了一次设置订阅。** 它挂在 gate 之下,而 gate 订阅的是自己的两个开关——
33
+ 子组件不会因为父组件订阅了而重渲染。没有这次订阅,改了间隔要等到下一次无关渲染才生效。
34
+
35
+ ### Tests
36
+
37
+ - 新增 `tests/refresh-interval.spec.tsx`(16 例):读取器的默认/全集/回落/`{get()}` 实时引用;
38
+ schema 接受五个值、拒绝 `7` 与 `0`;选择器在「能力」分栏、显示存储值、只写一个字段、
39
+ 只读时拒写;**卡片按存储间隔读**(10 分钟时 1 分钟处零请求、10 分钟处恰好一次);
40
+ **改间隔会替换定时器**(旧 60 分钟 → 新 1 分钟,一分钟处恰好一次读);**输入框读数用同一
41
+ 字段**(1 分钟处就有读,不等旧的 5 分钟常量)。
42
+ - **变异验证 4 次全部被抓**:卡片定时器改回固定 60s → 1 例失败;输入框读数改回自己的常量
43
+ → 1 例失败;读取器接受任意数字 → 1 例失败;(另有一次改动由既有 `card-styles` 的
44
+ 「每个渲染出来的类都要有规则」守卫抓出,补了 `.dsm-trae-refresh-interval` 的样式)。
45
+ - 全仓 1446 → **1462**。
46
+
47
+ ### 顺带
48
+
49
+ `styles.ts` 的注释里曾写了一个反引号包住的类名——那个模板字符串会当场截断,整份样式表变成
50
+ 语法碎片,而 typecheck 与 build 都不报错。`card-styles` 的「顶层语句只能是变量声明」守卫
51
+ 当场抓到,改回普通文字。这个坑本仓库文档里已经记过一次(workbuddy 的 busy veil)。
52
+
53
+ ## 4.16.0 (2026-11-08)
54
+
55
+ ### 模型标签槽:按宿主的声明注册,为 `keyed` 做好准备
56
+
57
+ `conversation.input.model.label` 的 kind 写在宿主包里(`ui-model-selection`)。
58
+ 在它改口之前是 `single`:只有一格,三家连接器只能按 `-101` / `-102` / `-105`
59
+ 互相压价。目标形态是 `keyed`——每个 `options.key` 自成一格,宿主按 `provider.id`
60
+ 派发 `{ entryKey }`,没人贡献的 provider 落回宿主自己的 fallback。
61
+
62
+ **本版做的是一件插件侧唯一该做的事:注册几条跟着宿主声明的 kind 走。**
63
+
64
+ - 新增 `src/client/model-label-seat.ts`:运行时读 `ctx.slots.spec(key)?.kind`,
65
+ `keyed` 就为 `trae` / `trae-enterprise` / `trae-global` 各注册一格,其余情况
66
+ (含读不到)保持历史上的单条。**一个构建同时服务两条宿主线**,宿主改口那天
67
+ 不需要重新发版。
68
+ - `registerSlotEntry` 现在接受「多个 disposer」,并把它们合成一个按**反序**释放的
69
+ 组合 disposer:注册是一条命,拆开释放会留下「某几格还占着、兄弟格已消失」的半死状态。
70
+
71
+ **为什么不能只改宿主。** 两个方向都会**静默**失败,而本仓库的注册路径把一个
72
+ try/catch 包在回调里(issue #74 的修复),于是它们都变成一行 `console.error`:
73
+
74
+ | 情况 | 结果 |
75
+ | --- | --- |
76
+ | keyed 的两条打到 `single` 宿主 | 第二条抛 `already has a registration`,第一条已落地 → 半注册 |
77
+ | 无 key 的一条打到 `keyed` 宿主 | 抛 `keyed slot … requires options.key` → 标签消失 |
78
+
79
+ `keyed` **强制要求 `key`**,这是上游提案里没写的第一道陷阱:**此前已发布**的本族每个
80
+ 版本都注册一条无 key 的条目,只改宿主声明会让它们一起失效(本版之后的新构建会跟着
81
+ 声明走,所以受影响的是「已经装在用户机器上」的那些版本)。第二道陷阱见下一节。
82
+
83
+ **改声明不够:宿主还必须传 `entryKey`。** 复核实机产物
84
+ (`ui-model-selection@0.2.2-rc.1`)发现,渲染该槽只有一个调用点,第三个参数只有
85
+ `fallback`、**没有 `entryKey`**;而 keyed 分支是
86
+ `find(e => e.options.key === opts?.entryKey)`——`undefined` 匹配不到任何条目,于是
87
+ **每一行**都落到 fallback。本插件的注册会成功、回调也跑、无异常无日志,只是组件再也
88
+ 不被调用。上游的最小改动是**两处**:声明 + 调用点补 `{ entryKey: provider.id }`。
89
+
90
+ `MODEL_LABEL_PRIORITY = -102` 不改。keyed 下它不再决定可见性,只剩「同一格内谁先」;
91
+ 三家之间不再有压制关系——停战靠的是换槽的基数,不是再压一次价。
92
+
93
+ 外来行分支保留:keyed 下运行时走不到,但一个构建要同时服务两种宿主,而 `single`
94
+ 宿主上它就是整行文字。它同时也是最便宜的哨兵——自己的行若走进那一支,说明 `key` 写错了。
95
+
3
96
  ## 4.15.0 (2026-11-08)
4
97
 
5
98
  ### 新增:模型选择器里的胶囊,改成「谁都能显示」
package/README.en.md CHANGED
@@ -61,11 +61,12 @@ In the account pool, open **More settings** and choose **Claude style** or **Def
61
61
  > `4011` is a **rate limit**, not "this model is unavailable" (measured for issue #33): an exhausted balance reports a different code, `4008`, so "I still have plenty of credits" and "I got a 4011" are both true at once. A throttled model answers again after a short wait — do not delete it.
62
62
  - **One-click test of all models** — the same probe applied to **every visible model** of the current region, not just the enabled ones. Use it when you want to know whether the models you have switched off could still answer — after a plan change, or after Trae reshuffles its lineup.
63
63
  - **Daily check-in claiming (per account)** — on the CN card **every account row carries its own check-in button**, with the day's reward written on it (`Check in +150`). A row reads **Claimed** and disables itself once *that account* has been paid, and reads **Device used** and disables itself when *this machine* has already spent today's slot through some other account — the two are reported honestly, and the second is never dressed up as "claimed". This is the plugin's **only** operation that changes account state, and it runs only when you click; one click targets that one row's account and sends exactly one request. There is deliberately **no "claim for every account"**: Trae allows **one check-in per device per day** (switching accounts does not reset the slot), so a sweep could only manufacture a row of false failures. The international side has no check-in activity, so no button is shown there.
64
- - **Composer readout** — the composer tool row (between "Expert" and the model selector) shows a clickable readout, refreshing every 5 minutes. **Both regions show it, but they do not show the same quantity, and that is the data's doing**: the CN side is credit-based, so it reads "Trae CN · <general credits>"; the international side is subscription-based and its usage document carries no credit field at all, so it reads "Trae Global · <plan>" (e.g. `Pro` / `Free`). The whole readout is clickable and opens a panel showing a TABLE: one row per account in the region — general credits on CN, plan on international — where clicking a row SWITCHES accounts (through the same merged `accounts` write the card uses, so the other region's selection survives). The panel's top right carries **two controls, Refresh and Settings** (as of 4.9.0 the Settings button targets the settings panel; an interim revision removed it on the belief that this was impossible, which was wrong — the host publishes no id-targeted *service*, but the settings shell pins its store seat to the registration so `slots.entries('sidebar.settings')[0].store.create().actions.openSection(id)` is a real route, and the host's own tests drive the dialog exactly that way). While Refresh runs its icon turns: the batch is serial, so the slowest account decides when it stops. **Refresh** asks every account checked into this panel's account pool to answer once (through the pool's `action=test` route — the same batch the pool section's own button runs) and reports the outcome in the panel's foot: `Pool tested · 2 of 2 answered`. **Two things about that press the label cannot say, so both live in the tooltip and in the foot line**: it tests the **pool's checked-in members**, not every account in the table you are looking at, and it is the **only press in this plugin that spends credits** (one minimal ping per member) — a bare "Refresh" reads as free, so the tooltip is worth reading. To change anything, open **Settings → Trae** — the plugin's only configuration surface. The balance needs no refresh button of its own — it re-reads when the panel opens, on every account switch, and every five minutes, which is why that button was replaced rather than joined by another. It appears **only while the session's selected model belongs to this plugin**, and renders nothing for any other provider — which is what keeps two connector plugins from competing for one shared row (the sidebar foot is a shared root row; the composer row is per-session and provider-scoped).
64
+ - **Composer readout** — the composer tool row (between "Expert" and the model selector) shows a clickable readout, refreshing every 5 minutes by default (the interval is configurable — see the next bullet). **Both regions show it, but they do not show the same quantity, and that is the data's doing**: the CN side is credit-based, so it reads "Trae CN · <general credits>"; the international side is subscription-based and its usage document carries no credit field at all, so it reads "Trae Global · <plan>" (e.g. `Pro` / `Free`). The whole readout is clickable and opens a panel showing a TABLE: one row per account in the region — general credits on CN, plan on international — where clicking a row SWITCHES accounts (through the same merged `accounts` write the card uses, so the other region's selection survives). The panel's top right carries **two controls, Refresh and Settings** (as of 4.9.0 the Settings button targets the settings panel; an interim revision removed it on the belief that this was impossible, which was wrong — the host publishes no id-targeted *service*, but the settings shell pins its store seat to the registration so `slots.entries('sidebar.settings')[0].store.create().actions.openSection(id)` is a real route, and the host's own tests drive the dialog exactly that way). While Refresh runs its icon turns: the batch is serial, so the slowest account decides when it stops. **Refresh** asks every account checked into this panel's account pool to answer once (through the pool's `action=test` route — the same batch the pool section's own button runs) and reports the outcome in the panel's foot: `Pool tested · 2 of 2 answered`. **Two things about that press the label cannot say, so both live in the tooltip and in the foot line**: it tests the **pool's checked-in members**, not every account in the table you are looking at, and it is the **only press in this plugin that spends credits** (one minimal ping per member) — a bare "Refresh" reads as free, so the tooltip is worth reading. To change anything, open **Settings → Trae** — the plugin's only configuration surface. The balance needs no refresh button of its own — it re-reads when the panel opens, on every account switch, and on every refresh interval, which is why that button was replaced rather than joined by another. It appears **only while the session's selected model belongs to this plugin**, and renders nothing for any other provider — which is what keeps two connector plugins from competing for one shared row (the sidebar foot is a shared root row; the composer row is per-session and provider-scoped).
65
65
 
66
66
  <img src="docs/assets/dsh-connect-trae-composer-credits.png" width="560" alt="Trae credits panel: a table of accounts with their general balance, and the composer readout below" />
67
67
 
68
68
  **Where that readout sits is a choice** (learned from the sibling `dsh-connect-workbuddy` issue #62). The default is the **composer toolbar** (the position described above); if another plugin occupies it and the readout does not appear, move it **below the input box** — the row carrying the round / step / token statistics, where the readout is APPENDED after the shipped figures rather than replacing them. The seat is set **per panel** (all three are stored separately), in the card's settings block next to the "show credits in the composer row" checkbox. Why a second seat exists at all: the composer toolbar is a **shared list slot** that the shell's own controls and every other plugin register into, and a shared slot **cannot promise that every occupant is visible** — the report behind that issue is a readout that vanished entirely once a third-party plugin was installed (this plugin has been squeezed out of a shared row once before too, down to a clipped "Tr" at the sidebar foot).
69
+ - **The automatic-refresh interval is configurable** — "refresh every" under **Settings → Trae → Capabilities** decides how often the card and the composer readout re-read usage by themselves: **1 / 5 / 10 / 30 / 60 minutes**, **5 by default**. **One value for the whole plugin**, not one per panel: both surfaces hit the same two read-only routes, so a per-panel copy would make one screen refresh at two cadences. A change takes effect **immediately** — the old timer is cleared and its in-flight read aborted before a new one starts at the new period, so a change cannot leave two loops running. **A shorter interval means more requests**: each refresh is two read-only upstream requests and nothing here spends credits. Before this setting the card was hardcoded to 60 seconds while the composer readout was hardcoded to five minutes — two cadences for the same data, decided by which surface you happened to be looking at. Both now read this one field.
69
70
 
70
71
  - **Optional: put Trae in the sidebar** (learned from the sibling `dsh-connect-workbuddy` PR #188, and **off by default** here too). Ticking "Show Trae in the sidebar" in the card's settings block adds a Trae row to the DSH sidebar; it opens the **full-page version of this same card** — same region tabs, same account / models / diagnostics sections, minus the card frame, with a title, a description and "Back to conversation" at the top. **Why it is off by default**: the sidebar's global rows are the host's own pages, and this row claims a seat **every installed bundle shares** while the settings page already reaches the same content — a nav row nobody asked for re-arranges a surface this plugin does not own. **It takes effect on the click** with no Save: writing `sidebarPanel` registers or disposes the row in the same second (it is not a component rendering null — that would leave a blank but still clickable button), and switching it off while the page is open sends the centre column back to the conversation by itself. The page's back control goes through the host's `layout.selectPanel(null)`; on a host without that service the control is **omitted rather than drawn dead**.
71
72
  <img src="docs/assets/dsh-connect-trae-sidebar-page.png" width="720" alt="The sidebar page: back control, title and description at the top, then the same card body below" />
package/README.md CHANGED
@@ -66,11 +66,12 @@
66
66
  > `4011` 是**限流**不是「模型不可用」(issue #33 实测):它额度耗尽走的是另一个码 `4008`,所以「额度还有很多却报 4011」两句话可以同时成立。被限流的模型**等你一会儿再试**即可,不要删。
67
67
  - **一键测试所有模型** —— 同一套探测,作用于当前 region 的**全部可见模型**(不限于已启用的)。想知道「没启用的那些到底还能不能用」时用它——比如套餐降级后,或 Trae 调整了模型阵容之后。
68
68
  - **每日签到领取(逐账号)** —— 国内版**每个账号行各有一个签到按钮**,当日奖励直接写在按钮上(`签到 +150`);该账号当天已领显示「已领取」并禁用,**本机今日名额已被别的账号用掉**时显示「本机已签」并禁用——如实区分「这个账号领了」和「这台设备领过了」,绝不把后者说成「已领取」。这是插件里**唯一会改变账号状态**的操作,由你主动点击触发;一次点击只针对该行账号、只发一次请求。Trae 的签到按「**每台设备每天一次**」计(换账号不会重置当天名额),所以逐账号签到始终是主路径。账号池里另有一个「全部签到」,它**不是**把同一件事盲目重复:一旦上游回 `9095`(本机今日已领),整批立刻停止并把剩余账号如实标成「本机已签」,产出的是真相而不是一排假失败。国际版没有签到活动,因此不显示该按钮。
69
- - **输入框读数** —— 在输入框工具栏(「专家」与模型选择器之间)显示一块可点击读数,5 分钟自动刷新。**两个区域都显示,但显示的东西不同,这是数据决定的**:国内版是积分制,显示「Trae CN · <通用积分>」;国际版是订阅制,它的用量文档里根本没有积分字段,所以显示「Trae 国际版 · <套餐>」(如 `Pro` / `Free`)。**整块可点击**,弹出浮层显示一张**表格**:本区域**每个账号一行**,国内版列**通用积分**、国际版列**套餐**,**点行即可切换账号**(写入与卡片相同的 `accounts` 字段、合并时不丢掉另一个区域的选择)。浮层右上角是**「刷新」与「设置」两个控件**(4.9.0 起「设置」改指设置面板;中途曾以为做不到而删掉,是错的——宿主确实没有 id 定向的服务,但设置面板把自己的 store 座位钉在注册声明上,`slots.entries('sidebar.settings')[0].store.create().actions.openSection(id)` 是真实可用的路径,宿主自己的测试就是这么驱动的)。按「刷新」时图标会转,直到这批探测结束——它是串行跑的,最慢的账号决定什么时候结束。**「刷新」**把账号池里勾选的成员逐个问一遍「能不能应答」(走账号池那条 `action=test` 路由,与账号池分栏里的测试是同一个动作),结果直接报在浮层底部:`账号池已测 · 2 of 2 可应答`。**这个按钮有两件事标签说不出来,都写在悬停提示与结果行里**:它测的是**账号池里勾选的成员**,不是你眼前这张表里的每个账号;而且它是**插件里唯一消耗额度**的按钮(每个成员一条最小 `ping`)——一个光秃秃的「刷新」读起来是免费的,所以工具提示必须留着看。要改配置就去「设置 → Trae」:那是本插件唯一一处配置界面。积分本身不需要刷新按钮:**打开浮层、切换账号、每 5 分钟**都会重新读一次,所以那个按钮被换掉而不是并排加进来。**只在当前会话选中的模型属于本插件时出现**——选其它供应商的模型时整块不渲染。这样两个连接器插件不会争同一个位置(侧边栏底部是共享的根级行,输入框工具栏是按会话、按供应商的)。
69
+ - **输入框读数** —— 在输入框工具栏(「专家」与模型选择器之间)显示一块可点击读数,默认每 5 分钟自动刷新(间隔可调,见下一条)。**两个区域都显示,但显示的东西不同,这是数据决定的**:国内版是积分制,显示「Trae CN · <通用积分>」;国际版是订阅制,它的用量文档里根本没有积分字段,所以显示「Trae 国际版 · <套餐>」(如 `Pro` / `Free`)。**整块可点击**,弹出浮层显示一张**表格**:本区域**每个账号一行**,国内版列**通用积分**、国际版列**套餐**,**点行即可切换账号**(写入与卡片相同的 `accounts` 字段、合并时不丢掉另一个区域的选择)。浮层右上角是**「刷新」与「设置」两个控件**(4.9.0 起「设置」改指设置面板;中途曾以为做不到而删掉,是错的——宿主确实没有 id 定向的服务,但设置面板把自己的 store 座位钉在注册声明上,`slots.entries('sidebar.settings')[0].store.create().actions.openSection(id)` 是真实可用的路径,宿主自己的测试就是这么驱动的)。按「刷新」时图标会转,直到这批探测结束——它是串行跑的,最慢的账号决定什么时候结束。**「刷新」**把账号池里勾选的成员逐个问一遍「能不能应答」(走账号池那条 `action=test` 路由,与账号池分栏里的测试是同一个动作),结果直接报在浮层底部:`账号池已测 · 2 of 2 可应答`。**这个按钮有两件事标签说不出来,都写在悬停提示与结果行里**:它测的是**账号池里勾选的成员**,不是你眼前这张表里的每个账号;而且它是**插件里唯一消耗额度**的按钮(每个成员一条最小 `ping`)——一个光秃秃的「刷新」读起来是免费的,所以工具提示必须留着看。要改配置就去「设置 → Trae」:那是本插件唯一一处配置界面。积分本身不需要刷新按钮:**打开浮层、切换账号、每个刷新间隔**都会重新读一次,所以那个按钮被换掉而不是并排加进来。**只在当前会话选中的模型属于本插件时出现**——选其它供应商的模型时整块不渲染。这样两个连接器插件不会争同一个位置(侧边栏底部是共享的根级行,输入框工具栏是按会话、按供应商的)。
70
70
 
71
71
  <img src="docs/assets/dsh-connect-trae-composer-credits.png" width="560" alt="Trae credits panel: a table of accounts with their general balance, and the composer readout below" />
72
72
 
73
73
  **读数显示在哪里可以选**(学自同族 `dsh-connect-workbuddy` issue #62)。默认在**输入框工具栏**(即上面说的位置);如果那里被别的插件占了、读数不显示,改成**输入框下方**——那是「轮次 / 步数 / token」统计所在的那一行,读数是**追加**在官方统计之后,不会顶掉它们。位置**按面板分别设置**(三个面板各存各的),在卡片「设置」区紧挨「在输入框工具栏显示积分读数」那个勾选框。之所以要有第二个位置:输入框工具栏是**共享的 list 插槽**,外壳自己的控件和其他插件都注册在那里,而共享插槽**无法保证每个占用者都可见**——同族的报告者装了第三方插件后读数就整个消失了(本插件早年放在侧边栏底部时也被挤成过一个「Tr」的残片)。
74
+ - **自动刷新间隔可调** —— 「设置 → Trae → 能力」里的「自动刷新」决定卡片与输入框读数各自多久自己重新读一次用量,可选 **1 / 5 / 10 / 30 / 60 分钟**,默认 **5 分钟**。**整个插件共用一个值**(不是每个面板一个):两处打的是同两条只读路由,分开设置只会让同一块屏幕出现两个节奏。改动**即时生效**——旧定时器先被清掉、在途请求被中止,再按新间隔重建,所以不会留下两个循环并跑。**间隔越短请求越频繁**:每次刷新是两次只读上游请求,这里不消耗积分。这个设置之前,卡片硬编码 60 秒、输入框读数硬编码 5 分钟——同一份数据两个节奏,取决于你正好在看哪个界面;现在统一到这一个字段。
74
75
 
75
76
  - **可选:把 Trae 放进侧边栏**(学自同族 `dsh-connect-workbuddy` 的 PR #188,同样**默认关闭**)。在卡片「设置」区勾上「在侧边栏显示 Trae」,DSH 侧边栏就多一个 Trae 行,点开是**这张卡片的整页版**:同样的地区页签、同样的账号 / 模型 / 诊断分栏,只是没有卡片边框,顶部是标题、说明和「返回会话」。**默认关闭的理由**:侧边栏的全局行是宿主自己的页面,这一行占的是**所有已安装插件共用**的座位,而「设置 → Trae」本来就能看到同样的内容——一行没人要求的导航会改变一个插件并不拥有的界面。**点一下即生效**,不用保存:开关写入 `sidebarPanel` 后,注册与注销在同一秒发生(不是把组件渲染成 null——那会留下一个空白却仍可点的按钮);页面开着时关掉它,中心栏会自己回到会话。整页的「返回会话」走宿主的 `layout.selectPanel(null)`,宿主没这个服务时**不画**这个按钮,而不是画一个点不动的。
76
77
  <img src="docs/assets/dsh-connect-trae-sidebar-page.png" width="720" alt="Trae 侧边栏整页版:顶部是返回会话、标题与说明,下面是同一张卡片的地区页签和账号栏" />
package/lib/client.js CHANGED
@@ -574,6 +574,45 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
574
574
  });
575
575
  }
576
576
  /**
577
+ * The automatic-refresh cadences this plugin offers, in MINUTES.
578
+ *
579
+ * The list is the setting's whole value domain: the schema validates against
580
+ * exactly these, so a hand-edited `7` is refused at write time instead of
581
+ * silently producing a cadence nobody chose. `1` is the floor because every
582
+ * usage read costs two upstream requests and a tighter loop would turn a
583
+ * background refresh into traffic the user never asked for.
584
+ */
585
+ const TRAE_REFRESH_INTERVAL_MINUTES = [
586
+ 1,
587
+ 5,
588
+ 10,
589
+ 30,
590
+ 60
591
+ ];
592
+ const REFRESH_INTERVAL_SET = new Set(TRAE_REFRESH_INTERVAL_MINUTES);
593
+ /**
594
+ * How often the plugin refreshes on its own, in minutes.
595
+ *
596
+ * Anything that is not one of {@link TRAE_REFRESH_INTERVAL_MINUTES} reads as
597
+ * the default — absent (a profile written before the setting existed), a
598
+ * hand-edited `7`, a string `'5'`, or a live reference that resolves to none of
599
+ * those. Falling back rather than refusing to refresh is deliberate: a readout
600
+ * that stops updating because of an unrecognized value is the failure this
601
+ * setting must never introduce, and the schema already refuses such a value on
602
+ * the write path.
603
+ *
604
+ * `value` is the whole settings section (this field is plugin-wide, not
605
+ * per-region — see `Config.refreshIntervalMinutes`).
606
+ */
607
+ function refreshIntervalMinutesOf(value) {
608
+ const stored = unwrapVolatileDeep(value)?.refreshIntervalMinutes;
609
+ return typeof stored === "number" && REFRESH_INTERVAL_SET.has(stored) ? stored : 5;
610
+ }
611
+ /** The same cadence as a millisecond period, for `setInterval`. */
612
+ function refreshIntervalMsOf(value) {
613
+ return refreshIntervalMinutesOf(value) * 6e4;
614
+ }
615
+ /**
577
616
  * One panel's saved pool PREFERENCES, as the card's settings block edits them.
578
617
  *
579
618
  * Read from the settings document rather than from the usage answer for the same
@@ -1403,6 +1442,19 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
1403
1442
  question and sits beside the words that ask it. */
1404
1443
  .dsm-trae-credit-position{flex:none;max-width:none;margin-top:0;padding:3px 8px;font-size:12px;line-height:16px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:7px;background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-primary,#e6e6e6);cursor:pointer}
1405
1444
  .dsm-trae-credit-position:disabled{cursor:default}
1445
+ /* The automatic-refresh picker, a sibling of the seat picker above and styled
1446
+ to match it — the two answer the same kind of question ("how this readout
1447
+ behaves") and sit on consecutive lines, so a second visual idiom would read
1448
+ as an unrelated control.
1449
+
1450
+ Its OWN class, deliberately NOT the seat picker's: a test that selects the
1451
+ seat picker by class must not start finding the cadence picker the moment
1452
+ this row is added, which is exactly the failure the seat picker's own comment
1453
+ records. The row borrows the checkbox column's padding for the same reason
1454
+ its sibling does. */
1455
+ .dsm-trae-refresh-interval-pref{align-items:flex-start;padding-left:25px}
1456
+ .dsm-trae-refresh-interval{flex:none;max-width:none;margin-top:0;padding:3px 8px;font-size:12px;line-height:16px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:7px;background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-primary,#e6e6e6);cursor:pointer}
1457
+ .dsm-trae-refresh-interval:disabled{cursor:default}
1406
1458
  /* The card's checkboxes are the platform's own, styled only with
1407
1459
  accent-color 鈥?the SAME idiom .dsm-trae-mainui-switch-label above already
1408
1460
  uses for the composer-line switch, and the one the sibling
@@ -4409,7 +4461,7 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
4409
4461
  * is precisely why this section has no 「保存」 of its own — the model catalog,
4410
4462
  * which does, lives in its own section.
4411
4463
  */
4412
- function TraeAccountPanel({ t, settingsScope, panel, status, statusLabel, credentialRegionIsAi, regionOn, showPointsInMainUi, onToggleShowPoints, creditPosition, onCreditPositionChange, sidebarPanelOn, sidebarPanelBusy, onToggleSidebarPanel, savingPreferences, creditsLabelId, creditsHintId, rows, busy, rescanNote, onRescan, switchingAccount, onSwitchAccount, onClaim, onTransferChanged, poolDetails, poolClaims, models = [], deviceClaimed = false, onPoolChanged = () => {}, onPoolRefresh = () => {}, creditsUnavailable, creditsExpiry, alternatives, onSwitchToAlternative, section = "all" }) {
4464
+ function TraeAccountPanel({ t, settingsScope, panel, status, statusLabel, credentialRegionIsAi, regionOn, showPointsInMainUi, onToggleShowPoints, creditPosition, onCreditPositionChange, refreshIntervalMinutes, onRefreshIntervalChange, sidebarPanelOn, sidebarPanelBusy, onToggleSidebarPanel, savingPreferences, creditsLabelId, creditsHintId, rows, busy, rescanNote, onRescan, switchingAccount, onSwitchAccount, onClaim, onTransferChanged, poolDetails, poolClaims, models = [], deviceClaimed = false, onPoolChanged = () => {}, onPoolRefresh = () => {}, creditsUnavailable, creditsExpiry, alternatives, onSwitchToAlternative, section = "all" }) {
4413
4465
  const writable = settingsScope?.getSnapshot().writable === true;
4414
4466
  const signedIn = status.status === "signed-in";
4415
4467
  const accounts = signedIn ? status.accounts : [];
@@ -4474,6 +4526,37 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
4474
4526
  })]
4475
4527
  })
4476
4528
  }),
4529
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
4530
+ className: "dsm-trae-pref dsm-trae-refresh-interval-pref",
4531
+ title: t("row.refreshIntervalHint"),
4532
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4533
+ className: "dsm-trae-pref-copy",
4534
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4535
+ className: "dsm-trae-pref-lead",
4536
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4537
+ className: "dsm-trae-pref-label",
4538
+ children: t("row.refreshIntervalLabel")
4539
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
4540
+ className: "dsm-trae-refresh-interval",
4541
+ value: String(refreshIntervalMinutes),
4542
+ disabled: !writable || savingPreferences,
4543
+ "aria-label": t("row.refreshIntervalAria"),
4544
+ onChange: (event) => {
4545
+ const picked = Number(event.currentTarget.value);
4546
+ const match = TRAE_REFRESH_INTERVAL_MINUTES.find((each) => each === picked);
4547
+ if (match !== void 0) onRefreshIntervalChange(match);
4548
+ },
4549
+ children: TRAE_REFRESH_INTERVAL_MINUTES.map((minutes) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
4550
+ value: String(minutes),
4551
+ children: t("row.refreshIntervalOption", { minutes: String(minutes) })
4552
+ }, minutes))
4553
+ })]
4554
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4555
+ className: "dsm-trae-pref-hint",
4556
+ children: t("row.refreshIntervalHint")
4557
+ })]
4558
+ })
4559
+ }),
4477
4560
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4478
4561
  className: "dsm-trae-pref",
4479
4562
  title: t("row.sidebarPanelHint"),
@@ -4881,7 +4964,16 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
4881
4964
  }
4882
4965
  return `HTTP ${status}`;
4883
4966
  }
4884
- const POLL_INTERVAL_MS = 6e4;
4967
+ /**
4968
+ * The card's own poll period is NOT a constant: the cadence is the user's
4969
+ * (`Config.refreshIntervalMinutes`, default 5 minutes), read through
4970
+ * {@link refreshIntervalMsOf} so the card and the composer readout cannot drift
4971
+ * apart. It used to be a fixed 60_000 here while the composer readout polled
4972
+ * every five minutes — two cadences for the same two upstream routes, decided
4973
+ * by which surface you happened to be looking at. The floor of the offered
4974
+ * values is one minute; every read is two read-only upstream calls, which is
4975
+ * why the setting does not go below that.
4976
+ */
4885
4977
  const TRAE_GITHUB_URL = "https://github.com/dingminhua/dsh-connect-trae";
4886
4978
  /**
4887
4979
  * Write one settings field and confirm the value actually landed.
@@ -5248,7 +5340,20 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
5248
5340
  * local state: a write made elsewhere (the DSH settings form, another window)
5249
5341
  * then shows up here instead of being shadowed by a stale copy.
5250
5342
  */
5251
- const committedSettings = { showPointsInMainUi: unwrapVolatileDeep(settingsScope?.getSnapshot().value)?.showPointsInMainUi === true };
5343
+ const committedSettings = {
5344
+ showPointsInMainUi: unwrapVolatileDeep(settingsScope?.getSnapshot().value)?.showPointsInMainUi === true,
5345
+ /**
5346
+ * The automatic-refresh cadence (`Config.refreshIntervalMinutes`).
5347
+ *
5348
+ * Read through the SHARED reader rather than off the document here: the
5349
+ * composer readout reads the same field through the same function, and the
5350
+ * two surfaces disagreeing about the cadence is exactly what a second
5351
+ * interpretation would produce. It also owns the fallback for a document
5352
+ * that never went through the schema (a hand-edited file), so the card
5353
+ * cannot end up on a cadence the write path would have refused.
5354
+ */
5355
+ refreshIntervalMinutes: refreshIntervalMinutesOf(settingsScope?.getSnapshot().value)
5356
+ };
5252
5357
  /**
5253
5358
  * Ids for the block's label/hint wiring. `useId`, not constants: DSH can mount
5254
5359
  * this card twice at once (the settings ROW and the expanded PAGE), and two
@@ -5260,11 +5365,25 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
5260
5365
  * over it. Reading BOTH sources here is what lets the write above stay simple —
5261
5366
  * it never has to write into component state that a Host refresh would fight.
5262
5367
  */
5263
- const settingsEdits = { showPointsInMainUi: optimisticSettings.current.showPointsInMainUi ?? committedSettings.showPointsInMainUi };
5368
+ const settingsEdits = {
5369
+ showPointsInMainUi: optimisticSettings.current.showPointsInMainUi ?? committedSettings.showPointsInMainUi,
5370
+ refreshIntervalMinutes: optimisticSettings.current.refreshIntervalMinutes ?? committedSettings.refreshIntervalMinutes
5371
+ };
5264
5372
  const settingsIds = (0, react.useId)();
5265
5373
  const creditsLabelId = `${settingsIds}-credits`;
5266
5374
  const creditsHintId = `${settingsIds}-credits-hint`;
5267
5375
  /**
5376
+ * The poll period, in milliseconds, as the CARD uses it.
5377
+ *
5378
+ * Derived from the committed-plus-optimistic value above rather than from the
5379
+ * document directly, so a pick shows its effect immediately: the timer is
5380
+ * rebuilt from this on the next render (see the poll effect's dependency
5381
+ * list). Reading it here also keeps the number and the picker in one place —
5382
+ * a picker on one cadence and a timer on another is precisely the class of
5383
+ * bug this setting replaced.
5384
+ */
5385
+ const refreshIntervalMs = settingsEdits.refreshIntervalMinutes * 6e4;
5386
+ /**
5268
5387
  * Write ONE preference switch, on the click that flipped it.
5269
5388
  *
5270
5389
  * This replaced a draft + 「保存」/「放弃」 pair, which is what the sibling
@@ -5302,6 +5421,47 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
5302
5421
  }
5303
5422
  };
5304
5423
  /**
5424
+ * Change how often the plugin refreshes on its own
5425
+ * (`Config.refreshIntervalMinutes`).
5426
+ *
5427
+ * Same shape as {@link writePreference}, and for the same reason: this is a
5428
+ * standing preference, so it commits on the pick and is read-back verified —
5429
+ * a picker that merely LOOKS changed while the timer keeps its old period is
5430
+ * the failure mode worth guarding against here. The read-back compares
5431
+ * through `refreshIntervalMinutesOf`, so the value has to land as one of the
5432
+ * offered cadences rather than merely being present.
5433
+ *
5434
+ * The write alone changes nothing on screen until the settings snapshot
5435
+ * notifies, which the poll effect below is keyed on: the interval is rebuilt
5436
+ * from the new value on the next render, so the old timer is cleared and a
5437
+ * fresh one started. That is deliberate — a cadence change must take effect
5438
+ * without a reload, and it must not leave the previous interval running.
5439
+ */
5440
+ const writeRefreshInterval = async (value) => {
5441
+ if (settingsScope === void 0 || settingsScope.getSnapshot().writable !== true) return;
5442
+ setSavingSettings(true);
5443
+ setWriteError(void 0);
5444
+ optimisticSettings.current = {
5445
+ ...optimisticSettings.current,
5446
+ refreshIntervalMinutes: value
5447
+ };
5448
+ setSettingsRevision((revision) => revision + 1);
5449
+ try {
5450
+ await writeSettingsField(settingsScope, "refreshIntervalMinutes", value, (readBack) => refreshIntervalMinutesOf(readBack) === value);
5451
+ } catch (error) {
5452
+ if (mounted.current) setWriteError(error instanceof Error ? error.message : t("row.requestFailed"));
5453
+ } finally {
5454
+ if (mounted.current) {
5455
+ optimisticSettings.current = {
5456
+ ...optimisticSettings.current,
5457
+ refreshIntervalMinutes: void 0
5458
+ };
5459
+ setSettingsRevision((revision) => revision + 1);
5460
+ }
5461
+ if (mounted.current) setSavingSettings(false);
5462
+ }
5463
+ };
5464
+ /**
5305
5465
  * Flip the OPTIONAL sidebar row on or off (`Config.sidebarPanel`).
5306
5466
  *
5307
5467
  * Two things happen on a verified write, and BOTH are required:
@@ -5563,7 +5723,7 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
5563
5723
  const controller = new AbortController();
5564
5724
  const timer = window.setInterval(() => {
5565
5725
  refreshUsage(activeRegion, controller.signal);
5566
- }, POLL_INTERVAL_MS);
5726
+ }, refreshIntervalMs);
5567
5727
  return () => {
5568
5728
  window.clearInterval(timer);
5569
5729
  controller.abort();
@@ -5573,7 +5733,8 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
5573
5733
  activeRegion,
5574
5734
  activeRegionOn,
5575
5735
  refreshUsage,
5576
- status.status
5736
+ status.status,
5737
+ refreshIntervalMs
5577
5738
  ]);
5578
5739
  const rescanAccounts = async () => {
5579
5740
  setBusy(true);
@@ -6635,6 +6796,10 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
6635
6796
  onCreditPositionChange: (position) => {
6636
6797
  changeCreditPosition(activeRegion, position);
6637
6798
  },
6799
+ refreshIntervalMinutes: settingsEdits.refreshIntervalMinutes,
6800
+ onRefreshIntervalChange: (minutes) => {
6801
+ writeRefreshInterval(minutes);
6802
+ },
6638
6803
  sidebarPanelOn,
6639
6804
  sidebarPanelBusy,
6640
6805
  onToggleSidebarPanel: (value) => {
@@ -6715,6 +6880,10 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
6715
6880
  onCreditPositionChange: (position) => {
6716
6881
  changeCreditPosition(activeRegion, position);
6717
6882
  },
6883
+ refreshIntervalMinutes: settingsEdits.refreshIntervalMinutes,
6884
+ onRefreshIntervalChange: (minutes) => {
6885
+ writeRefreshInterval(minutes);
6886
+ },
6718
6887
  sidebarPanelOn,
6719
6888
  sidebarPanelBusy,
6720
6889
  onToggleSidebarPanel: (value) => {
@@ -7644,9 +7813,17 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
7644
7813
  }
7645
7814
  }
7646
7815
  /**
7647
- * Automatic refresh period. Five minutes, matching the sidebar line: a credit
7648
- * balance moves on the scale of model calls, not seconds, and each read is two
7649
- * read-only upstream calls.
7816
+ * Automatic refresh period when nothing says otherwise.
7817
+ *
7818
+ * Five minutes, matching the sidebar line: a credit balance moves on the scale
7819
+ * of model calls, not seconds, and each read is two read-only upstream calls.
7820
+ *
7821
+ * The LIVE period is the user's `Config.refreshIntervalMinutes` (see
7822
+ * {@link refreshIntervalMsOf}), whose default is this same five minutes — so
7823
+ * this constant is now the fallback for a caller that renders the readout
7824
+ * without a settings scope (a test, or a host that serves none), not the rule.
7825
+ * Both polling surfaces read that ONE field, so the composer row and the card
7826
+ * cannot end up refreshing the same routes at different rates.
7650
7827
  */
7651
7828
  const COMPOSER_POINTS_REFRESH_INTERVAL_MS = 3e5;
7652
7829
  /**
@@ -7745,6 +7922,16 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
7745
7922
  if (t === void 0) throw new Error("Composer points readout requires its translation function");
7746
7923
  const owned = provider === void 0 ? void 0 : TRAE_COMPOSER_PROVIDERS[provider];
7747
7924
  const activeRegion = region ?? owned;
7925
+ /**
7926
+ * The poll period, from the user's own cadence setting.
7927
+ *
7928
+ * Read through the SHARED reader (the card's poll reads the same field the
7929
+ * same way), with the constant above as the fallback when this readout is
7930
+ * rendered with no settings scope at all. It re-reads on every render, so the
7931
+ * scope's notification after a write is enough to rebuild the timer — the
7932
+ * effect below lists it as a dependency for exactly that reason.
7933
+ */
7934
+ const refreshIntervalMs = settingsScope === void 0 ? COMPOSER_POINTS_REFRESH_INTERVAL_MS : refreshIntervalMsOf(settingsScope.getSnapshot().value);
7748
7935
  const [usage, setUsage] = (0, react.useState)(void 0);
7749
7936
  const [accountCredits, setAccountCredits] = (0, react.useState)(void 0);
7750
7937
  /**
@@ -7816,6 +8003,23 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
7816
8003
  mounted.current = false;
7817
8004
  };
7818
8005
  }, []);
8006
+ /**
8007
+ * Re-render when the settings document changes, so a cadence picked in the
8008
+ * card reaches THIS readout without a reload.
8009
+ *
8010
+ * The scope is a stable injected reference, so the subscription is set up once
8011
+ * per scope. `settingsRevision` is not read anywhere — bumping it is the whole
8012
+ * point, since `refreshIntervalMs` above is recomputed on every render.
8013
+ *
8014
+ * The gate above (`ComposerPointsGate`) already subscribes for its own two
8015
+ * switches, but a child does not re-render because its PARENT subscribed: this
8016
+ * component is mounted through that gate, so without its own subscription a
8017
+ * cadence change would only land on the next unrelated render.
8018
+ */
8019
+ const [, setSettingsRevision] = (0, react.useState)(0);
8020
+ (0, react.useEffect)(() => settingsScope?.subscribe(() => {
8021
+ setSettingsRevision((revision) => revision + 1);
8022
+ }), [settingsScope]);
7819
8023
  const position = useAnchoredPosition({
7820
8024
  open: open && activeRegion !== void 0,
7821
8025
  anchorRef: rootRef,
@@ -7877,14 +8081,18 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
7877
8081
  if (cancelled) return;
7878
8082
  timer = window.setInterval(() => {
7879
8083
  fetchUsage(controller.signal);
7880
- }, COMPOSER_POINTS_REFRESH_INTERVAL_MS);
8084
+ }, refreshIntervalMs);
7881
8085
  });
7882
8086
  return () => {
7883
8087
  cancelled = true;
7884
8088
  if (timer !== void 0) window.clearInterval(timer);
7885
8089
  controller.abort();
7886
8090
  };
7887
- }, [owned, fetchUsage]);
8091
+ }, [
8092
+ owned,
8093
+ fetchUsage,
8094
+ refreshIntervalMs
8095
+ ]);
7888
8096
  /**
7889
8097
  * Every account's PLAN, one status read per account — ai only.
7890
8098
  *
@@ -8844,6 +9052,10 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
8844
9052
  "row.creditPositionStats": "below the input box",
8845
9053
  "row.creditPositionAria": "Credit readout position for {region}",
8846
9054
  "row.creditPositionHint": "The default position is on the model picker row. If another connector occupies the same position, the readout loaded later moves left automatically. Choose below the input box to show it beside the round, step and token statistics.",
9055
+ "row.refreshIntervalLabel": "refresh every",
9056
+ "row.refreshIntervalOption": "{minutes} min",
9057
+ "row.refreshIntervalAria": "Automatic refresh interval",
9058
+ "row.refreshIntervalHint": "How often the card and the composer readout re-read usage by themselves, every 5 minutes by default. Each refresh is two read-only upstream requests, so a shorter interval means more traffic; nothing here spends credits. A change takes effect immediately, and turning a panel off stops its reads.",
8847
9059
  "composer.switchTo": "Switch to {account}",
8848
9060
  "composer.noAccounts": "No account in this region",
8849
9061
  "composer.panelTitle": "Trae credits",
@@ -9263,6 +9475,10 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
9263
9475
  "row.creditPositionStats": "输入框下方",
9264
9476
  "row.creditPositionAria": "{region} 的积分显示位置",
9265
9477
  "row.creditPositionHint": "默认与模型选择同一行显示;遇到其他连接器占用排序位置时,后加载的读数自动向左排列。也可选择「输入框下方」,与轮次、步数和 token 统计同一行显示。",
9478
+ "row.refreshIntervalLabel": "自动刷新",
9479
+ "row.refreshIntervalOption": "{minutes} 分钟",
9480
+ "row.refreshIntervalAria": "自动刷新间隔",
9481
+ "row.refreshIntervalHint": "卡片与输入框读数各自多久重新读取一次用量,默认 5 分钟。每次刷新是两次只读的上游请求,间隔越短请求越频繁;这里不消耗积分。改动即时生效;面板关闭后该面板不再自动读取。",
9266
9482
  "composer.switchTo": "切换到 {account}",
9267
9483
  "composer.noAccounts": "该区域暂无账号",
9268
9484
  "composer.panelTitle": "Trae 积分",
@@ -9554,6 +9770,17 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
9554
9770
  *
9555
9771
  * @module dsh-connect-trae/client/TraeModelLabel
9556
9772
  */
9773
+ /**
9774
+ * Every provider id this plugin owns, in panel order.
9775
+ *
9776
+ * Exported because on a `keyed` host the label seat is one registration PER
9777
+ * provider id — the registration side needs the same list the component uses to
9778
+ * decide "is this row mine", and a second literal would drift from
9779
+ * `providerOfPanel`. Derived rather than typed out: `trae-enterprise` is the id
9780
+ * most easily forgotten, and forgetting it means that panel's rows silently fall
9781
+ * through to the host's plain fallback.
9782
+ */
9783
+ const TRAE_MODEL_LABEL_PROVIDERS = TRAE_PANELS.map(providerOfPanel);
9557
9784
  /** How long a fetched snapshot is trusted, in milliseconds. */
9558
9785
  const CACHE_MS = 6e4;
9559
9786
  /** How often a mounted label re-reads the clock, in milliseconds. */
@@ -9825,6 +10052,17 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
9825
10052
  * host's own presentation — catalog name plus that model's optional badge — for
9826
10053
  * any provider that is not ours.
9827
10054
  *
10055
+ * WHAT CHANGES WHEN THE HOST KEYS THE SLOT — and why the number stays. On a host
10056
+ * that declares this slot `keyed`, the seat is registered once per provider id
10057
+ * (`./model-label-seat.ts`), each `key` is its own cell, and priority stops
10058
+ * deciding WHO IS VISIBLE: qoder's `-101` and workbuddy's `-105` no longer share
10059
+ * a cell with this `-102`, so none of the three can shadow another and the
10060
+ * armistice below stops being a race. Priority keeps exactly one meaning inside
10061
+ * a cell — the tie-breaker between two registrations claiming the SAME provider
10062
+ * id, where an equal value throws and the lowest renders. The value is therefore
10063
+ * kept as this plugin's rank for its own rows, not as a bid in an auction that
10064
+ * no longer happens.
10065
+ *
9828
10066
  * WHY A DISTINCT PRIORITY IS NOT ENOUGH ON ITS OWN. It removes the cross-plugin
9829
10067
  * collision, but issue #74's error was a SECOND registration of THIS plugin
9830
10068
  * against its own first one (the report's stack shows
@@ -9833,7 +10071,9 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
9833
10071
  * slot's declaration lifetime collapses and re-declares, so a callback that
9834
10072
  * registers unconditionally double-registers. `index.tsx` therefore also
9835
10073
  * disposes its previous entry before every attempt. Priority alone would have
9836
- * made the throw rarer and harder to read, not absent.
10074
+ * made the throw rarer and harder to read, not absent. (On a `keyed` host the
10075
+ * same rule applies per key: re-registering one provider's seat twice at one
10076
+ * priority throws just as loudly.)
9837
10077
  */
9838
10078
  const MODEL_LABEL_PRIORITY = -102;
9839
10079
  /** The four tones the host understands, as a lookup for narrowing. */
@@ -10030,6 +10270,114 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
10030
10270
  });
10031
10271
  }
10032
10272
  //#endregion
10273
+ //#region src/client/model-label-seat.ts
10274
+ /**
10275
+ * Read the declared kind of a slot, or `undefined` when it cannot be read.
10276
+ *
10277
+ * `spec()` is the documented read-only accessor (`SlotRegistry.spec`), present
10278
+ * on 0.1.7 and on 0.2.2. It is also the only way to learn the kind: the
10279
+ * declaration lives in the host package, and a plugin's own `SlotMap`
10280
+ * augmentation says what this plugin HOPES the slot is, not what the running
10281
+ * host declared.
10282
+ *
10283
+ * Anything unexpected — no `spec` method on an older line, a throw from a
10284
+ * service that is not ready, a kind this build does not know — returns
10285
+ * `undefined`, and the caller falls back to the single-cell shape. That is the
10286
+ * conservative direction: it is what every released version of this plugin
10287
+ * already does.
10288
+ *
10289
+ * @param slots - The `slots` service (typed loosely: the accessor is optional).
10290
+ * @param key - The slot key to inspect.
10291
+ * @returns The declared kind, or `undefined`.
10292
+ */
10293
+ function declaredSlotKind(slots, key) {
10294
+ const face = slots;
10295
+ if (face === void 0 || typeof face.spec !== "function") return void 0;
10296
+ let spec;
10297
+ try {
10298
+ spec = face.spec(key);
10299
+ } catch {
10300
+ return;
10301
+ }
10302
+ const kind = spec?.kind;
10303
+ if (kind === "single" || kind === "list" || kind === "keyed" || kind === "chain") return kind;
10304
+ }
10305
+ /**
10306
+ * The registrations this plugin should make for the model-label seat.
10307
+ *
10308
+ * `keyed` yields ONE seat per provider id: each is its own cell, each renders
10309
+ * only the rows whose owner props carry that provider id, and a provider this
10310
+ * plugin does not own falls through to the owner's own fallback (the catalog
10311
+ * name, plus that model's `badge` on hosts that have one).
10312
+ *
10313
+ * Every other kind — including `undefined`, i.e. a host that cannot be
10314
+ * inspected — yields the single historical seat. That is not a degraded mode:
10315
+ * it is what `single` requires, and on a not-yet-declared slot it is what the
10316
+ * inject callback is about to find.
10317
+ *
10318
+ * An empty provider list under `keyed` would silently disable the label, so it
10319
+ * falls back to the single seat instead: a wrong-shaped registration that still
10320
+ * draws something beats a correct-shaped registration that draws nothing.
10321
+ *
10322
+ * @param params - Slot key, declared kind, this plugin's provider ids, priority.
10323
+ * @returns One or more `register()` option objects, in registration order.
10324
+ */
10325
+ function modelLabelSeats(params) {
10326
+ const { name, kind, providerIds, priority } = params;
10327
+ if (kind === "list" || kind === "chain") return [];
10328
+ if (kind === "keyed" && providerIds.length > 0) return providerIds.map((id) => ({
10329
+ name,
10330
+ key: id,
10331
+ priority
10332
+ }));
10333
+ return [{
10334
+ name,
10335
+ priority
10336
+ }];
10337
+ }
10338
+ /**
10339
+ * Claim every planned seat, atomically, and hand back ONE disposer.
10340
+ *
10341
+ * WHY ATOMIC. The seat list is 2-3 entries on a `keyed` host, and `register()`
10342
+ * can throw on any ONE of them — a rival plugin claiming the same provider id at
10343
+ * the same priority, or the declaration collapsing mid-loop. A plain `.map()`
10344
+ * would then leave the earlier seats landed on the ledger with their disposers
10345
+ * unreachable (they were only ever held by the array that the throw discarded),
10346
+ * which is worse than failing outright: the component stays mounted, the caller
10347
+ * believes registration failed, and nothing will ever release those cells. On a
10348
+ * `single` host it also means the identity keeps a live entry that the next
10349
+ * `inject` re-run cannot displace without a priority clash.
10350
+ *
10351
+ * So a partial failure is rolled back in reverse order before the error is
10352
+ * rethrown, exactly as if it had never been attempted. The rethrow is important:
10353
+ * each caller already has a catch that logs, and swallowing here would hide the
10354
+ * refusal from the diagnostics that exist to explain it.
10355
+ *
10356
+ * A disposal that itself throws must not mask the original error, hence the
10357
+ * inner catch — the registry treats a second dispose as a no-op, and a broken
10358
+ * one must not turn one failure into a crash.
10359
+ *
10360
+ * @param seats - The plan from {@link modelLabelSeats}, in registration order.
10361
+ * @param register - Claims one seat and returns its disposer.
10362
+ * @returns One disposer that releases every seat, in reverse registration order.
10363
+ */
10364
+ function registerModelLabelSeats(seats, register) {
10365
+ const claimed = [];
10366
+ try {
10367
+ for (const seat of seats) claimed.push(register(seat));
10368
+ } catch (error) {
10369
+ for (const dispose of [...claimed].reverse()) try {
10370
+ dispose();
10371
+ } catch {}
10372
+ throw error;
10373
+ }
10374
+ return () => {
10375
+ for (const dispose of [...claimed].reverse()) try {
10376
+ dispose();
10377
+ } catch {}
10378
+ };
10379
+ }
10380
+ //#endregion
10033
10381
  //#region src/client/index.tsx
10034
10382
  /** Stable browser-plugin name. */
10035
10383
  const name = "dsh-connect-trae-client";
@@ -10081,10 +10429,19 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
10081
10429
  * the same priority. That refusal is OUR fault and we can fix it rather than
10082
10430
  * report it, so each run releases whatever this identity already holds.
10083
10431
  *
10432
+ * ONE DISPOSER PER CALL, HOWEVER MANY ENTRIES IT OWNS. A `keyed` host makes this
10433
+ * plugin claim one cell per provider id (see `./model-label-seat.ts`), and those
10434
+ * cells must live and die together — a partial teardown would leave a cell
10435
+ * occupied by a component whose siblings are gone, visible as some rows
10436
+ * decorated and others not. Folding them into one disposer is
10437
+ * `registerModelLabelSeats`'s job, so the contract here stays the simple one:
10438
+ * whatever `register` returns is the whole ownership of this identity, and
10439
+ * `SlotHold` keeps exactly one.
10440
+ *
10084
10441
  * @param ctx - The applying plugin's context (module scope has none).
10085
10442
  * @param identity - Stable key for this seat.
10086
10443
  * @param slotKey - The host slot to register into.
10087
- * @param register - Performs the registration, returning its disposer.
10444
+ * @param register - Performs the registration(s), returning ONE disposer for all.
10088
10445
  * @param describe - Names the seat in the failure log.
10089
10446
  */
10090
10447
  function registerSlotEntry(ctx, identity, slotKey, register, describe) {
@@ -10349,16 +10706,31 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
10349
10706
  * non-interactive inline content such as a coloured name or badge" while the
10350
10707
  * picker keeps owning clicks, focus and check marks.
10351
10708
  *
10709
+ * HOW MANY SEATS, AND WHY THAT IS READ AT MOUNT. On a `single` host this slot
10710
+ * holds ONE cell: a second registration at the same priority throws, a second
10711
+ * registration at any other priority is silently discarded, and whoever holds
10712
+ * the cell must draw EVERY provider's rows. On a `keyed` host each
10713
+ * `options.key` is its own cell, so one seat per provider id gives this
10714
+ * plugin exactly its own rows and leaves every other provider to the owner's
10715
+ * own fallback. Which shape is legal is the HOST's declaration, so the kind
10716
+ * is read from `ctx.slots.spec(...)` at mount and the seat list is derived
10717
+ * from it (`./model-label-seat.ts`). One build serves both lines.
10718
+ *
10352
10719
  * Registered unconditionally: the component renders the plain name for a
10353
10720
  * provider that is not ours, so there is nothing to gate on. Its own
10354
10721
  * try/catch keeps a host that does not serve this slot from taking the card
10355
10722
  * down with it.
10356
10723
  */
10357
10724
  const registerModelLabel = () => {
10358
- registerSlotEntry(ctx, "conversation.input.model.label", "conversation.input.model.label", () => ctx.slots.register({
10359
- name: "conversation.input.model.label",
10360
- priority: MODEL_LABEL_PRIORITY
10361
- }, TraeModelLabel), "model label slot");
10725
+ registerSlotEntry(ctx, "conversation.input.model.label", "conversation.input.model.label", () => {
10726
+ const registry = ctx.slots;
10727
+ return registerModelLabelSeats(modelLabelSeats({
10728
+ name: "conversation.input.model.label",
10729
+ kind: declaredSlotKind(ctx.slots, "conversation.input.model.label"),
10730
+ providerIds: TRAE_MODEL_LABEL_PROVIDERS,
10731
+ priority: MODEL_LABEL_PRIORITY
10732
+ }), (seat) => registry.register(seat, TraeModelLabel));
10733
+ }, "model label slot");
10362
10734
  };
10363
10735
  registerModelLabel();
10364
10736
  /**
package/lib/index.d.ts CHANGED
@@ -1331,6 +1331,18 @@ declare function regionCreditPositionOf(value: unknown, panel: TraePanel): TraeC
1331
1331
  * 'regions', ...)` needs.
1332
1332
  */
1333
1333
  declare function nextRegionCreditPosition(value: unknown, panel: TraePanel, position: TraeCreditPosition): Record<string, unknown>;
1334
+ /**
1335
+ * The automatic-refresh cadences this plugin offers, in MINUTES.
1336
+ *
1337
+ * The list is the setting's whole value domain: the schema validates against
1338
+ * exactly these, so a hand-edited `7` is refused at write time instead of
1339
+ * silently producing a cadence nobody chose. `1` is the floor because every
1340
+ * usage read costs two upstream requests and a tighter loop would turn a
1341
+ * background refresh into traffic the user never asked for.
1342
+ */
1343
+ declare const TRAE_REFRESH_INTERVAL_MINUTES: readonly [1, 5, 10, 30, 60];
1344
+ /** One accepted cadence. */
1345
+ type TraeRefreshIntervalMinutes = typeof TRAE_REFRESH_INTERVAL_MINUTES[number];
1334
1346
  /** Subscription status of an international (ai) account, rendered instead of the CN credit packs. */
1335
1347
  interface TraeWebPayStatus {
1336
1348
  isDollarUsageBilling: boolean;
@@ -4821,6 +4833,23 @@ interface Config {
4821
4833
  * where the entry seeds the switch at page load).
4822
4834
  */
4823
4835
  sidebarPanel?: boolean;
4836
+ /**
4837
+ * How often the plugin refreshes its own readouts, in MINUTES.
4838
+ *
4839
+ * ONE plugin-wide number rather than one per region, because the thing it
4840
+ * paces is the plugin's background chatter, not a region's data: both
4841
+ * surfaces that poll (the card's usage document and the composer readout)
4842
+ * read the same routes at the same cost, and a per-region copy would let the
4843
+ * same screen refresh at two cadences.
4844
+ *
4845
+ * `undefined` means the default (5 minutes), which is what both surfaces
4846
+ * already used before this was settable — so an upgrade moves nothing.
4847
+ * The accepted values are exactly {@link TRAE_REFRESH_INTERVAL_MINUTES}
4848
+ * (1 / 5 / 10 / 30 / 60); the schema below refuses anything else, and the
4849
+ * readers fall back to the default for a document that never went through
4850
+ * it (a hand-edited file, or a profile written before this field existed).
4851
+ */
4852
+ refreshIntervalMinutes?: TraeRefreshIntervalMinutes;
4824
4853
  /**
4825
4854
  * Per-region model state, keyed `cn` | `ai`. The CN and international apps
4826
4855
  * expose different rosters, so each keeps its own directory and selection
package/lib/index.js CHANGED
@@ -2411,6 +2411,23 @@ function nextRegionCreditPosition(value, panel, position) {
2411
2411
  });
2412
2412
  }
2413
2413
  /**
2414
+ * The automatic-refresh cadences this plugin offers, in MINUTES.
2415
+ *
2416
+ * The list is the setting's whole value domain: the schema validates against
2417
+ * exactly these, so a hand-edited `7` is refused at write time instead of
2418
+ * silently producing a cadence nobody chose. `1` is the floor because every
2419
+ * usage read costs two upstream requests and a tighter loop would turn a
2420
+ * background refresh into traffic the user never asked for.
2421
+ */
2422
+ const TRAE_REFRESH_INTERVAL_MINUTES = [
2423
+ 1,
2424
+ 5,
2425
+ 10,
2426
+ 30,
2427
+ 60
2428
+ ];
2429
+ new Set(TRAE_REFRESH_INTERVAL_MINUTES);
2430
+ /**
2414
2431
  * Panels whose text-to-image path this plugin has actually MEASURED, in
2415
2432
  * `TRAE_PANELS` tab order.
2416
2433
  *
@@ -11652,6 +11669,7 @@ const Config = z.object({
11652
11669
  accountId: z.string().description("Deprecated: pre-split account selector, attributed to its own region"),
11653
11670
  showPointsInMainUi: asVolatile(z.boolean().default(false).description("在输入框工具栏显示积分读数「Trae CN · <数值>」(仅当选中的模型属于本插件时出现,可点击查看账号与积分明细)")),
11654
11671
  sidebarPanel: asVolatile(z.boolean().default(false).description("在 DSH 侧边栏显示「Trae」面板:点开是这张卡片的整页版。默认关闭——这一行占的是所有插件共用的全局侧边栏座位,而「插件」页本来就能看到同样的内容。点一下即生效,不用保存。")),
11672
+ refreshIntervalMinutes: asVolatile(z.union(TRAE_REFRESH_INTERVAL_MINUTES).default(5).description("自动刷新间隔(分钟):卡片与输入框读数共用。可选 1 / 5 / 10 / 30 / 60,默认 5。每次刷新会读取用量(两次只读的上游请求),间隔越短请求越频繁。")),
11655
11673
  accounts: asVolatile(accountSelectionConfig.description("Per-panel account selections, keyed cn | enterprise | ai")),
11656
11674
  regions: asVolatile(z.dict(regionStateConfig).default({}).description("Per-panel model directory and selection, keyed cn | enterprise | ai")),
11657
11675
  lastCatalog: z.array(modelConfig).description("Deprecated: pre-region-split CN model directory"),
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": "4.15.0",
5
+ "version": "4.17.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",