@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 +13 -1
- package/cordis.patch.yml +4 -1
- package/docs/CHANGELOG.md +95 -0
- package/lib/client.js +199 -59
- package/lib/index.js +80 -2
- package/package.json +3 -6
package/README.md
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
# dsh-provider-usage — DSH「用量中心」
|
|
2
2
|
|
|
3
|
-
> 当前版本 **v0.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
212
|
+
return null;
|
|
192
213
|
}
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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 () {
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
787
|
-
|
|
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(
|
|
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(
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
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
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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.
|
|
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": "
|
|
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
|
},
|