@loommii/dsh-provider-usage 0.7.0 → 0.8.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,9 +1,17 @@
1
1
  # dsh-provider-usage — DSH「用量中心」
2
2
 
3
- > 当前版本 **v0.7.0** · npm `@loommii/dsh-provider-usage` · [更新历史](docs/CHANGELOG.md)
3
+ > 当前版本 **v0.8.0** · npm `@loommii/dsh-provider-usage` · [更新历史](docs/CHANGELOG.md)
4
4
 
5
5
  DSH Web GUI 插件 **「用量中心」**:一个常驻右下角的用量卡片,实时显示你的 AI 服务用量 / 余额,不用再打开网页查;附带本地 Token 统计页,不联网也能看自己今天用了多少。
6
6
 
7
+ ## v0.8.0 更新摘要(适配 dsh 0.1.2-alpha)
8
+
9
+ - **适配 dsh `0.1.2-alpha` 破坏性重构**:修复升级后「设置页无用量中心 / 卡片消失」与
10
+ `duplicate loader entry id` 启动崩溃(根因:v0.6.0 包名迁移时 `cordis.patch.yml` 与
11
+ client 注册 id 仍用旧名,叠加 alpha 新的客户端发现机制导致静默失联)
12
+ - host 侧 API 契约、会话文件解析、平台注入(react / slots)经逐项核对在 alpha.4 全部兼容
13
+ - 完整变更与用户升级指引见 [CHANGELOG](docs/CHANGELOG.md)
14
+
7
15
  ## v0.6.0 更新摘要
8
16
 
9
17
  - **新增提供方 Command Code(订阅+余额混合卡)**:5 小时 / 周 / 月窗口使用量 + 月度剩余额度(USD),主数字为月已用百分比
@@ -72,6 +80,10 @@ dsh plugin --profile web remove @loommii/dsh-provider-usage
72
80
  ## 使用说明
73
81
 
74
82
  - 提供方实例的增删改、Key 设置都在 **设置 → 用量中心** 里完成,改动即时生效,无需重启
83
+ - 每个实例可在**编辑**里用「启用」开关管理状态:**开 = 启用(绿)/ 关 = 暂停(灰)**,保存后生效;
84
+ 新添加的提供方默认启用。暂停后该实例**不再轮询查询**(卡片 30s 自动刷新跳过它),
85
+ 卡片切换菜单里也**不可选**;暂停的若是当前卡片实例会自动切到其他启用实例,全部暂停时卡片隐藏。
86
+ 启用/暂停只影响查询与卡片选择,编辑 / 删除 / Key 配置都原样保留
75
87
  - 设置页有两个标签:「提供方」管理实例,「用量统计」查看本地 Token 统计
76
88
  - 设置保存在本机浏览器中;删掉实例不会上传或泄露任何 Key
77
89
  - 卡片拖动后的位置会自动保存,下次打开还是老位置
package/cordis.patch.yml CHANGED
@@ -1,7 +1,10 @@
1
1
  # dsh-provider-usage M1: 插入插件行(自动 reconcile 进 profile bundles)
2
+ # v0.8.0: name 使用正式包名 @loommii/dsh-provider-usage(与 package.json 一致)。
3
+ # 0.7.0 及以前误用改名前的旧名 dsh-provider-usage,在 dsh 0.1.2-alpha 上
4
+ # 只能靠 profile node_modules 里的旧名残留符号链接解析——重装后插件即失联。
2
5
  - insert:
3
6
  - id: provider-usage
4
- name: dsh-provider-usage
7
+ name: '@loommii/dsh-provider-usage'
5
8
  config:
6
9
  baseUrl: https://opencode.ai/zen/go # {{baseUrl}}/v1/usage 即官方 usage 接口
7
10
  timeoutMs: 15000
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,100 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.8.0(2026-09-03)
4
+
5
+ ### 修复:重启/存储丢失后提供方清单被静默重建(严重数据丢失)
6
+
7
+ **原先的现象**:在用量中心删除某提供方(如 OpenCode Go)后,浏览器存储里的实例清单一旦丢失
8
+ (`dsh web` 重启且访问 origin 变化、清站点数据、浏览器驱逐临时存储、换浏览器等),下次打开页面
9
+ **已删除的默认实例(OpenCode Go/DeepSeek)会凭空复活,而自定义提供方(如 Command Code,`customOnly`
10
+ 不在默认清单里)永久消失**;其私有库 Key 成为孤儿,只能重新手动添加。用户实测即「之前删了 OpenCode Go,
11
+ 重启后变成 Command Code 被删了」。
12
+
13
+ **根因**:提供方实例清单此前**只存浏览器 localStorage**(host 端零持久化),且
14
+ `loadProviders()` 在存储 key 读取失败时静默走 `DEFAULT_PROVIDERS` 兜底重建默认清单;
15
+ host 也没有任何路由能枚举私有库 Key 供启动对账。
16
+
17
+ **修复后的现象**:提供方实例清单以 host 端 `$DSH_HOME/provider-usage/providers.json` 为
18
+ **单一事实源**(0600,与 credentials.json 同边界),localStorage 降级为写穿透缓存;
19
+ 浏览器存储全部丢失后重启,清单从 host 完整恢复,删除仍然生效、自定义实例不再消失。
20
+
21
+ - **host 新增 `GET/POST /api/provider-usage/providers`**(仅回环;POST 整单覆盖保存,幂等)
22
+ - 条目逐个规范化校验:未知 adapter / 缺 id 的脏条目剔除而非整单拒绝;`paused` 严格归一为布尔;
23
+ `ref/name/source/type` 长度钳制;上限 64 个实例
24
+ - 原子写(tmp → rename)+ 0600;损坏文件改名 `providers.json.corrupt-<ts>` 留证并按缺失处理,
25
+ 不卡死恢复流程;目录缺失时自动创建
26
+ - **client 启动恢复**:`useProviders` 首个订阅者触发一次 `GET /providers` 去重恢复;
27
+ host 有清单 → 应用为当前清单;host 无文件(首次升级 0.8.0)→ 把本地缓存清单一次性写回 host 固化;
28
+ host 失联 → 继续用本地缓存(乐观值),后续写操作会重试落盘
29
+ - **恢复竞态守卫**:恢复响应迟到且期间用户已增删改实例时,放弃 host 过期数据,
30
+ 不把用户刚删的实例复活(按会话内写计数判定)
31
+ - **`setProviders` 支持函数式更新**(修复同族丢更新竞态):所有写方(删除/导入/手动添加/编辑/
32
+ 旧数据自愈)改为基于写入时刻的最新清单计算,快速连删两个实例不再复活第一个、
33
+ 自愈写入不再覆盖刚添加的实例;传数组仍兼容旧用法
34
+ - host 场景 40(路由契约/落盘/跨重启恢复/脏条目剔除/400/损坏隔离,13 断言)
35
+
36
+ ### 小功能
37
+ - **提供方支持启用 / 暂停**:设置 → 用量中心 → 提供方设置,每个实例的**编辑卡**里新增「启用」开关
38
+ - 语义:**开 = 启用(开关亮绿色)/ 关 = 暂停(灰色)**;编辑后点「保存」生效
39
+ - 新添加(导入 / 手动)的提供方**默认启用**
40
+ - 暂停后该实例**不再参与轮询查询**(卡片 30s 自动刷新与设置页打开时的状态查询都跳过它),
41
+ 卡片**切换菜单中不可选**;若暂停的正是卡片当前实例,卡片立即切到另一个启用实例,
42
+ 全部暂停时卡片隐藏;恢复启用后立即重新生效(即时持久化,无需重启/刷新),
43
+ Key 等配置原样保留
44
+ - 视觉:编辑卡内官方样式 switch(开=绿 / 关=灰)+ 状态文案;列表中暂停行名称与状态点
45
+ 置灰为「已暂停」,编辑 / 删除仍可用(启用/暂停只影响查询与卡片选择)
46
+ - 启用/暂停状态随实例清单持久化到 host `providers.json`,跨浏览器/存储丢失后同样保留
47
+
48
+ ### 兼容性修复:适配 dsh 0.1.2-alpha(破坏性变更)
49
+
50
+ 本次 dsh `0.1.1-rc.2 → 0.1.2-alpha.4` 重构后,插件出现两类症状:升级当天 `dsh web` 启动即崩
51
+ (`duplicate loader entry id: provider-usage`),重新安装后变为 **host 半区正常(7 条 API 路由全部注册)
52
+ 但 Web UI 静默失联**(设置页无「用量中心」、无常驻卡片)。两个问题的根源都在插件自身的
53
+ 「包名迁移残留」与 alpha 新的客户端发现机制不兼容,本版全部修复:
54
+
55
+ - **`cordis.patch.yml` 的 entry `name` 改为正式包名 `@loommii/dsh-provider-usage`**
56
+ - v0.6.0 包名迁移(`dsh-provider-usage` → `@loommii/dsh-provider-usage`)时漏改了此文件,
57
+ 此后一直靠 profile `node_modules` 里改名前遗留的旧名符号链接 `dsh-provider-usage → 工作区`
58
+ 才能解析到包(rc.2 环境下侥幸可用)
59
+ - alpha.4 的客户端扫描(`dsh-client-modules` node 半区 `nearestPackage`)从 loader entry
60
+ 的模块位置向上查找 package.json,且要求 `name === entry 名`;旧名在重装后解析失败 →
61
+ 包被判定为「非客户端包」→ 永不进 `window.__DSH_BOOT__` 组合图 → UI 静默消失(host 不受影响)
62
+ - 升级首日的 `duplicate loader entry id` 崩溃亦与残留链接相关(旧名/新名两条路径同时进树,
63
+ loader `EntryGroup.update` 对同 id 双行 fail-loud);清理残留链接 + 本修复后消除
64
+ - **删除 `dsh.client.inject: ["@deepseek-ai/dsh-client-runtime"]` 死引用**
65
+ - alpha 重构将 `dsh-client-runtime` 并入 `dsh-client-modules`,该包已不存在;
66
+ 插件 client 半区从未 require 过 runtime 的任何导出,声明本就多余
67
+ - 两侧加载器对 graph 外的 inject 静默跳过,故这是卫生项而非故障源
68
+ - **`lib/client.js` 的 `__ModuleLoader__.load` 注册 id 同步改为 `@loommii/dsh-provider-usage`**
69
+ - alpha 的 boot graph 行 id = 包名,`serveBundle` 按 id 应答 combo bundle;注册 id 与
70
+ graph 行不一致时模块系统找不到 factory(对官方包此场景 fail-loud,对插件即静默失联)
71
+ - **peerDependencies 放宽**:`@deepseek-ai/dsh-host-webserver` `^0.1.0-rc.6` → `>=0.1.0-rc.6`
72
+ (npm prerelease semver 规则下 `^` 不匹配 `0.1.2-alpha.*`,消除安装时 peer 警告)
73
+
74
+ ### 兼容性核对(对 dsh-v0.1.2-alpha.4 源码逐项验证)
75
+
76
+ - `ctx.webServer.register({kind:'exact'|'prefix', path, handler(req,res)})` 契约不变(`dsh-host-webserver` 健在)
77
+ - `window.__ModuleLoader__.load({id, factory})` 仍是注册协议;`exports.inject`(`['slots']`)仍被
78
+ vendored cordis Loader 消费(`Cr.resolve(n.inject)`)
79
+ - 平台 seed 词 `react` / `react-dom/client` / `@deepseek-ai/cordis` / `dsh-client-ui-slots` 全保留
80
+ (React 仍为 18.3.1)
81
+ - `settings.section` 插槽契约保留(`dsh-client-ui-settings` contract/slots.ts)
82
+ - 会话文件格式:JSONL 后端仍默认(`.jsonl.zstd`、`SESSION_FORMAT_VERSION = 0`、目录布局、
83
+ `assistant/message` → `data.usage{inputTokens,outputTokens,cacheReadTokens}` 事件行全部不变),
84
+ 本地 Token 统计不受影响;alpha.3 已移除可选 SQLite 后端,此前的前瞻风险解除
85
+ - alpha.4 新增的「web 请求一次性 fetch 审批(SSRF 防护)」为 web 客户端侧,不影响 host 侧出站查询
86
+
87
+ ### 用户升级指引(从 ≤0.7.0 升级到 0.8.0)
88
+
89
+ 若升级 dsh 后曾出现 `duplicate loader entry id: provider-usage`,profile 的 `node_modules`
90
+ 里可能残留改名前的旧名符号链接。重装插件即可清理:
91
+
92
+ ```sh
93
+ dsh plugin --profile web remove @loommii/dsh-provider-usage
94
+ dsh plugin --profile web add github:loommii/dsh-provider-usage # 或 npm: @loommii/dsh-provider-usage
95
+ # 若 node_modules 下仍有 dsh-provider-usage/(旧名,非 @loommii scope)残留目录/链接,手动删除
96
+ ```
97
+
3
98
  ## v0.7.0(2026-09-01)
4
99
 
5
100
  ### 小功能
package/lib/client.js CHANGED
@@ -3,9 +3,11 @@
3
3
  // (OpenCode Go 订阅 / DeepSeek 余额),卡片按结果字段自适应渲染(%+倒计时=订阅卡;
4
4
  // 金额+币种=余额卡)。
5
5
  // 设置项全部即时生效(localStorage 持久化,无需重启)。
6
+ // v0.8.0: 注册 id 必须等于包名 @loommii/dsh-provider-usage —— dsh 0.1.2-alpha 的
7
+ // boot graph 行 id = 包名,serveBundle 按 id 应答 bundle,不一致即客户端静默失联。
6
8
 
7
9
  window.__ModuleLoader__.load({
8
- id: 'dsh-provider-usage',
10
+ id: '@loommii/dsh-provider-usage',
9
11
  factory: (require) => {
10
12
  var module = { exports: {} };
11
13
  var exports = module.exports;
@@ -119,6 +121,20 @@ window.__ModuleLoader__.load({
119
121
  '.zGbnIq_credentialDot.dsh-pu-dot-gray{background:var(--dsw-alias-label-tertiary,#8b93a7)}',
120
122
  '@keyframes dsh-pu-ms-breathe{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.72)}}@media (prefers-reduced-motion:reduce){.dsh-pu-chip.dsh-pu-chip-loading i,.zGbnIq_credentialDot.dsh-pu-dot-loading{animation:none}}',
121
123
  '.ms-plus{color:var(--dsw-alias-brand-primary);font-size:15px;line-height:1}',
124
+ // ── 提供方「启用/暂停」开关(官方 switch 视觉;开=启用=绿 / 关=暂停=灰)──
125
+ '.dsh-pu-switch{box-sizing:border-box;width:36px;height:20px;background:var(--dsw-alias-border-l3);cursor:pointer;border:0;border-radius:10px;flex:none;padding:2px;position:relative}',
126
+ '.dsh-pu-switch:hover:not(:disabled){background:var(--dsw-alias-border-l4)}',
127
+ '.dsh-pu-switch:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}',
128
+ '.dsh-pu-switch-on{background:var(--dsw-alias-state-success-primary)}',
129
+ '.dsh-pu-switch-on:hover:not(:disabled){background:var(--dsw-alias-state-success-primary)}',
130
+ '.dsh-pu-switch-thumb{box-sizing:border-box;background:var(--dsw-alias-label-primary-foreground);border-radius:50%;width:16px;height:16px;display:block;transition:transform .12s}',
131
+ '.dsh-pu-switch-on .dsh-pu-switch-thumb{transform:translate(16px)}',
132
+ '.zGbnIq_editorSwitchRow{align-items:center;gap:10px;display:flex}',
133
+ '.dsh-pu-switchText{color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px}',
134
+ // ── 提供方列表:暂停行(灰点 + 名称变灰,编辑/删除保持可用)──
135
+ '.zGbnIq_rowNameDim{color:var(--dsw-alias-label-tertiary)}',
136
+ '.zGbnIq_rowCard .zGbnIq_credentialDot.dsh-pu-dot-paused{background:var(--dsw-alias-label-tertiary,#8b93a7)}',
137
+ '.zGbnIq_rowPaused .zGbnIq_rowIdentity .zGbnIq_rowName{color:var(--dsw-alias-label-tertiary)}',
122
138
  ].join('');
123
139
  var tag = document.createElement('style');
124
140
  tag.dataset.plugin = 'dsh-provider-usage';
@@ -169,35 +185,67 @@ window.__ModuleLoader__.load({
169
185
  return s;
170
186
  }
171
187
 
172
- // ── 供应商实例清单(localStorage,即时生效)──────────────────────
173
- function loadProviders() {
188
+ // ── 供应商实例清单(v0.8.0:host 端 providers.json 为单一事实源,localStorage 只是写穿透缓存)──
189
+ // 修复「重启后提供方清单被静默重建」:此前清单只存 localStorage,存储 key 一旦丢失
190
+ // (换 origin/清站点数据/浏览器驱逐临时存储),启动就走 DEFAULT_PROVIDERS 兜底 →
191
+ // 已删除的默认实例(OpenCode Go/DeepSeek)复活、自定义实例(如 Command Code)永久消失。
192
+ // 现在启动时从 host 恢复;host 无数据时才一次性迁移本地缓存/默认清单并立即写回 host。
193
+ function normalizeProviders(arr) {
194
+ if (!Array.isArray(arr)) return null;
195
+ return arr.filter(function (p) {
196
+ return p && typeof p === 'object' && typeof p.adapter === 'string' && ADAPTER_META[p.adapter];
197
+ }).map(function (p) {
198
+ var name = typeof p.name === 'string' && p.name ? p.name : (ADAPTER_META[p.adapter] || {}).displayName;
199
+ if (String(p.id || '') === 'deepseek-balance' && name === 'DeepSeek 余额') name = 'DeepSeek'; // 0.4.0 更名
200
+ // source:Key 解析位置(vault=插件私有库直取;dsh=DSH 凭证链);type:创建方式(import=导入 / manual=自定义)
201
+ var source = p.source === 'vault' ? 'vault' : 'dsh';
202
+ // 显式 type 优先;旧数据无 type 时由 source 推导(vault→manual,其余→import)
203
+ var type = p.type === 'manual' || p.type === 'import' ? p.type : (source === 'vault' ? 'manual' : 'import');
204
+ // paused:true=暂停(不轮询查询、卡片菜单不可选);旧数据无该字段 = false(启用)
205
+ return { id: String(p.id || ''), name: name, adapter: p.adapter, ref: typeof p.ref === 'string' ? p.ref : '', source: source, type: type, paused: p.paused === true };
206
+ });
207
+ }
208
+ function readProvidersCache() {
174
209
  try {
175
- var arr = JSON.parse(window.localStorage.getItem(PROVIDERS_KEY) || 'null');
176
- if (Array.isArray(arr)) {
177
- if (arr.length === 0) return []; // 用户删光后保持空清单,不复活默认实例
178
- return arr.filter(function (p) {
179
- return p && typeof p === 'object' && typeof p.adapter === 'string' && ADAPTER_META[p.adapter];
180
- }).map(function (p) {
181
- var name = typeof p.name === 'string' && p.name ? p.name : (ADAPTER_META[p.adapter] || {}).displayName;
182
- if (String(p.id || '') === 'deepseek-balance' && name === 'DeepSeek 余额') name = 'DeepSeek'; // 0.4.0 更名
183
- // source:Key 解析位置(vault=插件私有库直取;dsh=DSH 凭证链);type:创建方式(import=导入 / manual=自定义)
184
- var source = p.source === 'vault' ? 'vault' : 'dsh';
185
- // 显式 type 优先;旧数据无 type 时由 source 推导(vault→manual,其余→import)
186
- var type = p.type === 'manual' || p.type === 'import' ? p.type : (source === 'vault' ? 'manual' : 'import');
187
- return { id: String(p.id || ''), name: name, adapter: p.adapter, ref: typeof p.ref === 'string' ? p.ref : '', source: source, type: type };
188
- });
189
- }
210
+ return normalizeProviders(JSON.parse(window.localStorage.getItem(PROVIDERS_KEY) || 'null'));
190
211
  } catch (e) { /* ignore */ }
191
- return DEFAULT_PROVIDERS.slice();
212
+ return null;
192
213
  }
193
- var providersState = loadProviders();
214
+ function saveProvidersCache(list) {
215
+ try { window.localStorage.setItem(PROVIDERS_KEY, JSON.stringify(list)); } catch (e) { /* ignore */ }
216
+ }
217
+ // host 恢复完成前的乐观缓存:优先用 localStorage 旧数据(多数场景与 host 一致),否则默认清单
218
+ var providersState = readProvidersCache() || DEFAULT_PROVIDERS.slice();
194
219
  var providersListeners = new Set();
220
+ // 本次页面会话内的写入计数:恢复响应迟到时,若期间用户已写入则放弃应用 host 过期数据(防复活)
221
+ var providersMutations = 0;
195
222
  function saveProviders() {
196
- try { window.localStorage.setItem(PROVIDERS_KEY, JSON.stringify(providersState)); } catch (e) { /* ignore */ }
223
+ // 写穿透:localStorage 缓存 + host 落盘(fire-and-forget;host 失联时本地缓存仍可用)
224
+ saveProvidersCache(providersState);
225
+ try {
226
+ fetch('/api/provider-usage/providers', {
227
+ method: 'POST',
228
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
229
+ body: JSON.stringify({ providers: providersState }),
230
+ }).catch(function () { /* ignore */ });
231
+ } catch (e) { /* ignore */ }
232
+ }
233
+ // 内部应用(不记写入数):host 恢复/一次性迁移专用
234
+ function applyProvidersUpdate(update) {
235
+ var next = typeof update === 'function' ? update(providersState) : update;
236
+ providersState = next;
237
+ saveProviders();
238
+ providersListeners.forEach(function (fn) { fn(); });
197
239
  }
198
240
  function getProviders() { return providersState; }
199
- function setProviders(list) {
200
- providersState = list;
241
+ // v0.8.0 修复(丢更新竞态):所有写方共享同一份 providersState 快照,整体覆盖式写入
242
+ // 在写入交错时会把先写的整单覆盖掉(如连删两个实例会复活第一个)。
243
+ // 现支持函数式更新:setProviders(function (prev) { return next; }) —— next 基于写入时刻的最新状态计算;
244
+ // 兼容旧用法:传数组视为恒等函数。
245
+ function setProviders(update) {
246
+ providersMutations += 1;
247
+ var next = typeof update === 'function' ? update(providersState) : update;
248
+ providersState = next;
201
249
  saveProviders();
202
250
  providersListeners.forEach(function (fn) { fn(); });
203
251
  }
@@ -205,15 +253,43 @@ window.__ModuleLoader__.load({
205
253
  providersListeners.add(fn);
206
254
  return function () { providersListeners.delete(fn); };
207
255
  }
256
+ // host 恢复只执行一次(首个订阅者触发;fetch 去重)
257
+ var hostRestoreStarted = false;
208
258
  function useProviders() {
209
259
  var [p, setP] = useState(getProviders);
210
- useEffect(function () { return subscribeProviders(function () { setP(getProviders()); }); }, []);
260
+ useEffect(function () {
261
+ var unsub = subscribeProviders(function () { setP(getProviders()); });
262
+ if (!hostRestoreStarted) {
263
+ hostRestoreStarted = true;
264
+ fetch('/api/provider-usage/providers', { headers: { accept: 'application/json' } })
265
+ .then(function (r) { return r.json().catch(function () { return null; }); })
266
+ .then(function (j) {
267
+ if (!j || !j.ok) return;
268
+ // 恢复期间用户已做过修改(增删改实例)→ 放弃 host 过期数据,避免把用户刚删的实例复活
269
+ if (providersMutations > 0) return;
270
+ if (j.exists && Array.isArray(j.providers)) {
271
+ // 主路径:host 有清单 → 恢复(host 为单一事实源;本地缓存仅作 host 失联时的乐观值)
272
+ applyProvidersUpdate(j.providers);
273
+ } else {
274
+ // 一次性迁移:host 无文件(首次升级到 0.8.0)→ 把当前缓存清单写回 host 固化
275
+ applyProvidersUpdate(getProviders());
276
+ }
277
+ })
278
+ .catch(function () { /* host 失联:继续用本地缓存,后续写操作会重试落盘 */ });
279
+ }
280
+ return unsub;
281
+ }, []);
211
282
  return p;
212
283
  }
213
- /** 当前选中实例;若被删除则回落到第一个。 */
284
+ /** 可用(未暂停)的实例清单:卡片轮询与切换只在这些实例里进行。 */
285
+ function activeProviders(providers) {
286
+ return providers.filter(function (p) { return !p.paused; });
287
+ }
288
+ /** 当前选中实例;若被删除或已暂停则回落到第一个可用实例(全暂停/无实例 → null)。 */
214
289
  function currentProvider(providers, providerId) {
215
- for (var i = 0; i < providers.length; i++) if (providers[i].id === providerId) return providers[i];
216
- return providers[0] || null;
290
+ var actives = activeProviders(providers);
291
+ for (var i = 0; i < actives.length; i++) if (actives[i].id === providerId) return actives[i];
292
+ return actives[0] || null;
217
293
  }
218
294
 
219
295
  // ── 工具 ──────────────────────────────────────────────────────────
@@ -326,7 +402,7 @@ window.__ModuleLoader__.load({
326
402
  return function () { window.removeEventListener('resize', onResize); };
327
403
  }, [s.right, s.bottom, s.visible, data]);
328
404
  var current = currentProvider(providers, s.providerId);
329
- var multiple = providers.length > 1;
405
+ var multiple = activeProviders(providers).length > 1;
330
406
  var curKey = current ? current.id + '|' + current.adapter + '|' + current.ref : 'none';
331
407
 
332
408
  var load = useCallback(function (force) {
@@ -440,7 +516,7 @@ window.__ModuleLoader__.load({
440
516
  var menu = null;
441
517
  if (multiple && menuOpen) {
442
518
  menu = React.createElement('div', { ref: menuRef, className: 'dsh-pu-menu' },
443
- providers.map(function (p) {
519
+ activeProviders(providers).map(function (p) {
444
520
  var active = p.id === current.id;
445
521
  var mini = p.adapter === 'balance-json'
446
522
  ? React.createElement('img', { src: LOGO_DEEPSEEK, width: 16, height: 16, alt: '' })
@@ -767,6 +843,8 @@ window.__ModuleLoader__.load({
767
843
  var [queryStatus, setQueryStatus] = useState({});
768
844
  var [errOpen, setErrOpen] = useState({}); // 展开的错误详情(按实例 id)
769
845
  var refsKey = providers.map(function (p) { return p.ref || ''; }).join(',');
846
+ // 暂停态也进依赖:切「恢复/暂停」后让设置页状态查询重新跑(暂停的不查、恢复的马上查)
847
+ var refsPauseKey = providers.map(function (p) { return (p.paused ? '1' : '0') + ':' + (p.ref || ''); }).join(',');
770
848
  // 添加卡:mode = null | 'import' | 'manual';编辑卡:edit = null | { id, kind, adapter, ref }
771
849
  var [mode, setMode] = useState(null);
772
850
  var [edit, setEdit] = useState(null);
@@ -777,14 +855,22 @@ window.__ModuleLoader__.load({
777
855
  var [formError, setFormError] = useState(null);
778
856
 
779
857
  function removeProvider(p) {
780
- var next = providers.filter(function (x) { return x.id !== p.id; });
781
858
  if (p.source === 'vault' && p.ref) {
782
859
  // 删除自定义实例时同步清理插件私有库中的 Key(fire-and-forget)
783
860
  fetch('/api/provider-usage/credentials?ref=' + encodeURIComponent(p.ref), { headers: { accept: 'application/json' } })
784
861
  .catch(function () { /* ignore */ });
785
862
  }
786
- setProviders(next);
787
- if ((s.providerId || '') === p.id) setSettings({ providerId: next.length > 0 ? next[0].id : '' });
863
+ // v0.8.0:函数式更新 —— 基于写入时刻的最新清单计算,连删/交错写入不再互相覆盖
864
+ var next = null;
865
+ setProviders(function (prev) {
866
+ next = prev.filter(function (x) { return x.id !== p.id; });
867
+ return next;
868
+ });
869
+ // 若当前卡片实例被删除,回落到第一个可用实例(跳过已暂停的)
870
+ if (currentProvider(next, s.providerId || '') === null) {
871
+ var first = activeProviders(next)[0];
872
+ setSettings({ providerId: first ? first.id : '' });
873
+ }
788
874
  }
789
875
 
790
876
  function openImport() {
@@ -818,8 +904,8 @@ window.__ModuleLoader__.load({
818
904
  var item = null;
819
905
  for (var i = 0; i < dshItems.length; i++) if (dshItems[i].route === form.provider) item = dshItems[i];
820
906
  if (!item) { setFormError('导入项不存在'); return; }
821
- var inst = { id: item.route + '-' + Date.now(), name: name, adapter: item.adapter, ref: item.ref, source: 'dsh', type: 'import' };
822
- setProviders(providers.concat([inst]));
907
+ var inst = { id: item.route + '-' + Date.now(), name: name, adapter: item.adapter, ref: item.ref, source: 'dsh', type: 'import', paused: false };
908
+ setProviders(function (prev) { return prev.concat([inst]); });
823
909
  setSettings({ providerId: inst.id });
824
910
  setMode(null);
825
911
  }
@@ -840,8 +926,8 @@ window.__ModuleLoader__.load({
840
926
  });
841
927
  var j = await resp.json().catch(function () { return null; });
842
928
  if (!j || !j.ok) { setFormError((j && j.error && j.error.message) || '保存 Key 失败'); return; }
843
- var inst = { id: instId, name: name, adapter: form.adapter, ref: instId, source: 'vault', type: 'manual' };
844
- setProviders(providers.concat([inst]));
929
+ var inst = { id: instId, name: name, adapter: form.adapter, ref: instId, source: 'vault', type: 'manual', paused: false };
930
+ setProviders(function (prev) { return prev.concat([inst]); });
845
931
  setSettings({ providerId: inst.id });
846
932
  setMode(null);
847
933
  } catch (e) {
@@ -851,11 +937,12 @@ window.__ModuleLoader__.load({
851
937
  }
852
938
  }
853
939
 
854
- // ── 编辑:导入的只改名称;自定义(vault)可改名称 + 覆盖 Key ──
940
+ // ── 编辑:导入的只改名称 + 启用状态;自定义(vault)可改名称 + 覆盖 Key + 启用状态 ──
941
+ // enabled = 表单暂存值(false 表示“暂停”);开关只暂存在表单里,点「保存」才真正写入实例
855
942
  function startEdit(p) {
856
943
  setMode(null);
857
- setEdit({ id: p.id, kind: p.type === 'manual' ? 'manual' : 'import', adapter: p.adapter, ref: p.ref });
858
- setForm({ provider: '', name: p.name, adapter: p.adapter, key: '', ref: p.ref, nameTouched: true });
944
+ setEdit({ id: p.id, kind: p.type === 'manual' ? 'manual' : 'import', adapter: p.adapter, ref: p.ref, paused: !!p.paused });
945
+ setForm({ provider: '', name: p.name, adapter: p.adapter, key: '', ref: p.ref, nameTouched: true, enabled: !p.paused, _editId: p.id });
859
946
  setFormError(null);
860
947
  }
861
948
 
@@ -883,10 +970,30 @@ window.__ModuleLoader__.load({
883
970
  setBusy(false);
884
971
  }
885
972
  }
886
- setProviders(providers.map(function (p) { return p.id === edit.id ? Object.assign({}, p, { name: name }) : p; }));
973
+ // 名称 + 启用状态在同一次 setProviders 里写入(避免两次基于旧数组的写入互相覆盖)
974
+ var enabled2 = form.enabled === false ? false : true;
975
+ // v0.8.0:函数式更新 —— next 基于写入时刻的最新清单计算,不再依赖渲染闭包的旧 providers
976
+ var next = null;
977
+ setProviders(function (prev) {
978
+ next = prev.map(function (p) {
979
+ return p.id === edit.id ? Object.assign({}, p, { name: name, paused: !enabled2 }) : p;
980
+ });
981
+ return next;
982
+ });
983
+ // 卡片当前实例处理(基于更新后的 next):
984
+ // - 停用的正是当前卡片实例 → 显式切到下一个仍启用的(currentProvider 会自动回落,不能依赖它判空)
985
+ // - 恢复某实例且此前没有任何可用实例(卡片隐藏态)→ 恢复的这个直接成为卡片当前实例
986
+ if (!enabled2 && (s.providerId || '') === edit.id) {
987
+ var fallback = null;
988
+ var act = activeProviders(next);
989
+ for (var i = 0; i < act.length; i++) if (act[i].id !== edit.id) { fallback = act[i]; break; }
990
+ setSettings({ providerId: fallback ? fallback.id : '' });
991
+ } else if (enabled2 && !currentProvider(next, s.providerId || '')) {
992
+ setSettings({ providerId: edit.id });
993
+ }
887
994
  if (keyChanged) setNonce(function (n) { return n + 1; });
888
995
  setEdit(null);
889
- setForm({ provider: '', name: '', adapter: 'usage-percent', key: '', ref: '', nameTouched: false });
996
+ setForm({ provider: '', name: '', adapter: 'usage-percent', key: '', ref: '', nameTouched: false, enabled: true, _editId: null });
890
997
  }
891
998
 
892
999
  // ── 状态查询(打开设置页时:describe + 每实例实际查询)──
@@ -900,6 +1007,7 @@ window.__ModuleLoader__.load({
900
1007
  var jobs = [];
901
1008
  providers.forEach(function (p) {
902
1009
  if (!p.ref) return;
1010
+ if (p.paused) return; // 已暂停的实例不做任何查询(设置页打开时也只显示「已暂停」灰点)
903
1011
  if (configuredMap && !configuredMap[p.ref]) return;
904
1012
  var key = p.adapter + '|' + p.ref;
905
1013
  setQueryStatus(function (prev) {
@@ -941,13 +1049,16 @@ window.__ModuleLoader__.load({
941
1049
  j.refs.forEach(function (x) { map[x.name] = !!x.configured; storeMap[x.name] = x.store; });
942
1050
  setRefStatus(map);
943
1051
  // 旧数据自愈:source 非 vault 但 Key 只存在插件私有库 → 升级为手动实例(可改名称+Key)
944
- var changed = false;
945
- var next = providers.map(function (p) {
946
- if (p.source === 'vault' || !p.ref) return p;
947
- if (storeMap[p.ref] === 'vault') { changed = true; return Object.assign({}, p, { source: 'vault' }); }
948
- return p;
1052
+ // v0.8.0:函数式更新,避免覆盖恢复/添加等交错写入
1053
+ setProviders(function (prev) {
1054
+ var changed = false;
1055
+ var next = prev.map(function (p) {
1056
+ if (p.source === 'vault' || !p.ref) return p;
1057
+ if (storeMap[p.ref] === 'vault') { changed = true; return Object.assign({}, p, { source: 'vault' }); }
1058
+ return p;
1059
+ });
1060
+ return changed ? next : prev;
949
1061
  });
950
- if (changed) setProviders(next);
951
1062
  return startQueries(map, nonce > 0);
952
1063
  }
953
1064
  return startQueries(null, nonce > 0);
@@ -958,7 +1069,7 @@ window.__ModuleLoader__.load({
958
1069
  startQueries(null, nonce > 0);
959
1070
  });
960
1071
  return function () { alive = false; };
961
- }, [refsKey, nonce]);
1072
+ }, [refsKey, refsPauseKey, nonce]);
962
1073
 
963
1074
  var adapterOptions = Object.keys(ADAPTER_META).map(function (a) {
964
1075
  return React.createElement('option', { key: a, value: a }, ADAPTER_META[a].displayName);
@@ -1033,6 +1144,19 @@ window.__ModuleLoader__.load({
1033
1144
  React.createElement('button', { type: 'button', className: 'zGbnIq_primaryButton', disabled: busy, onClick: saveManual }, '保存')));
1034
1145
  } else if (edit) {
1035
1146
  var editManual = edit.kind === 'manual';
1147
+ var enabled = form.enabled === false ? false : true;
1148
+ var enableToggle = React.createElement('div', { className: 'zGbnIq_field' },
1149
+ React.createElement('span', { className: 'zGbnIq_fieldLabel' }, '启用'),
1150
+ React.createElement('label', { className: 'zGbnIq_editorSwitchRow' },
1151
+ React.createElement('button', {
1152
+ type: 'button', role: 'switch',
1153
+ 'aria-checked': enabled ? 'true' : 'false',
1154
+ 'aria-label': enabled ? ('已启用 ' + form.name + ',点击暂停') : ('已暂停 ' + form.name + ',点击启用'),
1155
+ className: 'dsh-pu-switch' + (enabled ? ' dsh-pu-switch-on' : ''),
1156
+ onClick: function () { setForm(Object.assign({}, form, { enabled: !enabled })); },
1157
+ }, React.createElement('span', { className: 'dsh-pu-switch-thumb' })),
1158
+ React.createElement('span', { className: 'dsh-pu-switchText' }, enabled ? '已启用' : '已暂停')),
1159
+ React.createElement('span', { className: 'zGbnIq_advancedHint' }, enabled ? '启用中:正常轮询查询,卡片可切换选择' : '已暂停:不再轮询查询,卡片菜单中不可选(Key 与配置保留)'));
1036
1160
  addCard = React.createElement('div', { className: 'zGbnIq_editor' },
1037
1161
  React.createElement('div', { className: 'zGbnIq_editorHeader' },
1038
1162
  React.createElement('span', { className: 'zGbnIq_editorTitle' }, editManual ? '编辑自定义提供方' : '编辑提供方')),
@@ -1048,6 +1172,7 @@ window.__ModuleLoader__.load({
1048
1172
  className: 'zGbnIq_input', value: form.name,
1049
1173
  onChange: function (e) { setForm(Object.assign({}, form, { name: e.target.value, nameTouched: true })); },
1050
1174
  })),
1175
+ enableToggle,
1051
1176
  editManual ? React.createElement('div', { className: 'zGbnIq_field' },
1052
1177
  React.createElement('span', { className: 'zGbnIq_fieldLabel' }, 'API 密钥'),
1053
1178
  React.createElement('input', {
@@ -1094,7 +1219,8 @@ window.__ModuleLoader__.load({
1094
1219
  : providers.map(function (pl) {
1095
1220
  var qs = pl.ref ? queryStatus[pl.adapter + '|' + pl.ref] : null;
1096
1221
  var refKnownRow = pl.ref ? Object.prototype.hasOwnProperty.call(refStatus, pl.ref) : true;
1097
- var status = pl.ref ? statusOf(refKnownRow, refCheckFailed, !!refStatus[pl.ref], qs) : null;
1222
+ // 已暂停的实例不再轮询/查询,状态点固定为「已暂停」灰点,查询状态一律不展示
1223
+ var status = (pl.paused || !pl.ref) ? null : statusOf(refKnownRow, refCheckFailed, !!refStatus[pl.ref], qs);
1098
1224
  return React.createElement(ProviderRow, {
1099
1225
  key: pl.id,
1100
1226
  provider: pl,
@@ -1142,7 +1268,7 @@ window.__ModuleLoader__.load({
1142
1268
  usagePanel);
1143
1269
  }
1144
1270
  // 提供方单行:状态点 + 名称 + 编辑/删除 + 展开详情
1145
- // 状态判定全部交给 statusOf;本组件只做渲染
1271
+ // 状态判定全部交给 statusOf;本组件只做渲染;启用/暂停开关在编辑弹层里管理
1146
1272
  function ProviderRow(props) {
1147
1273
  var pl = props.provider;
1148
1274
  var status = props.status;
@@ -1150,24 +1276,36 @@ window.__ModuleLoader__.load({
1150
1276
  var onToggle = props.onToggle;
1151
1277
  var onEdit = props.onEdit;
1152
1278
  var onRemove = props.onRemove;
1153
- var dotEl = status.dotCls ? React.createElement('span', {
1154
- className: 'zGbnIq_credentialDot' + status.dotCls,
1155
- role: 'img', 'aria-label': status.label,
1156
- style: status.clickable ? { cursor: 'pointer' } : undefined,
1157
- onClick: status.clickable ? onToggle : undefined,
1158
- }) : null;
1279
+ var paused = pl.paused === true;
1280
+ var dotEl;
1281
+ if (paused) {
1282
+ // 已暂停:灰点「已暂停」,不可展开(暂停时不再查询,无新鲜结果/错误可看)
1283
+ dotEl = React.createElement('span', {
1284
+ className: 'zGbnIq_credentialDot dsh-pu-dot-paused',
1285
+ role: 'img', 'aria-label': '已暂停',
1286
+ });
1287
+ } else if (status && status.dotCls) {
1288
+ dotEl = React.createElement('span', {
1289
+ className: 'zGbnIq_credentialDot' + status.dotCls,
1290
+ role: 'img', 'aria-label': status.label,
1291
+ style: status.clickable ? { cursor: 'pointer' } : undefined,
1292
+ onClick: status.clickable ? onToggle : undefined,
1293
+ });
1294
+ } else {
1295
+ dotEl = null;
1296
+ }
1159
1297
  var detailEl = null;
1160
- if (expanded && status.detailKind) {
1298
+ if (!paused && expanded && status && status.detailKind) {
1161
1299
  if (status.detailKind === 'err') {
1162
1300
  detailEl = React.createElement('p', { className: 'zGbnIq_error', style: { margin: '10px 0 0' } }, status.detailText);
1163
1301
  } else if (status.detailKind === 'resp') {
1164
1302
  detailEl = React.createElement('div', { className: 'zGbnIq_json' }, status.detailText);
1165
1303
  }
1166
1304
  }
1167
- return React.createElement('li', { key: pl.id, className: 'zGbnIq_rowCard' },
1305
+ return React.createElement('li', { key: pl.id, className: 'zGbnIq_rowCard' + (paused ? ' zGbnIq_rowPaused' : '') },
1168
1306
  React.createElement('div', { className: 'zGbnIq_rowHead' },
1169
1307
  React.createElement('span', { className: 'zGbnIq_rowIdentity' },
1170
- React.createElement('span', { className: 'zGbnIq_rowName' }, pl.name),
1308
+ React.createElement('span', { className: 'zGbnIq_rowName' + (paused ? ' zGbnIq_rowNameDim' : '') }, pl.name),
1171
1309
  dotEl),
1172
1310
  React.createElement('span', { className: 'zGbnIq_rowActions' },
1173
1311
  React.createElement('button', { type: 'button', className: 'zGbnIq_secondaryButton', onClick: onEdit }, '编辑'),
@@ -1196,6 +1334,8 @@ window.__ModuleLoader__.load({
1196
1334
  try {
1197
1335
  if (!getSettings().visible) return; // 用户主动隐藏,不自动复活
1198
1336
  if (getProviders().length === 0) return; // 无提供方实例:卡片本就渲染 null,避免 10s 空转重挂
1337
+ // 全部已暂停同理:currentProvider 回落为 null → 卡片渲染 null,无需重挂
1338
+ if (!currentProvider(getProviders(), getSettings().providerId)) return;
1199
1339
  if (rootEl && rootEl.isConnected && document.querySelector('.dsh-pu-card')) return;
1200
1340
  mount();
1201
1341
  } catch (e) {
package/lib/index.js CHANGED
@@ -4,14 +4,15 @@
4
4
  // balance-json(余额型) :DeepSeek {{baseUrl}}/user/balance → balance_infos[]
5
5
  // 凭证链 per provider:DSH 设置 provider.apiKeyEnv → 凭证服务 → 环境变量。
6
6
  // 路由:/api/provider-usage/opencode-go(兼容旧版)、/api/provider-usage/query?provider=<id>、
7
- // /api/provider-usage/templates / credentials / dsh-providers / credential-refs / reset-local-stats(仅回环)。
7
+ // /api/provider-usage/templates / credentials / dsh-providers / credential-refs / reset-local-stats(仅回环)、
8
+ // /api/provider-usage/providers(提供方实例清单持久化,v0.8.0 —— host 端 providers.json 为单一事实源)。
8
9
  // 本地 Token 统计(v0.5.0):/api/provider-usage/local-usage(累计+按模型汇总,按天物化存储),
9
10
  // 数据源 = 自读 $DSH_HOME/sessions 会话文件(fzstd 解压,assistant/message 事件 data.usage),只读、不联网;
10
11
  // 存储 = $DSH_HOME/provider-usage/daily-stats/YYYY-MM-DD.json:历史天封存(deps mtime 校验),只算今天。
11
12
  // 缓存:每 provider 独立 30s 新鲜窗口 + 并发去重 + 3 快照环;stale-while-error。
12
13
 
13
14
  import { join, dirname } from 'node:path'
14
- import { stat as fsStat } from 'node:fs/promises'
15
+ import { stat as fsStat, readFile, writeFile, rename, chmod, mkdir } from 'node:fs/promises'
15
16
  import { readFileSync } from 'node:fs'
16
17
  import { createSecureStore, resolveProviderUsageDir } from './secure-store.js'
17
18
  import * as daily from './daily-stats.js'
@@ -406,12 +407,67 @@ export function apply(ctx, rawConfig) {
406
407
  ? rawConfig.sessionsDir
407
408
  : join(dirname(resolveProviderUsageDir()), 'sessions'),
408
409
  dailyDir: join(resolveProviderUsageDir(), 'daily-stats'),
410
+ // 提供方实例清单(v0.8.0):host 端单一事实源(localStorage 只是缓存),防重启后清单被静默重建
411
+ providersFile: join(resolveProviderUsageDir(), 'providers.json'),
409
412
  }
410
413
 
411
414
  // cordis 配置的 baseUrl 仅覆盖 opencode-go(历史语义),其余 preset 用内置 URL(插件自维护)
412
415
  const effectiveBaseUrl = (cfg) =>
413
416
  cfg.id === 'usage-percent' && config.baseUrlOverride ? config.baseUrlOverride : cfg.baseUrl
414
417
 
418
+ // ── 提供方实例清单持久化(v0.8.0)─
419
+ // 修复「重启后提供方清单被静默重建」:实例清单此前只存浏览器 localStorage,
420
+ // 存储 key 丢失(换 origin/清站点数据/浏览器驱逐)后 loadProviders() 走 DEFAULT_PROVIDERS
421
+ // 兜底 → 已删除的默认实例复活、自定义实例(如 Command Code)永久消失。
422
+ // 现在 host 端 providers.json 为单一事实源;客户端启动时从 host 恢复,写操作同步落盘。
423
+ const PROVIDER_ADAPTER_IDS = new Set(ADAPTERS.map((a) => a.id))
424
+ const PROVIDERS_MAX = 64
425
+
426
+ /** 规范并校验一个提供方实例(不合法字段一律剔除,而非整单拒绝——避免脏数据把恢复流程卡死)。 */
427
+ function normalizeProviderEntry(raw) {
428
+ if (!raw || typeof raw !== 'object') return null
429
+ if (typeof raw.adapter !== 'string' || !PROVIDER_ADAPTER_IDS.has(raw.adapter)) return null
430
+ const id = typeof raw.id === 'string' && raw.id.trim() !== '' ? raw.id.trim().slice(0, 128) : null
431
+ if (!id) return null
432
+ const name = typeof raw.name === 'string' && raw.name.trim() !== '' ? raw.name.trim().slice(0, 64) : ''
433
+ const ref = typeof raw.ref === 'string' ? raw.ref.slice(0, 128) : ''
434
+ const source = raw.source === 'vault' ? 'vault' : 'dsh'
435
+ const type = raw.type === 'manual' || raw.type === 'import' ? raw.type : (source === 'vault' ? 'manual' : 'import')
436
+ return { id, name, adapter: raw.adapter, ref, source, type, paused: raw.paused === true }
437
+ }
438
+
439
+ /** 读清单:providers.json 缺失/损坏 → null(调用方决定兜底,损坏文件同时改名留证)。 */
440
+ async function readProvidersDoc() {
441
+ let raw
442
+ try {
443
+ raw = await readFile(config.providersFile, 'utf8')
444
+ } catch (e) {
445
+ if (e && e.code === 'ENOENT') return null
446
+ return null // 其它读错误按缺失处理
447
+ }
448
+ try {
449
+ const doc = JSON.parse(raw)
450
+ if (!doc || doc.version !== 1 || !Array.isArray(doc.providers)) throw new Error('bad doc')
451
+ const providers = doc.providers.map(normalizeProviderEntry).filter(Boolean)
452
+ return { version: 1, providers }
453
+ } catch (e) {
454
+ // 损坏文件改名留证(.corrupt-<ts>),下次写盘重建;绝不静默吞掉用户清单
455
+ try { await rename(config.providersFile, config.providersFile + '.corrupt-' + Date.now()) } catch { /* ignore */ }
456
+ return null
457
+ }
458
+ }
459
+
460
+ /** 写清单:原子写(tmp → rename)+ 0600(与 credentials.json 同款边界);目录缺失时先建(0700)。 */
461
+ async function writeProvidersDoc(providers) {
462
+ const doc = { version: 1, providers, savedAt: Date.now() }
463
+ await mkdir(dirname(config.providersFile), { recursive: true, mode: 0o700 }).catch(() => {})
464
+ const tmp = config.providersFile + '.tmp'
465
+ await writeFile(tmp, JSON.stringify(doc, null, 2), { mode: 0o600 })
466
+ await chmod(tmp, 0o600)
467
+ try { await rename(tmp, config.providersFile) } catch (e) { await writeFile(config.providersFile, JSON.stringify(doc, null, 2), { mode: 0o600 }) }
468
+ return doc
469
+ }
470
+
415
471
  // 私有加密凭证库(方案 B,懒初始化:首次使用时生成密钥)
416
472
  let secure = null
417
473
  async function secureStore() {
@@ -1154,6 +1210,28 @@ export function apply(ctx, rawConfig) {
1154
1210
  return { ok: true, refs }
1155
1211
  }),
1156
1212
  }),
1213
+ // 提供方实例清单持久化(v0.8.0)─:host 端 providers.json 为单一事实源。
1214
+ // GET 读清单(缺失返回 ok:true + items:null,由客户端走一次性迁移);
1215
+ // POST 整单覆盖保存(幂等)。仅回环,条目逐个规范化,不合法条目剔除。
1216
+ ctx.webServer.register({
1217
+ kind: 'exact',
1218
+ path: '/api/provider-usage/providers',
1219
+ handler: jsonRoute(async (req, body) => {
1220
+ if (req.method === 'POST') {
1221
+ if (!body || !Array.isArray(body.providers)) {
1222
+ return { ok: false, error: { type: 'bad-providers', message: 'providers 必须是数组', httpStatus: 400 } }
1223
+ }
1224
+ if (body.providers.length > PROVIDERS_MAX) {
1225
+ return { ok: false, error: { type: 'too-many-providers', message: '提供方实例最多 ' + PROVIDERS_MAX + ' 个', httpStatus: 400 } }
1226
+ }
1227
+ const providers = body.providers.map(normalizeProviderEntry).filter(Boolean)
1228
+ await writeProvidersDoc(providers)
1229
+ return { ok: true, providers }
1230
+ }
1231
+ const doc = await readProvidersDoc()
1232
+ return { ok: true, providers: doc ? doc.providers : null, exists: doc !== null }
1233
+ }, true),
1234
+ }),
1157
1235
  ]
1158
1236
  return () => { for (const dispose of disposers) { if (typeof dispose === 'function') dispose() } }
1159
1237
  }, 'provider-usage: routes')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loommii/dsh-provider-usage",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "DSH 插件(用量中心):常驻用量卡片 · 多提供方用量/余额查询(OpenCode Go 订阅 + DeepSeek 余额 + Command Code 订阅/余额,对齐 cc-switch 查询语义)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -48,15 +48,12 @@
48
48
  "patch": "./cordis.patch.yml"
49
49
  },
50
50
  "client": {
51
- "platform": "web",
52
- "inject": [
53
- "@deepseek-ai/dsh-client-runtime"
54
- ]
51
+ "platform": "web"
55
52
  }
56
53
  },
57
54
  "peerDependencies": {
58
55
  "@deepseek-ai/cordis": "^4.0.1",
59
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
56
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.6",
60
57
  "react": "^18.2.0",
61
58
  "react-dom": "^18.2.0"
62
59
  },