dsh-ds-balance 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,81 +1,147 @@
1
- # ds-balance
1
+ <p align="center">
2
+ <h1 align="center">dsh-ds-balance</h1>
3
+ </p>
2
4
 
3
- [English](README_en.md)
5
+ <div align="center">
6
+ <p><strong>把 DeepSeek 账户余额放进 DSH 的左边栏</strong></p>
7
+ <p><em>DeepSeek account balance in the DSH sidebar</em></p>
4
8
 
5
- [![ci](https://github.com/zlZayn/dsh-ds-balance/actions/workflows/ci.yml/badge.svg?branch=main)](.github/workflows/ci.yml)
6
- [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
- [![node](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FzlZayn%2Fdsh-ds-balance%2Fmain%2Fpackage.json&query=%24.engines.node&label=node&color=brightgreen)](package.json)
8
- [![dsh](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FzlZayn%2Fdsh-ds-balance%2Fmain%2Fpackage.json&query=%24.engines.dsh&label=dsh&color=blueviolet)](package.json)
9
+ <p>
10
+ <a href="https://api-docs.deepseek.com/"><img src="https://img.shields.io/badge/DeepSeek%20API-Official-4D6BFE?style=flat" alt="DeepSeek 官方接口"></a>
11
+ <a href="https://github.com/deepseek-ai/deepseek-harness"><img src="https://img.shields.io/badge/DeepSeek%20Harness-Plugin-4176E6?style=flat" alt="DeepSeek Harness Plugin"></a>
12
+ </p>
9
13
 
10
- > **NOTE**
11
- > 本包尚未发布到 npm,现在只能从源码安装。发布流程与版本号判定链见
12
- > [docs/PUBLISHING.md](docs/PUBLISHING.md)。
14
+ <p>
15
+ <a href="https://github.com/zlZayn/dsh-ds-balance/actions/workflows/ci.yml"><img src="https://github.com/zlZayn/dsh-ds-balance/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
16
+ <a href="https://www.npmjs.com/package/dsh-ds-balance"><img src="https://img.shields.io/npm/v/dsh-ds-balance.svg" alt="npm"></a>
17
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT 许可证"></a>
18
+ </p>
13
19
 
14
- 在 DSH 的左边栏底部显示 DeepSeek 账户余额,并在设置里提供一张配置卡片。
15
- 余额真实读自官方 `GET /user/balance`;颜色只由后端返回的 `severity` 决定。
20
+ <p>
21
+ <strong><a href="README.md">简体中文</a></strong> · <a href="README_en.md">English</a>
22
+ </p>
23
+ </div>
16
24
 
17
- ![设置卡片](assets/settings-card.png)
25
+ ---
26
+
27
+ > [!NOTE]
28
+ > **余额读自 DeepSeek 官方 `GET /user/balance`**,不是估算。凭据只经 DSH 的凭据通道解析:API Key 不会出现在设置文件里,也不会返回给界面。
29
+
30
+ 余额要有地方看,但不该占地方。左边栏底部一个常驻的状态环,点开是三段金额与数据新鲜度;要改配置,进设置页那一张卡片。
31
+
32
+ <p align="center">
33
+ <img src="assets/sidebar-popover.png" alt="左边栏底部的「DeepSeek 余额」条目与展开的余额浮层" width="360">
34
+ <br>
35
+ <em>常驻<strong>左边栏底部</strong>,与「使用统计」「设置」并排;点开是余额、赠送 / 充值拆分与数据新鲜度。</em>
36
+ </p>
37
+
38
+ ## 界面一览
39
+
40
+ | 界面 | 一句话 | 你会用它来 |
41
+ |---|---|---|
42
+ | 侧栏圆环 | 常驻左边栏底部的状态环 + 名称,**环里填了多少 = 余额离该币种预警线还有多远** | 扫一眼就知道还剩多少、离预警线多远 |
43
+ | 余额浮层 | 点条目展开:总额、赠送 / 充值拆分、数据新鲜度、手动刷新(带冷却) | 核对具体数字,以及数据是几分钟前的 |
44
+ | 设置卡片 | 连接 / 展示 / 阈值 / 刷新四组,各自可折叠,默认四组全收起 | 换端点、换币种、调预警线、调节奏 |
45
+
46
+ 三种界面的分工是死的:**圆环回答「大概还剩多少」,浮层回答「具体是多少」,卡片回答「怎么算」。**
47
+
48
+ <p align="center">
49
+ <img src="assets/settings-card.png" alt="插件配置页中的「DeepSeek 余额」卡片" width="360">
50
+ <br>
51
+ <em>在 <strong>设置 → 插件 → 插件配置</strong> 中与其他插件并排;四组默认全收起,卡片一打开只占四行折叠头。</em>
52
+ </p>
18
53
 
19
54
  ## 能力
20
55
 
21
- - 侧栏底部常驻一个状态圆环加名称,点击展开浮层看余额明细;弧长是「余额占 warn 阈值」的比例,折叠与展开共用同一个圆环。
22
- - 真实读取 DeepSeek 官方余额(`GET /user/balance`),按 `serverRefreshSeconds` 在服务端刷新,
23
- 浏览器按 `clientPollSeconds` 取缓存,不穿透到上游。
24
- - 浮层:余额、赠送 / 充值拆分、数据新鲜度与手动刷新(带冷却)。
25
- - 多币种:由**后端**选定展示币种;设置里选的币种不在账户里时,浮层说明并给出一键改用。
26
- - 设置卡片分四组、各自可折叠:连接 / 展示 / 阈值 / 刷新,默认四组全收起;组内有非法草稿时该组强制展开。
27
- - 凭据字段带「已由启动环境提供 / 已配置 / 未配置 / 已覆盖」徽标;只读时框内留空,由徽标与说明行交代原因。凭据默认继承官方模型页配好的那一份,不必重填。
28
- - 只按后端给的 `severity` 上色;阈值只用来定圆环弧长,不参与配色。
56
+ - 左边栏底部常驻一个状态环加名称,点开看明细;收起与展开是同一个环,位置不变。
57
+ - 余额按设定的周期自动刷新,界面读的是缓存 —— 开着界面不会反复去打上游接口。
58
+ - 浮层给出总额、赠送 / 充值拆分、数据是多久之前的,以及一个带冷却的手动刷新。
59
+ - 多币种:显示哪个币种由账户决定;设置里选的币种账户里没有时,浮层会说明并给一键改用。
60
+ - 设置卡片分四组、各自可折叠,默认全收起;组里有填错的项时,那一组会自己展开。
61
+ - 凭据默认继承官方模型页配好的那一份,不必重填;徽标只报「已配置密钥。/ 未配置密钥。」(官方卡片同款),
62
+ 只读时不给编辑,并说明原因。
63
+ - 颜色只表达状态(正常 / 偏低 / 告急),与金额大小无关;读法与理由见「[圆环怎么读](#圆环怎么读)」。
29
64
 
30
65
  ## 安装
31
66
 
32
- 前置:DSH `^0.1.6-alpha.1`,Node `>=20`。
67
+ ### 前置
68
+
69
+ - **DSH**:版本范围以 [package.json](package.json) 的 `engines.dsh` 与 `peerDependencies` 为准,本插件跟的是宿主当前那条 alpha 线。
70
+ - Node `>= 20`(同上,真源是 `engines.node`)。
71
+
72
+ 装宿主时**要显式点名版本线**:`@deepseek-ai/dsh` 的 `latest` 标签比本插件要求的那条线还旧 —— 按默认方式装会落在声明范围之外。
73
+
74
+ ```bash
75
+ npm install -g @deepseek-ai/dsh@alpha # 本插件承诺支持的线
76
+ ```
77
+
78
+ 兼容性不是推断出来的:每周由 [compat.yml](.github/workflows/compat.yml) 在 `alpha` 与 `next` 两条线上换包实跑一遍现有测试。当前结论与红了怎么办见 [兼容性](docs/PUBLISHING.md#兼容性)。
33
79
 
34
- 从 npm 安装尚未开放(包未发布)。从源码安装:
80
+ ### 从 npm 安装
81
+
82
+ ```bash
83
+ dsh plugin --profile web add dsh-ds-balance
84
+ ```
85
+
86
+ 装完**重启 `dsh --profile web`** 生效。
87
+
88
+ ### 从源码安装
35
89
 
36
90
  ```bash
37
91
  git clone https://github.com/zlZayn/dsh-ds-balance.git
38
92
  cd dsh-ds-balance
39
- npm install
40
- npm run build
41
- dsh plugin --profile <profile> add .
93
+ npm install && npm run build
94
+
95
+ dsh plugin --profile web add "$PWD"
42
96
  ```
43
97
 
44
- 装完还要把插件行写进该 profile 的 `cordis.patch.yml`;完整步骤与回滚方式见
45
- [AGENTS.md](AGENTS.md) 的「常用命令」。
98
+ npm 那条路一样,重启后生效。
99
+
100
+ ### 发现与安装
46
101
 
47
- **宿主半边改了代码必须重启 DSH**;浏览器半边由客户端热更换入。
102
+ - **npm**:[`dsh-ds-balance`](https://www.npmjs.com/package/dsh-ds-balance)
103
+ - **GitHub**:[`zlZayn/dsh-ds-balance`](https://github.com/zlZayn/dsh-ds-balance)
48
104
 
49
- 装好之后它在两处出现:设置 插件 → 插件配置里的配置卡片,以及左边栏底部的状态圆环。
105
+ 仓库带有 GitHub topic [`dsh-plugin`](https://github.com/topics/dsh-plugin),插件市场据此自动发现插件。
50
106
 
51
107
  ## 配置
52
108
 
53
- 卡片分四组,默认全收起:
109
+ 打开 **设置 → 插件 → 插件配置 → DeepSeek 余额**,四组各自可折叠:
54
110
 
55
- - 连接:只读的凭据状态、可编辑的 API 地址,以及收在二级「自定义设置」里的 apiKey apiKeyRef。
56
- - 展示:金额用哪种币种,或让它自动跟随账户。
57
- - 阈值:几档提醒线。**由后端判定** —— 前端只拿 `warn` 当圆环弧长的刻度,不据此上色。同一币种的告急必须低于预警,否则保存按钮是灰的。
58
- - 刷新:服务端刷新周期与浏览器轮询周期。
111
+ - **连接**:API 地址与凭据,两项都默认留空 —— 地址留空即用 DeepSeek 官方端点,凭据继承官方模型页那一份、只读不可改;
112
+ 二级「自定义设置」里只放凭据引用名。
113
+ - **展示**:金额用哪种币种,或让它自动跟随账户。
114
+ - **阈值**:每个币种两档提醒线(预警 / 告急)。同一币种内**告急必须严格低于预警**,相等也会被拒绝。
115
+ - **刷新**:服务端刷新周期与界面轮询周期。
59
116
 
60
- ## 数据从哪来、到哪去
117
+ 保存即生效,不必重启 DSH。
61
118
 
62
- - 端点由宿主半边通过 `ctx.connection.fetch` 注册在 `/api/v1/*` 下,物理载体已做完信任与浏览器鉴权。
63
- - **API Key 永不返回前端**:配置接口只回一个固定长度的掩码串,连末几位也不给。
64
- - 密钥只经 DSH 的凭据通道解析,不落日志、不落本插件的文件。
65
- - 余额快照落在 DSH 自己的存储目录(`ctx.storageDomain`),按凭据派生出的账本标识分组;
66
- 换 key 自动开新账本,旧快照不会被混用。
67
- - 一个非记录型文件 `.salt` 落在 `$DSH_HOME` 下,用来派生账本标识;**它丢了旧快照会读不回来**。
119
+ ### 圆环怎么读
68
120
 
69
- ## 界面与后端的边界
121
+ - 环里填多少 = 当前余额占该币种**预警线**的比例,100% 封顶;告急线不参与画环 —— 它已经决定了颜色。
122
+ - 颜色只表达状态,与金额大小无关:正常、偏低、告急各一色;账户读不到时另画一个带叉号的环。
123
+ - 没配阈值的币种退回按状态定性:正常与不可用画满环,偏低 3/4,告急 1/4,未知空环。
124
+ - 为什么颜色不由金额算、阈值为什么只当刻度 → [数据流](docs/ARCHITECTURE.md#数据流)。
70
125
 
71
- - 颜色只由后端 `severity` 决定。前端只在圆环弧长里读 `warn` 阈值当刻度,不用它配色。
72
- - 金额一律是八位小数的字符串,前端按字符串裁两位显示,不经过浮点数。
73
- - 完整响应形状与配置契约 → [docs/ui-handoff.md](docs/ui-handoff.md)。
126
+ ### 凭据
74
127
 
75
- ## 贡献
128
+ 密钥只经 DSH 的凭据通道解析,卡片里的 `API Key` 一栏默认继承官方模型页配好的那一份,且只读不可改。
129
+ 由启动环境(环境变量)提供时,字段只读,徽标写明来源。
130
+
131
+ ## 安全与边界
132
+
133
+ - **API Key 永不返回界面**:配置接口只回一个固定长度的掩码串,连末几位也不给。
134
+ - 密钥不落日志、不落本插件的文件;设置文件里只有引用名,卡片可以安全截图或分享。
135
+ - 余额快照存在 DSH 自己的数据目录里,按凭据派生出的账本标识分组 —— 换 key 自动开新账本,旧快照不会被混用。
136
+ - 账本标识还要一个 `.salt` 文件(在 DSH 的 home 目录下)参与派生;**它丢了,旧快照就读不回来**。
137
+ - 只访问 `api.deepseek.com`,不代理、不转发其他流量。
76
138
 
77
- bug、提功能与提 PR 的前置条件见 [CONTRIBUTING.md](CONTRIBUTING.md)。
139
+ ## 许可
140
+
141
+ [MIT](LICENSE)。
142
+
143
+ ## 贡献
78
144
 
79
- ## License
145
+ 外部贡献入口(报 bug 带什么、提功能前先翻什么、提 PR 前做什么)→ [CONTRIBUTING.md](CONTRIBUTING.md)。
80
146
 
81
- MIT,见 [LICENSE](LICENSE)。
147
+ 设计取向与实现约束 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md);发布流程与版本号判定 → [docs/PUBLISHING.md](docs/PUBLISHING.md);维护者文档地图 → [AGENTS.md](AGENTS.md)
package/README_en.md CHANGED
@@ -1,81 +1,147 @@
1
- # ds-balance
1
+ <p align="center">
2
+ <h1 align="center">dsh-ds-balance</h1>
3
+ </p>
2
4
 
3
- [中文](README.md)
5
+ <div align="center">
6
+ <p><strong>DeepSeek account balance in the DSH sidebar</strong></p>
7
+ <p><em>把 DeepSeek 账户余额放进 DSH 的左边栏</em></p>
4
8
 
5
- [![ci](https://github.com/zlZayn/dsh-ds-balance/actions/workflows/ci.yml/badge.svg?branch=main)](.github/workflows/ci.yml)
6
- [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
- [![node](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FzlZayn%2Fdsh-ds-balance%2Fmain%2Fpackage.json&query=%24.engines.node&label=node&color=brightgreen)](package.json)
8
- [![dsh](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FzlZayn%2Fdsh-ds-balance%2Fmain%2Fpackage.json&query=%24.engines.dsh&label=dsh&color=blueviolet)](package.json)
9
+ <p>
10
+ <a href="https://api-docs.deepseek.com/"><img src="https://img.shields.io/badge/DeepSeek%20API-Official-4D6BFE?style=flat" alt="DeepSeek official API"></a>
11
+ <a href="https://github.com/deepseek-ai/deepseek-harness"><img src="https://img.shields.io/badge/DeepSeek%20Harness-Plugin-4176E6?style=flat" alt="DeepSeek Harness Plugin"></a>
12
+ </p>
9
13
 
10
- > **NOTE**
11
- > This package is not on npm yet; it installs from source only.
12
- > The release flow and the version-bump decision chain are in
13
- > [docs/PUBLISHING.md](docs/PUBLISHING.md).
14
+ <p>
15
+ <a href="https://github.com/zlZayn/dsh-ds-balance/actions/workflows/ci.yml"><img src="https://github.com/zlZayn/dsh-ds-balance/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
16
+ <a href="https://www.npmjs.com/package/dsh-ds-balance"><img src="https://img.shields.io/npm/v/dsh-ds-balance.svg" alt="npm"></a>
17
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT License"></a>
18
+ </p>
14
19
 
15
- Shows the DeepSeek account balance at the bottom of the DSH sidebar, with a configuration card in settings.
16
- The balance is read from the real `GET /user/balance`; colour comes only from the `severity` the server returns.
20
+ <p>
21
+ <a href="README.md">简体中文</a> · <strong><a href="README_en.md">English</a></strong>
22
+ </p>
23
+ </div>
17
24
 
18
- ![settings card](assets/settings-card_en.png)
25
+ ---
26
+
27
+ > [!NOTE]
28
+ > **The balance is read from the official `GET /user/balance`** — not estimated. The credential is resolved only through DSH's credential channel: the API key never lands in a settings file and is never returned to the UI.
29
+
30
+ The balance needs somewhere to live that does not take up room. A permanent status ring at the bottom of the sidebar; click it for the three amounts and the freshness of the data. Anything you want to configure lives on the settings card.
31
+
32
+ <p align="center">
33
+ <img src="assets/sidebar-popover_en.png" alt="The DeepSeek balance entry at the bottom of the sidebar, with its popover open" width="360">
34
+ <br>
35
+ <em>A permanent fixture at the <strong>bottom of the sidebar</strong>, alongside Usage statistics and Settings; click it for the balance, the granted / topped-up split, and how fresh the data is.</em>
36
+ </p>
37
+
38
+ ## Surface at a glance
39
+
40
+ | Surface | One line | Use it for |
41
+ |---|---|---|
42
+ | Sidebar ring | A status ring plus label at the bottom of the sidebar; **how full it is = how far the balance is from that currency's warning line** | Seeing at a glance how much is left and how close it is to the warning line |
43
+ | Balance popover | Click the entry: total, granted / topped-up split, data freshness, manual refresh (with cooldown) | Checking the exact figures, and how many minutes old they are |
44
+ | Settings card | Connection / Display / Thresholds / Refresh, each collapsible, all collapsed by default | Changing the endpoint, the currency, the warning lines, the cadence |
45
+
46
+ The division of labour is fixed: **the ring answers "roughly how much is left", the popover answers "exactly how much", and the card answers "how is that computed".**
47
+
48
+ <p align="center">
49
+ <img src="assets/settings-card_en.png" alt="The DeepSeek balance card in the plugin settings" width="360">
50
+ <br>
51
+ <em>Sits alongside other plugins in <strong>Settings → Plugins → Plugin configuration</strong>; all four groups are collapsed by default, so the card opens as four header rows.</em>
52
+ </p>
19
53
 
20
54
  ## Capabilities
21
55
 
22
- - A permanent status ring plus label at the bottom of the sidebar; click it to open a popover with the balance breakdown. Its arc is the balance as a fraction of the `warn` threshold, and the collapsed and expanded states share the same ring.
23
- - Reads the real DeepSeek balance (`GET /user/balance`); the server refreshes on `serverRefreshSeconds` and the browser reads the cache on `clientPollSeconds` without hitting the upstream.
24
- - Popover: total balance, granted / topped-up split, data freshness, and a manual refresh with cooldown.
25
- - Multiple currencies: the **server** picks which currency to show; when the currency chosen in settings is absent from the account, the popover says so and offers a one-click switch.
26
- - The settings card has four collapsible groups — Connection / Display / Thresholds / Refresh — all collapsed by default; a group with an invalid draft is forced open.
27
- - Credential fields carry a "provided by launch environment / configured / not configured / overridden" badge; a read-only field stays empty and lets the badge and the line under it explain why. The credential is inherited from the official model settings by default, so there is nothing to re-enter.
28
- - Colour comes only from the `severity` the server returns; thresholds only scale the ring arc and never affect colour.
56
+ - A permanent status ring plus label at the bottom of the sidebar; click it for the breakdown. The collapsed and expanded states share the same ring, in the same place.
57
+ - The balance refreshes on its own schedule and the UI reads a cache leaving the interface open does not hammer the upstream.
58
+ - The popover shows the total, the granted / topped-up split, how old the data is, and a manual refresh with a cooldown.
59
+ - Multiple currencies: the account decides which currency is shown; when the one chosen in settings is absent, the popover explains and offers a one-click switch.
60
+ - The settings card has four collapsible groups, all collapsed by default; a group holding a bad value opens itself.
61
+ - The credential is inherited from the official model settings by default, so there is nothing to re-enter; where it comes from is on a badge
62
+ (a key is configured / no key is configured, exactly the official card's wording), and a read-only field explains itself instead of offering an edit.
63
+ - Colour carries state only (normal / low / critical), never an amount; see "[Reading the ring](#reading-the-ring)".
29
64
 
30
65
  ## Installation
31
66
 
32
- Prerequisites: DSH `^0.1.6-alpha.1`, Node `>=20`.
67
+ ### Requirements
68
+
69
+ - **DSH**: the range is whatever [package.json](package.json) declares under `engines.dsh` and `peerDependencies`; this plugin follows the alpha line the host is on.
70
+ - Node `>= 20` (same source of truth: `engines.node`).
71
+
72
+ Install the host by **naming the version line explicitly**: the `latest` tag of `@deepseek-ai/dsh` is older than the line this plugin requires — a default install lands outside the declared range.
73
+
74
+ ```bash
75
+ npm install -g @deepseek-ai/dsh@alpha # the line this plugin promises to support
76
+ ```
77
+
78
+ Compatibility is measured, not inferred: every week [compat.yml](.github/workflows/compat.yml) swaps packages onto the `alpha` and `next` lines and reruns the existing tests. The current verdict, and what to do when it goes red, are in [Compatibility](docs/PUBLISHING.md#兼容性).
33
79
 
34
- Installing from npm is not open yet (the package is unpublished). From source:
80
+ ### From npm
81
+
82
+ ```bash
83
+ dsh plugin --profile web add dsh-ds-balance
84
+ ```
85
+
86
+ **Restarting `dsh --profile web`** is what makes it take effect.
87
+
88
+ ### From source
35
89
 
36
90
  ```bash
37
91
  git clone https://github.com/zlZayn/dsh-ds-balance.git
38
92
  cd dsh-ds-balance
39
- npm install
40
- npm run build
41
- dsh plugin --profile <profile> add .
93
+ npm install && npm run build
94
+
95
+ dsh plugin --profile web add "$PWD"
42
96
  ```
43
97
 
44
- After installing, add the plugin row to that profile's `cordis.patch.yml`; the full steps and rollback are in
45
- [AGENTS.md](AGENTS.md), section "常用命令".
98
+ Same as the npm route: it takes effect after a restart.
99
+
100
+ ### Discovery
46
101
 
47
- **Changing the host half requires restarting DSH**; the browser half is hot-swapped by the client.
102
+ - **npm**: [`dsh-ds-balance`](https://www.npmjs.com/package/dsh-ds-balance)
103
+ - **GitHub**: [`zlZayn/dsh-ds-balance`](https://github.com/zlZayn/dsh-ds-balance)
48
104
 
49
- Once installed it shows up in two places: a configuration card under Settings Plugins → Plugin configuration,
50
- and a status ring at the bottom of the sidebar.
105
+ The repository carries the GitHub topic [`dsh-plugin`](https://github.com/topics/dsh-plugin), which is how the plugin marketplace discovers plugins.
51
106
 
52
107
  ## Configuration
53
108
 
54
- The card has four groups, all collapsed by default:
109
+ Open **Settings Plugins → Plugin configuration → DeepSeek balance**. Four groups, each collapsible:
55
110
 
56
- - Connection: the read-only credential state, an editable API base URL, and the apiKey / apiKeyRef kept inside the nested "Customised settings".
57
- - Display: which currency to use for amounts, or let it follow the account.
58
- - Thresholds: the alert lines. They are **evaluated on the server** the frontend only scales the ring arc by `warn`, and never colours anything from them. Within one currency the critical line must be lower than the warning, or Save stays disabled.
59
- - Refresh: the server refresh interval and the browser poll interval.
111
+ - **Connection**: the API base URL and the credential, both blank by default — a blank URL means the official DeepSeek endpoint, and the credential is inherited from the official model page and is read-only.
112
+ The nested "Customised settings" holds only the credential reference name.
113
+ - **Display**: which currency to use for amounts, or let it follow the account.
114
+ - **Thresholds**: two alert lines per currency (warning / critical). **Within one currency the critical line must be strictly lower than the warning line** — equality is rejected too.
115
+ - **Refresh**: the server refresh interval and the UI poll interval.
60
116
 
61
- ## Where the data comes from and goes
117
+ Saving applies immediately; there is no need to restart DSH.
62
118
 
63
- - Endpoints are registered by the host half under `/api/v1/*` via `ctx.connection.fetch`; the physical carrier already applies trust and browser authentication.
64
- - **The API key is never returned to the frontend**: the config endpoint returns only a fixed-length mask, not even the last few characters.
65
- - The key is resolved only through the DSH credential channel; it is never logged and never written to a file owned by this plugin.
66
- - Balance snapshots land in DSH's own storage (`ctx.storageDomain`), grouped by a ledger identifier derived from the credential. Changing the key opens a new ledger; old snapshots are never mixed in.
67
- - One non-record file, `.salt`, lives under `$DSH_HOME` and derives the ledger identifier. **Losing it makes old snapshots unreadable.**
119
+ ### Reading the ring
68
120
 
69
- ## Boundary between UI and backend
121
+ - How full the ring is = the current balance as a fraction of that currency's **warning line**, capped at 100%; the critical line takes no part in drawing it — it already decided the colour.
122
+ - Colour carries state only, never an amount: normal, low and critical each get one hue; an account that cannot be read gets a ring with a cross instead.
123
+ - A currency with no threshold configured falls back to state: full ring for normal and unavailable, 3/4 for low, 1/4 for critical, empty for unknown.
124
+ - Why colour is never computed from an amount, and why thresholds are only a scale → [Data flow](docs/ARCHITECTURE.md#数据流).
70
125
 
71
- - Colour is decided only by the server's `severity`. The frontend reads the `warn` threshold only to scale the ring arc, never to colour anything.
72
- - Amounts are always eight-decimal strings; the frontend trims them to two characters-wise and never goes through floating point.
73
- - Full response shape and configuration contract → [docs/ui-handoff.md](docs/ui-handoff.md).
126
+ ### Credentials
74
127
 
75
- ## Contributing
128
+ The key is resolved only through DSH's credential channel; the card's "API key" inherits the one already configured on the official model settings page.
129
+ When it comes from the launch environment (an environment variable) the field is read-only and the badge says where it came from.
130
+
131
+ ## Security and boundaries
76
132
 
77
- Prerequisites for bug reports, feature proposals, and pull requests are in [CONTRIBUTING_en.md](CONTRIBUTING_en.md).
133
+ - **The API key is never returned to the UI**: the config endpoint returns only a fixed-length mask, not even the last few characters.
134
+ - The key is never logged and never written to a file owned by this plugin; the settings file holds only a reference name, so the card is safe to screenshot or share.
135
+ - Balance snapshots live in DSH's own data directory, grouped by a ledger identifier derived from the credential — changing the key opens a new ledger and old snapshots are never mixed in.
136
+ - That identifier also involves a `.salt` file in DSH's home directory; **lose it and old snapshots become unreadable**.
137
+ - Only `api.deepseek.com` is contacted; nothing is proxied or forwarded.
78
138
 
79
139
  ## License
80
140
 
81
- MIT, see [LICENSE](LICENSE).
141
+ [MIT](LICENSE).
142
+
143
+ ## Contributing
144
+
145
+ Where to report bugs, what to check before proposing a feature, and what to do before sending a PR → [CONTRIBUTING_en.md](CONTRIBUTING_en.md).
146
+
147
+ Design stance and implementation constraints → [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md); the release flow and the version-bump decision chain → [docs/PUBLISHING.md](docs/PUBLISHING.md); the maintainer's document map → [AGENTS.md](AGENTS.md).
package/lib/client.js CHANGED
@@ -85,9 +85,9 @@ var zh = {
85
85
  "settings.group.refresh": "\u5237\u65B0",
86
86
  "settings.group.display": "\u5C55\u793A",
87
87
  "settings.group.thresholds": "\u9608\u503C",
88
- "settings.field.apiKey": "API \u5BC6\u94A5",
88
+ "settings.field.apiKey": "API Key",
89
89
  "settings.field.apiKeyRef": "API \u5BC6\u94A5\u5F15\u7528\u540D",
90
- "settings.field.baseUrl": "API \u5730\u5740",
90
+ "settings.field.baseUrl": "\u63A5\u53E3\u5730\u5740",
91
91
  "settings.field.serverRefreshSeconds": "\u670D\u52A1\u7AEF\u5237\u65B0\u9891\u7387\uFF08\u79D2\uFF09",
92
92
  "settings.field.clientPollSeconds": "\u5BA2\u6237\u7AEF\u8F6E\u8BE2\u9891\u7387\uFF08\u79D2\uFF09",
93
93
  "settings.field.manualRefreshCooldownSeconds": "\u624B\u52A8\u5237\u65B0\u51B7\u5374\uFF08\u79D2\uFF09",
@@ -98,7 +98,7 @@ var zh = {
98
98
  "settings.field.usdCritical": "USD \u544A\u6025",
99
99
  "settings.hint.apiKey": "\u7559\u7A7A\u5219\u6539\u7528\u5F15\u7528\u540D\u4ECE\u51ED\u636E\u5B58\u50A8\u8BFB\u53D6\u3002",
100
100
  "settings.hint.apiKeyRef": "\u51ED\u636E\u5B58\u50A8\u91CC\u7684\u6761\u76EE\u540D\uFF1B\u975E\u7A7A\u65F6\u4F18\u5148\u4E8E API Key\u3002",
101
- "settings.hint.baseUrl": "\u5B98\u65B9\u7AEF\u70B9\uFF1B\u4F7F\u7528\u4EE3\u7406\u6216\u955C\u50CF\u65F6\u4FEE\u6539\u3002",
101
+ "settings.hint.baseUrl": "\u7559\u7A7A\u5219\u4F7F\u7528\u63D0\u4F9B\u65B9\u9ED8\u8BA4\u5730\u5740\u3002",
102
102
  "settings.hint.displayCurrency": "\u6240\u9009\u5E01\u79CD\u4E0D\u5B58\u5728\u65F6\uFF0C\u56DE\u843D\u5230\u8D26\u6237\u5B9E\u9645\u6301\u6709\u7684\u5E01\u79CD\u3002",
103
103
  "settings.hint.number": "\u8BF7\u586B\u6570\u5B57\uFF1B\u7559\u7A7A\u8868\u793A\u4F7F\u7528\u9ED8\u8BA4\u503C\u3002",
104
104
  "settings.hint.threshold": "\u540E\u7AEF\u5224\u5B9A\uFF1B\u754C\u9762\u989C\u8272\u8DDF\u968F\u8FD4\u56DE\u7684\u4E25\u91CD\u5EA6\u3002",
@@ -107,14 +107,13 @@ var zh = {
107
107
  "settings.hint.clientPollSeconds": "\u5237\u65B0\u663E\u793A\u7684\u95F4\u9694\uFF1B\u53EA\u8BFB\u672C\u5730\u7F13\u5B58\uFF0C\u4E0D\u8BBF\u95EE\u4E0A\u6E38\u3002",
108
108
  "settings.hint.manualRefreshCooldownSeconds": "\u4E24\u6B21\u624B\u52A8\u5237\u65B0\u4E4B\u95F4\u7684\u6700\u77ED\u95F4\u9694\u3002",
109
109
  "settings.currency.auto": "\u81EA\u52A8\uFF08\u8DDF\u968F\u8D26\u6237\uFF09",
110
- // 凭据只读时的**状态徽章**文案。此前这里放的是输入框占位符,逐字抄官方 settings.models
111
- // keyEnvLocked;现在输入框留空、状态走标签行右侧的徽章,措辞随之改短。
112
- "settings.credential.envLocked": "\u5DF2\u7531\u542F\u52A8\u73AF\u5883\u63D0\u4F9B",
113
- // 只读凭据行下方的说明,按状态四选一。其中「已配置」那条逐字用官方 webSearchApiKeyHint。
114
- "settings.hint.credential.env": "\u542F\u52A8\u73AF\u5883\u63D0\u4F9B\uFF0C\u53EA\u8BFB\u3002",
115
- "settings.hint.credential.configured": "\u4E0D\u5199\u5165\u8BBE\u7F6E\u6587\u4EF6\u3002\u7559\u7A7A\u8868\u793A\u4FDD\u6301\u5F53\u524D\u5BC6\u94A5\u3002",
116
- "settings.hint.credential.notConfigured": "\u5C1A\u672A\u914D\u7F6E\u3002",
117
- "settings.hint.credential.overridden": "\u4F7F\u7528\u81EA\u5B9A\u4E49\u8BBE\u7F6E\u91CC\u7684\u503C\uFF0C\u5FFD\u7565\u7EE7\u627F\u3002",
110
+ // 凭据行的**状态徽章**:两条逐字用官方措辞 —— 取自 ui-settings-plugins 的
111
+ // webSearchApiKeySet / webSearchApiKeyUnset(后者去掉搜索相关的后半句)。
112
+ "settings.credential.configured": "\u5DF2\u914D\u7F6E\u5BC6\u94A5\u3002",
113
+ "settings.credential.notConfigured": "\u672A\u914D\u7F6E\u5BC6\u94A5\u3002",
114
+ // 凭据行下方的说明。官方 webSearchApiKeyHint 是一句与状态无关的常量说明,这里照做:
115
+ // 状态由徽章交代,说明只讲字段契约。
116
+ "settings.hint.credential": "\u4E0D\u5199\u5165\u8BBE\u7F6E\u6587\u4EF6\u3002\u7559\u7A7A\u8868\u793A\u4FDD\u6301\u5F53\u524D\u5BC6\u94A5\u3002",
118
117
  "settings.group.customized": "\u81EA\u5B9A\u4E49\u8BBE\u7F6E",
119
118
  "settings.test": "\u6D4B\u8BD5\u8FDE\u63A5",
120
119
  "settings.testing": "\u6D4B\u8BD5\u4E2D\u2026",
@@ -168,7 +167,7 @@ var en = {
168
167
  "settings.group.thresholds": "Thresholds",
169
168
  "settings.field.apiKey": "API key",
170
169
  "settings.field.apiKeyRef": "API key reference",
171
- "settings.field.baseUrl": "Base URL",
170
+ "settings.field.baseUrl": "Endpoint",
172
171
  "settings.field.serverRefreshSeconds": "Server refresh (seconds)",
173
172
  "settings.field.clientPollSeconds": "Client poll (seconds)",
174
173
  "settings.field.manualRefreshCooldownSeconds": "Manual refresh cooldown (seconds)",
@@ -179,7 +178,7 @@ var en = {
179
178
  "settings.field.usdCritical": "USD critical",
180
179
  "settings.hint.apiKey": "Leave empty to resolve from the credential reference instead.",
181
180
  "settings.hint.apiKeyRef": "Credential store entry name; takes precedence over the API key.",
182
- "settings.hint.baseUrl": "Official endpoint; change it for a proxy or mirror.",
181
+ "settings.hint.baseUrl": "Leave blank to use the provider default.",
183
182
  "settings.hint.displayCurrency": "Falls back to a currency the account actually holds.",
184
183
  "settings.hint.number": "Enter a number; leave empty to use the default.",
185
184
  "settings.hint.threshold": "Decided on the server; the UI colour follows the returned severity.",
@@ -188,11 +187,9 @@ var en = {
188
187
  "settings.hint.clientPollSeconds": "How often the display refreshes; local cache only.",
189
188
  "settings.hint.manualRefreshCooldownSeconds": "Minimum gap between two manual refreshes.",
190
189
  "settings.currency.auto": "Automatic (follow account)",
191
- "settings.credential.envLocked": "Provided by launch environment",
192
- "settings.hint.credential.env": "Provided by the launch environment; read-only.",
193
- "settings.hint.credential.configured": "Never written to the settings file. Leave empty to keep the current key.",
194
- "settings.hint.credential.notConfigured": "Not configured yet.",
195
- "settings.hint.credential.overridden": "Uses the value in Customised settings; the inherited one is ignored.",
190
+ "settings.credential.configured": "A key is configured.",
191
+ "settings.credential.notConfigured": "No key is configured.",
192
+ "settings.hint.credential": "Stored outside the settings file. Leave blank to keep the current key.",
196
193
  "settings.group.customized": "Customized settings",
197
194
  "settings.test": "Test connection",
198
195
  "settings.testing": "Testing\u2026",
@@ -242,6 +239,7 @@ var fields_default = {
242
239
  badges: "fields_badges",
243
240
  reset: "fields_reset",
244
241
  input: "fields_input",
242
+ inputStatic: "fields_inputStatic",
245
243
  inputInvalid: "fields_inputInvalid",
246
244
  inputWithAction: "fields_inputWithAction",
247
245
  reveal: "fields_reveal",
@@ -342,11 +340,10 @@ function ReadOnlyControl(props) {
342
340
  "input",
343
341
  {
344
342
  id: props.id,
345
- className: fields_default.input,
343
+ className: clsx_default(fields_default.input, fields_default.inputStatic),
346
344
  type: "text",
347
345
  value: "",
348
- readOnly: true,
349
- disabled: true
346
+ readOnly: true
350
347
  }
351
348
  );
352
349
  }
@@ -367,39 +364,6 @@ function DetailsGroup(props) {
367
364
  }
368
365
  );
369
366
  }
370
- function SecretControl(props) {
371
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: fields_default.inputWithAction, children: [
372
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
373
- "input",
374
- {
375
- id: props.id,
376
- className: props.invalid ? fields_default.inputInvalid : fields_default.input,
377
- type: props.revealed ? "text" : "password",
378
- autoComplete: "off",
379
- spellCheck: false,
380
- ...props.invalid ? { "aria-invalid": true } : {},
381
- value: props.text,
382
- disabled: props.disabled,
383
- onChange: (event) => {
384
- props.onEdit(event.target.value);
385
- }
386
- }
387
- ),
388
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
389
- "button",
390
- {
391
- type: "button",
392
- className: clsx_default(fields_default.reveal, props.revealed && fields_default.revealOn),
393
- "aria-label": props.revealLabel,
394
- "aria-pressed": props.revealed,
395
- title: props.revealLabel,
396
- disabled: props.disabled,
397
- onClick: props.onToggleReveal,
398
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.IconInspectOutline12, { size: 14 })
399
- }
400
- )
401
- ] });
402
- }
403
367
  function SelectorControl(props) {
404
368
  const [open, setOpen] = (0, import_react.useState)(false);
405
369
  const selected = props.options.find((option) => option.id === props.selectedId);
@@ -545,7 +509,7 @@ var SPEC_BY_FIELD = new Map(
545
509
  var TEST_LATENCY_MS = 800;
546
510
  function probeFailure(baseUrl, apiKey, apiKeyRef) {
547
511
  const url = baseUrl.trim();
548
- if (!/^https?:\/\//i.test(url)) return "base URL must start with http:// or https://";
512
+ if (url !== "" && !/^https?:\/\//i.test(url)) return "base URL must start with http:// or https://";
549
513
  if (apiKey.trim() === "" && apiKeyRef.trim() === "") return "no API key or credential reference configured";
550
514
  return null;
551
515
  }
@@ -950,16 +914,12 @@ var BalanceSettingsCard_default = {
950
914
  var import_jsx_runtime2 = require("react/jsx-runtime");
951
915
  var DEFAULT_API_KEY_REF = "DEEPSEEK_API_KEY";
952
916
  var CREDENTIAL_BADGE = {
953
- env: "settings.credential.envLocked",
954
- configured: "settings.configured",
955
- notConfigured: "settings.notConfigured",
956
- overridden: "settings.overridden"
957
- };
958
- var CREDENTIAL_HINT = {
959
- env: "settings.hint.credential.env",
960
- configured: "settings.hint.credential.configured",
961
- notConfigured: "settings.hint.credential.notConfigured",
962
- overridden: "settings.hint.credential.overridden"
917
+ // 官方那张卡右上角只有一句话:「已配置密钥。」——继承来的、自己填的、环境提供的都算配置好了。
918
+ // 四档判据留着,是因为「覆盖 / 未覆盖」决定二级折叠里那两个字段的状态;徽章本身不区分它们。
919
+ env: "settings.credential.configured",
920
+ configured: "settings.credential.configured",
921
+ notConfigured: "settings.credential.notConfigured",
922
+ overridden: "settings.credential.configured"
963
923
  };
964
924
  var DEFAULT_GROUP_OPEN = {
965
925
  connection: false,
@@ -994,7 +954,6 @@ function isFilled(value) {
994
954
  function BalanceSettingsCard({ t, scope }) {
995
955
  const form = useConfigForm(scope);
996
956
  const [open, setOpen] = (0, import_react4.useState)(false);
997
- const [revealed, setRevealed] = (0, import_react4.useState)(false);
998
957
  const [groupOpen, setGroupOpen] = (0, import_react4.useState)(DEFAULT_GROUP_OPEN);
999
958
  const saveStarted = (0, import_react4.useRef)(false);
1000
959
  const toggleGroup = (key) => {
@@ -1189,7 +1148,7 @@ function BalanceSettingsCard({ t, scope }) {
1189
1148
  pendingLabel: t("settings.unsaved"),
1190
1149
  resetLabel: t("settings.reset"),
1191
1150
  invalid: false,
1192
- hint: t(CREDENTIAL_HINT[credentialView]),
1151
+ hint: t("settings.hint.credential"),
1193
1152
  disabled: true,
1194
1153
  onReset: () => {
1195
1154
  },
@@ -1211,44 +1170,7 @@ function BalanceSettingsCard({ t, scope }) {
1211
1170
  onClick: form.runTest
1212
1171
  }
1213
1172
  ) }, "test"),
1214
- /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(DetailsGroup, { title: t("settings.group.customized"), children: [
1215
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1216
- FieldFrame,
1217
- {
1218
- id: FIELD_IDS.apiKey ?? "apiKey",
1219
- label: t("settings.field.apiKey"),
1220
- status: credentialStatus("apiKey"),
1221
- pending: apiKey.dirty,
1222
- resettable: apiKey.overridden || apiKey.dirty,
1223
- pendingLabel: t("settings.unsaved"),
1224
- resetLabel: t("settings.reset"),
1225
- invalid: false,
1226
- hint: t("settings.hint.apiKey"),
1227
- disabled,
1228
- onReset: () => {
1229
- form.resetField("apiKey");
1230
- },
1231
- children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1232
- SecretControl,
1233
- {
1234
- id: FIELD_IDS.apiKey ?? "apiKey",
1235
- text: apiKey.text,
1236
- invalid: false,
1237
- disabled,
1238
- revealed,
1239
- revealLabel: t("settings.field.apiKey"),
1240
- onToggleReveal: () => {
1241
- setRevealed(!revealed);
1242
- },
1243
- onEdit: (text) => {
1244
- form.edit("apiKey", text);
1245
- }
1246
- }
1247
- )
1248
- }
1249
- ),
1250
- textRow("apiKeyRef", "settings.field.apiKeyRef", "settings.hint.apiKeyRef", true)
1251
- ] })
1173
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(DetailsGroup, { title: t("settings.group.customized"), children: textRow("apiKeyRef", "settings.field.apiKeyRef", "settings.hint.apiKeyRef", true) })
1252
1174
  ]
1253
1175
  }
1254
1176
  ),
@@ -1430,6 +1352,10 @@ function ageBucket(ageMs) {
1430
1352
  if (hours < 24) return { bucket: "hours", value: hours };
1431
1353
  return { bucket: "days", value: Math.floor(hours / 24) };
1432
1354
  }
1355
+ function currentAgeMs(seenAt, seenAgeMs, now) {
1356
+ const base = Number.isFinite(seenAgeMs) ? Math.max(0, seenAgeMs) : 0;
1357
+ return Math.max(0, base + (now - seenAt));
1358
+ }
1433
1359
 
1434
1360
  // src/client/mock/scenarios.ts
1435
1361
  var T0 = 176e10;
@@ -1638,8 +1564,7 @@ function BalancePopover(props) {
1638
1564
  t,
1639
1565
  selection,
1640
1566
  displayCurrency,
1641
- fetchedAt,
1642
- now,
1567
+ ageMs,
1643
1568
  refreshing,
1644
1569
  cooldownSeconds,
1645
1570
  panelRef,
@@ -1652,7 +1577,7 @@ function BalancePopover(props) {
1652
1577
  const totalText = shown === null ? BALANCE_PLACEHOLDER : formatMoney(shown.total, shown.currency);
1653
1578
  const grantedText = shown === null ? BALANCE_PLACEHOLDER : formatMoney(shown.granted, shown.currency);
1654
1579
  const toppedUpText = shown === null ? BALANCE_PLACEHOLDER : formatMoney(shown.toppedUp, shown.currency);
1655
- const age = ageBucket(now - fetchedAt);
1580
+ const age = ageBucket(ageMs);
1656
1581
  const updatedText = age.bucket === "just-now" || age.bucket === "unknown" ? t("popover.updated.justNow") : interpolate(t("popover.updated"), {
1657
1582
  value: ageValueText(age.bucket, age.value, unitLocale(t))
1658
1583
  });
@@ -1812,11 +1737,14 @@ function ringSpecFor(severity) {
1812
1737
  return { state: spec.state === "ongoing" ? "idle" : spec.state, marker: spec.marker };
1813
1738
  }
1814
1739
  function SidebarBalance({ wide, t, config }) {
1740
+ const [view, setView] = (0, import_react5.useState)(() => {
1741
+ const response2 = resolveScenario() === null ? pendingView() : currentBalance();
1742
+ return { response: response2, seenAt: Date.now(), seenAgeMs: response2.ageMs };
1743
+ });
1744
+ const response = view.response;
1815
1745
  const [mock, setMock] = (0, import_react5.useState)(() => resolveScenario() !== null);
1816
- const [response, setResponse] = (0, import_react5.useState)(() => resolveScenario() === null ? pendingView() : currentBalance());
1817
1746
  const loadedRef = (0, import_react5.useRef)(false);
1818
1747
  const [localCurrency, setLocalCurrency] = (0, import_react5.useState)(null);
1819
- const [localFetchedAt, setLocalFetchedAt] = (0, import_react5.useState)(null);
1820
1748
  const [open, setOpen] = (0, import_react5.useState)(false);
1821
1749
  const [refreshing, setRefreshing] = (0, import_react5.useState)(false);
1822
1750
  const [cooldownUntil, setCooldownUntil] = (0, import_react5.useState)(0);
@@ -1825,21 +1753,28 @@ function SidebarBalance({ wide, t, config }) {
1825
1753
  const panelRef = (0, import_react5.useRef)(null);
1826
1754
  const refreshTimer = (0, import_react5.useRef)(void 0);
1827
1755
  const lastScenario = (0, import_react5.useRef)(null);
1828
- const mountedAt = (0, import_react5.useRef)(Date.now());
1756
+ const acceptBalance = (0, import_react5.useCallback)((next) => {
1757
+ setView({ response: next, seenAt: Date.now(), seenAgeMs: next.ageMs });
1758
+ }, []);
1759
+ const showUnreachable = (0, import_react5.useCallback)((error) => {
1760
+ const message = error instanceof Error ? error.message : String(error);
1761
+ setView((current) => ({ ...current, response: unreachableView(message) }));
1762
+ }, []);
1763
+ const markFresh = (0, import_react5.useCallback)(() => {
1764
+ setView((current) => ({ ...current, seenAt: Date.now(), seenAgeMs: 0 }));
1765
+ }, []);
1829
1766
  const preference = localCurrency ?? config.displayCurrency;
1830
1767
  const selection = (0, import_react5.useMemo)(() => selectionOf(response, preference), [response, preference]);
1831
- const fetchedAt = localFetchedAt ?? mountedAt.current - response.ageMs;
1832
1768
  (0, import_react5.useEffect)(() => subscribeScenario((key) => {
1833
1769
  const active = resolveScenario() !== null;
1834
1770
  setMock(active);
1835
1771
  if (!active || lastScenario.current === key) return;
1836
1772
  lastScenario.current = key;
1837
- setResponse(currentBalance());
1838
- setLocalFetchedAt(null);
1773
+ acceptBalance(currentBalance());
1839
1774
  setRefreshing(false);
1840
1775
  setCooldownUntil(0);
1841
1776
  setOpen(false);
1842
- }), []);
1777
+ }), [acceptBalance]);
1843
1778
  (0, import_react5.useEffect)(() => {
1844
1779
  if (mock) return;
1845
1780
  let cancelled = false;
@@ -1848,11 +1783,10 @@ function SidebarBalance({ wide, t, config }) {
1848
1783
  const next = await requestBalance({ currency: preference });
1849
1784
  if (cancelled) return;
1850
1785
  loadedRef.current = true;
1851
- setResponse(next);
1852
- setLocalFetchedAt(null);
1786
+ acceptBalance(next);
1853
1787
  } catch (error) {
1854
1788
  if (cancelled || loadedRef.current) return;
1855
- setResponse(unreachableView(error instanceof Error ? error.message : String(error)));
1789
+ showUnreachable(error);
1856
1790
  }
1857
1791
  };
1858
1792
  void load();
@@ -1863,7 +1797,7 @@ function SidebarBalance({ wide, t, config }) {
1863
1797
  cancelled = true;
1864
1798
  window.clearInterval(id);
1865
1799
  };
1866
- }, [mock, preference, config.clientPollSeconds, config.configSignature]);
1800
+ }, [mock, preference, config.clientPollSeconds, config.configSignature, acceptBalance, showUnreachable]);
1867
1801
  (0, import_react5.useEffect)(() => () => {
1868
1802
  if (refreshTimer.current !== void 0) window.clearTimeout(refreshTimer.current);
1869
1803
  }, []);
@@ -1907,13 +1841,13 @@ function SidebarBalance({ wide, t, config }) {
1907
1841
  if (Date.now() < cooldownUntil) return;
1908
1842
  setRefreshing(true);
1909
1843
  const finish = () => {
1910
- setLocalFetchedAt(Date.now());
1911
1844
  setRefreshing(false);
1912
1845
  setCooldownUntil(Date.now() + Math.max(0, config.manualRefreshCooldownSeconds) * MS_PER_SECOND);
1913
1846
  };
1914
1847
  if (mock) {
1915
1848
  refreshTimer.current = window.setTimeout(() => {
1916
1849
  refreshTimer.current = void 0;
1850
+ markFresh();
1917
1851
  finish();
1918
1852
  }, REFRESH_SIMULATION_MS);
1919
1853
  return;
@@ -1923,14 +1857,14 @@ function SidebarBalance({ wide, t, config }) {
1923
1857
  await requestRefresh({ reason: "manual" });
1924
1858
  const next = await requestBalance({ currency: preference });
1925
1859
  loadedRef.current = true;
1926
- setResponse(next);
1860
+ acceptBalance(next);
1927
1861
  } catch (error) {
1928
- if (!loadedRef.current) setResponse(unreachableView(error instanceof Error ? error.message : String(error)));
1862
+ if (!loadedRef.current) showUnreachable(error);
1929
1863
  } finally {
1930
1864
  finish();
1931
1865
  }
1932
1866
  })();
1933
- }, [cooldownUntil, config.manualRefreshCooldownSeconds, mock, preference, refreshing]);
1867
+ }, [cooldownUntil, config.manualRefreshCooldownSeconds, mock, preference, refreshing, acceptBalance, showUnreachable, markFresh]);
1934
1868
  const handleUseShown = (0, import_react5.useCallback)(() => {
1935
1869
  const current = selection.shown;
1936
1870
  if (current === null) return;
@@ -1962,6 +1896,7 @@ function SidebarBalance({ wide, t, config }) {
1962
1896
  if (markerLabel !== null) ariaParts.push(markerLabel);
1963
1897
  const ariaLabel = ariaParts.join(" ");
1964
1898
  const cooldownSeconds = Math.max(0, Math.ceil((cooldownUntil - now) / MS_PER_SECOND));
1899
+ const ageMs = currentAgeMs(view.seenAt, view.seenAgeMs, now);
1965
1900
  const ringTitle = !wide && stateText !== null ? `${t("sidebar.aria.ring")} ${stateText}` : void 0;
1966
1901
  return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(
1967
1902
  "div",
@@ -1993,8 +1928,7 @@ function SidebarBalance({ wide, t, config }) {
1993
1928
  t,
1994
1929
  selection,
1995
1930
  displayCurrency: preference,
1996
- fetchedAt,
1997
- now,
1931
+ ageMs,
1998
1932
  refreshing,
1999
1933
  cooldownSeconds,
2000
1934
  panelRef,
@@ -2106,7 +2040,7 @@ function apply(ctx) {
2106
2040
  (seat) => /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(SidebarSeatComponent, { seat, scope })
2107
2041
  ));
2108
2042
  }
2109
- var __dsbCss = "/* src/client/settings/fields.module.css */\n.fields_group {\n padding: 12px 0 0;\n}\n.fields_groupLast {\n padding-bottom: 12px;\n}\n.fields_groupNote {\n margin: 4px 0 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n.fields_fields {\n display: flex;\n flex-direction: column;\n}\n.fields_fields > * + * {\n border-top: 0.5px solid var(--dsw-alias-border-l2);\n}\n.fields_details {\n border-top: 0.5px solid var(--dsw-alias-border-l2);\n margin-top: 12px;\n padding-top: 12px;\n}\n.fields_detailsSummary {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 12px;\n font-weight: 500;\n line-height: 18px;\n color: var(--dsw-alias-label-secondary);\n cursor: pointer;\n list-style: none;\n}\n.fields_detailsSummary::-webkit-details-marker {\n display: none;\n}\n.fields_detailsSummary::before {\n content: \"\";\n flex: none;\n width: 5px;\n height: 5px;\n border-right: 1.5px solid currentColor;\n border-bottom: 1.5px solid currentColor;\n transform: translate(-0.5px, -1px) rotate(-45deg);\n transition: transform 120ms ease;\n}\n.fields_details[open] > .fields_detailsSummary::before {\n transform: translate(-0.5px, -1px) rotate(45deg);\n}\n.fields_detailsBody {\n padding-top: 10px;\n}\n.fields_field {\n display: flex;\n flex-direction: column;\n gap: 6px;\n padding: 12px 0;\n}\n.fields_row {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 12px 0;\n}\n.fields_rowText {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n padding-right: 48px;\n}\n.fields_rowTitleLine {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.fields_rowTitle {\n flex: 1;\n min-width: 0;\n font-size: 13px;\n font-weight: 500;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.fields_head {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.fields_label {\n flex: 1;\n min-width: 0;\n font-size: 13px;\n font-weight: 500;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.fields_badges {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n}\n.fields_reset {\n border: none;\n background: none;\n padding: 0;\n font: inherit;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-secondary);\n cursor: pointer;\n}\n.fields_reset:hover:not(:disabled) {\n color: var(--dsw-alias-label-primary);\n}\n.fields_reset:disabled {\n cursor: default;\n}\n.fields_input {\n box-sizing: border-box;\n width: 100%;\n height: 34px;\n padding: 0 12px;\n border: 0.5px solid var(--dsw-alias-border-l4);\n border-radius: 8px;\n background: var(--dsw-alias-bg-layer-3);\n font: inherit;\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.fields_input:focus-visible {\n outline: none;\n border-color: var(--dsw-alias-brand-primary);\n}\n.fields_input:disabled {\n color: var(--dsw-alias-label-tertiary);\n opacity: 0.6;\n cursor: default;\n}\n.fields_inputInvalid {\n box-sizing: border-box;\n width: 100%;\n height: 34px;\n padding: 0 12px;\n border: 1px solid var(--dsw-alias-state-error-primary);\n border-radius: 8px;\n background: var(--dsw-alias-bg-layer-3);\n font: inherit;\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.fields_inputInvalid:focus-visible {\n outline: none;\n border-color: var(--dsw-alias-brand-primary);\n}\n.fields_inputInvalid:disabled {\n color: var(--dsw-alias-label-tertiary);\n cursor: default;\n}\n.fields_inputWithAction {\n position: relative;\n display: flex;\n align-items: center;\n}\n.fields_inputWithAction .fields_input,\n.fields_inputWithAction .fields_inputInvalid {\n padding-right: 38px;\n}\n.fields_reveal {\n position: absolute;\n right: 4px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n padding: 0;\n border: none;\n border-radius: 999px;\n corner-shape: round;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.fields_reveal:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n.fields_revealOn {\n color: var(--dsw-alias-label-primary);\n}\n.fields_reveal:disabled {\n cursor: default;\n opacity: 0.4;\n}\n.fields_selectorAnchor {\n flex: none;\n}\n.fields_selector {\n display: inline-flex;\n align-items: center;\n gap: 12px;\n flex: none;\n max-width: 100%;\n height: 36px;\n padding: 0 14px;\n border: none;\n border-radius: 18px;\n background: var(--dsw-alias-bg-module-platform);\n font: inherit;\n font-size: 14px;\n line-height: 22px;\n color: var(--dsw-alias-label-primary);\n text-align: left;\n cursor: pointer;\n}\n.fields_selector:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.fields_selector:disabled {\n color: var(--dsw-alias-label-tertiary);\n cursor: default;\n}\n.fields_selectorChevron {\n flex: none;\n}\n.fields_hint {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n.fields_pairNote {\n display: flex;\n align-items: center;\n gap: 6px;\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-state-warn-primary);\n}\n.fields_pairNote::before {\n content: \"\";\n flex: none;\n width: 5px;\n height: 5px;\n border-radius: 999px;\n corner-shape: round;\n background: currentColor;\n}\n.fields_invalid {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-state-error-primary);\n}\n.fields_actions {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.fields_actionButton {\n appearance: none;\n border: 0.5px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n padding: 5px 14px;\n font: inherit;\n font-size: 13px;\n line-height: 1.5;\n cursor: pointer;\n background: none;\n color: var(--dsw-alias-label-secondary);\n}\n.fields_actionButton:hover:not(:disabled) {\n color: var(--dsw-alias-label-primary);\n border-color: var(--dsw-alias-label-dimmed);\n}\n.fields_actionButton:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.fields_actionButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n.fields_resultOk {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-state-success-primary);\n}\n.fields_resultFail {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-state-error-primary);\n}\n\n/* src/client/settings/BalanceSettingsCard.module.css */\n.BalanceSettingsCard_card {\n list-style: none;\n border: 0.5px solid var(--dsw-alias-border-l4);\n border-radius: 16px;\n background: var(--dsw-alias-bg-layer-3);\n transition: border-color .16s, background .16s;\n}\n.BalanceSettingsCard_card:hover {\n border-color: var(--dsw-alias-label-dimmed);\n}\n.BalanceSettingsCard_cardOpen {\n background: var(--dsw-alias-bg-layer-2);\n border-color: var(--dsw-alias-label-dimmed);\n}\n.BalanceSettingsCard_header {\n width: 100%;\n appearance: none;\n border: 0;\n background: none;\n font: inherit;\n color: inherit;\n text-align: left;\n cursor: pointer;\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 14px 16px;\n border-radius: 12px;\n}\n.BalanceSettingsCard_header:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: -2px;\n}\n.BalanceSettingsCard_headText {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n.BalanceSettingsCard_name {\n font-size: 15px;\n font-weight: 600;\n line-height: 1.4;\n color: var(--dsw-alias-label-primary);\n}\n.BalanceSettingsCard_description {\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n.BalanceSettingsCard_chevronWrap {\n flex: none;\n display: inline-flex;\n align-items: center;\n}\n.BalanceSettingsCard_chevron {\n color: var(--dsw-alias-label-tertiary);\n transition: transform .16s;\n}\n.BalanceSettingsCard_chevronOpen {\n transform: rotate(180deg);\n}\n.BalanceSettingsCard_pending {\n flex: none;\n}\n.BalanceSettingsCard_body {\n border-top: 0.5px solid var(--dsw-alias-border-l2);\n margin: 0 16px;\n padding-bottom: 0;\n}\n.BalanceSettingsCard_readOnly {\n margin: 12px 0 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n.BalanceSettingsCard_footer {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n padding: 12px 0;\n border-top: 0.5px solid var(--dsw-alias-border-l2);\n}\n.BalanceSettingsCard_failed {\n flex: 1;\n min-width: 0;\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-state-error-primary);\n}\n.BalanceSettingsCard_notice {\n flex: 1;\n min-width: 0;\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n.BalanceSettingsCard_discard,\n.BalanceSettingsCard_save {\n appearance: none;\n border: 0.5px solid transparent;\n border-radius: 8px;\n padding: 5px 14px;\n font: inherit;\n font-size: 13px;\n line-height: 1.5;\n cursor: pointer;\n}\n.BalanceSettingsCard_discard {\n border-color: var(--dsw-alias-border-l2);\n background: none;\n color: var(--dsw-alias-label-secondary);\n}\n.BalanceSettingsCard_discard:hover:not(:disabled) {\n color: var(--dsw-alias-label-primary);\n border-color: var(--dsw-alias-label-dimmed);\n}\n.BalanceSettingsCard_save {\n background: var(--dsw-alias-label-primary);\n color: var(--dsw-alias-bg-layer-3);\n}\n.BalanceSettingsCard_discard:disabled,\n.BalanceSettingsCard_save:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.BalanceSettingsCard_discard:focus-visible,\n.BalanceSettingsCard_save:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n/* src/client/sidebar/BalancePopover.module.css */\n.BalancePopover_panel {\n position: fixed;\n z-index: 1100;\n box-sizing: border-box;\n width: max-content;\n min-width: min(300px, calc(100vw - 24px));\n max-width: min(440px, calc(100vw - 24px));\n padding: 16px;\n border: 0;\n border-radius: 12px;\n background: var(--dsw-specific-menu);\n --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);\n box-shadow: var(--dsw-elevation-prominent);\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-secondary);\n cursor: default;\n}\n.BalancePopover_title {\n display: flex;\n justify-content: space-between;\n gap: 16px;\n margin-bottom: 8px;\n color: var(--dsw-alias-label-primary);\n font-weight: 500;\n}\n.BalancePopover_titleLabel {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n min-width: 0;\n}\n.BalancePopover_titleLabel svg {\n flex: none;\n width: 14px;\n height: auto;\n}\n.BalancePopover_titleRule {\n margin-bottom: 10px;\n border-top: 0.5px solid var(--dsw-alias-border-l2);\n}\n.BalancePopover_details {\n display: grid;\n grid-template-columns: minmax(76px, auto) minmax(0, 1fr);\n gap: 6px 16px;\n margin: 0;\n color: var(--dsw-alias-label-tertiary);\n}\n.BalancePopover_details dt,\n.BalancePopover_details dd {\n min-width: 0;\n margin: 0;\n}\n.BalancePopover_details dd {\n color: var(--dsw-alias-label-secondary);\n font-variant-numeric: tabular-nums;\n text-align: right;\n}\n.BalancePopover_notice {\n margin-top: 12px;\n}\n.BalancePopover_noticeText {\n margin: 0 0 8px;\n color: var(--dsw-alias-label-tertiary);\n}\n.BalancePopover_noticeActions {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n}\n.BalancePopover_footer {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 12px;\n}\n.BalancePopover_updated {\n flex: none;\n color: var(--dsw-alias-label-tertiary);\n}\n.BalancePopover_status {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-align: right;\n color: var(--dsw-alias-label-tertiary);\n}\n.BalancePopover_refresh {\n display: inline-flex;\n flex: none;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 0;\n border: none;\n border-radius: 999px;\n corner-shape: round;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.BalancePopover_refresh:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n.BalancePopover_refresh:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n.BalancePopover_refresh:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.BalancePopover_refresh[data-cooling] {\n color: var(--dsw-alias-label-caption);\n}\n.BalancePopover_refresh[data-refreshing] svg {\n animation: BalancePopover_balance-refresh-spin 900ms linear infinite;\n}\n@keyframes BalancePopover_balance-refresh-spin {\n to {\n transform: rotate(360deg);\n }\n}\n@media (prefers-reduced-motion: reduce) {\n .BalancePopover_refresh[data-refreshing] svg {\n animation-duration: 2400ms;\n }\n}\n\n/* src/client/sidebar/PercentRing.module.css */\n.PercentRing_ring {\n display: block;\n flex: none;\n color: var(--dsw-alias-label-tertiary);\n}\n.PercentRing_ring[data-state=done] {\n color: var(--dsw-alias-state-success-primary);\n}\n.PercentRing_ring[data-state=warning] {\n color: var(--dsw-alias-state-warn-primary);\n}\n.PercentRing_ring[data-state=error] {\n color: var(--dsw-alias-state-error-primary);\n}\n.PercentRing_track {\n fill: none;\n stroke: var(--dsw-alias-border-l3);\n stroke-width: 2;\n}\n.PercentRing_fill {\n fill: none;\n stroke: currentColor;\n stroke-width: 2;\n stroke-linecap: round;\n}\n.PercentRing_cross {\n fill: none;\n stroke: currentColor;\n stroke-width: 1.5;\n stroke-linecap: round;\n}\n\n/* src/client/sidebar/SidebarBalance.module.css */\n.SidebarBalance_root {\n position: relative;\n display: flex;\n min-width: 0;\n}\n.SidebarBalance_root[data-mode=wide] {\n flex: none;\n width: calc(100% + 4px);\n margin: 4px -2px 0;\n}\n.SidebarBalance_root[data-mode=rail] {\n flex: none;\n margin: 8px 0 0;\n}\n.SidebarBalance_trigger {\n display: flex;\n align-items: center;\n box-sizing: border-box;\n border: none;\n background: transparent;\n color: var(--dsw-alias-label-primary);\n font-family: inherit;\n font-size: 14px;\n line-height: 22px;\n cursor: pointer;\n overflow: hidden;\n}\n.SidebarBalance_trigger:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.SidebarBalance_trigger:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: -2px;\n}\n.SidebarBalance_root[data-mode=wide] .SidebarBalance_trigger {\n flex: none;\n gap: 8px;\n width: 100%;\n height: 42px;\n padding: 0 10px 0 8px;\n border-radius: 12px;\n}\n.SidebarBalance_root[data-mode=rail] .SidebarBalance_trigger {\n flex: none;\n justify-content: center;\n width: 36px;\n height: 36px;\n padding: 0;\n border-radius: 50%;\n corner-shape: round;\n}\n.SidebarBalance_icon {\n display: inline-flex;\n flex: none;\n align-items: center;\n justify-content: center;\n width: 16px;\n height: 16px;\n}\n.SidebarBalance_label {\n flex: 0 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.SidebarBalance_marker {\n display: inline-flex;\n flex: none;\n align-items: center;\n justify-content: center;\n color: var(--dsw-alias-label-tertiary);\n}\n\n/* src/client/sidebar/footer-stack.module.css */\ndiv:has(> [data-slot=\"sidebar.footer.action\"]) {\n flex-direction: column;\n}\n";
2043
+ var __dsbCss = "/* src/client/settings/fields.module.css */\n.fields_group {\n padding: 12px 0 0;\n}\n.fields_groupLast {\n padding-bottom: 12px;\n}\n.fields_groupNote {\n margin: 4px 0 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n.fields_fields {\n display: flex;\n flex-direction: column;\n}\n.fields_fields > * + * {\n border-top: 0.5px solid var(--dsw-alias-border-l2);\n}\n.fields_details {\n border-top: 0.5px solid var(--dsw-alias-border-l2);\n margin-top: 12px;\n padding-top: 12px;\n}\n.fields_detailsSummary {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 12px;\n font-weight: 500;\n line-height: 18px;\n color: var(--dsw-alias-label-secondary);\n cursor: pointer;\n list-style: none;\n}\n.fields_detailsSummary::-webkit-details-marker {\n display: none;\n}\n.fields_detailsSummary::before {\n content: \"\";\n flex: none;\n width: 5px;\n height: 5px;\n border-right: 1.5px solid currentColor;\n border-bottom: 1.5px solid currentColor;\n transform: translate(-0.5px, -1px) rotate(-45deg);\n transition: transform 120ms ease;\n}\n.fields_details[open] > .fields_detailsSummary::before {\n transform: translate(-0.5px, -1px) rotate(45deg);\n}\n.fields_detailsBody {\n padding-top: 10px;\n}\n.fields_field {\n display: flex;\n flex-direction: column;\n gap: 6px;\n padding: 12px 0;\n}\n.fields_row {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 12px 0;\n}\n.fields_rowText {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n padding-right: 48px;\n}\n.fields_rowTitleLine {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.fields_rowTitle {\n flex: 1;\n min-width: 0;\n font-size: 13px;\n font-weight: 500;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.fields_head {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.fields_label {\n flex: 1;\n min-width: 0;\n font-size: 13px;\n font-weight: 500;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.fields_badges {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n}\n.fields_reset {\n border: none;\n background: none;\n padding: 0;\n font: inherit;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-secondary);\n cursor: pointer;\n}\n.fields_reset:hover:not(:disabled) {\n color: var(--dsw-alias-label-primary);\n}\n.fields_reset:disabled {\n cursor: default;\n}\n.fields_input {\n box-sizing: border-box;\n width: 100%;\n height: 34px;\n padding: 0 12px;\n border: 0.5px solid var(--dsw-alias-border-l4);\n border-radius: 8px;\n background: var(--dsw-alias-bg-layer-3);\n font: inherit;\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.fields_input:focus-visible {\n outline: none;\n border-color: var(--dsw-alias-brand-primary);\n}\n.fields_input:disabled {\n color: var(--dsw-alias-label-tertiary);\n opacity: 0.6;\n cursor: default;\n}\ninput.fields_inputStatic,\ninput.fields_inputStatic:hover,\ninput.fields_inputStatic:focus,\ninput.fields_inputStatic:focus-visible,\ninput.fields_inputStatic:active {\n border-color: var(--dsw-alias-border-l4);\n background: var(--dsw-alias-bg-layer-3);\n outline: none;\n box-shadow: none;\n cursor: default;\n pointer-events: none;\n}\n.fields_inputInvalid {\n box-sizing: border-box;\n width: 100%;\n height: 34px;\n padding: 0 12px;\n border: 1px solid var(--dsw-alias-state-error-primary);\n border-radius: 8px;\n background: var(--dsw-alias-bg-layer-3);\n font: inherit;\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.fields_inputInvalid:focus-visible {\n outline: none;\n border-color: var(--dsw-alias-brand-primary);\n}\n.fields_inputInvalid:disabled {\n color: var(--dsw-alias-label-tertiary);\n cursor: default;\n}\n.fields_inputWithAction {\n position: relative;\n display: flex;\n align-items: center;\n}\n.fields_inputWithAction .fields_input,\n.fields_inputWithAction .fields_inputInvalid {\n padding-right: 38px;\n}\n.fields_reveal {\n position: absolute;\n right: 4px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n padding: 0;\n border: none;\n border-radius: 999px;\n corner-shape: round;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.fields_reveal:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n.fields_revealOn {\n color: var(--dsw-alias-label-primary);\n}\n.fields_reveal:disabled {\n cursor: default;\n opacity: 0.4;\n}\n.fields_selectorAnchor {\n flex: none;\n}\n.fields_selector {\n display: inline-flex;\n align-items: center;\n gap: 12px;\n flex: none;\n max-width: 100%;\n height: 36px;\n padding: 0 14px;\n border: none;\n border-radius: 18px;\n background: var(--dsw-alias-bg-module-platform);\n font: inherit;\n font-size: 14px;\n line-height: 22px;\n color: var(--dsw-alias-label-primary);\n text-align: left;\n cursor: pointer;\n}\n.fields_selector:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.fields_selector:disabled {\n color: var(--dsw-alias-label-tertiary);\n cursor: default;\n}\n.fields_selectorChevron {\n flex: none;\n}\n.fields_hint {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n.fields_pairNote {\n display: flex;\n align-items: center;\n gap: 6px;\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-state-warn-primary);\n}\n.fields_pairNote::before {\n content: \"\";\n flex: none;\n width: 5px;\n height: 5px;\n border-radius: 999px;\n corner-shape: round;\n background: currentColor;\n}\n.fields_invalid {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-state-error-primary);\n}\n.fields_actions {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.fields_actionButton {\n appearance: none;\n border: 0.5px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n padding: 5px 14px;\n font: inherit;\n font-size: 13px;\n line-height: 1.5;\n cursor: pointer;\n background: none;\n color: var(--dsw-alias-label-secondary);\n}\n.fields_actionButton:hover:not(:disabled) {\n color: var(--dsw-alias-label-primary);\n border-color: var(--dsw-alias-label-dimmed);\n}\n.fields_actionButton:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.fields_actionButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n.fields_resultOk {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-state-success-primary);\n}\n.fields_resultFail {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-state-error-primary);\n}\n\n/* src/client/settings/BalanceSettingsCard.module.css */\n.BalanceSettingsCard_card {\n list-style: none;\n border: 0.5px solid var(--dsw-alias-border-l4);\n border-radius: 16px;\n background: var(--dsw-alias-bg-layer-3);\n transition: border-color .16s, background .16s;\n}\n.BalanceSettingsCard_card:hover {\n border-color: var(--dsw-alias-label-dimmed);\n}\n.BalanceSettingsCard_cardOpen {\n background: var(--dsw-alias-bg-layer-2);\n border-color: var(--dsw-alias-label-dimmed);\n}\n.BalanceSettingsCard_header {\n width: 100%;\n appearance: none;\n border: 0;\n background: none;\n font: inherit;\n color: inherit;\n text-align: left;\n cursor: pointer;\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 14px 16px;\n border-radius: 12px;\n}\n.BalanceSettingsCard_header:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: -2px;\n}\n.BalanceSettingsCard_headText {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n.BalanceSettingsCard_name {\n font-size: 15px;\n font-weight: 600;\n line-height: 1.4;\n color: var(--dsw-alias-label-primary);\n}\n.BalanceSettingsCard_description {\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n.BalanceSettingsCard_chevronWrap {\n flex: none;\n display: inline-flex;\n align-items: center;\n}\n.BalanceSettingsCard_chevron {\n color: var(--dsw-alias-label-tertiary);\n transition: transform .16s;\n}\n.BalanceSettingsCard_chevronOpen {\n transform: rotate(180deg);\n}\n.BalanceSettingsCard_pending {\n flex: none;\n}\n.BalanceSettingsCard_body {\n border-top: 0.5px solid var(--dsw-alias-border-l2);\n margin: 0 16px;\n padding-bottom: 0;\n}\n.BalanceSettingsCard_readOnly {\n margin: 12px 0 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n.BalanceSettingsCard_footer {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n padding: 12px 0;\n border-top: 0.5px solid var(--dsw-alias-border-l2);\n}\n.BalanceSettingsCard_failed {\n flex: 1;\n min-width: 0;\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-state-error-primary);\n}\n.BalanceSettingsCard_notice {\n flex: 1;\n min-width: 0;\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n.BalanceSettingsCard_discard,\n.BalanceSettingsCard_save {\n appearance: none;\n border: 0.5px solid transparent;\n border-radius: 8px;\n padding: 5px 14px;\n font: inherit;\n font-size: 13px;\n line-height: 1.5;\n cursor: pointer;\n}\n.BalanceSettingsCard_discard {\n border-color: var(--dsw-alias-border-l2);\n background: none;\n color: var(--dsw-alias-label-secondary);\n}\n.BalanceSettingsCard_discard:hover:not(:disabled) {\n color: var(--dsw-alias-label-primary);\n border-color: var(--dsw-alias-label-dimmed);\n}\n.BalanceSettingsCard_save {\n background: var(--dsw-alias-label-primary);\n color: var(--dsw-alias-bg-layer-3);\n}\n.BalanceSettingsCard_discard:disabled,\n.BalanceSettingsCard_save:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.BalanceSettingsCard_discard:focus-visible,\n.BalanceSettingsCard_save:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n/* src/client/sidebar/BalancePopover.module.css */\n.BalancePopover_panel {\n position: fixed;\n z-index: 1100;\n box-sizing: border-box;\n width: max-content;\n min-width: min(300px, calc(100vw - 24px));\n max-width: min(440px, calc(100vw - 24px));\n padding: 16px;\n border: 0;\n border-radius: 12px;\n background: var(--dsw-specific-menu);\n --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);\n box-shadow: var(--dsw-elevation-prominent);\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-secondary);\n cursor: default;\n}\n.BalancePopover_title {\n display: flex;\n justify-content: space-between;\n gap: 16px;\n margin-bottom: 8px;\n color: var(--dsw-alias-label-primary);\n font-weight: 500;\n}\n.BalancePopover_titleLabel {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n min-width: 0;\n}\n.BalancePopover_titleLabel svg {\n flex: none;\n width: 14px;\n height: auto;\n}\n.BalancePopover_titleRule {\n margin-bottom: 10px;\n border-top: 0.5px solid var(--dsw-alias-border-l2);\n}\n.BalancePopover_details {\n display: grid;\n grid-template-columns: minmax(76px, auto) minmax(0, 1fr);\n gap: 6px 16px;\n margin: 0;\n color: var(--dsw-alias-label-tertiary);\n}\n.BalancePopover_details dt,\n.BalancePopover_details dd {\n min-width: 0;\n margin: 0;\n}\n.BalancePopover_details dd {\n color: var(--dsw-alias-label-secondary);\n font-variant-numeric: tabular-nums;\n text-align: right;\n}\n.BalancePopover_notice {\n margin-top: 12px;\n}\n.BalancePopover_noticeText {\n margin: 0 0 8px;\n color: var(--dsw-alias-label-tertiary);\n}\n.BalancePopover_noticeActions {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n}\n.BalancePopover_footer {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 12px;\n}\n.BalancePopover_updated {\n flex: none;\n color: var(--dsw-alias-label-tertiary);\n}\n.BalancePopover_status {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-align: right;\n color: var(--dsw-alias-label-tertiary);\n}\n.BalancePopover_refresh {\n display: inline-flex;\n flex: none;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 0;\n border: none;\n border-radius: 999px;\n corner-shape: round;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.BalancePopover_refresh:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n.BalancePopover_refresh:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n.BalancePopover_refresh:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.BalancePopover_refresh[data-cooling] {\n color: var(--dsw-alias-label-caption);\n}\n.BalancePopover_refresh[data-refreshing] svg {\n animation: BalancePopover_balance-refresh-spin 900ms linear infinite;\n}\n@keyframes BalancePopover_balance-refresh-spin {\n to {\n transform: rotate(360deg);\n }\n}\n@media (prefers-reduced-motion: reduce) {\n .BalancePopover_refresh[data-refreshing] svg {\n animation-duration: 2400ms;\n }\n}\n\n/* src/client/sidebar/PercentRing.module.css */\n.PercentRing_ring {\n display: block;\n flex: none;\n color: var(--dsw-alias-label-tertiary);\n}\n.PercentRing_ring[data-state=done] {\n color: var(--dsw-alias-state-success-primary);\n}\n.PercentRing_ring[data-state=warning] {\n color: var(--dsw-alias-state-warn-primary);\n}\n.PercentRing_ring[data-state=error] {\n color: var(--dsw-alias-state-error-primary);\n}\n.PercentRing_track {\n fill: none;\n stroke: var(--dsw-alias-border-l3);\n stroke-width: 2;\n}\n.PercentRing_fill {\n fill: none;\n stroke: currentColor;\n stroke-width: 2;\n stroke-linecap: round;\n}\n.PercentRing_cross {\n fill: none;\n stroke: currentColor;\n stroke-width: 1.5;\n stroke-linecap: round;\n}\n\n/* src/client/sidebar/SidebarBalance.module.css */\n.SidebarBalance_root {\n position: relative;\n display: flex;\n min-width: 0;\n}\n.SidebarBalance_root[data-mode=wide] {\n flex: none;\n width: calc(100% + 4px);\n margin: 4px -2px 0;\n}\n.SidebarBalance_root[data-mode=rail] {\n flex: none;\n margin: 8px 0 0;\n}\n.SidebarBalance_trigger {\n display: flex;\n align-items: center;\n box-sizing: border-box;\n border: none;\n background: transparent;\n color: var(--dsw-alias-label-primary);\n font-family: inherit;\n font-size: 14px;\n line-height: 22px;\n cursor: pointer;\n overflow: hidden;\n}\n.SidebarBalance_trigger:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.SidebarBalance_trigger:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: -2px;\n}\n.SidebarBalance_root[data-mode=wide] .SidebarBalance_trigger {\n flex: none;\n gap: 8px;\n width: 100%;\n height: 42px;\n padding: 0 10px 0 8px;\n border-radius: 12px;\n}\n.SidebarBalance_root[data-mode=rail] .SidebarBalance_trigger {\n flex: none;\n justify-content: center;\n width: 36px;\n height: 36px;\n padding: 0;\n border-radius: 50%;\n corner-shape: round;\n}\n.SidebarBalance_icon {\n display: inline-flex;\n flex: none;\n align-items: center;\n justify-content: center;\n width: 16px;\n height: 16px;\n}\n.SidebarBalance_label {\n flex: 0 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.SidebarBalance_marker {\n display: inline-flex;\n flex: none;\n align-items: center;\n justify-content: center;\n color: var(--dsw-alias-label-tertiary);\n}\n\n/* src/client/sidebar/footer-stack.module.css */\ndiv:has(> [data-slot=\"sidebar.footer.action\"]) {\n flex-direction: column;\n}\n";
2110
2044
  var __dsbTagId = "dsh-ds-balance/client.css";
2111
2045
  if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="' + __dsbTagId + '"]') === null) {
2112
2046
  var __dsbTag = document.createElement('style');
package/lib/config.d.ts CHANGED
@@ -12,6 +12,15 @@ export declare const SETTINGS_NAMESPACE = "ds-balance";
12
12
  export declare const CURRENCY_AUTO = "auto";
13
13
  /** 官方凭据的默认引用名。与 `llm-deepseek` 的默认逐字相同。 */
14
14
  export declare const DEFAULT_API_KEY_REF = "DEEPSEEK_API_KEY";
15
+ /**
16
+ * 生效的端点基址。
17
+ *
18
+ * **空串翻译成官方默认地址的唯一一处**:界面默认留空(官方「网页搜索」卡片同款),
19
+ * 用户填了就用填的。抓取与测连接都必须走这里,不许各自判空。
20
+ * @param config - 至少含 `baseUrl` 的配置。
21
+ * @returns 可以直接拼路径的基址。
22
+ */
23
+ export declare function endpointOf(config: Pick<Config, 'baseUrl'>): string;
15
24
  /** 超时常量。UI 不暴露,改环境变量即可。 */
16
25
  export declare const DEFAULT_TIMEOUT_MS = 8000;
17
26
  /** 覆盖超时的环境变量名。 */
@@ -27,7 +36,7 @@ export interface Config {
27
36
  apiKey: string;
28
37
  /** 凭据引用名。必须匹配 `^[A-Za-z_][A-Za-z0-9_]*$`。 */
29
38
  apiKeyRef: string;
30
- /** 端点基址。**端点独立**,不继承对话适配器。 */
39
+ /** 端点基址。**端点独立**,不继承对话适配器;**留空表示用官方默认地址**。 */
31
40
  baseUrl: string;
32
41
  /** 服务端刷新频率(秒)。 */
33
42
  serverRefreshSeconds: number;
package/lib/config.js CHANGED
@@ -13,6 +13,18 @@ export const SETTINGS_NAMESPACE = 'ds-balance';
13
13
  export const CURRENCY_AUTO = 'auto';
14
14
  /** 官方凭据的默认引用名。与 `llm-deepseek` 的默认逐字相同。 */
15
15
  export const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY';
16
+ /**
17
+ * 生效的端点基址。
18
+ *
19
+ * **空串翻译成官方默认地址的唯一一处**:界面默认留空(官方「网页搜索」卡片同款),
20
+ * 用户填了就用填的。抓取与测连接都必须走这里,不许各自判空。
21
+ * @param config - 至少含 `baseUrl` 的配置。
22
+ * @returns 可以直接拼路径的基址。
23
+ */
24
+ export function endpointOf(config) {
25
+ const url = config.baseUrl.trim();
26
+ return url === '' ? DEFAULT_BASE_URL : url;
27
+ }
16
28
  /** 超时常量。UI 不暴露,改环境变量即可。 */
17
29
  export const DEFAULT_TIMEOUT_MS = 8000;
18
30
  /** 覆盖超时的环境变量名。 */
@@ -47,7 +59,7 @@ export const CONFIG_FIELDS = [
47
59
  export const Config = z.object({
48
60
  apiKey: z.string().role('secret').default(''),
49
61
  apiKeyRef: z.string().role('credential-ref').default(DEFAULT_API_KEY_REF),
50
- baseUrl: z.string().default(DEFAULT_BASE_URL),
62
+ baseUrl: z.string().default(''),
51
63
  serverRefreshSeconds: z.natural().min(10).max(3600).default(60),
52
64
  clientPollSeconds: z.natural().min(5).max(600).default(30),
53
65
  manualRefreshCooldownSeconds: z.natural().min(0).max(600).default(30),
@@ -10,7 +10,7 @@
10
10
  * @module dsh-ds-balance/http/handlers
11
11
  */
12
12
  import { classify } from '../domain/errors.js';
13
- import { CONFIG_FIELDS } from '../config.js';
13
+ import { CONFIG_FIELDS, endpointOf } from '../config.js';
14
14
  import { PLUGIN_VERSION, SCHEMA_VERSION } from '../version.js';
15
15
  import { newRequestId, toWireBalanceView, toWireError, } from './wire.js';
16
16
  /** `testConnection` 超时的可接受区间;越界一律回落默认值。 */
@@ -213,7 +213,7 @@ export async function handleTestConnection(request, deps) {
213
213
  const body = await readJsonObject(request);
214
214
  const config = deps.config.current();
215
215
  const rawBaseUrl = body?.baseUrl;
216
- const baseUrl = typeof rawBaseUrl === 'string' && rawBaseUrl.trim() !== '' ? rawBaseUrl.trim() : config.baseUrl;
216
+ const baseUrl = typeof rawBaseUrl === 'string' && rawBaseUrl.trim() !== '' ? rawBaseUrl.trim() : endpointOf(config);
217
217
  const rawTimeout = Number(body?.timeoutMs);
218
218
  const timeoutMs = Number.isFinite(rawTimeout) && rawTimeout >= TEST_TIMEOUT_RANGE.min && rawTimeout <= TEST_TIMEOUT_RANGE.max
219
219
  ? Math.trunc(rawTimeout)
@@ -10,6 +10,7 @@ import { normalize } from '../domain/normalize.js';
10
10
  import { pickBalance } from '../domain/select.js';
11
11
  import { severityOf, thresholdsFor } from '../domain/severity.js';
12
12
  import { noopMetrics } from '../ports/metrics.js';
13
+ import { endpointOf } from '../config.js';
13
14
  import { accountTag8, computeAccountTag } from './account-tag.js';
14
15
  /** 余额缓存与状态机。 */
15
16
  export class BalanceService {
@@ -127,7 +128,7 @@ export class BalanceService {
127
128
  const apiKey = await this.options.keys.resolve();
128
129
  const config = this.options.config.current();
129
130
  const raw = await this.options.client.fetchBalance({
130
- baseUrl: config.baseUrl,
131
+ baseUrl: endpointOf(config),
131
132
  apiKey,
132
133
  timeoutMs: this.options.config.timeoutMs(),
133
134
  });
@@ -34,9 +34,9 @@ export declare const zh: {
34
34
  readonly 'settings.group.refresh': "刷新";
35
35
  readonly 'settings.group.display': "展示";
36
36
  readonly 'settings.group.thresholds': "阈值";
37
- readonly 'settings.field.apiKey': "API 密钥";
37
+ readonly 'settings.field.apiKey': "API Key";
38
38
  readonly 'settings.field.apiKeyRef': "API 密钥引用名";
39
- readonly 'settings.field.baseUrl': "API 地址";
39
+ readonly 'settings.field.baseUrl': "接口地址";
40
40
  readonly 'settings.field.serverRefreshSeconds': "服务端刷新频率(秒)";
41
41
  readonly 'settings.field.clientPollSeconds': "客户端轮询频率(秒)";
42
42
  readonly 'settings.field.manualRefreshCooldownSeconds': "手动刷新冷却(秒)";
@@ -47,7 +47,7 @@ export declare const zh: {
47
47
  readonly 'settings.field.usdCritical': "USD 告急";
48
48
  readonly 'settings.hint.apiKey': "留空则改用引用名从凭据存储读取。";
49
49
  readonly 'settings.hint.apiKeyRef': "凭据存储里的条目名;非空时优先于 API Key。";
50
- readonly 'settings.hint.baseUrl': "官方端点;使用代理或镜像时修改。";
50
+ readonly 'settings.hint.baseUrl': "留空则使用提供方默认地址。";
51
51
  readonly 'settings.hint.displayCurrency': "所选币种不存在时,回落到账户实际持有的币种。";
52
52
  readonly 'settings.hint.number': "请填数字;留空表示使用默认值。";
53
53
  readonly 'settings.hint.threshold': "后端判定;界面颜色跟随返回的严重度。";
@@ -56,11 +56,9 @@ export declare const zh: {
56
56
  readonly 'settings.hint.clientPollSeconds': "刷新显示的间隔;只读本地缓存,不访问上游。";
57
57
  readonly 'settings.hint.manualRefreshCooldownSeconds': "两次手动刷新之间的最短间隔。";
58
58
  readonly 'settings.currency.auto': "自动(跟随账户)";
59
- readonly 'settings.credential.envLocked': "已由启动环境提供";
60
- readonly 'settings.hint.credential.env': "启动环境提供,只读。";
61
- readonly 'settings.hint.credential.configured': "不写入设置文件。留空表示保持当前密钥。";
62
- readonly 'settings.hint.credential.notConfigured': "尚未配置。";
63
- readonly 'settings.hint.credential.overridden': "使用自定义设置里的值,忽略继承。";
59
+ readonly 'settings.credential.configured': "已配置密钥。";
60
+ readonly 'settings.credential.notConfigured': "未配置密钥。";
61
+ readonly 'settings.hint.credential': "不写入设置文件。留空表示保持当前密钥。";
64
62
  readonly 'settings.group.customized': "自定义设置";
65
63
  readonly 'settings.test': "测试连接";
66
64
  readonly 'settings.testing': "测试中…";
@@ -91,3 +91,15 @@ export declare function ageBucket(ageMs: number): {
91
91
  bucket: AgeBucket;
92
92
  value: number;
93
93
  };
94
+ /**
95
+ * 当前年龄:上一次得知的年龄,加上此后流逝的时间。
96
+ *
97
+ * 基准必须是**收到那份响应的时刻**,不是组件挂载的时刻 ——
98
+ * 拿挂载时刻去换算 `ageMs` 的话,自动轮询带回来的新快照永远拨不回「刚刚」,
99
+ * 只有另记了时刻的手动刷新看起来才会动。
100
+ * @param seenAt - 收到那份响应的本地时刻(毫秒)。
101
+ * @param seenAgeMs - 那份响应里后端算好的年龄(毫秒)。
102
+ * @param now - 当前本地时刻(毫秒)。
103
+ * @returns 毫秒计的当前年龄;基准缺失时按 0 算。
104
+ */
105
+ export declare function currentAgeMs(seenAt: number, seenAgeMs: number, now: number): number;
@@ -97,12 +97,14 @@ export declare function TextControl(props: TextControlProps): import("react").JS
97
97
  /**
98
98
  * 只读输入:字段照常渲染,但不可编辑,**框内不写任何文字**。
99
99
  *
100
- * 不从「隐藏字段」也不从「换一块只读文本」走:仍是 `disabled` 的真输入框,
101
- * 与官方凭据字段同一形态。`readOnly` `disabled` 同时给 ——
102
- * 前者挡住程序化写入,后者给出官方的视觉与可访问语义。
100
+ * 不从「隐藏字段」也不从「换一块只读文本」走:仍是真输入框,与官方凭据字段同一形态。
101
+ * **只给 `readOnly`,不给 `disabled`** —— 官方「网页搜索」卡片里配好的密钥就是一个常态空框
102
+ * (不降透明度),置灰只属于整份设置不可写的那种情况。
103
+ * 只读的因由由标签行右侧的状态徽章与它下方的说明行承担,那两处的措辞来自词典。
103
104
  *
104
105
  * **为什么没有 placeholder**:灰字写在框里读起来像「这里该填但没填」。
105
- * 只读的因由改由标签行右侧的状态徽章与它下方的说明行承担,那两处的措辞来自词典。
106
+ * **交互一律不响应**:它看着像输入框,但悬停不换描边、指针不是插入符、点了也没有焦点环
107
+ * (`inputStatic` 那条规则,含 `pointer-events: none`)—— 看着像能编辑才是错的。
106
108
  */
107
109
  export interface ReadOnlyControlProps {
108
110
  id: string;
@@ -129,25 +131,6 @@ export interface DetailsGroupProps {
129
131
  * @returns 折叠块元素。
130
132
  */
131
133
  export declare function DetailsGroup(props: DetailsGroupProps): import("react").JSX.Element;
132
- /** 掩码输入:口令类型加一个显隐切换。 */
133
- export interface SecretControlProps {
134
- id: string;
135
- text: string;
136
- invalid: boolean;
137
- disabled: boolean;
138
- /** 当前是否明文显示。 */
139
- revealed: boolean;
140
- /** 切换显隐按钮的可访问名;语义状态走在 aria-pressed 上。 */
141
- revealLabel: string;
142
- onToggleReveal: () => void;
143
- onEdit: (text: string) => void;
144
- }
145
- /**
146
- * 渲染掩码输入与它的显隐切换。
147
- * @param props - 草稿文本、显隐状态与回调。
148
- * @returns 输入行元素。
149
- */
150
- export declare function SecretControl(props: SecretControlProps): import("react").JSX.Element;
151
134
  /** 选择器的一个选项。 */
152
135
  export interface SelectorOption {
153
136
  readonly id: string;
@@ -177,7 +177,8 @@ export interface ConfigFormApi {
177
177
  export declare const TEST_LATENCY_MS = 800;
178
178
  /**
179
179
  * 判定一次模拟连接测试是否失败。
180
- * 规则:Base URL 必须以 http:// 或 https:// 开头;API Key 与引用名不能同时为空。
180
+ * 规则:Base URL **留空合法**(走官方默认地址),非空时必须以 http:// 或 https:// 开头;
181
+ * API Key 与引用名不能同时为空。
181
182
  * 返回的是宿主诊断风格的英文短句,与官方卡片直接展示宿主诊断的做法一致。
182
183
  * @param baseUrl - 当前草稿或生效的 Base URL。
183
184
  * @param apiKey - 当前草稿或生效的 API Key。
@@ -19,10 +19,8 @@ export interface BalancePopoverProps {
19
19
  selection: CurrencySelection;
20
20
  /** 用户在设置里选的币种,用于不匹配文案。 */
21
21
  displayCurrency: string;
22
- /** 生效的抓取时刻(毫秒);模拟刷新会替换它。 */
23
- fetchedAt: number;
24
- /** 当前时刻(毫秒),由父组件按秒推进。 */
25
- now: number;
22
+ /** 数据现在的年龄(毫秒)= 收到那份响应时后端报的年龄 + 此后流逝的时间。 */
23
+ ageMs: number;
26
24
  /** 是否正在刷新。 */
27
25
  refreshing: boolean;
28
26
  /** 冷却剩余秒数;0 表示可以刷新。 */
package/lib/version.d.ts CHANGED
@@ -8,5 +8,5 @@
8
8
  /** 线上 schema 版本。响应里逐条回传,消费方据此判断兼容性。 */
9
9
  export declare const SCHEMA_VERSION = 1;
10
10
  /** 插件版本。与 `package.json` 的 `version` 一致(有测试兜底)。 */
11
- export declare const PLUGIN_VERSION = "1.0.0";
11
+ export declare const PLUGIN_VERSION = "1.1.0";
12
12
  //# sourceMappingURL=version.d.ts.map
package/lib/version.js CHANGED
@@ -8,5 +8,5 @@
8
8
  /** 线上 schema 版本。响应里逐条回传,消费方据此判断兼容性。 */
9
9
  export const SCHEMA_VERSION = 1;
10
10
  /** 插件版本。与 `package.json` 的 `version` 一致(有测试兜底)。 */
11
- export const PLUGIN_VERSION = '1.0.0';
11
+ export const PLUGIN_VERSION = '1.1.0';
12
12
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ds-balance",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "DSH plugin: shows the DeepSeek account balance at the bottom of the sidebar, with a configuration card in settings.",
5
5
  "keywords": [
6
6
  "dsh",