dsh-desktop-statusbar 2.0.1 → 2.0.2
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.en.md +1 -1
- package/README.md +1 -1
- package/lib/balance-api.js +224 -0
- package/lib/client.js +401 -138
- package/lib/index.js +88 -2
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<a href="https://github.com/raphael-y7/dsh-desktop-statusbar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/raphael-y7/dsh-desktop-statusbar?style=social"></a>
|
|
5
5
|
<a href="https://www.npmjs.com/package/dsh-desktop-statusbar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-desktop-statusbar?color=red"></a>
|
|
6
6
|
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-PolyForm%20Noncommercial%201.0.0-green"></a>
|
|
7
|
-
<a href="https://github.com/deepseek-ai/deepseek-harness"><img alt="DeepSeek Harness" src="https://img.shields.io/badge/DeepSeek%20Harness-0.1
|
|
7
|
+
<a href="https://github.com/deepseek-ai/deepseek-harness"><img alt="DeepSeek Harness" src="https://img.shields.io/badge/DeepSeek%20Harness-0.2.0--rc.1-%234d6bfe"></a>
|
|
8
8
|
<a href="https://nodejs.org/"><img alt="node" src="https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-%235fa04e"></a>
|
|
9
9
|
<img alt="language" src="https://img.shields.io/github/languages/top/raphael-y7/dsh-desktop-statusbar?color=yellow">
|
|
10
10
|
<a href="https://dshfind.com/zh/plugins/raphael-y7/dsh-desktop-statusbar"><img alt="dshfind" src="https://img.shields.io/badge/dshfind-listed-%2300a884"></a>
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<a href="https://github.com/raphael-y7/dsh-desktop-statusbar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/raphael-y7/dsh-desktop-statusbar?style=social"></a>
|
|
5
5
|
<a href="https://www.npmjs.com/package/dsh-desktop-statusbar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-desktop-statusbar?color=red"></a>
|
|
6
6
|
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-PolyForm%20Noncommercial%201.0.0-green"></a>
|
|
7
|
-
<a href="https://github.com/deepseek-ai/deepseek-harness"><img alt="DeepSeek Harness" src="https://img.shields.io/badge/DeepSeek%20Harness-0.1
|
|
7
|
+
<a href="https://github.com/deepseek-ai/deepseek-harness"><img alt="DeepSeek Harness" src="https://img.shields.io/badge/DeepSeek%20Harness-0.2.0--rc.1-%234d6bfe"></a>
|
|
8
8
|
<a href="https://nodejs.org/"><img alt="node" src="https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-%235fa04e"></a>
|
|
9
9
|
<img alt="language" src="https://img.shields.io/github/languages/top/raphael-y7/dsh-desktop-statusbar?color=yellow">
|
|
10
10
|
<a href="https://dshfind.com/zh/plugins/raphael-y7/dsh-desktop-statusbar"><img alt="dshfind" src="https://img.shields.io/badge/dshfind-listed-%2300a884"></a>
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 多平台余额:把「当前会话用的模型/provider」映射到「该平台的余额接口 + 响应解析」。
|
|
3
|
+
*
|
|
4
|
+
* 只收录**已核实过官方文档**的平台;每家的端点、鉴权、返回字段都写在注释里,方便日后复核。
|
|
5
|
+
* 本模块只做纯逻辑(选适配器、拼请求、解析响应),真正发请求由 index.js 负责 —— 这样能单测。
|
|
6
|
+
*
|
|
7
|
+
* 核实时间:2026-09。未核实到的平台宁可不写,也不猜端点。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/* 余额响应统一成这个形状:
|
|
11
|
+
* { currency: 'CNY' | 'USD' | ..., total: '12.34', parts: [{ label, amount }] }
|
|
12
|
+
* currency 缺失时由调用方按站点/域名推断;parts 用来填气泡里的构成行(充值 / 赠金等)。 */
|
|
13
|
+
|
|
14
|
+
const adapters = [
|
|
15
|
+
{
|
|
16
|
+
id: 'deepseek',
|
|
17
|
+
label: 'DeepSeek',
|
|
18
|
+
/* 核实:GET https://api.deepseek.com/user/balance,Authorization: Bearer <key>
|
|
19
|
+
响应 { balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }] } */
|
|
20
|
+
keys: ['deepseek', 'deepseek-official'],
|
|
21
|
+
refs: ['DEEPSEEK_API_KEY', 'deepseek-official', 'deepseek'],
|
|
22
|
+
request: (key) => ({
|
|
23
|
+
url: 'https://api.deepseek.com/user/balance',
|
|
24
|
+
headers: { authorization: 'Bearer ' + key, accept: 'application/json' }
|
|
25
|
+
}),
|
|
26
|
+
parse: (data) => {
|
|
27
|
+
const infos = Array.isArray(data && data.balance_infos) ? data.balance_infos : [];
|
|
28
|
+
if (infos.length === 0) return null;
|
|
29
|
+
const one = infos[0];
|
|
30
|
+
const total = typeof one.total_balance === 'string' ? one.total_balance : null;
|
|
31
|
+
if (total === null || total.length === 0) return null;
|
|
32
|
+
const parts = [];
|
|
33
|
+
if (typeof one.topped_up_balance === 'string') parts.push({ label: 'toppedUp', amount: one.topped_up_balance });
|
|
34
|
+
if (typeof one.granted_balance === 'string') parts.push({ label: 'granted', amount: one.granted_balance });
|
|
35
|
+
return { currency: typeof one.currency === 'string' ? one.currency : 'CNY', total, parts };
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'openrouter',
|
|
40
|
+
label: 'OpenRouter',
|
|
41
|
+
/* 核实:GET https://openrouter.ai/api/v1/credits,Authorization: Bearer <key>
|
|
42
|
+
响应 { data: { total_credits, total_usage } },可用额度 = total_credits - total_usage */
|
|
43
|
+
keys: ['openrouter'],
|
|
44
|
+
refs: ['OPENROUTER_API_KEY'],
|
|
45
|
+
request: (key) => ({
|
|
46
|
+
url: 'https://openrouter.ai/api/v1/credits',
|
|
47
|
+
headers: { authorization: 'Bearer ' + key, accept: 'application/json' }
|
|
48
|
+
}),
|
|
49
|
+
parse: (data) => {
|
|
50
|
+
const inner = data && data.data ? data.data : null;
|
|
51
|
+
if (inner === null) return null;
|
|
52
|
+
const total = Number(inner.total_credits);
|
|
53
|
+
const used = Number(inner.total_usage);
|
|
54
|
+
if (Number.isFinite(total) !== true) return null;
|
|
55
|
+
const left = total - (Number.isFinite(used) ? used : 0);
|
|
56
|
+
return {
|
|
57
|
+
currency: 'USD',
|
|
58
|
+
total: left.toFixed(2),
|
|
59
|
+
parts: [
|
|
60
|
+
{ label: 'toppedUp', amount: total.toFixed(2) },
|
|
61
|
+
{ label: 'used', amount: (Number.isFinite(used) ? used : 0).toFixed(2) }
|
|
62
|
+
]
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'bai',
|
|
68
|
+
label: 'B.AI',
|
|
69
|
+
/* 核实:文档 docs.b.ai/llmservice/api/balance/ 给出 GET /v1/balance,
|
|
70
|
+
鉴权 Authorization: Bearer <api_key>,响应含 balance 字段。
|
|
71
|
+
注:文档站是 docs.b.ai,API 域名按 api.b.ai 拼接,未经真实调用验证。 */
|
|
72
|
+
keys: ['b-ai', 'bai', 'b.ai'],
|
|
73
|
+
refs: ['B_AI_API_KEY', 'BAI_API_KEY'],
|
|
74
|
+
request: (key) => ({
|
|
75
|
+
url: 'https://api.b.ai/v1/balance',
|
|
76
|
+
headers: { authorization: 'Bearer ' + key, accept: 'application/json' }
|
|
77
|
+
}),
|
|
78
|
+
parse: (data) => {
|
|
79
|
+
const raw = data && (data.balance ?? (data.data ? data.data.balance : undefined));
|
|
80
|
+
if (raw === undefined || raw === null) return null;
|
|
81
|
+
const total = String(raw);
|
|
82
|
+
if (total.length === 0) return null;
|
|
83
|
+
return { currency: 'CNY', total, parts: [] };
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'siliconflow',
|
|
88
|
+
label: 'SiliconFlow',
|
|
89
|
+
/* 核实:GET https://api.siliconflow.com/v1/user/info(国内站 api.siliconflow.cn),
|
|
90
|
+
Authorization: Bearer <key>;响应 data.{balance, chargeBalance, totalBalance}(均为字符串)。
|
|
91
|
+
官方未声明币种,国际站按 USD、国内站按 CNY 处理。 */
|
|
92
|
+
keys: ['siliconflow'],
|
|
93
|
+
refs: ['SILICONFLOW_API_KEY'],
|
|
94
|
+
request: (key, base) => ({
|
|
95
|
+
url: (base || 'https://api.siliconflow.com') + '/v1/user/info',
|
|
96
|
+
headers: { authorization: 'Bearer ' + key, accept: 'application/json' }
|
|
97
|
+
}),
|
|
98
|
+
parse: (data) => {
|
|
99
|
+
const inner = data && data.data ? data.data : null;
|
|
100
|
+
if (inner === null) return null;
|
|
101
|
+
const total = typeof inner.totalBalance === 'string' ? inner.totalBalance : null;
|
|
102
|
+
if (total === null || total.length === 0) return null;
|
|
103
|
+
const parts = [];
|
|
104
|
+
if (typeof inner.chargeBalance === 'string') parts.push({ label: 'toppedUp', amount: inner.chargeBalance });
|
|
105
|
+
if (typeof inner.balance === 'string') parts.push({ label: 'granted', amount: inner.balance });
|
|
106
|
+
return { currency: 'USD', total, parts };
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: 'moonshot',
|
|
111
|
+
label: 'Kimi',
|
|
112
|
+
/* 核实:GET https://api.moonshot.ai/v1/users/me/balance(国内站 api.moonshot.cn),
|
|
113
|
+
Authorization: Bearer <key>;响应 { code, data: { available_balance, voucher_balance, cash_balance } }。
|
|
114
|
+
两站的 key 不通用,单位也不同:国际站 USD、国内站 CNY。 */
|
|
115
|
+
keys: ['moonshot', 'kimi'],
|
|
116
|
+
refs: ['MOONSHOT_API_KEY', 'KIMI_API_KEY'],
|
|
117
|
+
request: (key, base) => ({
|
|
118
|
+
url: (base || 'https://api.moonshot.ai') + '/v1/users/me/balance',
|
|
119
|
+
headers: { authorization: 'Bearer ' + key, accept: 'application/json' }
|
|
120
|
+
}),
|
|
121
|
+
parse: (data) => {
|
|
122
|
+
const inner = data && data.data ? data.data : null;
|
|
123
|
+
if (inner === null) return null;
|
|
124
|
+
const total = inner.available_balance;
|
|
125
|
+
if (typeof total !== 'number' || Number.isFinite(total) !== true) return null;
|
|
126
|
+
const parts = [];
|
|
127
|
+
if (typeof inner.cash_balance === 'number') parts.push({ label: 'toppedUp', amount: inner.cash_balance.toFixed(2) });
|
|
128
|
+
if (typeof inner.voucher_balance === 'number') parts.push({ label: 'granted', amount: inner.voucher_balance.toFixed(2) });
|
|
129
|
+
return { currency: 'USD', total: total.toFixed(2), parts };
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: 'zhipu',
|
|
134
|
+
label: 'GLM 余额',
|
|
135
|
+
/* 来源:cc-switch PR #6978 的自定义模板(截至 2026-09-27 未合并,社区实测方案)
|
|
136
|
+
GET https://open.bigmodel.cn/api/biz/account/query-customer-account-report
|
|
137
|
+
鉴权:Authorization: Bearer <api_key> —— 注意与 coding plan 配额的「裸 key」不同
|
|
138
|
+
响应 { code, msg, data: { availableBalance, balance, rechargeAmount, totalSpendAmount } }
|
|
139
|
+
code === 200 才算成功;可用余额 = availableBalance ?? balance,单位 CNY。 */
|
|
140
|
+
keys: ['zhipu', 'glm', 'bigmodel'],
|
|
141
|
+
refs: ['ZAI_API_KEY', 'ZHIPU_API_KEY', 'GLM_API_KEY', 'BIGMODEL_API_KEY'],
|
|
142
|
+
request: (key) => ({
|
|
143
|
+
/* 实测 2026-09-27:open.bigmodel.cn 与 api.z.ai 同后端,两个域名 + Bearer/裸 key 四种组合都返回 code=200 */
|
|
144
|
+
url: 'https://open.bigmodel.cn/api/biz/account/query-customer-account-report',
|
|
145
|
+
headers: { authorization: 'Bearer ' + key, accept: 'application/json' }
|
|
146
|
+
}),
|
|
147
|
+
parse: (data) => {
|
|
148
|
+
if (data === null || data === undefined) return null;
|
|
149
|
+
if (Number(data.code) !== 200) return null;
|
|
150
|
+
const inner = data.data ? data.data : {};
|
|
151
|
+
const raw = inner.availableBalance !== undefined && inner.availableBalance !== null ? inner.availableBalance : inner.balance;
|
|
152
|
+
const remaining = Number(raw);
|
|
153
|
+
if (Number.isFinite(remaining) !== true) return null;
|
|
154
|
+
const parts = [];
|
|
155
|
+
const recharge = Number(inner.rechargeAmount);
|
|
156
|
+
const gift = Number(inner.giveAmount);
|
|
157
|
+
const spent = Number(inner.totalSpendAmount);
|
|
158
|
+
if (Number.isFinite(recharge)) parts.push({ label: 'toppedUp', amount: recharge.toFixed(2) });
|
|
159
|
+
if (Number.isFinite(gift)) parts.push({ label: 'granted', amount: gift.toFixed(2) });
|
|
160
|
+
if (Number.isFinite(spent)) parts.push({ label: 'spent', amount: spent.toFixed(2) });
|
|
161
|
+
return { currency: 'CNY', total: remaining.toFixed(2), parts };
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: 'zhipu-quota',
|
|
166
|
+
label: 'GLM 配额',
|
|
167
|
+
/* 核实:cc-switch v3.20.4 的 src-tauri/src/services/coding_plan.rs:332-346
|
|
168
|
+
GET https://open.bigmodel.cn/api/monitor/usage/quota/limit(国际站 https://api.z.ai)
|
|
169
|
+
鉴权:Authorization: <api_key> —— 智谱**不加 Bearer 前缀**(源码注释两次强调)
|
|
170
|
+
响应 { success, data: { level, limits: [{ type: TOKENS_LIMIT|CREDIT_LIMIT, percentage, ... }] } }
|
|
171
|
+
percentage 就是配额使用率,直接当百分比显示,**不换算金额**。注意这是 coding plan
|
|
172
|
+
配额,不是按量计费的现金余额 —— 现金余额只有控制台接口能给,需要网页登录态。 */
|
|
173
|
+
keys: ['zhipu', 'glm', 'bigmodel'],
|
|
174
|
+
refs: ['ZHIPU_API_KEY', 'GLM_API_KEY', 'BIGMODEL_API_KEY'],
|
|
175
|
+
request: (key, base) => ({
|
|
176
|
+
url: (base || 'https://open.bigmodel.cn') + '/api/monitor/usage/quota/limit',
|
|
177
|
+
headers: {
|
|
178
|
+
authorization: key,
|
|
179
|
+
'content-type': 'application/json',
|
|
180
|
+
'accept-language': 'en-US,en'
|
|
181
|
+
}
|
|
182
|
+
}),
|
|
183
|
+
parse: (data) => {
|
|
184
|
+
if (data === null || data === undefined || data.success === false) return null;
|
|
185
|
+
const inner = data.data ? data.data : null;
|
|
186
|
+
const limits = inner !== null && Array.isArray(inner.limits) ? inner.limits : [];
|
|
187
|
+
let percent = null;
|
|
188
|
+
for (let i = 0; i < limits.length; i += 1) {
|
|
189
|
+
const one = limits[i];
|
|
190
|
+
const kind = typeof one.type === 'string' ? one.type.toUpperCase() : '';
|
|
191
|
+
if (kind !== 'TOKENS_LIMIT' && kind !== 'CREDIT_LIMIT') continue;
|
|
192
|
+
const value = Number(one.percentage);
|
|
193
|
+
if (Number.isFinite(value) !== true) continue;
|
|
194
|
+
/* 取用得最紧的那个窗口,保守一点 */
|
|
195
|
+
if (percent === null || value > percent) percent = value;
|
|
196
|
+
}
|
|
197
|
+
if (percent === null) return null;
|
|
198
|
+
return { currency: '', total: percent.toFixed(2) + '%', parts: [], isPercent: true };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
/** 会话线索(provider + 模型名)里出现这些词就认为是哪一家。 */
|
|
204
|
+
function adapterFor(provider, model) {
|
|
205
|
+
const text = (String(provider === undefined || provider === null ? '' : provider) + ' '
|
|
206
|
+
+ String(model === undefined || model === null ? '' : model)).toLowerCase();
|
|
207
|
+
if (text.trim().length === 0) return null;
|
|
208
|
+
for (let i = 0; i < adapters.length; i += 1) {
|
|
209
|
+
const one = adapters[i];
|
|
210
|
+
for (let k = 0; k < one.keys.length; k += 1) {
|
|
211
|
+
if (text.includes(one.keys[k])) return one;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 会话模型属于 DeepSeek 时,优先走官方账号通道(账号登录没有 API key 也能查)。 */
|
|
218
|
+
function isDeepSeek(provider, model) {
|
|
219
|
+
const text = (String(provider === undefined || provider === null ? '' : provider) + ' '
|
|
220
|
+
+ String(model === undefined || model === null ? '' : model)).toLowerCase();
|
|
221
|
+
return text.includes('deepseek');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export { adapters, adapterFor, isDeepSeek };
|
package/lib/client.js
CHANGED
|
@@ -41,7 +41,7 @@ window.__ModuleLoader__.load({
|
|
|
41
41
|
*/
|
|
42
42
|
const CONFIG_VERSION = 6;
|
|
43
43
|
/** 插件版本:显示在设置页底部,用来确认页面加载的是哪一版客户端代码。 */
|
|
44
|
-
const VERSION = "2.0.
|
|
44
|
+
const VERSION = "2.0.2";
|
|
45
45
|
/** 官方参考价的版本:官方调价时改这个数字,老价格库会自动并入新参考价。 */
|
|
46
46
|
const PRICE_VERSION = 2;
|
|
47
47
|
|
|
@@ -348,7 +348,10 @@ window.__ModuleLoader__.load({
|
|
|
348
348
|
* 累加用 costOf 的原始值,只在这里做一次舍入。
|
|
349
349
|
*/
|
|
350
350
|
function costText(amount) {
|
|
351
|
-
|
|
351
|
+
const n = Number(amount);
|
|
352
|
+
/* 兜住 NaN / -0 / 负数:它们会显示成 "NaN" 或 "-0.00",看着像坏掉了 */
|
|
353
|
+
if (Number.isFinite(n) !== true || n <= 0) return "0.00";
|
|
354
|
+
return n.toFixed(2);
|
|
352
355
|
}
|
|
353
356
|
|
|
354
357
|
/** 逐字段相减,得到两次全量用量之间的增量 —— 即本轮消耗。 */
|
|
@@ -457,6 +460,7 @@ window.__ModuleLoader__.load({
|
|
|
457
460
|
tipTpsSlow: "最慢输出速度",
|
|
458
461
|
tipTopUp: "充值余额",
|
|
459
462
|
tipGranted: "赠金余额",
|
|
463
|
+
tipSpent: "累计消费",
|
|
460
464
|
tipHitTurn: "本轮缓存命中率",
|
|
461
465
|
tipHitHigh: "最高缓存命中率",
|
|
462
466
|
tipHitLow: "最低缓存命中率",
|
|
@@ -469,7 +473,7 @@ window.__ModuleLoader__.load({
|
|
|
469
473
|
f_cost: "总计 {symbol}{cost}",
|
|
470
474
|
priceTierPeak: "高峰时段",
|
|
471
475
|
priceTierOffPeak: "空闲时段",
|
|
472
|
-
f_balanceDash: "余额 -",
|
|
476
|
+
f_balanceDash: "余额 {symbol}-",
|
|
473
477
|
f_lastCost: "本轮 {symbol}{cost}",
|
|
474
478
|
f_balance: "余额 {symbol}{amount}",
|
|
475
479
|
f_balanceSubCent: "余额 <{symbol}0.01",
|
|
@@ -546,6 +550,7 @@ window.__ModuleLoader__.load({
|
|
|
546
550
|
tipTpsSlow: "Slowest TPS",
|
|
547
551
|
tipTopUp: "Top-up balance",
|
|
548
552
|
tipGranted: "Granted balance",
|
|
553
|
+
tipSpent: "Total spent",
|
|
549
554
|
tipHitTurn: "Cache hit rate this turn",
|
|
550
555
|
tipHitHigh: "Highest cache hit rate",
|
|
551
556
|
tipHitLow: "Lowest cache hit rate",
|
|
@@ -581,7 +586,7 @@ window.__ModuleLoader__.load({
|
|
|
581
586
|
unitCountSteps: " steps",
|
|
582
587
|
unitSpeed: "t/s",
|
|
583
588
|
priceSuggestedHint: "The price book uses the DeepSeek reference prices.",
|
|
584
|
-
f_balanceDash: "Balance -",
|
|
589
|
+
f_balanceDash: "Balance {symbol}-",
|
|
585
590
|
priceNewModel: "Custom model",
|
|
586
591
|
priceAdd: "Add",
|
|
587
592
|
priceEmpty: "Price book is empty — type a model name (e.g. deepseek-flash) and click Add.",
|
|
@@ -770,18 +775,34 @@ window.__ModuleLoader__.load({
|
|
|
770
775
|
return !(Array.isArray(cfg.hidden) && cfg.hidden.indexOf(id) !== -1);
|
|
771
776
|
}
|
|
772
777
|
/**
|
|
773
|
-
*
|
|
774
|
-
*
|
|
778
|
+
* 拖动中算落点:浮层的下边界压过哪一行的中线,影子就往下滑到那一行;
|
|
779
|
+
* 上边界举过哪一行的中线,就往上滑。
|
|
780
|
+
* slots 是按下那一刻按原始顺序量下的各行矩形,shift 是这期间页面滚动的位移
|
|
781
|
+
* (每次移动都重新量锚点得到)—— 直接用真实矩形,行距里有没有 gap、
|
|
782
|
+
* 某行是不是比别人高,都不影响判定。
|
|
775
783
|
*/
|
|
776
|
-
function
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
784
|
+
function dropIndexAt(slots, shift, from, top, bottom) {
|
|
785
|
+
let to = from;
|
|
786
|
+
for (let i = from + 1; i < slots.length; i += 1) {
|
|
787
|
+
const rect = slots[i];
|
|
788
|
+
if (rect === null || rect === undefined) continue;
|
|
789
|
+
if (bottom <= rect.top + shift + rect.height / 2) break;
|
|
790
|
+
to = i;
|
|
791
|
+
}
|
|
792
|
+
for (let i = from - 1; i >= 0; i -= 1) {
|
|
793
|
+
const rect = slots[i];
|
|
794
|
+
if (rect === null || rect === undefined) continue;
|
|
795
|
+
if (top >= rect.top + shift + rect.height / 2) break;
|
|
796
|
+
to = i;
|
|
797
|
+
}
|
|
798
|
+
return to;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** 把 from 位置的元素挪到 to 位置(不动原数组)。 */
|
|
802
|
+
function moveItem(list, from, to) {
|
|
803
|
+
const next = list.slice();
|
|
804
|
+
next.splice(to, 0, next.splice(from, 1)[0]);
|
|
805
|
+
return next;
|
|
785
806
|
}
|
|
786
807
|
|
|
787
808
|
/* ---------------------------------------------------------------- 工具 */
|
|
@@ -923,6 +944,19 @@ window.__ModuleLoader__.load({
|
|
|
923
944
|
* 令牌面板数据:第一行是总用量,第二行三段占比(顶满 100%),下面三行明细。
|
|
924
945
|
* 数字一律千分位精确值,不带单位(面板标题已经说明是 token 用量)。
|
|
925
946
|
*/
|
|
947
|
+
/** 没有 token 数据时的面板:不画三段条,三行都给横杠 —— 保证气泡在任何情况下都点得开。 */
|
|
948
|
+
function dashTokenPanel(t) {
|
|
949
|
+
return {
|
|
950
|
+
totalText: "-",
|
|
951
|
+
segments: [],
|
|
952
|
+
rows: [
|
|
953
|
+
{ label: t("f_tokensHit"), color: "var(--dsw-static-neutral-bluish-400)", value: "-" },
|
|
954
|
+
{ label: t("f_tokensMiss"), color: "#a78bfa", value: "-" },
|
|
955
|
+
{ label: t("f_tokensOut"), color: "var(--dsw-static-blue-450)", value: "-" }
|
|
956
|
+
]
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
926
960
|
function tokenPanelView(cached, missInput, output, t) {
|
|
927
961
|
const total = cached + missInput + output;
|
|
928
962
|
if (total <= 0) return null;
|
|
@@ -1001,10 +1035,10 @@ window.__ModuleLoader__.load({
|
|
|
1001
1035
|
});
|
|
1002
1036
|
}
|
|
1003
1037
|
|
|
1004
|
-
/** 令牌面板的 tip
|
|
1038
|
+
/** 令牌面板的 tip。没有数据也给面板(三行横杠)—— 气泡必须任何情况下都点得开。 */
|
|
1005
1039
|
function tokensTip(parts, title, t) {
|
|
1006
1040
|
const panel = parts === null ? null : tokenPanelView(parts.cached, parts.missInput, parts.output, t);
|
|
1007
|
-
return
|
|
1041
|
+
return { kind: "tokens", title: title, panel: panel === null ? dashTokenPanel(t) : panel };
|
|
1008
1042
|
}
|
|
1009
1043
|
|
|
1010
1044
|
/** 毫秒 → 面板里的时长文本(精确到秒);0 或缺数据给横杠。 */
|
|
@@ -1258,9 +1292,9 @@ window.__ModuleLoader__.load({
|
|
|
1258
1292
|
|
|
1259
1293
|
if (id === "cost") {
|
|
1260
1294
|
const sessionUsage = src.sessionUsage;
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1295
|
+
const calls = sessionUsage === undefined || sessionUsage === null || Array.isArray(sessionUsage.calls) !== true
|
|
1296
|
+
? []
|
|
1297
|
+
: sessionUsage.calls;
|
|
1264
1298
|
/* 每条调用按自己发生的那一刻、自己的模型定峰谷:跨时段、跨模型都不会串价。
|
|
1265
1299
|
累加的是未舍入金额,最后一次性舍入 —— 逐条舍入会累积出几毛钱的误差。 */
|
|
1266
1300
|
const fallbackModel = src.sessionModel !== undefined && src.sessionModel !== null ? src.sessionModel.model : null;
|
|
@@ -1273,10 +1307,10 @@ window.__ModuleLoader__.load({
|
|
|
1273
1307
|
priced = true;
|
|
1274
1308
|
total += one;
|
|
1275
1309
|
}
|
|
1276
|
-
if (!priced) return null;
|
|
1277
1310
|
return {
|
|
1278
1311
|
id: id,
|
|
1279
|
-
|
|
1312
|
+
/* 算不出价(价格库里没这个模型)时给 ¥-:与"这笔真的是 0"区分开 */
|
|
1313
|
+
text: t("f_cost", { symbol: symbol, cost: priced ? costText(total) : "-" }),
|
|
1280
1314
|
/* token 计数并进总计:点这一段看会话的 token 明细 */
|
|
1281
1315
|
tip: tokensTip(sessionTokenParts(src.usage), t("f_tokensTotal"), t)
|
|
1282
1316
|
};
|
|
@@ -1291,29 +1325,31 @@ window.__ModuleLoader__.load({
|
|
|
1291
1325
|
&& Array.isArray(sessionUsageNow.current.calls)
|
|
1292
1326
|
? sessionUsageNow.current.calls
|
|
1293
1327
|
: null;
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1328
|
+
/* 本轮的 token 汇总与定价一起算:即使价格库里没有这个模型(算不出钱),
|
|
1329
|
+
也要留住 token 汇总 —— 本轮气泡照样能点开看明细。 */
|
|
1330
|
+
const turnParts = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
1331
|
+
let turnTotal = 0;
|
|
1332
|
+
let turnPriced = false;
|
|
1333
|
+
const turnList = turnCalls === null ? [] : turnCalls;
|
|
1334
|
+
for (let i = 0; i < turnList.length; i += 1) {
|
|
1335
|
+
const callModel = typeof turnList[i].model === "string" ? turnList[i].model : fallbackModel;
|
|
1336
|
+
const one = costOf(turnList[i], callModel, turnList[i].at);
|
|
1337
|
+
turnParts.input += turnList[i].input || 0;
|
|
1338
|
+
turnParts.cacheRead += turnList[i].cacheRead || 0;
|
|
1339
|
+
turnParts.cacheWrite += turnList[i].cacheWrite || 0;
|
|
1340
|
+
turnParts.output += turnList[i].output || 0;
|
|
1341
|
+
if (one === null) continue;
|
|
1342
|
+
turnPriced = true;
|
|
1343
|
+
turnTotal += Number(one);
|
|
1344
|
+
}
|
|
1345
|
+
const turnTip = tokensTip(billableParts(turnParts), t("f_tokensTurn"), t);
|
|
1346
|
+
if (turnPriced) {
|
|
1347
|
+
return {
|
|
1348
|
+
id: id,
|
|
1349
|
+
text: t("f_lastCost", { symbol: symbol, cost: costText(turnTotal) }),
|
|
1350
|
+
/* 本轮点开的是本轮 token 明细,读法与总计那个面板同一套 */
|
|
1351
|
+
tip: turnTip
|
|
1352
|
+
};
|
|
1317
1353
|
}
|
|
1318
1354
|
/* 本轮是不是正在跑(与状态段同一套判断) */
|
|
1319
1355
|
const active = src.running === true
|
|
@@ -1330,37 +1366,77 @@ window.__ModuleLoader__.load({
|
|
|
1330
1366
|
if (active === true && bucket !== null && sameTurn === true) {
|
|
1331
1367
|
const runningCost = costOf(bucket, fallbackModel, src.now);
|
|
1332
1368
|
if (runningCost !== null) {
|
|
1333
|
-
return { id: id, text: t("f_lastCost", { symbol: symbol, cost: costText(runningCost) }), tip:
|
|
1369
|
+
return { id: id, text: t("f_lastCost", { symbol: symbol, cost: costText(runningCost) }), tip: turnTip };
|
|
1334
1370
|
}
|
|
1335
1371
|
}
|
|
1336
|
-
/*
|
|
1337
|
-
|
|
1338
|
-
|
|
1372
|
+
/* 新一轮已经开跑、但还没有属于它的调用:必须归零,不能退回上一轮
|
|
1373
|
+
—— 节点折叠曾错位一轮,底栏显示成上上轮的金额,这条就是为堵它留的。
|
|
1374
|
+
一次模型调用都还没完成过(第一轮)时没有"归零"可言,跟总计一样给横杠。
|
|
1375
|
+
等这一轮真的产生了调用,上面那条精确口径就接管了。 */
|
|
1339
1376
|
if (active === true) {
|
|
1340
|
-
|
|
1377
|
+
const settled = src.sessionUsage !== undefined && src.sessionUsage !== null
|
|
1378
|
+
&& Array.isArray(src.sessionUsage.calls) === true
|
|
1379
|
+
&& src.sessionUsage.calls.length > 0;
|
|
1380
|
+
return {
|
|
1381
|
+
id: id,
|
|
1382
|
+
text: t("f_lastCost", { symbol: symbol, cost: settled ? costText(0) : "-" }),
|
|
1383
|
+
tip: turnTip
|
|
1384
|
+
};
|
|
1341
1385
|
}
|
|
1342
|
-
/* 空闲:显示最近完成的一轮(节点折叠 →
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
return {
|
|
1386
|
+
/* 空闲:显示最近完成的一轮(节点折叠 → 官方差分兜底),同样只出金额。
|
|
1387
|
+
没有可用数据时也给 ¥0.00:本轮不整段消失,底栏长度稳定,
|
|
1388
|
+
也不会退回上一轮(节点折叠曾错位一轮,显示成上上轮)的旧金额。 */
|
|
1389
|
+
const cost = bucket === null ? null : costOf(bucket, fallbackModel, src.now);
|
|
1390
|
+
return {
|
|
1391
|
+
id: id,
|
|
1392
|
+
text: t("f_lastCost", { symbol: symbol, cost: cost === null ? "-" : costText(cost) }),
|
|
1393
|
+
tip: turnTip
|
|
1394
|
+
};
|
|
1347
1395
|
}
|
|
1348
1396
|
|
|
1349
1397
|
if (id === "balance") {
|
|
1398
|
+
/* 查不到余额(非 DeepSeek 会话 / 还没查到 / 查询失败)时用同一套两行结构,值都是横杠:
|
|
1399
|
+
气泡里仍然是「充值余额 / 赠金余额」两行,只是没有数字。 */
|
|
1400
|
+
const dashBalance = () => {
|
|
1401
|
+
const dashSymbol = currencySymbol(src.currency);
|
|
1402
|
+
return {
|
|
1403
|
+
id: id,
|
|
1404
|
+
text: t("f_balanceDash", { symbol: dashSymbol }),
|
|
1405
|
+
tip: {
|
|
1406
|
+
kind: "rows",
|
|
1407
|
+
title: t("segBalance"),
|
|
1408
|
+
rows: [
|
|
1409
|
+
{ label: t("tipTopUp"), value: dashSymbol + "-" },
|
|
1410
|
+
{ label: t("tipGranted"), value: dashSymbol + "-" }
|
|
1411
|
+
]
|
|
1412
|
+
}
|
|
1413
|
+
};
|
|
1414
|
+
};
|
|
1415
|
+
const sessionModelNow = src.sessionModel;
|
|
1416
|
+
const modelText = sessionModelNow === undefined || sessionModelNow === null
|
|
1417
|
+
? ""
|
|
1418
|
+
: String(sessionModelNow.provider === undefined ? "" : sessionModelNow.provider)
|
|
1419
|
+
+ " " + String(sessionModelNow.model === undefined ? "" : sessionModelNow.model);
|
|
1420
|
+
if (modelText.toLowerCase().includes("deepseek") !== true) return dashBalance();
|
|
1350
1421
|
const balance = src.balance;
|
|
1351
|
-
if (balance === null || balance === undefined) return
|
|
1352
|
-
if (balance.ok !== true) return
|
|
1353
|
-
if (typeof balance.total !== "string") return
|
|
1422
|
+
if (balance === null || balance === undefined) return dashBalance();
|
|
1423
|
+
if (balance.ok !== true) return dashBalance();
|
|
1424
|
+
if (typeof balance.total !== "string") return dashBalance();
|
|
1354
1425
|
/* 余额用接口自己报的币种:账户里是人民币就显示 ¥,不跟着上面的计价单位走 */
|
|
1355
|
-
|
|
1426
|
+
/* 百分比口径(GLM 配额)不带货币符号,否则会显示成 ¥37.50% */
|
|
1427
|
+
const balSymbol = balance.isPercent === true ? "" : currencySymbol(balance.currency !== undefined && balance.currency !== null ? balance.currency : src.currency);
|
|
1356
1428
|
/* 点开看构成:total = 充值余额 + 赠金余额;没有赠金时接口不给这个字段,按 0.00 显示 */
|
|
1357
1429
|
const tip = {
|
|
1358
1430
|
kind: "rows",
|
|
1359
1431
|
title: t("segBalance"),
|
|
1360
1432
|
rows: [
|
|
1361
1433
|
{ label: t("tipTopUp"), value: balSymbol + balanceAmount(balance.toppedUp) },
|
|
1362
|
-
{ label: t("tipGranted"), value: balSymbol + balanceAmount(balance.granted) }
|
|
1363
|
-
|
|
1434
|
+
{ label: t("tipGranted"), value: balSymbol + balanceAmount(balance.granted) },
|
|
1435
|
+
/* 累计消费只有部分平台能给(智谱有,DeepSeek 的接口没有),没有就不显示这一行 */
|
|
1436
|
+
balance.spent === undefined || balance.spent === null
|
|
1437
|
+
? null
|
|
1438
|
+
: { label: t("tipSpent"), value: balSymbol + balanceAmount(balance.spent) }
|
|
1439
|
+
].filter((one) => one !== null)
|
|
1364
1440
|
};
|
|
1365
1441
|
/* 不足一分钱时官方显示 <¥0.01,这里跟着来 */
|
|
1366
1442
|
if (balance.subCent === true) return { id: id, text: t("f_balanceSubCent", { symbol: balSymbol }), tip: tip };
|
|
@@ -1390,8 +1466,8 @@ window.__ModuleLoader__.load({
|
|
|
1390
1466
|
/* 速度占位不带单位:英文那边标签已经是 TPS,再跟 t/s 就重复了 */
|
|
1391
1467
|
if (id === "tps") return label + " " + dash;
|
|
1392
1468
|
if (id === "sessionTime") return label + " " + dash + "m " + dash + "s";
|
|
1393
|
-
if (id === "cost" || id === "lastCost") return label + " " +
|
|
1394
|
-
if (id === "balance") return t("f_balanceDash");
|
|
1469
|
+
if (id === "cost" || id === "lastCost") return label + " " + currencySymbol(config.currency) + dash;
|
|
1470
|
+
if (id === "balance") return t("f_balanceDash", { symbol: currencySymbol(config.currency) });
|
|
1395
1471
|
return dash;
|
|
1396
1472
|
}
|
|
1397
1473
|
/* ---------------------------------------------------------------- css */
|
|
@@ -1467,21 +1543,29 @@ window.__ModuleLoader__.load({
|
|
|
1467
1543
|
".dsb-settings .dsb-section{display:flex;flex-direction:column;gap:8px}",
|
|
1468
1544
|
".dsb-settings .dsb-sectitle{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;letter-spacing:.02em}",
|
|
1469
1545
|
".dsb-settings .dsb-hint{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6}",
|
|
1470
|
-
".dsb-settings .dsb-row{display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:8px;padding:6px 8px;background:var(--dsw-alias-bg-layer-1,transparent)}",
|
|
1546
|
+
".dsb-settings .dsb-row{display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:8px;padding:6px 8px;background:var(--dsw-alias-bg-layer-1,transparent);user-select:none;-webkit-user-select:none}",
|
|
1471
1547
|
".dsb-settings .dsb-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.08))}",
|
|
1548
|
+
/* 拖动中不给悬停反馈:浮层不吃指针事件,鼠标其实悬在下面那行上,会莫名发灰 */
|
|
1549
|
+
".dsb-settings:has(.dsb-dragging) .dsb-row:hover{background:var(--dsw-alias-bg-layer-1,transparent)}",
|
|
1550
|
+
/* 拖动中光标一律抓手:光标由鼠标底下那个元素决定,而浮层不吃指针事件,
|
|
1551
|
+
底下既可能是握把(grab)、文字(pointer)、也可能是行间空隙(默认箭头)。
|
|
1552
|
+
!important 是因为这些元素各自都写了 cursor,靠继承压不住握把那条。 */
|
|
1553
|
+
".dsb-settings:has(.dsb-dragging) *{cursor:grabbing !important}",
|
|
1472
1554
|
".dsb-settings .dsb-check{display:flex;align-items:flex-start;gap:8px;cursor:pointer;flex:1;min-width:0}",
|
|
1473
1555
|
".dsb-settings .dsb-check input{flex:none;margin:2px 0 0}",
|
|
1474
1556
|
".dsb-settings input[type=checkbox]{accent-color:var(--dsw-alias-label-primary,#202020)}",
|
|
1475
1557
|
".dsb-settings .dsb-labels{display:flex;flex-direction:column;gap:2px;min-width:0}",
|
|
1476
1558
|
".dsb-settings .dsb-name{color:var(--dsw-alias-label-primary)}",
|
|
1477
1559
|
".dsb-settings .dsb-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}",
|
|
1478
|
-
|
|
1479
|
-
".dsb-settings .dsb-row.dsb-
|
|
1480
|
-
|
|
1560
|
+
/* 被拿起来的那一行:原位留个淡影,跟手的是下面那张卡 */
|
|
1561
|
+
".dsb-settings .dsb-row.dsb-dragging{opacity:.32}",
|
|
1562
|
+
/* 跟手的浮层:气泡同款材质(只有 .5px 描边,没有柔光),不吃指针事件 */
|
|
1563
|
+
".dsb-settings .dsb-drag-card{position:fixed;z-index:40;pointer-events:none;box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 8px;border-radius:var(--dsw-radius-md,12px);background:rgb(from var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f)) r g b / .382);backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));box-shadow:0 0 0 .5px var(--dsw-alias-border-l1,rgba(127,127,127,.3))}",
|
|
1481
1564
|
".dsb-settings .dsb-handle{position:relative;flex:none;width:16px;height:16px;color:var(--dsw-alias-label-tertiary);cursor:grab}",
|
|
1482
1565
|
'.dsb-settings .dsb-handle::before{content:"";position:absolute;left:4px;top:1.5px;width:3px;height:3px;border-radius:50%;background:currentColor;box-shadow:5px 0 0 currentColor,0 5px 0 currentColor,5px 5px 0 currentColor,0 10px 0 currentColor,5px 10px 0 currentColor}',
|
|
1483
1566
|
".dsb-settings .dsb-handle:active{cursor:grabbing}",
|
|
1484
|
-
|
|
1567
|
+
/* 未勾选的段也能调位置,所以光标跟勾选时一样是抓手,只是点阵淡一些 */
|
|
1568
|
+
".dsb-settings .dsb-handle-off{opacity:.25}",
|
|
1485
1569
|
/* 价格库 */
|
|
1486
1570
|
".dsb-settings .dsb-card{border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.25));border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:10px}",
|
|
1487
1571
|
".dsb-settings .dsb-current{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.6}",
|
|
@@ -1699,19 +1783,34 @@ window.__ModuleLoader__.load({
|
|
|
1699
1783
|
}, []);
|
|
1700
1784
|
|
|
1701
1785
|
const wantsBalance = isSegmentOn(cfg, "balance");
|
|
1786
|
+
/* 当前会话用的 provider / model:host 按它决定查哪个平台的余额。
|
|
1787
|
+
这两个值变了要重新查,所以下面 effect 的依赖里带着它们。 */
|
|
1788
|
+
const balanceProvider = sessionModel !== undefined && sessionModel !== null ? sessionModel.provider : null;
|
|
1789
|
+
const balanceModel = sessionModel !== undefined && sessionModel !== null ? sessionModel.model : null;
|
|
1790
|
+
const balanceQuery = "?provider=" + encodeURIComponent(String(balanceProvider === undefined || balanceProvider === null ? "" : balanceProvider))
|
|
1791
|
+
+ "&model=" + encodeURIComponent(String(balanceModel === undefined || balanceModel === null ? "" : balanceModel));
|
|
1792
|
+
/* 点开余额气泡时先强制重查一次:host 端余额不缓存,带上 force 就不用等
|
|
1793
|
+
下一次轮询(最多 60 秒),气泡里直接是此刻的数字。轮询本身不带 force。 */
|
|
1794
|
+
const balanceReloadRef = react.useRef(null);
|
|
1702
1795
|
react.useEffect(() => {
|
|
1703
1796
|
if (!wantsBalance) return undefined;
|
|
1704
1797
|
let alive = true;
|
|
1705
|
-
const load = () => {
|
|
1706
|
-
|
|
1798
|
+
const load = (force) => {
|
|
1799
|
+
const url = BALANCE_URL + balanceQuery + (force === true ? "&force=1" : "");
|
|
1800
|
+
window.fetch(url, { cache: "no-store" })
|
|
1707
1801
|
.then((response) => response.json())
|
|
1708
1802
|
.then((data) => { if (alive) setBalance(data); })
|
|
1709
1803
|
.catch(() => { if (alive) setBalance({ ok: false, reason: "fetch-failed" }); });
|
|
1710
1804
|
};
|
|
1711
|
-
load();
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1805
|
+
balanceReloadRef.current = () => load(true);
|
|
1806
|
+
load(false);
|
|
1807
|
+
const timer = window.setInterval(() => load(false), BALANCE_POLL_MS);
|
|
1808
|
+
return () => {
|
|
1809
|
+
alive = false;
|
|
1810
|
+
balanceReloadRef.current = null;
|
|
1811
|
+
window.clearInterval(timer);
|
|
1812
|
+
};
|
|
1813
|
+
}, [wantsBalance, balanceProvider, balanceModel]);
|
|
1715
1814
|
|
|
1716
1815
|
/* 只在"点别处"时收起。这里刻意不监听 scroll:底栏固定在视口底部,对话区滚动
|
|
1717
1816
|
不会移动触发项,但每来一次工具调用/新一轮,对话区都会自动滚到底 ——
|
|
@@ -1851,7 +1950,9 @@ window.__ModuleLoader__.load({
|
|
|
1851
1950
|
const at = tipKeys.indexOf(key);
|
|
1852
1951
|
if (at < 0) return "center";
|
|
1853
1952
|
if (at === 0) return "left";
|
|
1854
|
-
|
|
1953
|
+
/* 只有余额贴右边(它固定在栏尾)。其余一律居中、装不下时再夹 ——
|
|
1954
|
+
否则某一段没有气泡时会被当成"最右项"改成右对齐,看起来就不居中了。 */
|
|
1955
|
+
return key === "balance" ? "right" : "center";
|
|
1855
1956
|
};
|
|
1856
1957
|
/* 打开时保存触发项的位置并收起上一次的气泡;真正的坐标等面板挂上、量到宽度后再算 */
|
|
1857
1958
|
const toggleTip = (id, event, mode) => {
|
|
@@ -1860,6 +1961,8 @@ window.__ModuleLoader__.load({
|
|
|
1860
1961
|
setTipPos(null);
|
|
1861
1962
|
return;
|
|
1862
1963
|
}
|
|
1964
|
+
/* 点开余额:顺手重查一次(收起那一下不查),数据回来气泡自己更新 */
|
|
1965
|
+
if (id === "balance" && balanceReloadRef.current !== null) balanceReloadRef.current();
|
|
1863
1966
|
const target = event !== undefined && event !== null ? event.currentTarget : null;
|
|
1864
1967
|
/* 存节点而不是当次的矩形:窗口缩放后要重新量 */
|
|
1865
1968
|
anchorRef.current = target !== null && typeof target.getBoundingClientRect === "function" ? target : null;
|
|
@@ -1918,9 +2021,9 @@ window.__ModuleLoader__.load({
|
|
|
1918
2021
|
const t = props.t;
|
|
1919
2022
|
const cfg = useConfig();
|
|
1920
2023
|
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
const [
|
|
2024
|
+
/* 自绘拖拽:全程用 pointer 事件自己跟手。不用原生 HTML5 拖拽 ——
|
|
2025
|
+
原生拖拽会话期间浏览器不给页面 wheel 事件(滚轮失灵),而且只有松手才换位。 */
|
|
2026
|
+
const [drag, setDrag] = react.useState(null); /* { id, from, to, dy, startY, height, rects } */
|
|
1924
2027
|
const [newModel, setNewModel] = react.useState("");
|
|
1925
2028
|
const [hostModel, setHostModel] = react.useState(null);
|
|
1926
2029
|
const [editing, setEditing] = react.useState(null);
|
|
@@ -2097,7 +2200,168 @@ window.__ModuleLoader__.load({
|
|
|
2097
2200
|
.map((id) => byId[id])
|
|
2098
2201
|
.filter((segment) => segment !== undefined && segment !== null);
|
|
2099
2202
|
|
|
2100
|
-
|
|
2203
|
+
/* 行节点 + 拖前位置:前者用来量落点,后者给 FLIP 补位移动画 */
|
|
2204
|
+
const rowNodes = react.useRef(new Map());
|
|
2205
|
+
const rowTops = react.useRef(null);
|
|
2206
|
+
/* 拖动是否还活着:pointerup 会被行和 window 各收到一次,靠它兜住重复落盘 */
|
|
2207
|
+
const draggingRef = react.useRef(false);
|
|
2208
|
+
const captureTops = () => {
|
|
2209
|
+
const tops = new Map();
|
|
2210
|
+
rowNodes.current.forEach((node, id) => {
|
|
2211
|
+
if (node !== null && node !== undefined && typeof node.getBoundingClientRect === "function") {
|
|
2212
|
+
tops.set(id, node.getBoundingClientRect().top);
|
|
2213
|
+
}
|
|
2214
|
+
});
|
|
2215
|
+
rowTops.current = tops;
|
|
2216
|
+
};
|
|
2217
|
+
|
|
2218
|
+
const measure = (segmentId) => {
|
|
2219
|
+
const node = rowNodes.current.get(segmentId);
|
|
2220
|
+
return node !== null && node !== undefined && typeof node.getBoundingClientRect === "function"
|
|
2221
|
+
? node.getBoundingClientRect()
|
|
2222
|
+
: null;
|
|
2223
|
+
};
|
|
2224
|
+
|
|
2225
|
+
const startDrag = (id, index, event) => {
|
|
2226
|
+
const at = measure(id);
|
|
2227
|
+
const anchor = measure("__context");
|
|
2228
|
+
draggingRef.current = true;
|
|
2229
|
+
setDrag({
|
|
2230
|
+
id: id, from: index, to: index, at: at,
|
|
2231
|
+
/* 指针在行内的抓取位置:浮层位置和落点都由它推出来,不依赖按下时的绝对坐标 */
|
|
2232
|
+
grab: at === null || at === undefined ? 0 : event.clientY - at.top,
|
|
2233
|
+
cardTop: at === null || at === undefined ? event.clientY : at.top,
|
|
2234
|
+
pointerY: event.clientY,
|
|
2235
|
+
/* 各槽位按下那一刻的真实矩形;滚动多少由锚点差值补回来 */
|
|
2236
|
+
slots: orderedSegments.map((one) => measure(one.id)),
|
|
2237
|
+
anchorTop: anchor === null || anchor === undefined ? 0 : anchor.top,
|
|
2238
|
+
/* base 是按下那一刻的顺序:每次换位都从它重算,不会累积误差 */
|
|
2239
|
+
base: cfg.segments.slice(),
|
|
2240
|
+
order: cfg.segments.slice()
|
|
2241
|
+
});
|
|
2242
|
+
};
|
|
2243
|
+
|
|
2244
|
+
/* 按给定指针位置重算落点。单独抽出来是因为滚轮滚动不产生 pointermove,
|
|
2245
|
+
滚动时得靠 scroll 事件带着"上一次的指针位置"再算一遍,影子才不会落在后面。 */
|
|
2246
|
+
const applyDrop = (current, pointerY) => {
|
|
2247
|
+
const cardTop = pointerY - current.grab;
|
|
2248
|
+
const height = current.at === null || current.at === undefined ? 0 : current.at.height;
|
|
2249
|
+
/* 锚点每次实时量:中途滚了滚轮,按下时的坐标早就失效了 */
|
|
2250
|
+
const anchor = measure("__context");
|
|
2251
|
+
const shift = anchor === null || anchor === undefined ? 0 : anchor.top - current.anchorTop;
|
|
2252
|
+
const to = dropIndexAt(current.slots, shift, current.from, cardTop, cardTop + height);
|
|
2253
|
+
/* 落点和指针都没动就别重渲染:滚动事件很密,大多数帧其实什么都没变 */
|
|
2254
|
+
if (to === current.to && cardTop === current.cardTop) return;
|
|
2255
|
+
if (to !== current.to) captureTops(); /* 顺序要变了,先记下当前位置 */
|
|
2256
|
+
setDrag({
|
|
2257
|
+
id: current.id, from: current.from, to: to, cardTop: cardTop, pointerY: pointerY,
|
|
2258
|
+
grab: current.grab, at: current.at, slots: current.slots, anchorTop: current.anchorTop,
|
|
2259
|
+
base: current.base,
|
|
2260
|
+
order: moveItem(current.base, current.from, to)
|
|
2261
|
+
});
|
|
2262
|
+
};
|
|
2263
|
+
|
|
2264
|
+
const moveDrag = (event) => {
|
|
2265
|
+
if (drag === null) return;
|
|
2266
|
+
/* 指针已经松开(截屏工具之类把 pointerup 吃掉了):按取消处理,回原位 */
|
|
2267
|
+
if (event.buttons === 0) {
|
|
2268
|
+
cancelDrag();
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
applyDrop(drag, event.clientY);
|
|
2272
|
+
};
|
|
2273
|
+
|
|
2274
|
+
/* 松手才落盘:拖动全程只在内存里重排,config 一动不动 */
|
|
2275
|
+
const endDrag = () => {
|
|
2276
|
+
if (draggingRef.current !== true || drag === null) return;
|
|
2277
|
+
draggingRef.current = false;
|
|
2278
|
+
if (drag.to !== drag.from) setConfig({ segments: drag.order });
|
|
2279
|
+
setDrag(null);
|
|
2280
|
+
};
|
|
2281
|
+
|
|
2282
|
+
/* 拖动被系统打断(截屏、切窗口、指针事件丢失):回原位,什么账都不记 */
|
|
2283
|
+
const cancelDrag = () => {
|
|
2284
|
+
if (draggingRef.current !== true) return;
|
|
2285
|
+
draggingRef.current = false;
|
|
2286
|
+
setDrag(null);
|
|
2287
|
+
};
|
|
2288
|
+
|
|
2289
|
+
/* 拖动中的重排没有过渡可言:DOM 顺序一变,行就是瞬移。
|
|
2290
|
+
这里把移动补回来 —— 变之前记下位置,变之后先反推回原处,再过渡回去。 */
|
|
2291
|
+
useTipLayout(() => {
|
|
2292
|
+
const before = rowTops.current;
|
|
2293
|
+
if (before === null) return;
|
|
2294
|
+
rowTops.current = null;
|
|
2295
|
+
rowNodes.current.forEach((node, id) => {
|
|
2296
|
+
if (node === null || node === undefined || node.style === undefined) return;
|
|
2297
|
+
const was = before.get(id);
|
|
2298
|
+
if (was === undefined) return;
|
|
2299
|
+
const delta = was - node.getBoundingClientRect().top;
|
|
2300
|
+
if (delta === 0) return;
|
|
2301
|
+
node.style.transition = "none";
|
|
2302
|
+
node.style.transform = "translateY(" + delta + "px)";
|
|
2303
|
+
void node.offsetHeight; /* 强制回流,否则紧接着的过渡不会触发 */
|
|
2304
|
+
node.style.transition = "transform .18s ease";
|
|
2305
|
+
node.style.transform = "";
|
|
2306
|
+
});
|
|
2307
|
+
}, [drag === null ? "" : drag.order.join(",")]);
|
|
2308
|
+
|
|
2309
|
+
/* 指针事件再挂一份到 window 兜底:被拖的那一行在拖动中会重排,指针捕获一旦丢了,
|
|
2310
|
+
pointerup 就永远收不到,浮层会一直粘着鼠标。挂在 window 上不受重排影响。
|
|
2311
|
+
顺带在拖动期间禁掉文本选中 —— 长按拖动会把整页文字刷成蓝底。 */
|
|
2312
|
+
const dragMoveRef = react.useRef(null);
|
|
2313
|
+
const dragEndRef = react.useRef(null);
|
|
2314
|
+
const dragCancelRef = react.useRef(null);
|
|
2315
|
+
const dragRef = react.useRef(null);
|
|
2316
|
+
const dragDropRef = react.useRef(null);
|
|
2317
|
+
dragMoveRef.current = moveDrag;
|
|
2318
|
+
dragEndRef.current = endDrag;
|
|
2319
|
+
dragCancelRef.current = cancelDrag;
|
|
2320
|
+
dragRef.current = drag;
|
|
2321
|
+
dragDropRef.current = applyDrop;
|
|
2322
|
+
react.useEffect(() => {
|
|
2323
|
+
if (drag === null) return undefined;
|
|
2324
|
+
const onMove = (event) => { if (dragMoveRef.current !== null) dragMoveRef.current(event); };
|
|
2325
|
+
const onUp = () => { if (dragEndRef.current !== null) dragEndRef.current(); };
|
|
2326
|
+
/* 打断 = 取消(回原位),绝不能当成落点:截屏工具常把 pointerup 整个吃掉 */
|
|
2327
|
+
const onCancel = () => { if (dragCancelRef.current !== null) dragCancelRef.current(); };
|
|
2328
|
+
window.addEventListener("pointermove", onMove);
|
|
2329
|
+
window.addEventListener("pointerup", onUp);
|
|
2330
|
+
window.addEventListener("pointercancel", onCancel);
|
|
2331
|
+
window.addEventListener("blur", onCancel);
|
|
2332
|
+
/* 滚轮滚动不产生 pointermove:不补这一手,影子会落在后面,要再动一下鼠标才跟上 */
|
|
2333
|
+
const onScroll = () => {
|
|
2334
|
+
const current = dragRef.current;
|
|
2335
|
+
if (current !== null && dragDropRef.current !== null) dragDropRef.current(current, current.pointerY);
|
|
2336
|
+
};
|
|
2337
|
+
window.addEventListener("scroll", onScroll, true);
|
|
2338
|
+
const body = document === undefined || document === null ? undefined : document.body;
|
|
2339
|
+
const style = body === undefined || body === null ? undefined : body.style;
|
|
2340
|
+
/* 禁选:长按拖动会把整页文字刷成蓝底;抓手:光标统一,不再忽抓忽指 */
|
|
2341
|
+
const hadSelect = style === undefined ? null : style.userSelect;
|
|
2342
|
+
const hadCursor = style === undefined ? null : style.cursor;
|
|
2343
|
+
if (hadSelect !== null) style.userSelect = "none";
|
|
2344
|
+
if (hadCursor !== null) style.cursor = "grabbing";
|
|
2345
|
+
return () => {
|
|
2346
|
+
window.removeEventListener("pointermove", onMove);
|
|
2347
|
+
window.removeEventListener("pointerup", onUp);
|
|
2348
|
+
window.removeEventListener("pointercancel", onCancel);
|
|
2349
|
+
window.removeEventListener("blur", onCancel);
|
|
2350
|
+
window.removeEventListener("scroll", onScroll, true);
|
|
2351
|
+
if (hadSelect !== null) style.userSelect = hadSelect;
|
|
2352
|
+
if (hadCursor !== null) style.cursor = hadCursor;
|
|
2353
|
+
};
|
|
2354
|
+
}, [drag === null]);
|
|
2355
|
+
|
|
2356
|
+
const contextRow = h("div", {
|
|
2357
|
+
className: "dsb-row",
|
|
2358
|
+
key: "__context",
|
|
2359
|
+
/* 落点靠它算槽位几何:它在栏首、不参与排序、也没有过渡动画,位置最稳 */
|
|
2360
|
+
ref: (node) => {
|
|
2361
|
+
if (node === null || node === undefined) rowNodes.current.delete("__context");
|
|
2362
|
+
else rowNodes.current.set("__context", node);
|
|
2363
|
+
}
|
|
2364
|
+
},
|
|
2101
2365
|
h("label", { className: "dsb-check" },
|
|
2102
2366
|
h("input", {
|
|
2103
2367
|
type: "checkbox",
|
|
@@ -2111,75 +2375,74 @@ window.__ModuleLoader__.load({
|
|
|
2111
2375
|
)
|
|
2112
2376
|
);
|
|
2113
2377
|
|
|
2114
|
-
|
|
2378
|
+
/* 行内容抽出来:列表里的行和跟手的浮层共用同一份 */
|
|
2379
|
+
const rowParts = (segment, on) => [
|
|
2380
|
+
h("label", { className: "dsb-check", key: "__check" },
|
|
2381
|
+
h("input", {
|
|
2382
|
+
type: "checkbox",
|
|
2383
|
+
checked: on,
|
|
2384
|
+
onChange: (event) => toggleSegment(segment.id, event.target.checked)
|
|
2385
|
+
}),
|
|
2386
|
+
h("span", { className: "dsb-labels" },
|
|
2387
|
+
h("span", { className: "dsb-name" }, t(segment.label)),
|
|
2388
|
+
h("span", { className: "dsb-desc" }, t(segment.hint))
|
|
2389
|
+
)
|
|
2390
|
+
),
|
|
2391
|
+
h("span", {
|
|
2392
|
+
className: on === true ? "dsb-handle" : "dsb-handle dsb-handle-off",
|
|
2393
|
+
title: on === true ? t("dragHint") : "",
|
|
2394
|
+
key: "__handle"
|
|
2395
|
+
})
|
|
2396
|
+
];
|
|
2397
|
+
|
|
2398
|
+
/* 拖动中就地按落点排好:列表里被拖那一格留淡影,跟手的是上面的浮层 */
|
|
2399
|
+
const visibleOrder = drag === null ? cfg.segments : drag.order;
|
|
2400
|
+
const visibleSegments = visibleOrder
|
|
2401
|
+
.map((id) => byId[id])
|
|
2402
|
+
.filter((segment) => segment !== undefined && segment !== null);
|
|
2403
|
+
|
|
2404
|
+
const segmentRows = visibleSegments.map((segment, index) => {
|
|
2115
2405
|
const on = isSegmentOn(cfg, segment.id);
|
|
2116
|
-
const
|
|
2117
|
-
if (dragId === segment.id) classes.push("dsb-dragging");
|
|
2118
|
-
if (overId === segment.id && dragId !== segment.id) {
|
|
2119
|
-
classes.push(overAfter === true ? "dsb-over-after" : "dsb-over-before");
|
|
2120
|
-
}
|
|
2406
|
+
const dragged = drag !== null && drag.id === segment.id;
|
|
2121
2407
|
return h("div", {
|
|
2122
|
-
className:
|
|
2408
|
+
className: dragged === true ? "dsb-row dsb-dragging" : "dsb-row",
|
|
2123
2409
|
key: segment.id,
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
try {
|
|
2128
|
-
event.dataTransfer.effectAllowed = "move";
|
|
2129
|
-
event.dataTransfer.setData("text/plain", segment.id);
|
|
2130
|
-
} catch (error) { /* 某些环境 dataTransfer 受限 */ }
|
|
2410
|
+
ref: (node) => {
|
|
2411
|
+
if (node === null || node === undefined) rowNodes.current.delete(segment.id);
|
|
2412
|
+
else rowNodes.current.set(segment.id, node);
|
|
2131
2413
|
},
|
|
2132
|
-
|
|
2133
|
-
if (
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
if (Math.abs(event.clientY - middle) < 4) return;
|
|
2140
|
-
const after = event.clientY > middle;
|
|
2141
|
-
if (overId !== segment.id || overAfter !== after) {
|
|
2142
|
-
setOverId(segment.id);
|
|
2143
|
-
setOverAfter(after);
|
|
2144
|
-
}
|
|
2145
|
-
},
|
|
2146
|
-
onDragLeave: () => {
|
|
2147
|
-
if (overId === segment.id) {
|
|
2148
|
-
setOverId(null);
|
|
2149
|
-
setOverAfter(false);
|
|
2150
|
-
}
|
|
2414
|
+
onPointerDown: (event) => {
|
|
2415
|
+
if (event.button !== 0) return;
|
|
2416
|
+
/* 只有右侧那六个点能抓:整行可抓会跟勾选、选中文字打架 */
|
|
2417
|
+
const target = event.target;
|
|
2418
|
+
if (target === null || target === undefined) return;
|
|
2419
|
+
if (String(target.className === undefined ? "" : target.className).indexOf("dsb-handle") === -1) return;
|
|
2420
|
+
startDrag(segment.id, index, event);
|
|
2151
2421
|
},
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
setOverId(null);
|
|
2157
|
-
setOverAfter(false);
|
|
2158
|
-
},
|
|
2159
|
-
onDragEnd: () => { setDragId(null); setOverId(null); setOverAfter(false); }
|
|
2160
|
-
},
|
|
2161
|
-
h("label", { className: "dsb-check" },
|
|
2162
|
-
h("input", {
|
|
2163
|
-
type: "checkbox",
|
|
2164
|
-
checked: on,
|
|
2165
|
-
onChange: (event) => toggleSegment(segment.id, event.target.checked)
|
|
2166
|
-
}),
|
|
2167
|
-
h("span", { className: "dsb-labels" },
|
|
2168
|
-
h("span", { className: "dsb-name" }, t(segment.label)),
|
|
2169
|
-
h("span", { className: "dsb-desc" }, t(segment.hint))
|
|
2170
|
-
)
|
|
2171
|
-
),
|
|
2172
|
-
h("span", {
|
|
2173
|
-
className: on === true ? "dsb-handle" : "dsb-handle dsb-handle-off",
|
|
2174
|
-
title: on === true ? t("dragHint") : ""
|
|
2175
|
-
})
|
|
2176
|
-
);
|
|
2422
|
+
onPointerMove: dragged === true ? moveDrag : undefined,
|
|
2423
|
+
onPointerUp: dragged === true ? endDrag : undefined,
|
|
2424
|
+
onPointerCancel: dragged === true ? cancelDrag : undefined
|
|
2425
|
+
}, rowParts(segment, on));
|
|
2177
2426
|
});
|
|
2427
|
+
|
|
2428
|
+
/* 跟着鼠标的那张卡:位置锁在按下那一行的矩形上,纵向随 dy 走 */
|
|
2429
|
+
const dragCard = drag === null || drag.at === null || drag.at === undefined ? null : h("div", {
|
|
2430
|
+
className: "dsb-drag-card",
|
|
2431
|
+
key: "__dragcard",
|
|
2432
|
+
style: {
|
|
2433
|
+
left: drag.at.left + "px",
|
|
2434
|
+
top: drag.cardTop + "px",
|
|
2435
|
+
width: drag.at.width + "px",
|
|
2436
|
+
height: drag.at.height + "px"
|
|
2437
|
+
}
|
|
2438
|
+
}, rowParts(byId[drag.id], isSegmentOn(cfg, drag.id)));
|
|
2439
|
+
|
|
2178
2440
|
sections.push(h("div", { className: "dsb-section", key: "__segments" },
|
|
2179
2441
|
h("div", { className: "dsb-sectitle" }, t("secSegments")),
|
|
2180
2442
|
h("p", { className: "dsb-hint" }, t("secSegmentsHint")),
|
|
2181
2443
|
contextRow,
|
|
2182
|
-
segmentRows
|
|
2444
|
+
segmentRows,
|
|
2445
|
+
dragCard
|
|
2183
2446
|
));
|
|
2184
2447
|
|
|
2185
2448
|
/* 自定义模型价格 */
|
package/lib/index.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* @module dsh-desktop-statusbar
|
|
23
23
|
*/
|
|
24
24
|
import { z } from 'zod'
|
|
25
|
+
import { adapterFor } from './balance-api.js'
|
|
25
26
|
import { holidayHtmlToText, parseHolidayNotice } from './holiday.js'
|
|
26
27
|
import { readFileSync } from 'node:fs'
|
|
27
28
|
import { join } from 'node:path'
|
|
@@ -640,6 +641,82 @@ function keyFromCredentialStore(ctx) {
|
|
|
640
641
|
return null
|
|
641
642
|
}
|
|
642
643
|
|
|
644
|
+
/**
|
|
645
|
+
* 按适配器的凭据引用名逐个解析 key。用 credentials 服务的 resolve()(子代理核实:
|
|
646
|
+
* describe() 只回答"配了没",resolve() 才给出真实值 { value, source })。
|
|
647
|
+
* ref 在运行时就是普通字符串 —— credentialRef() 只做编译期品牌标记,所以不必 import 它。
|
|
648
|
+
*/
|
|
649
|
+
async function resolveAdapterKey(ctx, adapter) {
|
|
650
|
+
const credentials = ctx !== null && ctx !== undefined && typeof ctx.get === 'function' ? ctx.get('credentials') : null
|
|
651
|
+
if (credentials !== null && credentials !== undefined && typeof credentials.resolve === 'function') {
|
|
652
|
+
for (let i = 0; i < adapter.refs.length; i += 1) {
|
|
653
|
+
try {
|
|
654
|
+
const hit = await credentials.resolve(adapter.refs[i])
|
|
655
|
+
if (hit !== undefined && hit !== null && typeof hit.value === 'string' && hit.value.length > 0) {
|
|
656
|
+
return { key: hit.value, source: adapter.refs[i] }
|
|
657
|
+
}
|
|
658
|
+
} catch (error) {
|
|
659
|
+
/* 换下一个引用名 */
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
/* 没挂凭据服务时的旧路径:环境变量 / .credentials.yaml */
|
|
664
|
+
const fallback = findApiKey(ctx)
|
|
665
|
+
return { key: fallback.key, source: fallback.source }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** 用适配器查第三方平台的余额,返回与 DeepSeek 那条路一致的形状。 */
|
|
669
|
+
/** 从适配器返回的 parts 里按 label 取金额;没有这个 label 就返回 undefined(气泡少一行)。 */
|
|
670
|
+
function partAmount(parts, label) {
|
|
671
|
+
const list = Array.isArray(parts) ? parts : []
|
|
672
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
673
|
+
if (list[i] !== null && list[i] !== undefined && list[i].label === label) return list[i].amount
|
|
674
|
+
}
|
|
675
|
+
return undefined
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function queryWithAdapter(ctx, adapter, now) {
|
|
679
|
+
const found = await resolveAdapterKey(ctx, adapter)
|
|
680
|
+
if (found.key === null) {
|
|
681
|
+
logHost(ctx, 'balance(' + adapter.id + '): 没取到 key,试过 ' + adapter.refs.join(' / '))
|
|
682
|
+
return { ok: false, at: now, reason: 'no-credential', provider: adapter.id }
|
|
683
|
+
}
|
|
684
|
+
const spec = adapter.request(found.key)
|
|
685
|
+
try {
|
|
686
|
+
const response = await fetch(spec.url, {
|
|
687
|
+
method: 'GET',
|
|
688
|
+
headers: spec.headers,
|
|
689
|
+
signal: AbortSignal.timeout(10000),
|
|
690
|
+
})
|
|
691
|
+
if (!response.ok) {
|
|
692
|
+
logHost(ctx, 'balance(' + adapter.id + '): HTTP ' + String(response.status))
|
|
693
|
+
return { ok: false, at: now, reason: 'http-' + String(response.status), source: found.source, provider: adapter.id }
|
|
694
|
+
}
|
|
695
|
+
const parsed = adapter.parse(await response.json())
|
|
696
|
+
if (parsed === null) {
|
|
697
|
+
logHost(ctx, 'balance(' + adapter.id + '): 响应里解析不出余额')
|
|
698
|
+
return { ok: false, at: now, reason: 'no-balance-info', source: found.source, provider: adapter.id }
|
|
699
|
+
}
|
|
700
|
+
return {
|
|
701
|
+
ok: true,
|
|
702
|
+
at: now,
|
|
703
|
+
source: found.source,
|
|
704
|
+
provider: adapter.id,
|
|
705
|
+
currency: parsed.currency,
|
|
706
|
+
total: parsed.total,
|
|
707
|
+
isPercent: parsed.isPercent === true,
|
|
708
|
+
parts: parsed.parts,
|
|
709
|
+
/* 余额气泡那几行读的是顶层字段(与 DeepSeek 那条路保持同一形状),这里按 label 摊平 */
|
|
710
|
+
toppedUp: partAmount(parsed.parts, 'toppedUp'),
|
|
711
|
+
granted: partAmount(parsed.parts, 'granted'),
|
|
712
|
+
spent: partAmount(parsed.parts, 'spent'),
|
|
713
|
+
}
|
|
714
|
+
} catch (error) {
|
|
715
|
+
logHost(ctx, 'balance(' + adapter.id + '): ' + String(error === null || error === undefined ? 'unknown' : error.message))
|
|
716
|
+
return { ok: false, at: now, reason: 'network', source: found.source, provider: adapter.id }
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
643
720
|
function findApiKey(ctx) {
|
|
644
721
|
const fromEnv = keyFromEnv()
|
|
645
722
|
if (fromEnv !== null) return { key: fromEnv, source: 'env' }
|
|
@@ -730,12 +807,21 @@ async function queryAccountBalance(ctx) {
|
|
|
730
807
|
}
|
|
731
808
|
}
|
|
732
809
|
|
|
733
|
-
async function queryBalance(ctx, force) {
|
|
810
|
+
async function queryBalance(ctx, force, provider, model) {
|
|
734
811
|
const now = Date.now()
|
|
735
812
|
if (force !== true && balanceCache.value !== null && now - balanceCache.at < BALANCE_TTL_MS) {
|
|
736
813
|
return balanceCache.value
|
|
737
814
|
}
|
|
738
815
|
|
|
816
|
+
/* 当前会话用的是第三方平台就交给适配器:每个平台有自己的端点、鉴权与字段。
|
|
817
|
+
DeepSeek(含识别不出来的情况)继续走下面的账号服务 + API key 老路。 */
|
|
818
|
+
const adapter = adapterFor(provider, model)
|
|
819
|
+
if (adapter !== null && adapter.id !== 'deepseek') {
|
|
820
|
+
const third = await queryWithAdapter(ctx, adapter, now)
|
|
821
|
+
balanceCache = { at: now, value: third, provider: adapter.id }
|
|
822
|
+
return third
|
|
823
|
+
}
|
|
824
|
+
|
|
739
825
|
/* 先问官方账号服务:与设置页同一份数据(充值 + 赠金),拿不到才走下面的 API key 查询 */
|
|
740
826
|
const fromAccount = await queryAccountBalance(ctx)
|
|
741
827
|
if (fromAccount !== null) {
|
|
@@ -936,7 +1022,7 @@ export function apply(ctx) {
|
|
|
936
1022
|
return
|
|
937
1023
|
}
|
|
938
1024
|
const force = url.searchParams.get('force') === '1'
|
|
939
|
-
const value = await queryBalance(ctx, force)
|
|
1025
|
+
const value = await queryBalance(ctx, force, url.searchParams.get('provider'), url.searchParams.get('model'))
|
|
940
1026
|
sendJson(res, 200, Object.assign({}, value, { probe: accountProbeNote }))
|
|
941
1027
|
},
|
|
942
1028
|
}), 'dsh-desktop-statusbar: balance api')
|