dsh-subusage 0.7.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 +187 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +1096 -0
- package/lib/index.js +534 -0
- package/lib/mimo-login.js +122 -0
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +66 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1096 @@
|
|
|
1
|
+
// dsh-subusage —— 订阅用量显示(Client 侧)
|
|
2
|
+
//
|
|
3
|
+
// 两块 UI:
|
|
4
|
+
// 1. 用量药丸(conversation.input.right 槽,紧邻模型选择器左侧):按当前选中模型的
|
|
5
|
+
// provider 显示对应模型商的订阅用量,拉取成功 🟢 / 失败 🔴;点开看三窗明细。
|
|
6
|
+
// 2. 设置页(settings.section 槽,OpenCode Go 同款布局):检测已配置模型商、继承
|
|
7
|
+
// 环境 Key、缺失项手动填写;MiMo 仅使用官方站点链接与显式 Cookie 替换。
|
|
8
|
+
//
|
|
9
|
+
// Remote 契约与 Host 的 lib/index.js 对齐(dsh-subusage#SubUsageResult / Settings)。
|
|
10
|
+
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: "dsh-subusage",
|
|
13
|
+
factory: (require) => {
|
|
14
|
+
var module = { exports: {} };
|
|
15
|
+
var exports = module.exports;
|
|
16
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
17
|
+
const react = require("react");
|
|
18
|
+
const h = react.createElement;
|
|
19
|
+
|
|
20
|
+
// ── 词典 ────────────────────────────────────────────────────────────────
|
|
21
|
+
const zh = {
|
|
22
|
+
nav: "订阅用量",
|
|
23
|
+
title: "订阅用量显示",
|
|
24
|
+
hint: "按当前会话模型显示对应模型商的订阅用量。Key 可继承凭据服务或环境变量,也可手动设置;MiMo 使用官方平台 Cookie。仅活跃模型商每分钟刷新,数据获取成功不表示仍有额度。",
|
|
25
|
+
refresh: "立即刷新",
|
|
26
|
+
refreshing: "刷新中…",
|
|
27
|
+
save: "保存设置",
|
|
28
|
+
saving: "保存中…",
|
|
29
|
+
saved: "已保存",
|
|
30
|
+
lastUpdated: "更新于",
|
|
31
|
+
lightOk: "全部拉取成功",
|
|
32
|
+
lightFail: "存在拉取失败",
|
|
33
|
+
statusOk: "数据获取成功",
|
|
34
|
+
statusPartial: "数据不完整,额度未知",
|
|
35
|
+
statusCache: "缓存数据",
|
|
36
|
+
cacheAge: "缓存 · {n} 分钟前",
|
|
37
|
+
cacheUnknown: "缓存时间未知",
|
|
38
|
+
badgeUnknown: "未确认",
|
|
39
|
+
statusDisabled: "订阅检测已关闭",
|
|
40
|
+
statusNoKey: "未配置 API Key",
|
|
41
|
+
statusNoCookie: "需登录",
|
|
42
|
+
statusError: "拉取失败",
|
|
43
|
+
detected: "已配置路由",
|
|
44
|
+
notDetected: "未配置路由",
|
|
45
|
+
keyEnv: "已继承环境变量 {name}",
|
|
46
|
+
keyManual: "手动填写的 Key",
|
|
47
|
+
keyNone: "未继承环境 Key",
|
|
48
|
+
keyPlaceholder: "留空保持不变;填写即覆盖",
|
|
49
|
+
keyLabel: "API Key",
|
|
50
|
+
zaiType: "套餐类型",
|
|
51
|
+
zaiTypePersonal: "个人(type=1)",
|
|
52
|
+
zaiTypeTeam: "团队(type=2)",
|
|
53
|
+
zaiOrg: "组织 ID(团队套餐)",
|
|
54
|
+
zaiProject: "项目 ID(团队套餐)",
|
|
55
|
+
zaiOrgHint: "仅团队套餐需要:在 bigmodel.cn/coding-plan 登录后从 DevTools 请求头 bigmodel-organization / bigmodel-project 抄取。",
|
|
56
|
+
mimoCookie: "敏感 Cookie(仅替换,不回填)",
|
|
57
|
+
mimoCookieHint: "在官方平台登录后导入 Cookie,支持 JSON、多行 name=value、TAB 表格;保存前验证并规范化,必须含 api-platform_serviceToken 与 userId。Cookie 不会回填,请仅在需要替换时输入。",
|
|
58
|
+
resets: "重置于",
|
|
59
|
+
limited: "已达限额",
|
|
60
|
+
cascadeLimited: "(受更长周期限额连累,暂不可用)",
|
|
61
|
+
retry: "立即重试",
|
|
62
|
+
loading: "正在读取用量…",
|
|
63
|
+
unavailable: "暂不可用",
|
|
64
|
+
planLabel: "套餐",
|
|
65
|
+
balanceLabel: "余额",
|
|
66
|
+
monthlyBalanceLabel: "月额度剩余",
|
|
67
|
+
planSnapshotLimit: "月总额来自套餐快照,不含已购/赠送余额;未知套餐不推算上限",
|
|
68
|
+
w5h: "5 小时",
|
|
69
|
+
w7d: "7 天",
|
|
70
|
+
wweek: "每周",
|
|
71
|
+
wday: "每日",
|
|
72
|
+
wmonth: "每月",
|
|
73
|
+
wperiod: "本周期",
|
|
74
|
+
wsub: "本周期",
|
|
75
|
+
wrolling: "滚动",
|
|
76
|
+
wall: "全部",
|
|
77
|
+
detailUsed: "已用 {used} / 总计 {limit} {unit}",
|
|
78
|
+
detailReset: "重置于 {time}",
|
|
79
|
+
detailResetIn: "重置于 {time}(还有 {rest})",
|
|
80
|
+
detailResetDue: "已到重置时间,等待刷新",
|
|
81
|
+
restDays: "{n} 天",
|
|
82
|
+
restHours: "{n} 小时",
|
|
83
|
+
restMins: "{n} 分钟",
|
|
84
|
+
badgeOk: "可用",
|
|
85
|
+
badgeHigh: "偏高",
|
|
86
|
+
badgeTight: "紧张",
|
|
87
|
+
badgeLimited: "不可用",
|
|
88
|
+
badgeBlocked: "受连累",
|
|
89
|
+
numUsed: "已用 {p}%",
|
|
90
|
+
numLimited: "⚠ 已达限额",
|
|
91
|
+
numBlocked: "⚠ 暂不可用",
|
|
92
|
+
pillRemaining: "余 {p}%",
|
|
93
|
+
pillLimited: "已达限额",
|
|
94
|
+
pillBlocked: "受连累",
|
|
95
|
+
needConfigShort: "需配置",
|
|
96
|
+
pillNeedKey: "未配置 API Key —— 请打开 设置 → 订阅用量 填写,或导出同名环境变量后重启。",
|
|
97
|
+
pillNeedCookie: "需登录 —— 请在 设置 → 订阅用量 访问官方平台并替换 Cookie。",
|
|
98
|
+
noUsageData: "暂无额度数据",
|
|
99
|
+
providerUnavailable: "用量暂不可用",
|
|
100
|
+
pillNeedCommandcode: "需配置 —— 可在 设置 → Command Code 登录、运行 cmd login,或导出 COMMANDCODE_API_KEY 后重试。",
|
|
101
|
+
accountLabel: "账户",
|
|
102
|
+
commandcodeManaged: "凭据由 Command Code 提供方插件(设置 → Command Code)管理;本页只读、不保存凭据。用量由本插件直接拉取其计费接口,取用同一凭据来源(凭据服务 COMMANDCODE_API_KEY → 启动环境 → ~/.commandcode/auth.json)。",
|
|
103
|
+
commandcodeHelp: "并行读取 /alpha/billing/credits 和 /alpha/billing/subscriptions(默认 API 地址 https://api.commandcode.ai)。月总额按已知套餐快照推算,未知套餐只显示月剩余;月池耗尽不代表已购/赠送池不可用。多账户轮换或固定 activeAccount 时这里显示默认账户(顶层 Key)的额度,可能与实际服务账户不同;插件里自定义 apiBase 不会被跟随。"
|
|
104
|
+
};
|
|
105
|
+
const en = {
|
|
106
|
+
nav: "Subscription usage",
|
|
107
|
+
title: "Subscription usage display",
|
|
108
|
+
hint: "Shows subscription usage for the current model provider. Keys inherit from the credential service or environment, or can be set manually. MiMo uses the official platform Cookie. Only active providers refresh every minute. Fetch success does not mean quota remains.",
|
|
109
|
+
refresh: "Refresh now",
|
|
110
|
+
refreshing: "Refreshing…",
|
|
111
|
+
save: "Save settings",
|
|
112
|
+
saving: "Saving…",
|
|
113
|
+
saved: "Saved",
|
|
114
|
+
lastUpdated: "Updated",
|
|
115
|
+
lightOk: "All fetches succeeded",
|
|
116
|
+
lightFail: "Some fetches failed",
|
|
117
|
+
statusOk: "Fetch OK",
|
|
118
|
+
statusPartial: "Partial data; quota unknown",
|
|
119
|
+
statusCache: "Cached data",
|
|
120
|
+
cacheAge: "Cached · {n} min ago",
|
|
121
|
+
cacheUnknown: "Cache age unknown",
|
|
122
|
+
badgeUnknown: "Unconfirmed",
|
|
123
|
+
statusDisabled: "Subscription detection disabled",
|
|
124
|
+
statusNoKey: "No API key configured",
|
|
125
|
+
statusNoCookie: "Login required",
|
|
126
|
+
statusError: "Fetch failed",
|
|
127
|
+
detected: "Route configured",
|
|
128
|
+
notDetected: "No route configured",
|
|
129
|
+
keyEnv: "Inherited from {name}",
|
|
130
|
+
keyManual: "Manual API key",
|
|
131
|
+
keyNone: "No inherited key",
|
|
132
|
+
keyPlaceholder: "Leave empty to keep; type to override",
|
|
133
|
+
keyLabel: "API key",
|
|
134
|
+
zaiType: "Plan type",
|
|
135
|
+
zaiTypePersonal: "Personal (type=1)",
|
|
136
|
+
zaiTypeTeam: "Team (type=2)",
|
|
137
|
+
zaiOrg: "Organization ID (team plans)",
|
|
138
|
+
zaiProject: "Project ID (team plans)",
|
|
139
|
+
zaiOrgHint: "Team plans only: log in at bigmodel.cn/coding-plan and copy the bigmodel-organization / bigmodel-project request headers from DevTools.",
|
|
140
|
+
mimoCookie: "Sensitive Cookie (replace only; never filled back)",
|
|
141
|
+
mimoCookieHint: "Sign in on the official platform and import JSON, name=value lines or a TAB table. Cookie is validated before saving and is never filled back. Requires api-platform_serviceToken and userId.",
|
|
142
|
+
resets: "Resets",
|
|
143
|
+
limited: "Limit reached",
|
|
144
|
+
cascadeLimited: " (blocked by a larger window's limit)",
|
|
145
|
+
retry: "Retry now",
|
|
146
|
+
loading: "Loading usage…",
|
|
147
|
+
unavailable: "Unavailable",
|
|
148
|
+
planLabel: "Plan",
|
|
149
|
+
balanceLabel: "Balance",
|
|
150
|
+
monthlyBalanceLabel: "Monthly credits remaining",
|
|
151
|
+
planSnapshotLimit: "Monthly total uses a plan snapshot, excluding purchased/free credits; unknown plans have no estimated cap",
|
|
152
|
+
w5h: "5 hours",
|
|
153
|
+
w7d: "7 days",
|
|
154
|
+
wweek: "Week",
|
|
155
|
+
wday: "Day",
|
|
156
|
+
wmonth: "Month",
|
|
157
|
+
wperiod: "Period",
|
|
158
|
+
wsub: "Monthly",
|
|
159
|
+
wrolling: "Rolling",
|
|
160
|
+
wall: "All",
|
|
161
|
+
detailUsed: "Used {used} / total {limit} {unit}",
|
|
162
|
+
detailReset: "Resets {time}",
|
|
163
|
+
detailResetIn: "Resets {time} (in {rest})",
|
|
164
|
+
detailResetDue: "Reset due — waiting for refresh",
|
|
165
|
+
restDays: "{n} d",
|
|
166
|
+
restHours: "{n} h",
|
|
167
|
+
restMins: "{n} min",
|
|
168
|
+
badgeOk: "OK",
|
|
169
|
+
badgeHigh: "High",
|
|
170
|
+
badgeTight: "Tight",
|
|
171
|
+
badgeLimited: "Unavailable",
|
|
172
|
+
badgeBlocked: "Blocked",
|
|
173
|
+
numUsed: "Used {p}%",
|
|
174
|
+
numLimited: "⚠ Limit reached",
|
|
175
|
+
numBlocked: "⚠ Unavailable",
|
|
176
|
+
pillRemaining: "{p}% left",
|
|
177
|
+
pillLimited: "Limit reached",
|
|
178
|
+
pillBlocked: "Blocked",
|
|
179
|
+
needConfigShort: "Setup",
|
|
180
|
+
pillNeedKey: "No API key — open Settings → Subscription usage to fill one in, or export the same-named env variable and restart.",
|
|
181
|
+
pillNeedCookie: "Login required — paste a cookie in Settings → Subscription usage.",
|
|
182
|
+
noUsageData: "No quota data",
|
|
183
|
+
providerUnavailable: "Usage unavailable",
|
|
184
|
+
pillNeedCommandcode: "Setup required — sign in under Settings → Command Code, run cmd login, or export COMMANDCODE_API_KEY and retry.",
|
|
185
|
+
accountLabel: "Account",
|
|
186
|
+
commandcodeManaged: "Credentials are managed by the Command Code provider plugin (Settings → Command Code); this page is read-only and stores nothing. Usage is fetched directly by this plugin from its billing endpoint, using the same credential sources (credential service COMMANDCODE_API_KEY → launch environment → ~/.commandcode/auth.json).",
|
|
187
|
+
commandcodeHelp: "Parallel requests to /alpha/billing/credits and /alpha/billing/subscriptions (default API base https://api.commandcode.ai). Monthly totals use a known-plan snapshot; unknown plans show remaining credits only. Monthly-pool depletion does not imply purchased/free credits are unusable. With account rotation or a pinned activeAccount this shows the default (top-level key) account's quota, which may differ from the serving account; a custom apiBase in the provider plugin is not followed."
|
|
188
|
+
};
|
|
189
|
+
const NS = "settings.subusage";
|
|
190
|
+
const KIND_LABEL = { "5h": "w5h", "7d": "w7d", day: "wday", week: "wweek", month: "wmonth", period: "wperiod", sub: "wsub", rolling: "wrolling" };
|
|
191
|
+
const KIND_SHORT = { "5h": "5h", "7d": "7d", day: "D", week: "W", month: "M", period: "P", sub: "Sub", rolling: "R" };
|
|
192
|
+
const PROVIDER_META = {
|
|
193
|
+
"zai-coding": { label: "Z.ai Coding (International)", short: "Z.ai Intl", envName: "ZAI_CODING_API_KEY", defaultEnabled: false },
|
|
194
|
+
"synthetic": { label: "Synthetic", short: "Synthetic", envName: "SYNTHETIC_API_KEY", defaultEnabled: false },
|
|
195
|
+
"nanogpt": { label: "NanoGPT", short: "NanoGPT", envName: "NANOGPT_API_KEY", defaultEnabled: false },
|
|
196
|
+
"zai-coding-cn": { label: "Z.ai Coding (CN)", short: "Z.ai", envName: "ZAI_CODING_CN_API_KEY" },
|
|
197
|
+
"kimi-coding": { label: "Kimi Coding", short: "Kimi", envName: "KIMI_CODING_API_KEY" },
|
|
198
|
+
"xiaomi-token-plan-cn": { label: "Xiaomi Token Plan (CN)", short: "MiMo", envName: "XIAOMI_TOKEN_PLAN_CN_API_KEY" },
|
|
199
|
+
"opencode-go": { label: "OpenCode Go", short: "OpenCode Go", envName: "OPENCODE_API_KEY" },
|
|
200
|
+
// 凭据与多账户由提供方插件管理;managedByPlugin 时不渲染本地凭据编辑。
|
|
201
|
+
"commandcode": { label: "Command Code", short: "Command", envName: "COMMANDCODE_API_KEY", managedByPlugin: true },
|
|
202
|
+
"minimax": { label: "MiniMax (International)", short: "MiniMax", envName: "MINIMAX_API_KEY" },
|
|
203
|
+
"minimax-cn": { label: "MiniMax (CN)", short: "MiniMax CN", envName: "MINIMAX_CN_API_KEY" }
|
|
204
|
+
};
|
|
205
|
+
const PROVIDER_ORDER = ["zai-coding-cn", "kimi-coding", "xiaomi-token-plan-cn", "opencode-go", "commandcode", "minimax", "minimax-cn", "zai-coding", "synthetic", "nanogpt"];
|
|
206
|
+
function providerEnabled(settings, id) { return settings?.visibility?.providers?.[id] ?? (PROVIDER_META[id]?.defaultEnabled !== false); }
|
|
207
|
+
function providerVisible(settings, id, entry) {
|
|
208
|
+
if (!providerEnabled(settings, id)) return false;
|
|
209
|
+
if (settings?.visibility?.hideWithoutApi === false) return true;
|
|
210
|
+
return entry?.apiDetected !== false && !["no-key", "no-cookie"].includes(entry?.state);
|
|
211
|
+
}
|
|
212
|
+
/** token 数紧凑格式:1,234,567,890 → 1.23B */
|
|
213
|
+
function fmtTokens(n) {
|
|
214
|
+
const v = Number(n) || 0;
|
|
215
|
+
if (v >= 1e9) return (v / 1e9).toFixed(2) + "B";
|
|
216
|
+
if (v >= 1e6) return (v / 1e6).toFixed(2) + "M";
|
|
217
|
+
if (v >= 1e3) return (v / 1e3).toFixed(1) + "K";
|
|
218
|
+
return String(v);
|
|
219
|
+
}
|
|
220
|
+
/** 用量档位:颜色 + 徽标 + 主数字文案(药丸/弹层/设置页三重编码统一来源)。 */
|
|
221
|
+
function usageTier(w) {
|
|
222
|
+
if (w.status === "rate-limited") {
|
|
223
|
+
return w.cascade
|
|
224
|
+
? { icon: "✕", badge: "badgeBlocked", color: "#ef4444", num: "numBlocked", rank: 4 }
|
|
225
|
+
: { icon: "✕", badge: "badgeLimited", color: "#ef4444", num: "numLimited", rank: 4 };
|
|
226
|
+
}
|
|
227
|
+
if (w.percent >= 91) return { icon: "⚠", badge: "badgeTight", color: "#f97316", num: null, rank: 3 };
|
|
228
|
+
if (w.percent >= 71) return { icon: "⚠", badge: "badgeHigh", color: "#eab308", num: null, rank: 2 };
|
|
229
|
+
return { icon: "✓", badge: "badgeOk", color: "#22c55e", num: null, rank: 1 };
|
|
230
|
+
}
|
|
231
|
+
/** tag 圆点色:最差窗口档位色(绿/黄/橙/红);拉取失败红。 */
|
|
232
|
+
function tagColor(entry) {
|
|
233
|
+
if (!entry || entry.state === "loading") return "#94a3b8";
|
|
234
|
+
if (entry.state !== "ok") return "#ef4444";
|
|
235
|
+
if ((entry.windows || []).some((w) => w.status === "rate-limited")) return "#ef4444";
|
|
236
|
+
if (entry.coverage === "partial" || entry.freshness === "stale") return "#eab308";
|
|
237
|
+
const rank = { "#22c55e": 0, "#eab308": 1, "#f97316": 2, "#ef4444": 3 };
|
|
238
|
+
let worst = "#22c55e";
|
|
239
|
+
for (const w of entry.windows || []) {
|
|
240
|
+
const c = usageTier(w).color;
|
|
241
|
+
if (rank[c] > rank[worst]) worst = c;
|
|
242
|
+
}
|
|
243
|
+
return worst;
|
|
244
|
+
}
|
|
245
|
+
/** 数字色:0–70 白(前景),其余随档位。 */
|
|
246
|
+
function numColor(w) {
|
|
247
|
+
return usageTier(w).rank === 1 ? "inherit" : usageTier(w).color;
|
|
248
|
+
}
|
|
249
|
+
/** 最差窗:档位最高者优先;同档时根因(非连坐)优先,再取已用更多者。 */
|
|
250
|
+
function worstWindow(windows) {
|
|
251
|
+
let worst = null;
|
|
252
|
+
for (const w of windows) {
|
|
253
|
+
if (worst === null) { worst = w; continue; }
|
|
254
|
+
const a = usageTier(w);
|
|
255
|
+
const b = usageTier(worst);
|
|
256
|
+
if (a.rank !== b.rank) { if (a.rank > b.rank) worst = w; continue; }
|
|
257
|
+
const ownA = w.status === "rate-limited" && !w.cascade;
|
|
258
|
+
const ownB = worst.status === "rate-limited" && !worst.cascade;
|
|
259
|
+
if (ownA !== ownB) { if (ownA) worst = w; continue; }
|
|
260
|
+
if (w.percent > worst.percent) worst = w;
|
|
261
|
+
}
|
|
262
|
+
return worst;
|
|
263
|
+
}
|
|
264
|
+
/** 药丸文案:永远答「还能用多少」(余),限额/连坐直说。多窗带窗口短名。 */
|
|
265
|
+
function pillText(w, multi, t) {
|
|
266
|
+
const short = multi ? `${KIND_SHORT[w.kind] ?? w.kind} ` : "";
|
|
267
|
+
if (w.status === "rate-limited" && !w.cascade) return `${short}${t("pillLimited")}`;
|
|
268
|
+
if (w.status === "rate-limited") return `${short}${t("pillBlocked")}`;
|
|
269
|
+
return `${short}${t("pillRemaining").replace("{p}", String(Math.round(Math.max(0, 100 - w.percent) * 10) / 10))}`;
|
|
270
|
+
}
|
|
271
|
+
/** extras 标签:plan / account / balance 三类,未知类型回退为余额。 */
|
|
272
|
+
function extraLabel(x, t) {
|
|
273
|
+
return x.kind === "plan" ? t("planLabel") : x.kind === "account" ? t("accountLabel") : x.kind === "monthly-balance" ? t("monthlyBalanceLabel") : t("balanceLabel");
|
|
274
|
+
}
|
|
275
|
+
/** 重置行:带倒计时;到期未刷新显式说明。 */
|
|
276
|
+
function resetLine(w, t, getLocale) {
|
|
277
|
+
if (!w.resetsAt) return null;
|
|
278
|
+
const at = new Date(w.resetsAt);
|
|
279
|
+
const ms = at.getTime() - Date.now();
|
|
280
|
+
if (!(ms > 0)) return t("detailResetDue");
|
|
281
|
+
const mins = Math.round(ms / 60000);
|
|
282
|
+
const rest = mins >= 1440 ? t("restDays").replace("{n}", String(Math.round(mins / 1440)))
|
|
283
|
+
: mins >= 60 ? t("restHours").replace("{n}", String(Math.round(mins / 60)))
|
|
284
|
+
: t("restMins").replace("{n}", String(Math.max(1, mins)));
|
|
285
|
+
return t("detailResetIn").replace("{time}", at.toLocaleString(getLocale())).replace("{rest}", rest);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── remote 契约(与 Host 对齐)────────────────────────────────────────
|
|
289
|
+
function parseEntry(value) {
|
|
290
|
+
if (!value || typeof value !== "object" || typeof value.providerId !== "string" || typeof value.state !== "string") {
|
|
291
|
+
throw new Error("Invalid dsh-subusage entry");
|
|
292
|
+
}
|
|
293
|
+
return value;
|
|
294
|
+
}
|
|
295
|
+
function parseResult(value) {
|
|
296
|
+
if (!value || typeof value !== "object" || !Array.isArray(value.entries)) throw new Error("Invalid dsh-subusage result");
|
|
297
|
+
for (const e of value.entries) parseEntry(e);
|
|
298
|
+
return value;
|
|
299
|
+
}
|
|
300
|
+
function parseSettings(value) {
|
|
301
|
+
if (!value || typeof value !== "object") throw new Error("Invalid dsh-subusage settings");
|
|
302
|
+
if (value.visibility !== undefined) {
|
|
303
|
+
const v = value.visibility;
|
|
304
|
+
if (!v || typeof v !== "object" || Array.isArray(v) || Object.keys(value).some(k => !["visibility", "expectedRevision"].includes(k)) || typeof value.expectedRevision !== "string" || !value.expectedRevision || !Object.keys(v).length || Object.keys(v).some(k => !["providers", "hideWithoutApi"].includes(k))) throw new Error("Invalid visibility patch");
|
|
305
|
+
if (v.hideWithoutApi !== undefined && typeof v.hideWithoutApi !== "boolean") throw new Error("Invalid auto-hide switch");
|
|
306
|
+
if (v.providers !== undefined && (!v.providers || typeof v.providers !== "object" || Array.isArray(v.providers) || !Object.keys(v.providers).length || Object.entries(v.providers).some(([id, enabled]) => !PROVIDER_ORDER.includes(id) || typeof enabled !== "boolean"))) throw new Error("Invalid provider switches");
|
|
307
|
+
}
|
|
308
|
+
return value;
|
|
309
|
+
}
|
|
310
|
+
const resultCodec = {
|
|
311
|
+
mode: "strict",
|
|
312
|
+
typeSymbol: "dsh-subusage#SubUsageResult",
|
|
313
|
+
schema: { parse: parseResult },
|
|
314
|
+
create: () => ({ parse: parseResult })
|
|
315
|
+
};
|
|
316
|
+
const queryCodec = {
|
|
317
|
+
mode: "strict", typeSymbol: "dsh-subusage#SubUsageQuery",
|
|
318
|
+
schema: { parse: parseQuery }, create: () => ({ parse: parseQuery })
|
|
319
|
+
};
|
|
320
|
+
function parseQuery(value) {
|
|
321
|
+
if (!value || !Array.isArray(value.providerIds) || !value.providerIds.length || value.providerIds.some((id) => !PROVIDER_ORDER.includes(id)) || typeof value.force !== "boolean") throw new Error("Invalid dsh-subusage query");
|
|
322
|
+
return value;
|
|
323
|
+
}
|
|
324
|
+
const settingsCodec = {
|
|
325
|
+
mode: "strict",
|
|
326
|
+
typeSymbol: "dsh-subusage#SubUsageSettings",
|
|
327
|
+
schema: { parse: parseSettings },
|
|
328
|
+
create: () => ({ parse: parseSettings })
|
|
329
|
+
};
|
|
330
|
+
function parseLoginState(value) {
|
|
331
|
+
if (!value || !["idle", "launching", "waiting", "verifying", "success", "cancelled", "error"].includes(value.state) || !(value.jobId === null || typeof value.jobId === "string")) throw new Error("Invalid dsh-subusage login state");
|
|
332
|
+
if (value.result) parseResult(value.result);
|
|
333
|
+
return value;
|
|
334
|
+
}
|
|
335
|
+
const loginCodec = { mode: "strict", typeSymbol: "dsh-subusage#SubUsageLoginState", schema: { parse: parseLoginState }, create: () => ({ parse: parseLoginState }) };
|
|
336
|
+
const loginRequestCodec = (typeSymbol, field) => {
|
|
337
|
+
const parse = (value) => { if (!value || typeof value[field] !== "string" || !value[field]) throw new Error("Invalid dsh-subusage login request"); return value; };
|
|
338
|
+
return { mode: "strict", typeSymbol, schema: { parse }, create: () => ({ parse }) };
|
|
339
|
+
};
|
|
340
|
+
const subUsageRemote = {
|
|
341
|
+
package: "dsh-subusage",
|
|
342
|
+
descriptors: [
|
|
343
|
+
{
|
|
344
|
+
id: "dsh-subusage#subUsage/read",
|
|
345
|
+
service: "subUsage",
|
|
346
|
+
namespace: "subUsage",
|
|
347
|
+
method: "read",
|
|
348
|
+
invocation: { kind: "direct" },
|
|
349
|
+
parameters: [],
|
|
350
|
+
result: resultCodec
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
id: "dsh-subusage#subUsage/refresh",
|
|
354
|
+
service: "subUsage", namespace: "subUsage", method: "refresh",
|
|
355
|
+
invocation: { kind: "direct" },
|
|
356
|
+
parameters: [{ name: "request", wire: "request", source: "json", codec: queryCodec }], result: resultCodec
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
id: "dsh-subusage#subUsage/save",
|
|
360
|
+
service: "subUsage",
|
|
361
|
+
namespace: "subUsage",
|
|
362
|
+
method: "save",
|
|
363
|
+
invocation: { kind: "direct" },
|
|
364
|
+
parameters: [{
|
|
365
|
+
name: "settings",
|
|
366
|
+
wire: "settings",
|
|
367
|
+
source: "json",
|
|
368
|
+
codec: settingsCodec
|
|
369
|
+
}],
|
|
370
|
+
result: resultCodec
|
|
371
|
+
},
|
|
372
|
+
...[["startMimoLogin", "dsh-subusage#MimoLoginStart", "expectedRevision"], ["getMimoLoginStatus"], ["cancelMimoLogin", "dsh-subusage#MimoLoginCancel", "jobId"]].map(([method, typeSymbol, field]) => ({
|
|
373
|
+
id: `dsh-subusage#subUsage/${method}`, service: "subUsage", namespace: "subUsage", method, invocation: { kind: "direct" },
|
|
374
|
+
parameters: field ? [{ name: "request", wire: "request", source: "json", codec: loginRequestCodec(typeSymbol, field) }] : [], result: loginCodec
|
|
375
|
+
}))
|
|
376
|
+
]
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/** Cookie 解析失败抛错:调用者必须保留原输入,不允许静默清空。 */
|
|
380
|
+
function normalizeCookieText(raw) {
|
|
381
|
+
const text = String(raw || "").trim();
|
|
382
|
+
if (!text) return "";
|
|
383
|
+
if (/\r(?!\n)/.test(String(raw || "")) || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(String(raw || ""))) throw new Error("Cookie 含非法控制字符");
|
|
384
|
+
const pairs = [];
|
|
385
|
+
const domainOK = (domain) => !domain || ["platform.xiaomimimo.com", "xiaomimimo.com"].includes(String(domain).toLowerCase().replace(/^\./, ""));
|
|
386
|
+
const add = (name, value, domain) => {
|
|
387
|
+
if (!domainOK(domain)) return;
|
|
388
|
+
if (typeof name !== "string" || !/^[A-Za-z0-9_!#$%&'*+\-.^`|~]+$/.test(name)) throw new Error("Cookie 名称无效");
|
|
389
|
+
if (value === undefined || value === null || typeof value === "object") throw new Error("Cookie 值无效");
|
|
390
|
+
const v = String(value);
|
|
391
|
+
if (!/^("[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]*"|[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]*)$/.test(v)) throw new Error("Cookie 值含非法分隔符、空白、非 ASCII 字符或 CRLF");
|
|
392
|
+
pairs.push([name, v]);
|
|
393
|
+
};
|
|
394
|
+
if (/^[\[{]/.test(text)) {
|
|
395
|
+
let data;
|
|
396
|
+
try { data = JSON.parse(text); } catch { throw new Error("Cookie JSON 解析失败,请检查格式(原输入已保留)"); }
|
|
397
|
+
const list = Array.isArray(data) ? data : Array.isArray(data.cookies) ? data.cookies : Object.entries(data).map(([name, value]) => ({ name, value }));
|
|
398
|
+
for (const item of list) { if (!item || typeof item !== "object") throw new Error("Cookie JSON 项无效"); add(item.name, item.value, item.domain); }
|
|
399
|
+
} else {
|
|
400
|
+
// TAB 末列可以是合法空值,不能用整行/整段 trim 丢掉分隔符。
|
|
401
|
+
const lines = String(raw || "").split(/\r?\n/).filter((line) => line.trim()).map((line) => line.includes("\t") ? line : line.trim());
|
|
402
|
+
if (lines.some((line) => line.includes("\t"))) {
|
|
403
|
+
const netscape = lines.some((line) => { const cells = line.split("\t"); return cells.length >= 7 && /^(TRUE|FALSE)$/i.test(cells[1].trim()); });
|
|
404
|
+
let columns = null;
|
|
405
|
+
for (const line of lines) {
|
|
406
|
+
if (netscape && /^#(?!HttpOnly_)/i.test(line.trim())) continue;
|
|
407
|
+
const cells = line.split("\t").map((v) => v.trim());
|
|
408
|
+
if (/^(name|名称)$/i.test(cells[0])) { columns = cells.map((v) => v.toLowerCase()); continue; }
|
|
409
|
+
if (cells.length < 2) throw new Error("Cookie TAB 表格缺少值");
|
|
410
|
+
if (!columns && cells.length >= 7 && /^(TRUE|FALSE)$/i.test(cells[1])) { add(cells[5], cells[6], cells[0].replace(/^#HttpOnly_/i, "")); continue; }
|
|
411
|
+
const domainIndex = columns ? columns.findIndex((v) => v === "domain" || v === "域") : 2;
|
|
412
|
+
add(cells[0], cells[1], domainIndex >= 0 ? cells[domainIndex] : undefined);
|
|
413
|
+
}
|
|
414
|
+
} else if (lines.every((line) => line.includes("="))) {
|
|
415
|
+
for (const line of lines) for (const part of line.split(";")) {
|
|
416
|
+
if (!part.trim()) continue;
|
|
417
|
+
const at = part.indexOf("=");
|
|
418
|
+
if (at < 1) throw new Error("Cookie 必须为 name=value");
|
|
419
|
+
add(part.slice(0, at).trim(), part.slice(at + 1).trim());
|
|
420
|
+
}
|
|
421
|
+
} else {
|
|
422
|
+
if (lines[0] === "当前页面 Cookies") lines.shift();
|
|
423
|
+
if (lines.length % 2) throw new Error("Cookie 多行清单名称和值必须成对(原输入已保留)");
|
|
424
|
+
for (let i = 0; i < lines.length; i += 2) add(lines[i], lines[i + 1]);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (!pairs.length) throw new Error("未找到官方 MiMo 域的有效 Cookie(原输入已保留)");
|
|
428
|
+
return [...new Map(pairs)].map(([name, value]) => `${name}=${value}`).join("; ");
|
|
429
|
+
}
|
|
430
|
+
function validateCookieText(raw) {
|
|
431
|
+
const text = normalizeCookieText(raw);
|
|
432
|
+
const names = new Map(text.split("; ").map((pair) => { const at = pair.indexOf("="); return [pair.slice(0, at), pair.slice(at + 1).replace(/^"|"$/g, "")]; }));
|
|
433
|
+
if (!names.get("api-platform_serviceToken") || !names.get("userId")) throw new Error("Cookie 必须包含非空 api-platform_serviceToken 和 userId(原输入已保留)");
|
|
434
|
+
return text;
|
|
435
|
+
}
|
|
436
|
+
function canShowUsage(entry) {
|
|
437
|
+
if (!entry || entry.state === "no-key" || entry.state === "no-cookie" || /auth|credential|no-key|no-cookie/i.test(entry.errorCode || "")) return false;
|
|
438
|
+
if (entry.freshness === "stale") return entry.retainPrevious === true;
|
|
439
|
+
return entry.state === "ok";
|
|
440
|
+
}
|
|
441
|
+
function statusText(entry, t) {
|
|
442
|
+
if (!entry || entry.state === "loading") return t("loading");
|
|
443
|
+
if (entry.state === "disabled") return t("statusDisabled");
|
|
444
|
+
if (entry.state === "no-key") return t("statusNoKey");
|
|
445
|
+
if (entry.state === "no-cookie") return t("statusNoCookie");
|
|
446
|
+
if (entry.state !== "ok") return t("statusError");
|
|
447
|
+
if (entry.freshness === "stale") return t("statusCache");
|
|
448
|
+
if (entry.coverage === "partial") return t("statusPartial");
|
|
449
|
+
return t("statusOk");
|
|
450
|
+
}
|
|
451
|
+
function providerDescription(entry, t) {
|
|
452
|
+
const windows = canShowUsage(entry) ? entry.windows || [] : [];
|
|
453
|
+
const worst = worstWindow(windows);
|
|
454
|
+
return `${statusText(entry, t)}${worst ? " · " + pillText(worst, windows.length > 1, t) : " · " + t("noUsageData")}`;
|
|
455
|
+
}
|
|
456
|
+
function providerIcon(entry) {
|
|
457
|
+
if (!entry || entry.state === "loading") return "…";
|
|
458
|
+
if ((entry.windows || []).some((w) => w.status === "rate-limited")) return "✕";
|
|
459
|
+
if (entry.state !== "ok") return "!";
|
|
460
|
+
if (entry.coverage === "partial" || entry.freshness === "stale") return "?";
|
|
461
|
+
const worst = worstWindow(entry.windows || []);
|
|
462
|
+
return worst ? usageTier(worst).icon : "?";
|
|
463
|
+
}
|
|
464
|
+
function cacheAge(entry, t) {
|
|
465
|
+
if (!entry || entry.freshness !== "stale" || !canShowUsage(entry)) return null;
|
|
466
|
+
const at = Date.parse(entry.lastSuccessAt);
|
|
467
|
+
return Number.isFinite(at) ? t("cacheAge").replace("{n}", String(Math.max(0, Math.floor((Date.now() - at) / 60000)))) : t("cacheUnknown");
|
|
468
|
+
}
|
|
469
|
+
/** 每次 apply 一个 store;partial response 只合并请求项,所有槽共享 inflight 与活动计时。 */
|
|
470
|
+
function createUsageStore(call) {
|
|
471
|
+
let snapshot = { updatedAt: null, settings: null, configured: {}, entries: [] };
|
|
472
|
+
let disposed = false, initial = null, timer = null, epoch = 0, sequence = 0, metadataSequence = 0;
|
|
473
|
+
let loginGeneration = 0, loginJobId = null, loginPending = false;
|
|
474
|
+
const mergedLoginJobs = new Set();
|
|
475
|
+
const providerSequence = new Map();
|
|
476
|
+
const markRequest = (ids) => { const version = ++sequence; for (const id of ids) providerSequence.set(id, version); return { version, ids }; };
|
|
477
|
+
const listeners = new Set(), active = new Map(), inflight = new Map(), readers = new Map(), resetSeen = new Set();
|
|
478
|
+
const emit = () => { if (!disposed) for (const fn of listeners) fn(); };
|
|
479
|
+
const invalidate = (ids) => {
|
|
480
|
+
snapshot = { ...snapshot, entries: snapshot.entries.map((e) => ids.includes(e.providerId) ? { providerId: e.providerId, apiDetected: e.apiDetected, keySource: e.keySource, state: "loading", coverage: "partial", freshness: "unknown", windows: [], extras: [] } : e) };
|
|
481
|
+
for (const id of ids) inflight.delete(id);
|
|
482
|
+
};
|
|
483
|
+
const merge = (incoming, request = null, localChange = false) => {
|
|
484
|
+
if (disposed) return snapshot;
|
|
485
|
+
const value = { ...incoming };
|
|
486
|
+
if (request && request.version < metadataSequence) { delete value.settings; delete value.configured; delete value.updatedAt; }
|
|
487
|
+
if (value.settings) metadataSequence = request ? request.version : ++sequence;
|
|
488
|
+
if (!localChange && snapshot.settings && value.settings && snapshot.settings.revision !== value.settings.revision) { epoch++; invalidate(PROVIDER_ORDER); }
|
|
489
|
+
const entries = new Map(snapshot.entries.map((e) => [e.providerId, e]));
|
|
490
|
+
for (const e of value.entries || []) if (!request || providerSequence.get(e.providerId) === request.version) entries.set(e.providerId, e);
|
|
491
|
+
snapshot = { ...snapshot, ...value, entries: [...entries.values()] };
|
|
492
|
+
emit(); return snapshot;
|
|
493
|
+
};
|
|
494
|
+
const refresh = (ids, force = false) => {
|
|
495
|
+
if (disposed) return Promise.reject(new Error("Client 已卸载"));
|
|
496
|
+
const unique = [...new Set(ids)].filter((id) => PROVIDER_ORDER.includes(id) && providerEnabled(snapshot.settings, id));
|
|
497
|
+
const missing = unique.filter((id) => !inflight.has(id));
|
|
498
|
+
if (missing.length) {
|
|
499
|
+
const startedEpoch = epoch, request = markRequest(missing);
|
|
500
|
+
const task = Promise.resolve().then(() => call("refresh", { providerIds: missing, force: !!force })).then((value) => startedEpoch === epoch ? merge(value, request) : snapshot).catch((error) => {
|
|
501
|
+
// 传输失败不能擅自 retainPrevious:只有 Host 明确标 stale 才保留。
|
|
502
|
+
if (startedEpoch === epoch) merge({ entries: missing.map((providerId) => ({ providerId, state: "error", error: error instanceof Error ? error.message : String(error), freshness: "unknown", coverage: "partial", windows: [], extras: [] })) }, request);
|
|
503
|
+
throw error;
|
|
504
|
+
}).finally(() => { for (const id of missing) if (inflight.get(id) === task) inflight.delete(id); });
|
|
505
|
+
for (const id of missing) inflight.set(id, task);
|
|
506
|
+
}
|
|
507
|
+
return Promise.all([...new Set(unique.map((id) => inflight.get(id)))]).then(() => snapshot);
|
|
508
|
+
};
|
|
509
|
+
const visible = () => { if (typeof document === "undefined" || document.visibilityState !== "hidden") void refresh([...active.keys()]).catch(() => {}); };
|
|
510
|
+
const tick = () => {
|
|
511
|
+
snapshot = { ...snapshot }; emit();
|
|
512
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
|
513
|
+
for (const id of active.keys()) {
|
|
514
|
+
const e = snapshot.entries.find((item) => item.providerId === id);
|
|
515
|
+
for (const w of canShowUsage(e) ? e.windows || [] : []) {
|
|
516
|
+
const key = `${id}:${w.kind}:${w.resetsAt}`;
|
|
517
|
+
if (w.resetsAt && Date.parse(w.resetsAt) <= Date.now() && !resetSeen.has(key)) { resetSeen.add(key); void refresh([id], true).catch(() => {}); }
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
visible();
|
|
521
|
+
};
|
|
522
|
+
const subscribe = (fn) => { listeners.add(fn); return () => listeners.delete(fn); };
|
|
523
|
+
const activate = (id) => {
|
|
524
|
+
active.set(id, (active.get(id) || 0) + 1);
|
|
525
|
+
if (!timer) { timer = setInterval(tick, 60000); if (typeof document !== "undefined") document.addEventListener("visibilitychange", visible); }
|
|
526
|
+
return () => { const n = (active.get(id) || 1) - 1; if (n) active.set(id, n); else active.delete(id); if (!active.size && timer) { clearInterval(timer); timer = null; if (typeof document !== "undefined") document.removeEventListener("visibilitychange", visible); } };
|
|
527
|
+
};
|
|
528
|
+
const acceptLogin = (value, generation, startedEpoch, expectedJobId = null) => {
|
|
529
|
+
if (disposed || generation !== loginGeneration || (value.state === "success" && value.result && startedEpoch !== epoch) || (expectedJobId && value.jobId !== expectedJobId && !(value.state === "idle" && value.jobId === null))) return null;
|
|
530
|
+
loginJobId = value.jobId;
|
|
531
|
+
loginPending = ["launching", "waiting", "verifying"].includes(value.state);
|
|
532
|
+
if (value.state === "success" && value.result && !mergedLoginJobs.has(value.jobId)) {
|
|
533
|
+
mergedLoginJobs.add(value.jobId);
|
|
534
|
+
// 自动导入已经落盘:阻断旧账号在途额度与元数据,公共结果不含 Cookie。
|
|
535
|
+
epoch++; markRequest(PROVIDER_ORDER); invalidate(["xiaomi-token-plan-cn"]); merge(value.result, null, true);
|
|
536
|
+
}
|
|
537
|
+
return value;
|
|
538
|
+
};
|
|
539
|
+
return {
|
|
540
|
+
subscribe, getSnapshot: () => snapshot, refresh, activate,
|
|
541
|
+
startMimoLogin: async (request) => { const generation = ++loginGeneration, startedEpoch = epoch; loginJobId = null; return acceptLogin(await call("startMimoLogin", request), generation, startedEpoch); },
|
|
542
|
+
getMimoLoginStatus: async () => { const generation = loginGeneration, startedEpoch = epoch, jobId = loginPending ? loginJobId : null; return acceptLogin(await call("getMimoLoginStatus"), generation, startedEpoch, jobId); },
|
|
543
|
+
cancelMimoLogin: async (request) => { if (loginJobId && request.jobId !== loginJobId) return null; const generation = ++loginGeneration, startedEpoch = epoch; return acceptLogin(await call("cancelMimoLogin", request), generation, startedEpoch, request.jobId); },
|
|
544
|
+
readAll: () => { if (!initial) { const startedEpoch = epoch, request = markRequest(PROVIDER_ORDER); initial = Promise.resolve().then(() => call("read")).then((value) => startedEpoch === epoch ? merge(value, request) : snapshot).catch((error) => { initial = null; throw error; }); } return initial.then(() => snapshot); },
|
|
545
|
+
save: async (patch) => {
|
|
546
|
+
const value = await call("save", patch); epoch++;
|
|
547
|
+
if (patch.visibility) { const entries = snapshot.entries; markRequest(PROVIDER_ORDER); return merge({ ...value, entries }, null, true); }
|
|
548
|
+
invalidate([patch.providerId]); return merge(value, null, true);
|
|
549
|
+
},
|
|
550
|
+
reader: (id) => {
|
|
551
|
+
if (!readers.has(id)) {
|
|
552
|
+
const current = () => { const e = snapshot.entries.find((item) => item.providerId === id); return { ...(e || { providerId: id, state: "loading", windows: [] }), visible: providerVisible(snapshot.settings, id, e) }; };
|
|
553
|
+
const fn = async (force = false) => { await refresh([id], force); return current(); };
|
|
554
|
+
fn.subscribe = (cb) => subscribe(() => cb(current()));
|
|
555
|
+
fn.activate = () => activate(id);
|
|
556
|
+
readers.set(id, fn);
|
|
557
|
+
}
|
|
558
|
+
return readers.get(id);
|
|
559
|
+
},
|
|
560
|
+
dispose: () => { disposed = true; if (timer) clearInterval(timer); timer = null; if (typeof document !== "undefined") document.removeEventListener("visibilitychange", visible); listeners.clear(); active.clear(); inflight.clear(); readers.clear(); resetSeen.clear(); }
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── 用量药丸(匹配当前选中模型的 provider)──────────────────────────────
|
|
565
|
+
function severity(percent, status) {
|
|
566
|
+
if (status === "rate-limited" || percent >= 100) return "limited";
|
|
567
|
+
return percent >= 80 ? "high" : void 0;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** 单窗口行(药丸弹层与设置页共用):徽标 + 已用% 主数字 + 进度条 + 明细行。 */
|
|
571
|
+
function UsageWindowRow({ w, t, getLocale, uncertain = false }) {
|
|
572
|
+
const tier = uncertain && w.status !== "rate-limited" ? { icon: "?", badge: "badgeUnknown", color: "#b7791f", rank: 2 } : usageTier(w);
|
|
573
|
+
const blocked = w.status === "rate-limited";
|
|
574
|
+
return h("div", {
|
|
575
|
+
style: {
|
|
576
|
+
marginTop: 8, padding: "8px 10px", borderRadius: 8,
|
|
577
|
+
background: blocked ? "rgba(239,68,68,.08)" : "transparent",
|
|
578
|
+
border: blocked ? "1px solid rgba(239,68,68,.35)" : "1px solid transparent"
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 8 } },
|
|
582
|
+
h("span", { style: { fontSize: 12, opacity: 0.85, minWidth: 52 } }, t(KIND_LABEL[w.kind] ?? "wall")),
|
|
583
|
+
h("span", {
|
|
584
|
+
style: {
|
|
585
|
+
fontSize: 11, padding: "1px 8px", borderRadius: 999, whiteSpace: "nowrap",
|
|
586
|
+
border: `1px solid ${tier.color}`, color: tier.color
|
|
587
|
+
}
|
|
588
|
+
}, `${tier.icon} ${t(tier.badge)}`),
|
|
589
|
+
h("span", { style: { flex: 1 } }),
|
|
590
|
+
h("strong", { style: { fontSize: 13, color: numColor(w), whiteSpace: "nowrap" } },
|
|
591
|
+
blocked ? t(tier.num) : t("numUsed").replace("{p}", String(w.percent)))
|
|
592
|
+
),
|
|
593
|
+
h("progress", {
|
|
594
|
+
max: 100, value: Math.min(100, w.percent),
|
|
595
|
+
style: { width: "100%", height: 6, marginTop: 6, accentColor: tier.color }
|
|
596
|
+
}),
|
|
597
|
+
h("div", { style: { fontSize: 11, opacity: 0.7, marginTop: 4 } },
|
|
598
|
+
[
|
|
599
|
+
w.detail && typeof w.detail.used === "number" && typeof w.detail.limit === "number" && w.detail.limit > 0
|
|
600
|
+
? t("detailUsed").replace("{used}", fmtTokens(w.detail.used)).replace("{limit}", fmtTokens(w.detail.limit)).replace("{unit}", w.detail.unit || "")
|
|
601
|
+
: null,
|
|
602
|
+
w.detail && w.detail.limitSource === "plan-snapshot" ? t("planSnapshotLimit") : null,
|
|
603
|
+
resetLine(w, t, getLocale),
|
|
604
|
+
w.cascade ? t("cascadeLimited") + (w.detail && w.detail.blockedBy ? ` [${w.detail.blockedBy.join(", ")}]` : "") : null
|
|
605
|
+
].filter(Boolean).join(" · "))
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function UsagePill({ providerId, label, readEntry, t, getLocale }) {
|
|
610
|
+
const [snapshot, setSnapshot] = react.useState(null);
|
|
611
|
+
const [failed, setFailed] = react.useState(null);
|
|
612
|
+
const [failState, setFailState] = react.useState(void 0);
|
|
613
|
+
const [refreshing, setRefreshing] = react.useState(false);
|
|
614
|
+
const [open, setOpen] = react.useState(false);
|
|
615
|
+
const root = react.useRef(null);
|
|
616
|
+
const retry = react.useRef(() => {});
|
|
617
|
+
react.useEffect(() => {
|
|
618
|
+
let alive = true;
|
|
619
|
+
let busy = false;
|
|
620
|
+
setSnapshot(null);
|
|
621
|
+
setFailed(null);
|
|
622
|
+
setFailState(void 0);
|
|
623
|
+
const refresh = async (manual = false) => {
|
|
624
|
+
if (busy || (!manual && document.visibilityState === "hidden")) return;
|
|
625
|
+
busy = true;
|
|
626
|
+
setRefreshing(true);
|
|
627
|
+
try {
|
|
628
|
+
const entry = await readEntry(manual);
|
|
629
|
+
if (alive) {
|
|
630
|
+
setSnapshot({ entry, updatedAt: entry.lastAttemptAt || entry.lastSuccessAt || Date.now() });
|
|
631
|
+
setFailed(entry.state !== "ok" ? entry.error || entry.state : null);
|
|
632
|
+
setFailState(entry.state !== "ok" ? entry.state : void 0);
|
|
633
|
+
}
|
|
634
|
+
} catch (error) {
|
|
635
|
+
if (alive) {
|
|
636
|
+
setSnapshot(error && error.entry ? { entry: error.entry, updatedAt: Date.now() } : null);
|
|
637
|
+
setFailed(error instanceof Error ? error.message : String(error));
|
|
638
|
+
setFailState(error && error.entry ? error.entry.state : "error");
|
|
639
|
+
}
|
|
640
|
+
} finally {
|
|
641
|
+
busy = false;
|
|
642
|
+
if (alive) setRefreshing(false);
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
retry.current = () => void refresh(true);
|
|
646
|
+
void refresh();
|
|
647
|
+
const unsubscribe = readEntry.subscribe ? readEntry.subscribe((entry) => { if (alive && entry) { setSnapshot({ entry, updatedAt: entry.lastAttemptAt || entry.lastSuccessAt || Date.now() }); setFailed(entry.state !== "ok" ? entry.error || entry.state : null); setFailState(entry.state); } }) : () => {};
|
|
648
|
+
const deactivate = readEntry.activate ? readEntry.activate() : () => {};
|
|
649
|
+
const timer = readEntry.activate ? null : setInterval(() => void refresh(), 60000);
|
|
650
|
+
const visible = () => void refresh();
|
|
651
|
+
if (!readEntry.activate) document.addEventListener("visibilitychange", visible);
|
|
652
|
+
return () => {
|
|
653
|
+
alive = false;
|
|
654
|
+
retry.current = () => {};
|
|
655
|
+
if (timer) clearInterval(timer);
|
|
656
|
+
unsubscribe(); deactivate();
|
|
657
|
+
if (!readEntry.activate) document.removeEventListener("visibilitychange", visible);
|
|
658
|
+
};
|
|
659
|
+
}, [readEntry]);
|
|
660
|
+
react.useEffect(() => {
|
|
661
|
+
if (!open) return;
|
|
662
|
+
const click = (event) => {
|
|
663
|
+
if (!root.current || !root.current.contains(event.target)) setOpen(false);
|
|
664
|
+
};
|
|
665
|
+
const key = (event) => {
|
|
666
|
+
if (event.key === "Escape") setOpen(false);
|
|
667
|
+
};
|
|
668
|
+
document.addEventListener("mousedown", click);
|
|
669
|
+
document.addEventListener("keydown", key);
|
|
670
|
+
return () => {
|
|
671
|
+
document.removeEventListener("mousedown", click);
|
|
672
|
+
document.removeEventListener("keydown", key);
|
|
673
|
+
};
|
|
674
|
+
}, [open]);
|
|
675
|
+
const rawEntry = snapshot && snapshot.entry;
|
|
676
|
+
if (rawEntry?.visible === false || rawEntry?.state === "disabled") return null;
|
|
677
|
+
const entry = canShowUsage(rawEntry) ? rawEntry : null;
|
|
678
|
+
const ok = !!(rawEntry && rawEntry.state === "ok" && rawEntry.freshness !== "stale" && rawEntry.coverage !== "partial");
|
|
679
|
+
const light = ok ? "🟢" : (snapshot !== null || failed !== null ? "🔴" : "🟡");
|
|
680
|
+
const windows = entry ? entry.windows || [] : [];
|
|
681
|
+
const worst = windows.length > 0 ? worstWindow(windows) : null;
|
|
682
|
+
const worstTier = worst ? usageTier(worst) : null;
|
|
683
|
+
const degraded = !!(rawEntry && (rawEntry.state !== "ok" || rawEntry.freshness === "stale" || rawEntry.coverage === "partial"));
|
|
684
|
+
const summary = worst ? pillText(worst, windows.length > 1, t) : (entry ? t("noUsageData") : t("providerUnavailable"));
|
|
685
|
+
const stateText = !ok ? (snapshot && snapshot.entry ? snapshot.entry.state : failState) : void 0;
|
|
686
|
+
const stateLabel = rawEntry ? statusText(rawEntry, t) : failState === "no-key" ? t("statusNoKey") : failState === "no-cookie" ? t("statusNoCookie") : failed ? t("statusError") : t("loading");
|
|
687
|
+
// 配置缺失类失败给可行动指引,不倒裸报错。
|
|
688
|
+
const needConfig = stateText === "no-key" ? (providerId === "commandcode" ? t("pillNeedCommandcode") : t("pillNeedKey"))
|
|
689
|
+
: stateText === "no-cookie" ? t("pillNeedCookie")
|
|
690
|
+
: null;
|
|
691
|
+
const isConfig = stateText === "no-key" || stateText === "no-cookie";
|
|
692
|
+
// 余额请求成功不代表有额度窗口(例如小米接口暂时只返回余额)。
|
|
693
|
+
const triggerIcon = worstTier && worstTier.rank >= 4 ? worstTier.icon : degraded ? "⚠" : worstTier ? worstTier.icon : ok ? "ℹ" : isConfig ? "⚙" : light;
|
|
694
|
+
const triggerText = worst ? `${degraded ? statusText(rawEntry, t) + " · " : ""}${pillText(worst, windows.length > 1, t)}` : entry ? `${degraded ? stateLabel + " · " : ""}${t("noUsageData")}` : isConfig ? t("needConfigShort") : stateLabel;
|
|
695
|
+
return h("span", { ref: root, style: { position: "relative", display: "inline-flex", alignItems: "center" } },
|
|
696
|
+
h("button", {
|
|
697
|
+
type: "button",
|
|
698
|
+
"aria-expanded": open,
|
|
699
|
+
"aria-haspopup": "dialog",
|
|
700
|
+
"aria-label": `${label}: ${stateLabel} · ${summary}`,
|
|
701
|
+
title: `${label}: ${stateLabel} · ${summary}`,
|
|
702
|
+
onClick: () => setOpen(!open),
|
|
703
|
+
style: {
|
|
704
|
+
display: "inline-flex", alignItems: "center", gap: 6, padding: "4px 10px",
|
|
705
|
+
borderRadius: 999, border: "1px solid rgba(128,128,128,.35)", background: "transparent",
|
|
706
|
+
color: "inherit", font: "inherit", cursor: "pointer", whiteSpace: "nowrap",
|
|
707
|
+
...(worstTier && worstTier.rank >= 4 ? { background: "rgba(239,68,68,.10)", borderColor: "rgba(239,68,68,.45)" } : {})
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
h("span", { "aria-hidden": "true", style: { fontSize: 11, lineHeight: 1 } }, triggerIcon),
|
|
711
|
+
h("span", { style: worstTier && worstTier.rank > 1 ? { color: worstTier.color } : null }, triggerText),
|
|
712
|
+
failed && ok ? h("span", { style: { opacity: 0.7, fontSize: 11 } }, `(${t("unavailable")})`) : null
|
|
713
|
+
),
|
|
714
|
+
open && h("div", {
|
|
715
|
+
role: "dialog",
|
|
716
|
+
"aria-label": `${label} ${t("nav")}`,
|
|
717
|
+
style: {
|
|
718
|
+
position: "absolute", bottom: "calc(100% + 6px)", right: 0, zIndex: 50,
|
|
719
|
+
width: "min(340px, calc(100vw - 24px))", boxSizing: "border-box", overflowWrap: "anywhere", padding: 12, borderRadius: 10,
|
|
720
|
+
background: "var(--dsh-surface, Canvas)", color: "var(--dsh-text, CanvasText)",
|
|
721
|
+
border: "1px solid rgba(128,128,128,.35)",
|
|
722
|
+
boxShadow: "0 8px 24px rgba(0,0,0,.35)", font: "12px/1.6 sans-serif"
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
h("strong", null, label),
|
|
726
|
+
needConfig ? h("p", { style: { margin: "6px 0 0", opacity: 0.9 } }, needConfig) : null,
|
|
727
|
+
failed && !needConfig ? h("p", { style: { color: "#f87171", margin: "6px 0 0", whiteSpace: "pre-wrap" } }, failed) : null,
|
|
728
|
+
!ok && !snapshot && !failed ? h("p", { style: { margin: "6px 0 0", opacity: 0.8 } }, t("loading")) : null,
|
|
729
|
+
cacheAge(rawEntry, t) ? h("p", { style: { color: "#b7791f" } }, cacheAge(rawEntry, t)) : null,
|
|
730
|
+
windows.map((w) => h(UsageWindowRow, { key: w.kind, w, t, getLocale, uncertain: degraded })),
|
|
731
|
+
entry && entry.extras && entry.extras.length > 0
|
|
732
|
+
? h("div", { style: { marginTop: 10, opacity: 0.85 } },
|
|
733
|
+
entry.extras.map((x) => h("div", { key: x.kind }, `${extraLabel(x, t)}: ${x.value}`)))
|
|
734
|
+
: null,
|
|
735
|
+
snapshot ? h("div", { style: { marginTop: 10, opacity: 0.7 } }, `${t("lastUpdated")} ${new Date(snapshot.updatedAt).toLocaleString(getLocale())}`) : null,
|
|
736
|
+
h("button", {
|
|
737
|
+
type: "button",
|
|
738
|
+
onClick: () => retry.current(),
|
|
739
|
+
disabled: refreshing,
|
|
740
|
+
style: { marginTop: 10, padding: "4px 12px", borderRadius: 6, border: "1px solid rgba(128,128,128,.5)", background: "transparent", color: "inherit", cursor: "pointer" }
|
|
741
|
+
}, t(refreshing ? "refreshing" : "retry"))
|
|
742
|
+
)
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ── 设置页:提供商开关、响应式按钮导航、敏感凭据显式 patch ────────────
|
|
747
|
+
function initialTab() {
|
|
748
|
+
try { const id = window.localStorage.getItem("dsh-subusage:last-provider"); if (PROVIDER_ORDER.includes(id)) return id; } catch {}
|
|
749
|
+
return PROVIDER_ORDER[0];
|
|
750
|
+
}
|
|
751
|
+
function draftFor(settings, id) {
|
|
752
|
+
return { keyMode: settings && settings.keyModes && settings.keyModes[id] || "inherit", keyAction: "keep", key: "", cookieAction: "keep", cookie: "", zai: { type: 1, organization: "", project: "", ...(settings && settings.zai) } };
|
|
753
|
+
}
|
|
754
|
+
function settingsPatch(id, draft, revision) {
|
|
755
|
+
if (typeof revision !== "string" || !revision) throw new Error("请先加载最新设置,再编辑保存 / Load settings before saving");
|
|
756
|
+
const patch = { providerId: id, expectedRevision: revision };
|
|
757
|
+
// 凭据由提供方插件管理的模型商没有任何本地凭据补丁可保存。
|
|
758
|
+
if (PROVIDER_META[id].managedByPlugin) return patch;
|
|
759
|
+
if (id === "xiaomi-token-plan-cn") {
|
|
760
|
+
patch.cookieUpdate = { action: draft.cookieAction };
|
|
761
|
+
if (draft.cookieAction === "replace") patch.cookieUpdate.value = validateCookieText(draft.cookie);
|
|
762
|
+
} else {
|
|
763
|
+
patch.keyMode = draft.keyMode;
|
|
764
|
+
patch.keyUpdate = { action: draft.keyAction };
|
|
765
|
+
if (draft.keyAction === "replace") {
|
|
766
|
+
if (!draft.key.trim() || /[\r\n\x00-\x1f\x7f]/.test(draft.key)) throw new Error("API Key 不能为空或包含控制字符");
|
|
767
|
+
patch.keyUpdate.value = draft.key.trim();
|
|
768
|
+
}
|
|
769
|
+
if (id === "zai-coding-cn") patch.zai = { ...draft.zai };
|
|
770
|
+
}
|
|
771
|
+
return patch;
|
|
772
|
+
}
|
|
773
|
+
function SubusageSection({ usageStore, t, getLocale }) {
|
|
774
|
+
const result = react.useSyncExternalStore(usageStore.subscribe, usageStore.getSnapshot, usageStore.getSnapshot);
|
|
775
|
+
const [tab, setTab] = react.useState(initialTab);
|
|
776
|
+
const [draft, setDraft] = react.useState(() => draftFor(result.settings, tab));
|
|
777
|
+
const [dirty, setDirty] = react.useState(false);
|
|
778
|
+
const [busy, setBusy] = react.useState(false);
|
|
779
|
+
const [notice, setNotice] = react.useState(null);
|
|
780
|
+
const [loadError, setLoadError] = react.useState(null);
|
|
781
|
+
const [login, setLogin] = react.useState({ jobId: null, state: "idle" });
|
|
782
|
+
const [editorOpen, setEditorOpen] = react.useState(false);
|
|
783
|
+
const [usageTab, setUsageTab] = react.useState(initialTab);
|
|
784
|
+
const loginActive = ["launching", "waiting", "verifying"].includes(login.state);
|
|
785
|
+
const loginRef = react.useRef(login);
|
|
786
|
+
loginRef.current = login;
|
|
787
|
+
const handledLogin = react.useRef(null);
|
|
788
|
+
const aliveRef = react.useRef(true);
|
|
789
|
+
react.useEffect(() => { aliveRef.current = true; return () => { aliveRef.current = false; }; }, []);
|
|
790
|
+
const dirtyRef = react.useRef(false);
|
|
791
|
+
const revisionRef = react.useRef(result.settings && result.settings.revision);
|
|
792
|
+
const root = react.useRef(null);
|
|
793
|
+
const local = getLocale().startsWith("zh");
|
|
794
|
+
const text = (cn, en) => local ? cn : en;
|
|
795
|
+
react.useEffect(() => {
|
|
796
|
+
let alive = true;
|
|
797
|
+
void usageStore.readAll().then(() => { if (alive) setLoadError(null); }).catch((error) => { if (alive) setLoadError(String(error.message || error)); });
|
|
798
|
+
return () => { alive = false; };
|
|
799
|
+
}, [usageStore]);
|
|
800
|
+
react.useEffect(() => {
|
|
801
|
+
let alive = true;
|
|
802
|
+
const enabled = editorOpen && providerEnabled(result.settings, tab);
|
|
803
|
+
const deactivate = enabled ? usageStore.activate(tab) : () => {};
|
|
804
|
+
void usageStore.readAll().then(() => { if (alive && enabled && providerEnabled(usageStore.getSnapshot().settings, tab)) return usageStore.refresh([tab], false); }).catch((error) => { if (alive) setLoadError(String(error.message || error)); });
|
|
805
|
+
return () => { alive = false; deactivate(); };
|
|
806
|
+
}, [usageStore, tab, editorOpen, providerEnabled(result.settings, tab)]);
|
|
807
|
+
react.useEffect(() => {
|
|
808
|
+
if (!dirtyRef.current) { setDraft(draftFor(result.settings, tab)); revisionRef.current = result.settings && result.settings.revision; }
|
|
809
|
+
}, [result.settings, tab]);
|
|
810
|
+
// 设置卸载/切换只停止 UI 轮询;Host 负责浏览器任务的 lifetime。
|
|
811
|
+
react.useEffect(() => {
|
|
812
|
+
if (tab !== "xiaomi-token-plan-cn") return;
|
|
813
|
+
let alive = true;
|
|
814
|
+
if (usageStore.getMimoLoginStatus) void usageStore.getMimoLoginStatus().then((value) => { if (alive && value) setLogin(value); }).catch((error) => { if (alive) setLogin({ jobId: null, state: "error", message: String(error.message || error) }); });
|
|
815
|
+
return () => { alive = false; };
|
|
816
|
+
}, [usageStore, tab]);
|
|
817
|
+
react.useEffect(() => {
|
|
818
|
+
if (tab !== "xiaomi-token-plan-cn" || !loginActive || !login.jobId) return;
|
|
819
|
+
let alive = true, timer;
|
|
820
|
+
const poll = async () => {
|
|
821
|
+
try {
|
|
822
|
+
const value = await usageStore.getMimoLoginStatus();
|
|
823
|
+
if (!alive) return;
|
|
824
|
+
if (value) setLogin(value);
|
|
825
|
+
if (!value || ["launching", "waiting", "verifying"].includes(value.state)) timer = setTimeout(poll, 2000);
|
|
826
|
+
} catch (error) { if (alive) setLogin({ jobId: login.jobId, state: "error", message: String(error.message || error) }); }
|
|
827
|
+
};
|
|
828
|
+
timer = setTimeout(poll, 2000);
|
|
829
|
+
return () => { alive = false; clearTimeout(timer); };
|
|
830
|
+
}, [usageStore, tab, loginActive, login.jobId]);
|
|
831
|
+
react.useEffect(() => {
|
|
832
|
+
if (login.state !== "success" || !login.result || handledLogin.current === login.jobId) return;
|
|
833
|
+
handledLogin.current = login.jobId;
|
|
834
|
+
if (!dirtyRef.current) { setDraft(draftFor(login.result.settings, tab)); revisionRef.current = login.result.settings && login.result.settings.revision; }
|
|
835
|
+
setNotice({ ok: true, text: dirtyRef.current ? text("登录凭据已自动保存;当前未保存编辑已保留,请取消或重新核对后保存。", "Login saved automatically; unsaved edits retained. Cancel or review before saving.") : text("登录成功,凭据已自动保存。", "Signed in; credentials saved automatically.") });
|
|
836
|
+
}, [login, tab]);
|
|
837
|
+
const startLogin = async () => {
|
|
838
|
+
if (dirtyRef.current) { setNotice({ ok: false, text: text("自动登录会立即保存凭据,请先保存或取消当前编辑。", "Automatic login saves immediately. Save or cancel your edits first.") }); return; }
|
|
839
|
+
if (["launching", "waiting", "verifying"].includes(loginRef.current.state) || busy) return;
|
|
840
|
+
loginRef.current = { jobId: null, state: "launching" }; setLogin(loginRef.current); setNotice(null);
|
|
841
|
+
try { const value = await usageStore.startMimoLogin({ expectedRevision: result.settings.revision }); if (aliveRef.current && value) setLogin(value); }
|
|
842
|
+
catch (error) { if (aliveRef.current) setLogin({ jobId: null, state: "error", message: String(error.message || error) }); }
|
|
843
|
+
};
|
|
844
|
+
const cancelLogin = async () => {
|
|
845
|
+
const jobId = loginRef.current.jobId;
|
|
846
|
+
if (!jobId) return;
|
|
847
|
+
try { const value = await usageStore.cancelMimoLogin({ jobId }); if (aliveRef.current && loginRef.current.jobId === jobId && value) setLogin(value); }
|
|
848
|
+
catch (error) { if (aliveRef.current && loginRef.current.jobId === jobId) setNotice({ ok: false, text: String(error.message || error) }); }
|
|
849
|
+
};
|
|
850
|
+
const editDraft = (patch) => { if ((tab === "xiaomi-token-plan-cn" && loginActive) || !result.settings || typeof result.settings.revision !== "string") return; dirtyRef.current = true; setDirty(true); setNotice(null); setDraft((prev) => ({ ...prev, ...patch })); };
|
|
851
|
+
const cancel = () => { dirtyRef.current = false; setDirty(false); setDraft(draftFor(result.settings, tab)); revisionRef.current = result.settings && result.settings.revision; setNotice(null); };
|
|
852
|
+
const openEditor = (id) => {
|
|
853
|
+
if (id === tab) { setEditorOpen(true); return; }
|
|
854
|
+
if (dirtyRef.current) { setNotice({ ok: false, text: text("请先保存或取消当前凭据编辑,再展开其他提供商。", "Save or cancel credential edits before opening another provider.") }); return; }
|
|
855
|
+
setTab(id); setEditorOpen(true); setNotice(null); setLoadError(null);
|
|
856
|
+
};
|
|
857
|
+
const chooseUsage = (id) => {
|
|
858
|
+
setUsageTab(id);
|
|
859
|
+
if (!editorOpen && !dirtyRef.current) setTab(id);
|
|
860
|
+
try { window.localStorage.setItem("dsh-subusage:last-provider", id); } catch {}
|
|
861
|
+
};
|
|
862
|
+
const refresh = async (all) => {
|
|
863
|
+
setBusy(true);
|
|
864
|
+
try { if (!result.settings) await usageStore.readAll(); else await usageStore.refresh(all ? PROVIDER_ORDER : usageId ? [usageId] : [], true); if (aliveRef.current) setLoadError(null); }
|
|
865
|
+
catch (error) { if (aliveRef.current) setLoadError(String(error.message || error)); }
|
|
866
|
+
finally { if (aliveRef.current) setBusy(false); }
|
|
867
|
+
};
|
|
868
|
+
const saveVisibility = async (visibility) => {
|
|
869
|
+
if (busy || !result.settings || !result.settings.revision) return;
|
|
870
|
+
if (dirtyRef.current) { setNotice({ ok: false, text: text("请先保存或取消凭据编辑。", "Save or cancel credential edits first.") }); return; }
|
|
871
|
+
setBusy(true);
|
|
872
|
+
try {
|
|
873
|
+
const saved = await usageStore.save({ expectedRevision: result.settings.revision, visibility });
|
|
874
|
+
if (!aliveRef.current) return;
|
|
875
|
+
revisionRef.current = saved.settings.revision;
|
|
876
|
+
setNotice({ ok: true, text: text("提供商设置已保存", "Provider settings saved") });
|
|
877
|
+
// 新启用条目先重新探测凭据;保存成功不与接口验证混为一谈。
|
|
878
|
+
const enabled = Object.entries(visibility.providers || {}).filter(([, value]) => value).map(([id]) => id);
|
|
879
|
+
if (enabled.length) { try { await usageStore.refresh(enabled, false); } catch { if (aliveRef.current) setLoadError(text("设置已保存;用量刷新失败,可稍后重试。", "Settings saved; usage refresh failed. Retry later.")); } }
|
|
880
|
+
} catch (error) { if (aliveRef.current) setNotice({ ok: false, text: String(error.message || error) }); }
|
|
881
|
+
finally { if (aliveRef.current) setBusy(false); }
|
|
882
|
+
};
|
|
883
|
+
const onSave = async () => {
|
|
884
|
+
if (tab === "xiaomi-token-plan-cn" && ["launching", "waiting", "verifying"].includes(loginRef.current.state)) return;
|
|
885
|
+
setBusy(true);
|
|
886
|
+
try {
|
|
887
|
+
const patch = settingsPatch(tab, draft, revisionRef.current);
|
|
888
|
+
const saved = await usageStore.save(patch);
|
|
889
|
+
if (!aliveRef.current) return;
|
|
890
|
+
dirtyRef.current = false; setDirty(false);
|
|
891
|
+
setDraft(draftFor(saved.settings, tab)); revisionRef.current = saved.settings && saved.settings.revision;
|
|
892
|
+
setNotice({ ok: true, text: t("saved") });
|
|
893
|
+
if (!providerEnabled(saved.settings, tab)) return;
|
|
894
|
+
// 保存已成功落盘;独立验证失败不改写成“保存失败”。
|
|
895
|
+
try {
|
|
896
|
+
const verified = await usageStore.refresh([tab], true);
|
|
897
|
+
const current = verified.entries.find((e) => e.providerId === tab);
|
|
898
|
+
if (aliveRef.current) setLoadError(current && current.state === "ok" ? null : text("已保存;验证获取失败:", "Saved; validation fetch failed: ") + (current && current.error || statusText(current, t)));
|
|
899
|
+
} catch (error) { if (aliveRef.current) setLoadError(text("已保存;验证获取失败:", "Saved; validation fetch failed: ") + String(error.message || error)); }
|
|
900
|
+
} catch (error) { if (aliveRef.current) setNotice({ ok: false, text: String(error.message || error) }); }
|
|
901
|
+
finally { if (aliveRef.current) setBusy(false); }
|
|
902
|
+
};
|
|
903
|
+
const inputStyle = { width: "100%", minWidth: 0, boxSizing: "border-box", padding: "6px 8px", borderRadius: 6, border: "1px solid rgba(128,128,128,.4)", background: "transparent", color: "inherit", font: "inherit" };
|
|
904
|
+
const btnStyle = { padding: "6px 10px", borderRadius: 6, border: "1px solid rgba(128,128,128,.35)", background: "transparent", color: "inherit", cursor: "pointer", font: "inherit" };
|
|
905
|
+
const primary = { ...btnStyle, background: "rgba(59,130,246,.12)", borderColor: "rgba(59,130,246,.65)" };
|
|
906
|
+
const entries = result.entries || [];
|
|
907
|
+
const entry = entries.find((e) => e.providerId === tab);
|
|
908
|
+
const visibleIds = PROVIDER_ORDER.filter(id => providerVisible(result.settings, id, entries.find(e => e.providerId === id)));
|
|
909
|
+
const success = entries.filter((e) => visibleIds.includes(e.providerId) && e.state === "ok" && e.freshness !== "stale").length;
|
|
910
|
+
const hideWithoutApi = result.settings?.visibility?.hideWithoutApi !== false;
|
|
911
|
+
react.useEffect(() => {
|
|
912
|
+
if (visibleIds.length && !visibleIds.includes(usageTab)) setUsageTab(visibleIds[0]);
|
|
913
|
+
}, [usageTab, visibleIds.join("|")]);
|
|
914
|
+
const usageId = visibleIds.includes(usageTab) ? usageTab : visibleIds[0];
|
|
915
|
+
const usageEntry = entries.find(e => e.providerId === usageId);
|
|
916
|
+
const usageMeta = PROVIDER_META[usageId];
|
|
917
|
+
react.useEffect(() => {
|
|
918
|
+
if (!usageId) return;
|
|
919
|
+
const deactivate = usageStore.activate(usageId);
|
|
920
|
+
void usageStore.refresh([usageId], false).catch(() => {});
|
|
921
|
+
return deactivate;
|
|
922
|
+
}, [usageStore, usageId]);
|
|
923
|
+
const meta = PROVIDER_META[tab];
|
|
924
|
+
const source = entry && entry.keySource || "none";
|
|
925
|
+
const sources = { credentials: text("来自凭据服务", "From credential service"), env: text("来自环境变量", "From environment"), manual: text("手动 Key", "Manual key"), none: text("无可用凭据", "No credentials"), cookie: text("官方平台 Cookie", "Platform Cookie"), "auth-file": text("来自 ~/.commandcode/auth.json(cmd login)", "From ~/.commandcode/auth.json (cmd login)") };
|
|
926
|
+
const credentialButtons = (kind, hasSaved) => h("div", { style: { display: "flex", gap: 8, flexWrap: "wrap" } },
|
|
927
|
+
draft[kind + "Action"] !== "replace" ? h("button", { type: "button", style: btnStyle, disabled: kind === "cookie" && loginActive, onClick: () => editDraft({ [kind + "Action"]: "replace", [kind]: "", ...(kind === "key" ? { keyMode: "manual" } : {}) }) }, text(kind === "cookie" ? "手动导入" : "更换", kind === "cookie" ? "Manual import" : "Change")) : null,
|
|
928
|
+
hasSaved && draft[kind + "Action"] !== "clear" ? h("button", { type: "button", style: btnStyle, disabled: kind === "cookie" && loginActive, onClick: () => { editDraft({ [kind + "Action"]: "clear", [kind]: "" }); setNotice({ ok: false, text: text("待清除:保存设置后将删除已保存的敏感凭据;取消编辑可保留。", "Pending removal: saving will delete the saved secret. Cancel edits to keep it.") }); } }, text(kind === "cookie" ? "清除登录凭据" : "清除", kind === "cookie" ? "Clear login credentials" : "Clear")) : null,
|
|
929
|
+
draft[kind + "Action"] === "clear" ? h("span", { role: "status" }, text("将删除凭据(尚未保存)", "Credential will be deleted (not saved yet)")) : null
|
|
930
|
+
);
|
|
931
|
+
const credentialEditor = h(react.Fragment, null,
|
|
932
|
+
notice ? h("p", { role: "status", style: { color: notice.ok ? "inherit" : "#dc2626" } }, notice.text) : null,
|
|
933
|
+
h("div", { style: { marginTop: 10 } },
|
|
934
|
+
h("fieldset", { disabled: busy || !result.settings || typeof result.settings.revision !== "string", style: { border: 0, padding: 0, margin: 0, minWidth: 0 } },
|
|
935
|
+
tab === "zai-coding" ? h("p", null, text("使用 Z.ai 国际站 Coding Plan Key;与中国站凭据独立。", "Use an international Z.ai Coding Plan key; credentials are separate from the China region.")) : null,
|
|
936
|
+
tab === "synthetic" ? h("p", null, text("使用 Synthetic API Key,仅显示模型订阅请求额度。", "Use a Synthetic API key. Shows the model subscription request quota.")) : null,
|
|
937
|
+
tab === "nanogpt" ? h("p", null, text("可用 NanoGPT API Key,推荐手动填写 Usage only 管理令牌(sk-nano-mgmt-…)。显示日/周输入 Token 与试用额度,图像额度单独计算,不纳入此处。", "Use a NanoGPT API key or, preferably, a manual Usage only management token (sk-nano-mgmt-…). Shows daily/weekly input tokens and trial quotas; image quotas are separate."), " ", h("a", { href: "https://nano-gpt.com/settings", target: "_blank", rel: "noopener noreferrer" }, text("打开官方设置", "Open official settings"))) : null,
|
|
938
|
+
h("p", null, `${result.configured && result.configured[tab] ? t("detected") : t("notDetected")} · ${sources[source] || sources.none}`),
|
|
939
|
+
tab === "commandcode" ? h(react.Fragment, null, h("p", null, t("commandcodeManaged"))) : tab === "xiaomi-token-plan-cn" ? h(react.Fragment, null,
|
|
940
|
+
h("button", { type: "button", style: primary, disabled: loginActive, onClick: startLogin }, text("登录并自动导入", "Sign in & import automatically")),
|
|
941
|
+
loginActive ? h("div", { role: "status", "aria-live": "polite", style: { marginTop: 8 } },
|
|
942
|
+
h("progress", { "aria-label": text("登录进度", "Login progress"), style: { width: 100, marginRight: 8 } }),
|
|
943
|
+
({ launching: text("正在启动登录浏览器…", "Launching login browser…"), waiting: text("请在浏览器中完成登录…", "Complete sign-in in the browser…"), verifying: text("正在验证并导入…", "Verifying & importing…") })[login.state],
|
|
944
|
+
h("button", { type: "button", style: btnStyle, disabled: !login.jobId, onClick: cancelLogin }, text("取消登录", "Cancel login"))) : null,
|
|
945
|
+
login.state === "error" || login.state === "cancelled" ? h("p", { role: "status" }, login.state === "cancelled" ? text("登录已取消,可重新登录或手动导入。", "Login cancelled. Retry or import manually.") : text("自动登录失败,可重试或手动导入。", "Automatic login failed. Retry or import manually.") + (login.message ? " " + login.message : "")) : null,
|
|
946
|
+
h("p", null, h("a", { href: "https://platform.xiaomimimo.com", target: "_blank", rel: "noopener noreferrer" }, text("仅打开 MiMo 官网(不会自动导入)", "Open MiMo website only (no automatic import)"))),
|
|
947
|
+
h("p", null, result.settings && result.settings.xiaomi && result.settings.xiaomi.hasCookie ? text("已保存 Cookie(不回填)", "Cookie saved (never filled back)") : t("statusNoCookie")),
|
|
948
|
+
credentialButtons("cookie", result.settings && result.settings.xiaomi && result.settings.xiaomi.hasCookie),
|
|
949
|
+
draft.cookieAction === "replace" ? h(react.Fragment, null,
|
|
950
|
+
h("label", null, t("mimoCookie"), h("textarea", { rows: 3, autoComplete: "off", spellCheck: false, value: draft.cookie, disabled: busy || loginActive, onChange: (e) => editDraft({ cookie: e.target.value }), style: { ...inputStyle, resize: "vertical" } })),
|
|
951
|
+
h("button", { type: "button", style: btnStyle, disabled: busy, onClick: () => { try { const normalized = validateCookieText(draft.cookie); editDraft({ cookie: normalized }); setNotice({ ok: true, text: text("Cookie 格式验证通过,尚未保存。", "Cookie format validated; not saved yet.") }); } catch (error) { setNotice({ ok: false, text: String(error.message || error) }); } } }, text("验证并规范化导入", "Validate & normalize import")),
|
|
952
|
+
h("p", { style: { fontSize: 12, opacity: .75 } }, t("mimoCookieHint"))) : null)
|
|
953
|
+
: h(react.Fragment, null,
|
|
954
|
+
h("div", { role: "group", "aria-label": text("Key 来源模式", "Key source mode"), style: { display: "flex", gap: 8, flexWrap: "wrap" } },
|
|
955
|
+
["inherit", "manual"].map((mode) => h("button", { key: mode, type: "button", "aria-pressed": draft.keyMode === mode, style: draft.keyMode === mode ? primary : btnStyle, onClick: () => editDraft({ keyMode: mode }) }, `${draft.keyMode === mode ? "✓ " : ""}${mode === "inherit" ? text("继承凭据 / 环境变量", "Inherit credentials / environment") : text("手动 Key", "Manual key")}`))),
|
|
956
|
+
h("p", { style: { fontSize: 12 } }, result.settings && result.settings.hasKeys && result.settings.hasKeys[tab] ? text("已保存 Key(不回填)", "Key saved (never filled back)") : text("未保存手动 Key", "No manual key saved")),
|
|
957
|
+
credentialButtons("key", result.settings && result.settings.hasKeys && result.settings.hasKeys[tab]),
|
|
958
|
+
draft.keyAction === "replace" ? h("label", null, t("keyLabel"), h("input", { type: "password", autoComplete: "new-password", value: draft.key, disabled: busy, onChange: (e) => editDraft({ key: e.target.value }), style: inputStyle })) : null),
|
|
959
|
+
tab === "zai-coding-cn" ? h(react.Fragment, null,
|
|
960
|
+
h("div", { role: "radiogroup", "aria-label": t("zaiType"), style: { display: "flex", gap: 8, flexWrap: "wrap" } }, [1, 2].map(type => h("button", { key: type, type: "button", role: "radio", "aria-checked": draft.zai.type === type, style: draft.zai.type === type ? primary : btnStyle, disabled: busy, onClick: () => editDraft({ zai: { ...draft.zai, type } }) }, t(type === 1 ? "zaiTypePersonal" : "zaiTypeTeam")))),
|
|
961
|
+
draft.zai.type === 2 ? ["organization", "project"].map((name) => h("label", { key: name }, t(name === "organization" ? "zaiOrg" : "zaiProject"), h("input", { value: draft.zai[name] || "", disabled: busy, style: inputStyle, onChange: (e) => editDraft({ zai: { ...draft.zai, [name]: e.target.value } }) }))) : null) : null,
|
|
962
|
+
dirty ? h("div", { style: { display: "flex", gap: 8, marginTop: 10 } }, h("button", { type: "button", disabled: busy || (tab === "xiaomi-token-plan-cn" && loginActive), style: primary, onClick: onSave }, t(busy ? "saving" : "save")), h("button", { type: "button", disabled: busy, style: btnStyle, onClick: cancel }, text("取消编辑", "Cancel edits"))) : null)),
|
|
963
|
+
h("details", { style: { marginTop: 12 } }, h("summary", { style: { cursor: "pointer" } }, text("技术信息与帮助", "Technical information & help")),
|
|
964
|
+
h("code", null, tab), tab !== "xiaomi-token-plan-cn" ? h("p", null, text("继承变量:", "Inherited variable: "), h("code", null, meta.envName)) : null, h("p", null, t("hint")), ["minimax", "minimax-cn"].includes(tab) ? h("p", null, text("需要对应区域的 Token Plan / Coding Plan 订阅 Key,不是按量计费 Key。读取通用/编程池的剩余百分比;零总计、无上限或未报告的窗口不会显示为零用量,周加量不折算为额外百分比。", "Use a Token Plan / Coding Plan subscription key for the selected region, not a pay-as-you-go key. Shows the general/coding pool; zero-total, unlimited or absent windows are not displayed as zero usage. Weekly boosts are not converted into extra percentage.")) : null, tab === "zai-coding-cn" ? h("p", null, t("zaiOrgHint")) : null, tab === "commandcode" ? h("p", null, t("commandcodeHelp")) : null,
|
|
965
|
+
entry && entry.lastAttemptAt ? h("p", null, `${t("lastUpdated")} ${new Date(entry.lastAttemptAt).toLocaleString(getLocale())}`) : null)
|
|
966
|
+
);
|
|
967
|
+
const providerManagement = h("details", { id: "subusage-provider-management", open: !visibleIds.length || undefined, "aria-label": text("提供商管理", "Provider management"), style: { border: "1px solid rgba(128,128,128,.3)", borderRadius: 10, padding: 12, marginTop: 16 } },
|
|
968
|
+
h("summary", { style: { cursor: "pointer", fontWeight: 600 } }, text("提供商管理", "Provider management")),
|
|
969
|
+
h("p", { style: { opacity: .75 } }, text("开关立即保存,仅控制本插件的订阅检测和显示,不删除凭据或禁用模型路由。", "Switches save immediately and only affect this plugin's usage detection and display, not credentials or model routes.")),
|
|
970
|
+
h("div", { style: { border: 0, margin: 0, padding: 0, minWidth: 0 } },
|
|
971
|
+
!editorOpen && notice ? h("p", { role: "status", style: { color: notice.ok ? "inherit" : "#dc2626" } }, notice.text) : null,
|
|
972
|
+
h("button", { type: "button", role: "switch", "aria-checked": hideWithoutApi, disabled: busy || dirty || !result.settings?.revision, "aria-label": text("没有检测到API的默认隐藏", "Hide providers without detected API credentials"), style: hideWithoutApi ? primary : btnStyle, onClick: () => saveVisibility({ hideWithoutApi: !hideWithoutApi }) }, `${hideWithoutApi ? "✓ " : "○ "}${text("没有检测到API的默认隐藏", "Hide providers without detected API credentials")}`),
|
|
973
|
+
h("p", { style: { fontSize: 12, opacity: .75 } }, text("检测依据为可用 Key 或 MiMo Cookie 是否存在;认证失败和网络错误不会当成未配置 API。隐藏的提供商仍可在这里配置。", "Detection checks for a Key or MiMo Cookie, not request success. Auth/network errors are not treated as missing API credentials. Hidden providers remain configurable here.")),
|
|
974
|
+
h("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 240px), 1fr))", gap: 8 } }, PROVIDER_ORDER.map(id => {
|
|
975
|
+
const enabled = providerEnabled(result.settings, id), e = entries.find(item => item.providerId === id), editing = tab === id && editorOpen;
|
|
976
|
+
return h("div", { key: id, style: { gridColumn: editing ? "1 / -1" : undefined, display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center", border: "1px solid rgba(128,128,128,.25)", borderRadius: 8, padding: 8 } },
|
|
977
|
+
h("span", { style: { flex: 1, minWidth: 80 } }, PROVIDER_META[id].short),
|
|
978
|
+
h("button", { type: "button", role: "switch", "aria-checked": enabled, disabled: busy || dirty || !result.settings?.revision, "aria-label": `${text("启用", "Enable")} ${PROVIDER_META[id].short}`, style: enabled ? primary : btnStyle, onClick: () => saveVisibility({ providers: { [id]: !enabled } }) }, enabled ? text("已开启", "On") : text("已关闭", "Off")),
|
|
979
|
+
h("span", { style: { width: "100%", fontSize: 12, opacity: .75 } }, !enabled ? text("检测已关闭", "Detection disabled") : e?.apiDetected === false || ["no-key", "no-cookie"].includes(e?.state) ? text(hideWithoutApi ? "未检测到 API · 默认隐藏" : "未检测到 API", "No API credentials detected") : e?.apiDetected === true || (e?.keySource && e.keySource !== "none") ? text("已检测到 API / 凭据", "API credentials detected") : text("待检测", "Detection pending")),
|
|
980
|
+
h("details", { id: `subusage-credentials-${id}`, open: editing, style: { width: "100%" } },
|
|
981
|
+
h("summary", { "aria-label": `${text("连接与凭据", "Connection & credentials")} ${PROVIDER_META[id].short}`, style: { cursor: "pointer" }, onClick: event => { event.preventDefault(); if (busy) return; if (editing) setEditorOpen(false); else openEditor(id); } }, text(id === "xiaomi-token-plan-cn" ? "登录与凭据" : id === "commandcode" ? "凭据来源" : "连接与凭据", id === "xiaomi-token-plan-cn" ? "Login & credentials" : id === "commandcode" ? "Credential source" : "Connection & credentials")),
|
|
982
|
+
tab === id ? credentialEditor : null));
|
|
983
|
+
}))
|
|
984
|
+
)
|
|
985
|
+
);
|
|
986
|
+
return h("div", { ref: root, style: { padding: 16, maxWidth: 720, minWidth: 0, font: "13px/1.6 sans-serif", containerType: "inline-size", containerName: "subusage" } },
|
|
987
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" } },
|
|
988
|
+
h("h2", { style: { margin: 0, fontSize: 16 } }, t("title")),
|
|
989
|
+
h("span", { title: text("仅表示数据获取成功,不表示还有额度", "Fetch success only; not remaining quota"), style: { opacity: .75 } }, text(`${success}/${visibleIds.length} 家数据获取成功`, `${success}/${visibleIds.length} providers fetched`)),
|
|
990
|
+
h("button", { type: "button", disabled: busy || !usageId, style: primary, onClick: () => refresh(false) }, text("刷新当前", "Refresh current")),
|
|
991
|
+
h("button", { type: "button", disabled: busy, style: btnStyle, onClick: () => refresh(true) }, text("刷新全部", "Refresh all"))
|
|
992
|
+
),
|
|
993
|
+
loadError ? h("p", { role: "alert", style: { color: "#dc2626", overflowWrap: "anywhere" } }, loadError) : null,
|
|
994
|
+
!visibleIds.length ? h("p", { role: "status" }, text("没有可显示的提供商,请展开下方「提供商管理」开启、配置 API,或关闭默认隐藏。", "No visible providers. Expand Provider management below to enable a provider, configure API credentials, or turn off auto-hide.")) : null,
|
|
995
|
+
h("div", { className: "subusage-provider-tabs", role: "tablist", "aria-label": text("模型商", "Providers"), style: { display: "flex", flexWrap: "wrap", gap: 4, marginTop: 14 } },
|
|
996
|
+
visibleIds.map((id, index) => {
|
|
997
|
+
const e = entries.find((item) => item.providerId === id), active = usageId === id;
|
|
998
|
+
return h("button", { key: id, id: `subusage-tab-${id}`, role: "tab", type: "button", tabIndex: active || (!visibleIds.includes(usageId) && index === 0) ? 0 : -1, "aria-selected": active,
|
|
999
|
+
"aria-controls": "subusage-provider-panel", title: `${PROVIDER_META[id].label}: ${providerDescription(e, t)}`, "aria-label": `${PROVIDER_META[id].short}: ${providerDescription(e, t)}`,
|
|
1000
|
+
onClick: () => chooseUsage(id), onKeyDown: (event) => {
|
|
1001
|
+
let next;
|
|
1002
|
+
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % visibleIds.length;
|
|
1003
|
+
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + visibleIds.length - 1) % visibleIds.length;
|
|
1004
|
+
else if (event.key === "Home") next = 0; else if (event.key === "End") next = visibleIds.length - 1; else return;
|
|
1005
|
+
event.preventDefault(); chooseUsage(visibleIds[next]);
|
|
1006
|
+
const button = event.currentTarget.parentElement.querySelectorAll('[role="tab"]')[next]; if (button) button.focus();
|
|
1007
|
+
}, style: { ...btnStyle, minWidth: 0, padding: "7px 10px", whiteSpace: "nowrap", fontSize: 12, borderRadius: "6px 6px 0 0", border: "1px solid transparent", borderBottom: active ? "2px solid #3b82f6" : "2px solid transparent", background: active ? "rgba(59,130,246,.10)" : "transparent" }
|
|
1008
|
+
}, h("span", { "aria-hidden": true, style: { display: "inline-block", width: 6, height: 6, marginRight: 4, borderRadius: 99, background: tagColor(e) } }), h("span", { "aria-hidden": true, style: { marginRight: 3 } }, providerIcon(e)), PROVIDER_META[id].short);
|
|
1009
|
+
})),
|
|
1010
|
+
!!usageId ? h("section", { role: "tabpanel", id: "subusage-provider-panel", "aria-label": usageMeta.label, style: { marginTop: 12, border: "1px solid rgba(128,128,128,.25)", borderRadius: 10, padding: 14 } },
|
|
1011
|
+
h("strong", null, usageMeta.label),
|
|
1012
|
+
h("p", { role: "status", style: { margin: "4px 0", color: usageEntry && usageEntry.state !== "ok" ? "#dc2626" : "inherit" } }, statusText(usageEntry, t)),
|
|
1013
|
+
cacheAge(usageEntry, t) ? h("p", { style: { color: "#b7791f" } }, cacheAge(usageEntry, t)) : null,
|
|
1014
|
+
usageEntry && usageEntry.error ? h("p", { role: "alert", style: { color: "#dc2626", overflowWrap: "anywhere" } }, usageEntry.error) : null,
|
|
1015
|
+
canShowUsage(usageEntry) && (usageEntry.windows || []).length ? (usageEntry.windows || []).map((w) => h(UsageWindowRow, { key: w.kind, w, t, getLocale, uncertain: usageEntry.coverage === "partial" || usageEntry.freshness === "stale" || usageEntry.state !== "ok" })) : h("p", { style: { opacity: .75 } }, usageEntry ? t("noUsageData") : t("loading")),
|
|
1016
|
+
canShowUsage(usageEntry) && usageEntry.extras ? h("p", { style: { opacity: .75 } }, usageEntry.extras.map((x) => `${extraLabel(x, t)}: ${x.value}`).join(" · ")) : null,
|
|
1017
|
+
usageEntry?.lastAttemptAt ? h("p", { style: { opacity: .65 } }, `${t("lastUpdated")} ${new Date(usageEntry.lastAttemptAt).toLocaleString(getLocale())}`) : null
|
|
1018
|
+
) : null,
|
|
1019
|
+
providerManagement
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// ── 装配:只在 apply scope 创建共享 store 与生命周期 disposer ───────────
|
|
1024
|
+
const name = "dsh-subusage";
|
|
1025
|
+
const inject = ["slots", "locale", "remote"];
|
|
1026
|
+
function apply(ctx) {
|
|
1027
|
+
try { ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-subusage: copy dictionaries"); } catch (error) { console.error("[dsh-subusage] locale 注册失败:", error); }
|
|
1028
|
+
const t = ctx.locale.bind(NS);
|
|
1029
|
+
const getLocale = () => ctx.locale.getLocale().active;
|
|
1030
|
+
let mounted = Promise.resolve(), disposed = false;
|
|
1031
|
+
const pendingTimers = new Map();
|
|
1032
|
+
try { mounted = ctx.remote.$mount(subUsageRemote); ctx.effect(async () => await mounted, "dsh-subusage: mount remotes"); }
|
|
1033
|
+
catch (error) { mounted = Promise.reject(error); mounted.catch(() => {}); }
|
|
1034
|
+
const wait = (ms) => new Promise((resolve, reject) => { const timer = setTimeout(() => { pendingTimers.delete(timer); resolve(); }, ms); pendingTimers.set(timer, reject); });
|
|
1035
|
+
const call = async (method, ...args) => {
|
|
1036
|
+
const deadline = Date.now() + 20000;
|
|
1037
|
+
let mountTimer;
|
|
1038
|
+
const mountTimeout = new Promise((_, reject) => { mountTimer = setTimeout(() => { pendingTimers.delete(mountTimer); reject(new Error("remote 挂载超时(20 秒)")); }, 20000); pendingTimers.set(mountTimer, reject); });
|
|
1039
|
+
try { await Promise.race([mounted, mountTimeout]); } finally { clearTimeout(mountTimer); pendingTimers.delete(mountTimer); }
|
|
1040
|
+
for (;;) {
|
|
1041
|
+
if (disposed) throw new Error("Client 已卸载");
|
|
1042
|
+
const service = ctx.get("remote.subUsage");
|
|
1043
|
+
if (service !== undefined) {
|
|
1044
|
+
if (typeof service[method] !== "function") throw new Error(getLocale().startsWith("zh") ? `Host/Client 契约版本不匹配:Host 缺少 ${method};请同时更新两端并重新加载或重启。` : `Host/Client contract version mismatch: Host has no ${method}; update both sides and reload or restart.`);
|
|
1045
|
+
let timer;
|
|
1046
|
+
const timeout = new Promise((_, reject) => { timer = setTimeout(() => { pendingTimers.delete(timer); reject(new Error(`remote ${method} 调用超时(20 秒)`)); }, Math.max(1, deadline - Date.now())); pendingTimers.set(timer, reject); });
|
|
1047
|
+
try {
|
|
1048
|
+
const rpc = Promise.resolve(service[method](...args)).then((result) => { if (!result.ok) throw result.error; return result.value; });
|
|
1049
|
+
return await Promise.race([rpc, timeout]);
|
|
1050
|
+
} finally { clearTimeout(timer); pendingTimers.delete(timer); }
|
|
1051
|
+
}
|
|
1052
|
+
if (Date.now() > deadline) throw new Error("remote.subUsage 挂载超时(20 秒):Host 端 subUsage 服务未就绪");
|
|
1053
|
+
await wait(250);
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
const usageStore = createUsageStore(call);
|
|
1057
|
+
ctx.effect(() => () => { disposed = true; usageStore.dispose(); for (const [timer, reject] of pendingTimers) { clearTimeout(timer); reject(new Error("Client 已卸载")); } pendingTimers.clear(); }, "dsh-subusage: dispose shared store and RPC timers");
|
|
1058
|
+
const readEntry = (id) => usageStore.reader(id);
|
|
1059
|
+
try {
|
|
1060
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({ name: "settings.section", id: "subusage", order: 22, label: () => t("nav"), inject: () => ({ usageStore, t, getLocale }) }, SubusageSection));
|
|
1061
|
+
} catch (error) { console.error("[dsh-subusage] 设置页注册失败:", error); }
|
|
1062
|
+
ctx.inject(["modelDirectories"], (scope) => {
|
|
1063
|
+
try {
|
|
1064
|
+
scope.slots.inject("conversation.input.right", () => scope.slots.register({ name: "conversation.input.right", id: "subusage-usage", order: 1001,
|
|
1065
|
+
inject: (sessionId) => ({ store: scope.modelDirectories.directoryFor(sessionId).store, readEntry, t, getLocale }) }, SubusagePillEntry));
|
|
1066
|
+
} catch (error) { console.error("[dsh-subusage] 药丸注册失败:", error); }
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** 槽包装:先看当前模型的 provider,再分发到对应药丸。 */
|
|
1071
|
+
function SubusagePillEntry({ store, readEntry, t, getLocale }) {
|
|
1072
|
+
const state = react.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
1073
|
+
const provider = state.current && state.current.provider;
|
|
1074
|
+
const info = PROVIDER_ORDER.includes(provider) ? PROVIDER_META[provider] : null;
|
|
1075
|
+
const meta = info ? { label: provider === "commandcode" ? info.label : info.short, id: provider } : null;
|
|
1076
|
+
// 所有 Hook 必须先于条件返回:模型切换或重连时 provider 可能暂时为空。
|
|
1077
|
+
// 稳定引用:否则父组件每次渲染都新建函数,药丸 effect 会反复重启轮询。
|
|
1078
|
+
const providerId = meta ? meta.id : null;
|
|
1079
|
+
const reader = react.useMemo(() => providerId ? readEntry(providerId) : null, [readEntry, providerId]);
|
|
1080
|
+
if (!meta) return null;
|
|
1081
|
+
return h(UsagePill, {
|
|
1082
|
+
key: meta.id,
|
|
1083
|
+
providerId: meta.id,
|
|
1084
|
+
label: meta.label,
|
|
1085
|
+
readEntry: reader,
|
|
1086
|
+
t,
|
|
1087
|
+
getLocale
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
exports.apply = apply;
|
|
1092
|
+
exports.inject = inject;
|
|
1093
|
+
exports.name = name;
|
|
1094
|
+
return module.exports;
|
|
1095
|
+
}
|
|
1096
|
+
});
|