@konnga/dsh-hud 0.9.2 → 0.10.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/lib/client.js CHANGED
@@ -61,9 +61,52 @@ const CSS = [
61
61
  ".dshh-pill:hover{color:var(--dsw-alias-label-primary,#e5e7eb);border-color:var(--dsw-alias-border-l2,rgba(127,127,127,0.45));}",
62
62
  ".dshh-timer{cursor:default;gap:5px;}",
63
63
  ".dshh-tip{position:fixed;z-index:60;box-sizing:border-box;max-width:420px;padding:6px 10px;border-radius:8px;background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-2,#2c2c2e));border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.25));box-shadow:0 6px 20px rgba(0,0,0,0.28);color:var(--dsw-alias-label-primary,#f9fafb);font-size:11.5px;line-height:1.55;white-space:pre-wrap;word-break:break-word;pointer-events:none;}",
64
+ ".dshh-tipLine{display:block;}",
65
+ ".dshh-tipSeg{white-space:pre-wrap;}",
66
+ ".dshh-tipAccent,.dshh-tipSuccess,.dshh-tipWarn,.dshh-tipValue{font-variant-numeric:tabular-nums;}",
67
+ ".dshh-tipLabel{color:var(--dsw-alias-label-secondary,#9aa0a6);}",
68
+ ".dshh-tipValue{color:var(--dsw-alias-label-primary,#f9fafb);margin-left:2px;}",
69
+ ".dshh-tipSuccess{color:var(--dsw-alias-state-success-primary,#22c55e);}",
70
+ ".dshh-tipWarn{color:var(--dsw-alias-state-warn-primary,#f59e0b);}",
71
+ ".dshh-tipAccent{color:var(--dsw-alias-state-business-primary,#7aaaff);}",
72
+ ".dshh-tipMuted{color:var(--dsw-alias-label-tertiary,#6b7280);}",
64
73
  ".dshh-timerGlyph{flex:none;opacity:.85;}",
65
74
  ".dshh-timerValue{font-variant-numeric:tabular-nums;}",
75
+ ".dshh-balance{cursor:pointer;gap:2px;font-variant-numeric:tabular-nums;}",
76
+ ".dshh-balanceValue{letter-spacing:.2px;}",
77
+ ".dshh-balanceBonus{opacity:.75;margin-left:1px;}",
78
+ ".dshh-balanceStale{color:var(--dsw-alias-state-warning-primary,#d7a14a);margin-left:2px;}",
79
+ ".dshh-balanceWarn{flex:none;color:var(--dsw-alias-state-warning-primary,#d7a14a);margin-right:2px;}",
80
+ ".dshh-balanceLow{color:var(--dsw-alias-state-warning-primary,#d7a14a);}",
81
+ ".dshh-balanceUsed{flex:none;opacity:.65;margin-right:1px;}",
82
+ ".dshh-balanceUnknown{opacity:.7;}",
83
+ ".dshh-balanceProvider{flex:none;margin-right:3px;opacity:.8;}",
66
84
  ".dshh-panel{color:var(--dsw-alias-label-primary,#e5e7eb);font-size:12.5px;line-height:1.6;}",
85
+ ".dshh-tokenPanel{position:fixed;left:50%;transform:translateX(-50%);bottom:96px;z-index:2147483000;",
86
+ "width:min(420px,calc(100vw - 32px));box-sizing:border-box;display:flex;flex-direction:column;gap:8px;padding:12px 14px;",
87
+ "border-radius:12px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,0.28));",
88
+ "background:var(--dsw-alias-bg-layer-1,#1c1d21);color:var(--dsw-alias-label-primary,#e5e7eb);",
89
+ "box-shadow:0 12px 32px rgba(0,0,0,0.32);font-size:12.5px;line-height:1.55;}",
90
+ ".dshh-tokenHead{display:flex;align-items:baseline;gap:6px;}",
91
+ ".dshh-tokenTitle{font-size:13px;font-weight:600;}",
92
+ ".dshh-tokenProvider{font-family:ui-monospace,Consolas,monospace;font-size:11px;padding:1px 6px;border-radius:6px;",
93
+ "background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.14));color:var(--dsw-alias-label-secondary,#8a8f98);}",
94
+ ".dshh-tokenHint{font-size:11.5px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
95
+ ".dshh-tokenInput{box-sizing:border-box;width:100%;height:30px;padding:0 9px;border-radius:8px;font:inherit;",
96
+ "font-family:ui-monospace,Consolas,monospace;font-size:12px;",
97
+ "border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,0.36));",
98
+ "background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.10));color:var(--dsw-alias-label-primary,#e5e7eb);}",
99
+ ".dshh-tokenInput:focus{outline:none;border-color:var(--dsw-alias-state-business-primary,#4b8bf5);}",
100
+ ".dshh-tokenRef{font-size:10.5px;color:var(--dsw-alias-label-tertiary,#6b7280);",
101
+ "font-family:ui-monospace,Consolas,monospace;}",
102
+ ".dshh-tokenActions{display:flex;gap:8px;justify-content:flex-end;}",
103
+ ".dshh-tokenSave,.dshh-tokenCancel{height:26px;padding:0 12px;border-radius:7px;font:inherit;font-size:12px;cursor:pointer;}",
104
+ ".dshh-tokenSave{border:1px solid transparent;background:var(--dsw-alias-button-primary-fill,#4b8bf5);",
105
+ "color:var(--dsw-alias-label-primary-foreground,#fff);}",
106
+ ".dshh-tokenSave:disabled{opacity:.45;cursor:default;}",
107
+ ".dshh-tokenCancel{border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,0.36));background:transparent;",
108
+ "color:var(--dsw-alias-label-secondary,#8a8f98);}",
109
+ ".dshh-tokenError{font-size:11.5px;color:var(--dsw-alias-state-error-primary,#f87171);}",
67
110
  ".dshh-header{display:flex;align-items:center;gap:8px;padding:6px 8px;flex:none;cursor:move;line-height:1.4;",
68
111
  "user-select:none;touch-action:none;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.22));}",
69
112
  ".dshh-title{font-size:13px;font-weight:600;flex:none;}",
@@ -257,6 +300,88 @@ const CSS = [
257
300
  * 于是依赖方向永远是"由外向内",不会绕成环。
258
301
  */
259
302
  var h = react.default.createElement;
303
+ /**
304
+ * 一行在"拼平"(日志/断言/兼容 `state.text`)时长的样子。
305
+ *
306
+ * 标签与数值之间补一个空格:屏幕上它们之间是 CSS 的 `margin-left`,拼平之后没这个
307
+ * 间距,`余额$742.23` 读起来是错的,日志里也不好 grep。
308
+ */
309
+ function tipLineText(line) {
310
+ if (Array.isArray(line.segments)) return line.segments.map(function(segment) {
311
+ return segment.text;
312
+ }).join("");
313
+ if (typeof line.text === "string") return line.text;
314
+ var label = line.label ?? "";
315
+ var value = line.value ?? "";
316
+ if (label !== "" && value !== "") return label + " " + value;
317
+ return label + value;
318
+ }
319
+ /**
320
+ * 把一句白话按给定的"读数"切成段:命中的部分高亮,其余保持普通文字。
321
+ *
322
+ * 做法是**字面量匹配**而不是猜数字:调用方知道这句话里哪些是读数
323
+ *(`3h 24m`、`1469`、`$742.23`),交给我们按出现顺序切段。
324
+ * 不去用 `\d+` 自动扫 —— 那样会把「2026-10-20 12:41」这类时间戳也染上,
325
+ * 满屏都是颜色,等于没有重点。
326
+ *
327
+ * @param text - 整句白话。
328
+ * @param readings - 要突出的字面量(按长度从长到短匹配,避免短串先吃长串)。
329
+ * @param tone - 用什么颜色。
330
+ * @returns 分段数组(没有命中就退化成一段)。
331
+ */
332
+ function emphasize(text, readings, tone = "accent") {
333
+ var hits = readings.filter(function(item) {
334
+ return typeof item === "string" && item !== "";
335
+ }).map(function(item) {
336
+ return item;
337
+ }).sort(function(left, right) {
338
+ return right.length - left.length;
339
+ });
340
+ if (hits.length === 0 || text === "") return [{ text }];
341
+ var segments = [];
342
+ var plain = "";
343
+ var index = 0;
344
+ while (index < text.length) {
345
+ var matched = null;
346
+ for (var at = 0; at < hits.length; at += 1) if (text.startsWith(hits[at], index)) {
347
+ matched = hits[at];
348
+ break;
349
+ }
350
+ if (matched === null) {
351
+ plain += text[index];
352
+ index += 1;
353
+ continue;
354
+ }
355
+ if (plain !== "") {
356
+ segments.push({ text: plain });
357
+ plain = "";
358
+ }
359
+ segments.push({
360
+ text: matched,
361
+ tone
362
+ });
363
+ index += matched.length;
364
+ }
365
+ if (plain !== "") segments.push({ text: plain });
366
+ return segments.length === 0 ? [{ text }] : segments;
367
+ }
368
+ /**
369
+ * 把气泡内容归一成「逐行结构 + 拼平文本」。
370
+ *
371
+ * 两处都用它:真 store 的 tip 片(渲染用 `lines`,`text` 留给日志与断言),
372
+ * 以及测试 harness 的假 `showTip`(让老用例继续按字符串断言,不必全部重写)。
373
+ */
374
+ function normalizeTipContent(content) {
375
+ var lines = typeof content === "string" ? [{ text: content }] : Array.isArray(content) ? content.filter(function(line) {
376
+ return line !== null && typeof line === "object";
377
+ }) : [];
378
+ return {
379
+ lines,
380
+ text: lines.map(function(line) {
381
+ return tipLineText(line);
382
+ }).join("\n")
383
+ };
384
+ }
260
385
  var CHANNEL = "/dsh-hud-rpc";
261
386
  var NS = "dshHud";
262
387
  var UI_KEY = "dsh-hud.ui.v1";
@@ -282,6 +407,30 @@ var ROWS_POLL_MS = 3e4;
282
407
  * 密一点才能让"正在跑什么"真的看到;代价用"内容没变就不 set"抵掉。
283
408
  */
284
409
  var TOOLS_POLL_MS = 3e3;
410
+ /**
411
+ * 账号余额的轮询节拍:**比别的读数都慢**。
412
+ *
413
+ * 这一枚走的不是本地 git,而是宿主替我们去平台查账(一次真实网络请求),
414
+ * 而且余额是按分钟变的,密了纯属浪费。页面隐藏时照例跳过,点击胶囊可强制刷新。
415
+ */
416
+ var BALANCE_POLL_MS = 3e5;
417
+ /** 同一次请求的 TTL:轮询与点击之间不重复打平台(失败时用更短的间隔重试,见切片)。 */
418
+ var BALANCE_TTL_MS = 24e4;
419
+ /** 失败后的重问间隔:账目查不到多半是网络/登录态,隔一小会儿再试就够。 */
420
+ var BALANCE_RETRY_MS = 3e4;
421
+ /** 「当前会话模型来源」余额的轮询节拍:真实请求由 store 的 TTL 兜着,这里只决定问的频率。 */
422
+ var PROVIDER_POLL_MS = 3e4;
423
+ /**
424
+ * 「还没问出来」时的重问间隔。
425
+ *
426
+ * 重启/刚开会话的那一下,宿主往往还没读到模型选择投影 —— 第一次问会落空。
427
+ * 用 30 秒节拍兜的话胶囊要空半分钟(用户报的就是这个);1 秒问一次,
428
+ * 正常情况一两秒内就补上了。真的出错时也不会打风暴:store 的失败退避是
429
+ * 1s→2s→4s→…→30s,这里的轮询只是"看一眼",多半被 TTL 直接挡回。
430
+ */
431
+ var PROVIDER_RETRY_TICK_MS = 1e3;
432
+ /** 覆盖低余额阈值的 localStorage 键(值是十进制数,比如 `50`)。 */
433
+ var BALANCE_LOW_KEY = "dsh-hud.balanceLow";
285
434
  /** 面板默认宽度与“停靠在输入框上方”的锚点高度。 */
286
435
  var PANEL_WIDTH = 380;
287
436
  var VIEWPORT_MARGIN = 8;
@@ -863,6 +1012,727 @@ function errorText(code, t) {
863
1012
  if (code === "unknown-endpoint") return t("err.stale-host");
864
1013
  return t("err.git-failed");
865
1014
  }
1015
+ /**
1016
+ * 这个金额真的大于 0 吗(不引 big.js:只看字符串里有没有非零数字、且不是负数)。
1017
+ *
1018
+ * 平台会给 0 余额的钱包(赠送额度用完了也还在列里),DSH 自己的账号卡片就是
1019
+ * 这么过滤的(`new Big(wallet.balance).gt(0)`)—— 两边判定要一致,否则会出现
1020
+ * "这里有 ¥0.00 的赠送余额"这种没人想看的一行。
1021
+ *
1022
+ * @param amount - 平台给的十进制字符串。
1023
+ * @returns 严格大于 0 为 `true`。
1024
+ */
1025
+ function isPositiveAmount(amount) {
1026
+ if (typeof amount !== "string") return false;
1027
+ var raw = amount.trim();
1028
+ if (raw.charAt(0) === "-") return false;
1029
+ return /[1-9]/.test(raw);
1030
+ }
1031
+ /** 币种 → 符号。认不出的币种原样接在后面(`123.45 XYZ`),不猜符号。 */
1032
+ function currencySymbol(currency) {
1033
+ if (currency === "CNY") return "¥";
1034
+ if (currency === "USD") return "$";
1035
+ return null;
1036
+ }
1037
+ /**
1038
+ * 十进制字符串 → 千分位整数部分,**不经过浮点**。
1039
+ *
1040
+ * 余额是账目数字:`Number('1234567.89')` 在极大值上会丢精度,所以整数部分只做字符串分组,
1041
+ * 小数部分原样保留。符号与小数位由调用方决定(两者在本插件里是两条独立规则)。
1042
+ */
1043
+ function groupDigits(integer) {
1044
+ var negative = integer.charAt(0) === "-";
1045
+ var digits = negative ? integer.slice(1) : integer;
1046
+ var out = "";
1047
+ for (var index = 0; index < digits.length; index += 1) {
1048
+ var rest = digits.length - index;
1049
+ out += digits.charAt(index);
1050
+ if (rest > 1 && rest % 3 === 1) out += ",";
1051
+ }
1052
+ return negative ? "-" + out : out;
1053
+ }
1054
+ /**
1055
+ * 把十进制字符串拆成符号 / 整数 / 小数三块。
1056
+ *
1057
+ * 允许的写法与平台一致:`123`、`123.45`、`.5`、`5.`、科学计数法。
1058
+ * 不接受空串、`+` 号以外的其它前缀、以及 `1,234`(千分位是我们**输出**的形状,不是输入)。
1059
+ *
1060
+ * @param amount - 十进制字符串。
1061
+ * @returns 分解结果;非法输入回 `null`。
1062
+ */
1063
+ function parseAmount(amount) {
1064
+ if (typeof amount !== "string") return null;
1065
+ var raw = amount.trim();
1066
+ if (!/^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(raw)) return null;
1067
+ var negative = raw.charAt(0) === "-";
1068
+ var body = negative ? raw.slice(1) : raw;
1069
+ if (/[eE]/.test(body)) {
1070
+ var expanded = Number(raw);
1071
+ if (!Number.isFinite(expanded)) return null;
1072
+ negative = expanded < 0;
1073
+ body = String(Math.abs(expanded));
1074
+ }
1075
+ var dot = body.indexOf(".");
1076
+ var integerPart = dot === -1 ? body : body.slice(0, dot);
1077
+ var fractionPart = dot === -1 ? "" : body.slice(dot + 1);
1078
+ if (integerPart.length === 0) integerPart = "0";
1079
+ integerPart = integerPart.replace(/^0+(?=\d)/, "");
1080
+ if (fractionPart.length === 0) fractionPart = "0";
1081
+ return {
1082
+ negative,
1083
+ integer: integerPart,
1084
+ fraction: fractionPart
1085
+ };
1086
+ }
1087
+ /** 参与加减的最大小数位(Decimal 的 8 位对账目足够,再长也没人会看)。 */
1088
+ var AMOUNT_SCALE_MAX = 8;
1089
+ /**
1090
+ * 把两个金额对齐到同一标度,转成 BigInt(值为 `真实值 × 10^scale`)。
1091
+ *
1092
+ * @param left - 第一个十进制字符串。
1093
+ * @param right - 第二个十进制字符串。
1094
+ * @returns 两个 BigInt 与它们共用的标度;任一非法回 `null`。
1095
+ */
1096
+ function scaledPair(left, right) {
1097
+ var a = parseAmount(left);
1098
+ var b = parseAmount(right);
1099
+ if (a === null || b === null) return null;
1100
+ var scale = Math.min(AMOUNT_SCALE_MAX, Math.max(a.fraction.length, b.fraction.length));
1101
+ var toScaled = function(value) {
1102
+ var digits = (value.fraction + "0".repeat(scale)).slice(0, scale);
1103
+ var magnitude = BigInt(value.integer + digits);
1104
+ return value.negative ? -magnitude : magnitude;
1105
+ };
1106
+ return {
1107
+ left: toScaled(a),
1108
+ right: toScaled(b),
1109
+ scale
1110
+ };
1111
+ }
1112
+ /** 标度整数 → 十进制字符串(`-30` + scale 2 → `-0.30`)。 */
1113
+ function formatScaled(value, scale) {
1114
+ var negative = value < BigInt(0);
1115
+ var digits = (negative ? -value : value).toString();
1116
+ if (scale === 0) return (negative ? "-" : "") + digits;
1117
+ var padded = digits.padStart(scale + 1, "0");
1118
+ var head = padded.slice(0, padded.length - scale);
1119
+ var tail = padded.slice(padded.length - scale);
1120
+ return (negative ? "-" : "") + head + "." + tail;
1121
+ }
1122
+ /**
1123
+ * 两个金额相减(`left - right`),精确到小数末位。
1124
+ *
1125
+ * 为什么不用 `Number`:余额是账目数字,`8.6 - 8.3` 在 double 里是 `0.2999999999999998`,
1126
+ * 两次刷新差一分钱就能看出误差。这里按十进制对齐后做整数减法,结果直接可用于展示。
1127
+ *
1128
+ * @param left - 被减数(十进制字符串)。
1129
+ * @param right - 减数。
1130
+ * @returns 差值(十进制字符串,可能带 `-`,小数位到 max(两侧位数));任一非法回 `null`。
1131
+ */
1132
+ function subtractAmounts(left, right) {
1133
+ var pair = scaledPair(left, right);
1134
+ if (pair === null) return null;
1135
+ return formatScaled(pair.left - pair.right, pair.scale);
1136
+ }
1137
+ /**
1138
+ * `amount` 是否严格小于 `limit`(同样走十进制对齐,不走浮点)。
1139
+ *
1140
+ * @param amount - 待比较的金额。
1141
+ * @param limit - 阈值(十进制字符串)。
1142
+ * @returns 严格小于为 `true`;任一非法回 `false`(拿不准就不报警)。
1143
+ */
1144
+ function isBelowAmount(amount, limit) {
1145
+ var pair = scaledPair(amount, limit);
1146
+ if (pair === null) return false;
1147
+ return pair.left < pair.right;
1148
+ }
1149
+ /**
1150
+ * 读取低余额阈值(`dsh-hud.balanceLow`,没有就用默认 10)。
1151
+ *
1152
+ * 键值是十进制数;解析不出正数就退回默认值 —— 一个写坏了的存储项不该让提醒消失。
1153
+ *
1154
+ * @param fallback - 默认阈值(`BALANCE_LOW_DEFAULT`)。
1155
+ * @returns 阈值字符串(交给 `isBelowAmount`)。
1156
+ */
1157
+ function lowThresholdOf(fallback) {
1158
+ try {
1159
+ var raw = globalThis.localStorage ? globalThis.localStorage.getItem(BALANCE_LOW_KEY) : null;
1160
+ if (typeof raw === "string" && raw.trim() !== "") {
1161
+ var value = Number(raw.trim());
1162
+ if (Number.isFinite(value) && value > 0) return String(value);
1163
+ }
1164
+ } catch (error) {}
1165
+ return String(fallback);
1166
+ }
1167
+ /**
1168
+ * 变化量的展示文本:`+¥0.30` / `-¥0.30`(零值回 `null`,调用方据此不画那一行)。
1169
+ *
1170
+ * @param amount - `subtractAmounts` 的输出。
1171
+ * @param currency - 币种。
1172
+ * @returns 带正负号的文本;零值或金额非法回 `null`。
1173
+ */
1174
+ function formatDelta(amount, currency) {
1175
+ var parsed = parseAmount(amount);
1176
+ if (parsed === null) return null;
1177
+ var abs = parsed.negative ? amount.slice(1) : amount;
1178
+ var text = formatBalance(abs, currency);
1179
+ if (text === null) return null;
1180
+ if (/^0*(\.0*)?$/.test(abs.trim())) return null;
1181
+ return (parsed.negative ? "-" : "+") + text;
1182
+ }
1183
+ /**
1184
+ * 把一个钱包余额格式化成界面文本。
1185
+ *
1186
+ * 规则照 DSH 自己的账号卡片(`formatBalance`,`dsh-client-ui-settings-account`):
1187
+ * 0 → `¥0.00`;不足一分 → `<¥0.01`;负的小额进位成 `-¥0.01`;其余**向零截断**到两位并加千分位。
1188
+ * 两边读同一份余额就该显示同一个数,所以这里刻意复刻而不是"差不多"。
1189
+ *
1190
+ * 认不出的币种没有符号可用:直接给 `123.45 XYZ` 这样带代码的文本,不做符号猜测。
1191
+ *
1192
+ * @param amount - 平台给的十进制字符串(可能带负号;非法输入按 0 处理)。
1193
+ * @param currency - `CNY` / `USD`,其它值按代码显示。
1194
+ * @returns 可展示文本;`amount` 非法时回 `null`(宁可不画,也不画错数字)。
1195
+ */
1196
+ function formatBalance(amount, currency) {
1197
+ var parsed = parseAmount(amount);
1198
+ if (parsed === null) return null;
1199
+ var symbol = currencySymbol(currency);
1200
+ var suffix = symbol === null ? " " + currency : "";
1201
+ var negative = parsed.negative;
1202
+ var integerPart = parsed.integer;
1203
+ var fractionPart = parsed.fraction;
1204
+ var sign = negative ? "-" : "";
1205
+ /** 按币种符号规则拼一段文本。 */
1206
+ var render = function(value) {
1207
+ return symbol === null ? sign + value + suffix : sign + symbol + value;
1208
+ };
1209
+ var integerIsZero = /^0*$/.test(integerPart);
1210
+ var fractionHasDigit = /[1-9]/.test(fractionPart);
1211
+ if (integerIsZero && !fractionHasDigit) return render("0.00");
1212
+ if (integerIsZero && (fractionPart + "00").slice(0, 2) === "00") {
1213
+ if (negative) return render("0.01");
1214
+ return "<" + render("0.01");
1215
+ }
1216
+ var cents = (fractionPart + "00").slice(0, 2);
1217
+ return render(groupDigits(integerPart) + "." + cents);
1218
+ }
1219
+
1220
+ //#endregion
1221
+ //#region src/client/components/balance-pill.ts
1222
+ /**
1223
+ * 挑出要摆在胶囊上的那个钱包。
1224
+ *
1225
+ * 平台一般只给一个充值钱包;真给了多个,取**第一个**(顺序由平台决定),其余全在
1226
+ * 悬停里 —— 胶囊上塞两个币种会挤掉它"扫一眼"的价值。
1227
+ *
1228
+ * @param state - 快照(只读 `balance`)。
1229
+ * @returns 主钱包,或 `null`(没有可展示的钱包)。
1230
+ */
1231
+ function primaryWallet(state) {
1232
+ var balance = state.balance;
1233
+ if (!balance || !Array.isArray(balance.wallets)) return null;
1234
+ for (var index = 0; index < balance.wallets.length; index += 1) {
1235
+ var wallet = balance.wallets[index];
1236
+ if (!wallet || typeof wallet.balance !== "string") continue;
1237
+ if (typeof wallet.currency !== "string") continue;
1238
+ return wallet;
1239
+ }
1240
+ return null;
1241
+ }
1242
+ /**
1243
+ * 赠送钱包里**真正有钱**的那些。
1244
+ *
1245
+ * 平台会把用完的赠送额度继续挂在 `bonusWallets` 里(余额 `0`),过滤规则与 DSH 自己的
1246
+ * 账号卡片一致(`isPositiveAmount`,等价于它的 `new Big(…).gt(0)`)—— store 那侧已经过滤
1247
+ * 过一次,这里再兜一次:组件不该假设上游一定干净。
1248
+ *
1249
+ * @param balance - 余额快照。
1250
+ * @returns 余额大于 0 的赠送钱包。
1251
+ */
1252
+ function visibleBonus(balance) {
1253
+ var bonus = Array.isArray(balance.bonus) ? balance.bonus : [];
1254
+ var out = [];
1255
+ for (var index = 0; index < bonus.length; index += 1) {
1256
+ var wallet = bonus[index];
1257
+ if (!wallet || typeof wallet.balance !== "string") continue;
1258
+ if (!isPositiveAmount(wallet.balance)) continue;
1259
+ out.push(wallet);
1260
+ }
1261
+ return out;
1262
+ }
1263
+ /**
1264
+ * 某个钱包**较上一次成功刷新**变化了多少。
1265
+ *
1266
+ * 只在我们自己采样到的两个时刻之间比较(不是平台账单的"今日消耗"):所以文案写的是
1267
+ * "较上次刷新",而不是"今天花了多少"。上一次读数与这次币种对不上、或数值没变,都回
1268
+ * `null`(那时没有可展示的变化量)。
1269
+ *
1270
+ * @param balance - 余额快照(含 `prev`)。
1271
+ * @param wallet - 当前主钱包。
1272
+ * @returns `{ delta, at }`;没有变化或没有前一份读数时回 `null`。
1273
+ */
1274
+ function deltaOf(balance, wallet) {
1275
+ var prev = balance.prev;
1276
+ if (!prev || !Array.isArray(prev.wallets)) return null;
1277
+ for (var index = 0; index < prev.wallets.length; index += 1) {
1278
+ var older = prev.wallets[index];
1279
+ if (!older || older.currency !== wallet.currency) continue;
1280
+ if (typeof older.balance !== "string") return null;
1281
+ var diff = subtractAmounts(wallet.balance, older.balance);
1282
+ if (diff === null) return null;
1283
+ var text = formatDelta(diff, wallet.currency);
1284
+ if (text === null) return null;
1285
+ return {
1286
+ delta: text,
1287
+ at: prev.at
1288
+ };
1289
+ }
1290
+ return null;
1291
+ }
1292
+ /**
1293
+ * 这个钱包是否低于低余额阈值。
1294
+ *
1295
+ * 阈值默认 `BALANCE_LOW_DEFAULT`,可被 `dsh-hud.balanceLow` 覆盖(见 README)。
1296
+ * 拿不准(金额非法、阈值读坏)时**不报警**:误报比漏报更烦人。
1297
+ *
1298
+ * @param wallet - 钱包。
1299
+ * @returns 严格低于阈值为 `true`。
1300
+ */
1301
+ function isLowWallet(wallet) {
1302
+ return isBelowAmount(wallet.balance, lowThresholdOf(10));
1303
+ }
1304
+ /**
1305
+ * 从提供方余额里挑出胶囊该画的那个数。
1306
+ *
1307
+ * 顺序是有讲究的:**剩余优先**。中转站把额度写成"无限"占位值时 `remaining` 是 `null`
1308
+ * (站点自己也不知道剩多少),这时只能画已用 —— 一个能核对的数字,胜过编一个天文数字。
1309
+ *
1310
+ * @param balance - 提供方余额快照。
1311
+ * @returns 主数字;一个能画的都没有时回 `null`(那就不画胶囊)。
1312
+ */
1313
+ function providerFigureOf(balance) {
1314
+ if (typeof balance.remaining === "string") return {
1315
+ amount: balance.remaining,
1316
+ currency: balance.currency,
1317
+ kind: "remaining"
1318
+ };
1319
+ if (typeof balance.usedKey === "string") return {
1320
+ amount: balance.usedKey,
1321
+ currency: balance.currency,
1322
+ kind: "used"
1323
+ };
1324
+ if (typeof balance.usedAccount === "string") return {
1325
+ amount: balance.usedAccount,
1326
+ currency: balance.currency,
1327
+ kind: "used"
1328
+ };
1329
+ var wallets = Array.isArray(balance.wallets) ? balance.wallets : [];
1330
+ for (var index = 0; index < wallets.length; index += 1) {
1331
+ var wallet = wallets[index];
1332
+ if (wallet && typeof wallet.balance === "string") return {
1333
+ amount: wallet.balance,
1334
+ currency: wallet.currency,
1335
+ kind: "remaining"
1336
+ };
1337
+ }
1338
+ return null;
1339
+ }
1340
+ /**
1341
+ * 金额文本:币种未知时不要凭空造一个符号。
1342
+ *
1343
+ * @param amount - 十进制字符串。
1344
+ * @param currency - 币种(可能为 `null`)。
1345
+ * @returns 展示文本;金额非法回 `null`。
1346
+ */
1347
+ function amountText(amount, currency) {
1348
+ var text = formatBalance(amount, currency === null ? "" : currency);
1349
+ if (text === null) return null;
1350
+ return currency === null ? text.trim() : text;
1351
+ }
1352
+ /** `amountText` 的"可能没有"版本:不是十进制字符串就回 `null`。 */
1353
+ function amountTextOf(amount, currency) {
1354
+ if (typeof amount !== "string") return null;
1355
+ return amountText(amount, currency);
1356
+ }
1357
+ /** i18n 模板里用来定位"标签/数值"分界点的哨兵(不可能出现在译文里)。 */
1358
+ const TIP_SPLIT = "\0";
1359
+ /**
1360
+ * 把 `'余额 {amount}'` 这样的模板拆成「标签 + 数值」两段。
1361
+ *
1362
+ * 为什么要拆:用户要「数字亮、标签灰」,而一整段文本在 DOM 里只有一个文本节点。
1363
+ * 做法是把参数换成一个哨兵再渲染,按哨兵位置切开 —— 这样**标签部分始终来自词典**,
1364
+ * 中英文都自动跟着走,不需要为每种语言另立一个"只有标签"的 key。
1365
+ *
1366
+ * @param t - 取词。
1367
+ * @param key - 词典里那条带 `{param}` 的模板。
1368
+ * @param param - 模板里的参数名。
1369
+ * @param value - 已经格式化好的数值文本。
1370
+ * @returns 逐行结构里的一行。
1371
+ */
1372
+ function labelledLine(t, key, param, value, tone = "primary") {
1373
+ var probe = {};
1374
+ probe[param] = TIP_SPLIT;
1375
+ var rendered = t(key, probe);
1376
+ var at = rendered.indexOf(TIP_SPLIT);
1377
+ if (at < 0) return { text: t(key, probe) };
1378
+ return {
1379
+ label: rendered.slice(0, at).trim(),
1380
+ value,
1381
+ tone
1382
+ };
1383
+ }
1384
+ /**
1385
+ * 提供方余额的悬停明细。
1386
+ *
1387
+ * 只放读数:提供方 id、余额、账号已用、请求数、本 Key 已用、更新时间。
1388
+ * 不再解释"为什么没有剩余额度"—— 没有那一行本身就是答案。
1389
+ *
1390
+ * @param balance - 提供方余额快照。
1391
+ * @param t - 取词。
1392
+ * @returns 逐行结构(标签灰、数字亮)。
1393
+ */
1394
+ function providerBalanceTitle(balance, t) {
1395
+ var lines = [{
1396
+ text: balance.provider,
1397
+ tone: "muted"
1398
+ }];
1399
+ var figure = providerFigureOf(balance);
1400
+ if (figure !== null && figure.kind === "remaining") {
1401
+ var remainingText = amountText(figure.amount, figure.currency);
1402
+ if (remainingText !== null) lines.push(labelledLine(t, "balance.providerRemaining", "amount", remainingText, "success"));
1403
+ }
1404
+ var bonus = Array.isArray(balance.bonus) ? balance.bonus : [];
1405
+ var parts = [];
1406
+ for (var index = 0; index < bonus.length; index += 1) {
1407
+ if (!isPositiveAmount(bonus[index].balance)) continue;
1408
+ var bonusText = amountText(bonus[index].balance, bonus[index].currency);
1409
+ if (bonusText !== null) parts.push(bonusText);
1410
+ }
1411
+ if (parts.length > 0) lines.push({
1412
+ label: t("balance.bonus"),
1413
+ value: parts.join(" · "),
1414
+ tone: "muted"
1415
+ });
1416
+ if (typeof balance.requests === "number" && balance.requests > 0) lines.push(labelledLine(t, "balance.providerRequests", "count", balance.requests.toLocaleString("en-US"), "accent"));
1417
+ var accountUsed = amountTextOf(balance.usedAccount, balance.currency);
1418
+ if (accountUsed !== null) lines.push(labelledLine(t, "balance.providerUsedAccount", "amount", accountUsed, "warn"));
1419
+ var keyUsed = amountTextOf(balance.usedKey, balance.currency);
1420
+ if (keyUsed !== null) lines.push(labelledLine(t, "balance.providerUsedKey", "amount", keyUsed, "warn"));
1421
+ lines.push({
1422
+ text: t("balance.updated", { time: fullWhen(balance.at) }),
1423
+ tone: "muted"
1424
+ });
1425
+ return lines;
1426
+ }
1427
+ /**
1428
+ * 点胶囊该干什么。
1429
+ *
1430
+ * 分两种情况,因为"点了没反应"是最难查的那种问题:
1431
+ * - 这个提供方的**后台凭据还没配**(`token.configured === false`)→ 直接弹设置面板。
1432
+ * 这才是用户此刻真正要做的事;让他自己去翻配置文件就是把人挡在门外。
1433
+ * - 配了(或宿主没告诉我们状态)→ 刷新一次读数(跳过宿主 60 秒缓存)。
1434
+ *
1435
+ * @param ledger - 当前账本结论(`token` 在里面)。
1436
+ * @param store - 共享 store。
1437
+ */
1438
+ function onPillClick(ledger, store) {
1439
+ var token = ledger.token;
1440
+ if (token !== null && token !== void 0 && token.configured === false) {
1441
+ store.openTokenPanel(typeof ledger.provider === "string" && ledger.provider !== "" ? ledger.provider : "", token.ref);
1442
+ return;
1443
+ }
1444
+ store.loadProviderBalance(true);
1445
+ }
1446
+ /** 平台用量页地址(没有就回 `null`:更旧的宿主不给这个链接)。 */
1447
+ function usageUrlOf(balance) {
1448
+ var url = balance.usageUrl;
1449
+ return typeof url === "string" && url !== "" ? url : null;
1450
+ }
1451
+ /**
1452
+ * 在新标签页打开一个外链。
1453
+ *
1454
+ * 为什么要 try/catch:`window.open` 在假 DOM 与某些沙箱里不存在,而"打不开链接"
1455
+ * 不该把整枚胶囊的点击路径打崩(它是只读读数,不是导航控件)。
1456
+ *
1457
+ * @param url - 目标地址。
1458
+ * @returns 是否成功发起打开。
1459
+ */
1460
+ function openExternal(url) {
1461
+ try {
1462
+ var opened = globalThis.open;
1463
+ if (typeof opened === "function") {
1464
+ opened.call(globalThis, url, "_blank");
1465
+ return true;
1466
+ }
1467
+ } catch (error) {}
1468
+ return false;
1469
+ }
1470
+ /**
1471
+ * 悬停明细:每个钱包一行、赠送余额、变化量、低余额提醒、更新时间,以及失败时那句"可能已过期"。
1472
+ *
1473
+ * 刻意**不写操作说明**("点一下刷新" / "右键开用量页"):气泡是读数,不是帮助文档;
1474
+ * 两句话就把本来五行的明细撑成七行,还挤掉了真正会变的那几个数。
1475
+ *
1476
+ * @param balance - 余额快照。
1477
+ * @param t - 取词。
1478
+ * @returns 多行文本(气泡按 `pre-wrap` 原样显示)。
1479
+ */
1480
+ function balanceTitle(balance, t) {
1481
+ var lines = [{
1482
+ text: t("balance.title"),
1483
+ tone: "muted"
1484
+ }];
1485
+ var wallets = Array.isArray(balance.wallets) ? balance.wallets : [];
1486
+ for (var index = 0; index < wallets.length; index += 1) {
1487
+ var text = formatBalance(wallets[index].balance, wallets[index].currency);
1488
+ if (text === null) continue;
1489
+ lines.push({
1490
+ text: index === 0 ? text : text + " (" + wallets[index].currency + ")",
1491
+ tone: "success"
1492
+ });
1493
+ }
1494
+ var bonus = visibleBonus(balance);
1495
+ if (bonus.length > 0) {
1496
+ var parts = [];
1497
+ for (var k = 0; k < bonus.length; k += 1) {
1498
+ var bonusText = formatBalance(bonus[k].balance, bonus[k].currency);
1499
+ if (bonusText !== null) parts.push(bonusText);
1500
+ }
1501
+ if (parts.length > 0) lines.push({
1502
+ label: t("balance.bonus"),
1503
+ value: parts.join(" · "),
1504
+ tone: "muted"
1505
+ });
1506
+ }
1507
+ var wallet = primaryWallet({ balance });
1508
+ if (wallet !== null) {
1509
+ var move = deltaOf(balance, wallet);
1510
+ if (move !== null) lines.push({
1511
+ text: t("balance.delta", {
1512
+ delta: move.delta,
1513
+ time: fullWhen(move.at)
1514
+ }),
1515
+ tone: "muted"
1516
+ });
1517
+ }
1518
+ lines.push({
1519
+ text: t("balance.updated", { time: fullWhen(balance.at) }),
1520
+ tone: "muted"
1521
+ });
1522
+ if (balance.failed === true) lines.push({
1523
+ text: t("balance.stale", { time: fullWhen(balance.at) }),
1524
+ tone: "muted"
1525
+ });
1526
+ return lines;
1527
+ }
1528
+ /**
1529
+ * 画那枚余额胶囊。
1530
+ * @param state - store 快照(只读 `balance`)。
1531
+ * @param options - 取词与 store(悬停接线、刷新都经它)。
1532
+ * @returns 胶囊节点;没有可展示的余额时 `null`。
1533
+ */
1534
+ function balancePillNode(state, options) {
1535
+ var t = options.t;
1536
+ var ledger = state.provider;
1537
+ if (ledger && ledger.mode === "provider" && ledger.balance !== null) {
1538
+ var accountUsage = state.balance && typeof state.balance === "object" ? usageUrlOf(state.balance) : null;
1539
+ return providerPillNode(ledger, ledger.balance, accountUsage, options);
1540
+ }
1541
+ if (ledger && ledger.mode === "unavailable") {
1542
+ if (typeof ledger.provider === "string" && ledger.provider !== "") return unknownProviderPillNode(ledger, options);
1543
+ return placeholderPillNode(ledger, options);
1544
+ }
1545
+ if (ledger !== void 0 && ledger !== null && ledger.mode === "unknown") return placeholderPillNode(ledger, options);
1546
+ var snapshot = state.balance;
1547
+ if (!snapshot || typeof snapshot !== "object") return null;
1548
+ const balance = snapshot;
1549
+ var wallet = primaryWallet({ balance });
1550
+ if (wallet === null) return null;
1551
+ var text = formatBalance(wallet.balance, wallet.currency);
1552
+ if (text === null) return null;
1553
+ var low = isLowWallet(wallet);
1554
+ var children = [];
1555
+ if (low) children.push(h("span", {
1556
+ key: "low",
1557
+ className: "dshh-balanceWarn"
1558
+ }, "⚠"));
1559
+ children.push(h("span", {
1560
+ key: "value",
1561
+ className: "dshh-balanceValue" + (low ? " dshh-balanceLow" : "")
1562
+ }, text));
1563
+ if (visibleBonus(balance).length > 0) children.push(h("span", {
1564
+ key: "bonus",
1565
+ className: "dshh-balanceBonus"
1566
+ }, "+"));
1567
+ if (balance.failed === true) children.push(h("span", {
1568
+ key: "stale",
1569
+ className: "dshh-balanceStale"
1570
+ }, "?"));
1571
+ probe.log("balance-pill", {
1572
+ ledger: "account",
1573
+ currency: wallet.currency,
1574
+ failed: balance.failed === true,
1575
+ bonus: Array.isArray(balance.bonus) ? balance.bonus.length : 0,
1576
+ low,
1577
+ delta: deltaOf(balance, wallet) === null ? null : "yes"
1578
+ });
1579
+ return h("button", Object.assign({
1580
+ type: "button",
1581
+ className: "dshh-pill dshh-balance",
1582
+ "data-dsh-hud": "balance-pill",
1583
+ key: "balance",
1584
+ "aria-label": t("balance.title"),
1585
+ onClick: function() {
1586
+ options.store.loadBalance(true);
1587
+ },
1588
+ onContextMenu: function(event) {
1589
+ var url = usageUrlOf(balance);
1590
+ if (url === null) return;
1591
+ if (event && typeof event.preventDefault === "function") event.preventDefault();
1592
+ if (event && typeof event.stopPropagation === "function") event.stopPropagation();
1593
+ openExternal(url);
1594
+ }
1595
+ }, options.store.tipHandlers("balance-pill", function() {
1596
+ return balanceTitle(balance, t);
1597
+ })), children);
1598
+ }
1599
+ /**
1600
+ * 画"还没有读数"的占位胶囊:`--`。
1601
+ *
1602
+ * 为什么不是干脆不画:不画就分不清"还在查"和"插件没装好"(用户当年正是这么误判的)。
1603
+ * 为什么不用平台账号的余额顶上:那是**另一个账本**的钱;新会话还不知道要用谁的钱,
1604
+ * 拿官方账号余额去填,等于报一个和本次会话无关的数。
1605
+ *
1606
+ * 点击照样重取一次 —— 用户点它,通常就是"怎么还没出来"。
1607
+ *
1608
+ * @param ledger - 当前账本结论(可能是 `null`:连结论都还没有)。
1609
+ * @param options - 取词与 store。
1610
+ * @returns 胶囊节点。
1611
+ */
1612
+ function placeholderPillNode(ledger, options) {
1613
+ var t = options.t;
1614
+ probe.log("balance-pill", {
1615
+ ledger: "placeholder",
1616
+ reason: ledger?.reason ?? null
1617
+ });
1618
+ var text = t("balance.providerUnknown");
1619
+ return h("button", Object.assign({
1620
+ type: "button",
1621
+ className: "dshh-pill dshh-balance dshh-balanceUnknown",
1622
+ "data-dsh-hud": "balance-pill",
1623
+ key: "balance",
1624
+ "aria-label": text,
1625
+ onClick: function() {
1626
+ options.store.loadProviderBalance(true);
1627
+ }
1628
+ }, options.store.tipHandlers("balance-pill", function() {
1629
+ return text;
1630
+ })), [h("span", {
1631
+ key: "value",
1632
+ className: "dshh-balanceValue"
1633
+ }, text)]);
1634
+ }
1635
+ /**
1636
+ * 画"知道是哪个提供方、但读不到它的余额"的胶囊:`ur --`。
1637
+ *
1638
+ * 为什么值得画:`—` 是一个**读数**("这个账本的数读不到"),而空白只是一个疑点。
1639
+ * 用户看到空白的第一反应是"插件坏了",于是去翻日志、重装、重启 —— 那才是真正的成本。
1640
+ *
1641
+ * @param ledger - 账本结论(含提供方与原因)。
1642
+ * @param options - 取词与 store。
1643
+ * @returns 胶囊节点。
1644
+ */
1645
+ function unknownProviderPillNode(ledger, options) {
1646
+ var t = options.t;
1647
+ var provider = typeof ledger.provider === "string" ? ledger.provider : "?";
1648
+ var reason = typeof ledger.reason === "string" && ledger.reason !== "" ? t("balance.reason." + ledger.reason) : "";
1649
+ probe.log("balance-pill", {
1650
+ ledger: "unavailable",
1651
+ provider,
1652
+ reason: ledger.reason
1653
+ });
1654
+ return h("button", Object.assign({
1655
+ type: "button",
1656
+ className: "dshh-pill dshh-balance dshh-balanceUnknown",
1657
+ "data-dsh-hud": "balance-pill",
1658
+ key: "balance",
1659
+ "aria-label": provider,
1660
+ onClick: function() {
1661
+ onPillClick(ledger, options.store);
1662
+ }
1663
+ }, options.store.tipHandlers("balance-pill", function() {
1664
+ return reason === "" ? provider : provider + "\n" + reason;
1665
+ })), [h("span", {
1666
+ key: "provider",
1667
+ className: "dshh-balanceProvider"
1668
+ }, provider), h("span", {
1669
+ key: "value",
1670
+ className: "dshh-balanceValue"
1671
+ }, t("balance.providerUnknown"))]);
1672
+ }
1673
+ /**
1674
+ * 画"当前提供方"的余额胶囊(中转站 / 官方 API Key)。
1675
+ *
1676
+ * 与账号那一路的两处不同:
1677
+ * - 没有赠送标记 `+` 那一套区分含义(中转站只报一个数),但官方 Key 带 `bonus`,照旧缀起来;
1678
+ * - 只有"已用"可画时(站点把额度写成无限占位值)在数字前加一个词,否则读的人会把它当成余额。
1679
+ *
1680
+ * @param balance - 提供方余额快照。
1681
+ * @param options - 取词与 store。
1682
+ * @returns 胶囊节点;没有可画的数字时 `null`。
1683
+ */
1684
+ function providerPillNode(ledger, balance, usageUrl, options) {
1685
+ var t = options.t;
1686
+ var figure = providerFigureOf(balance);
1687
+ if (figure === null) return null;
1688
+ var text = amountText(figure.amount, figure.currency);
1689
+ if (text === null) return null;
1690
+ var low = figure.kind === "remaining" && isBelowAmount(figure.amount, lowThresholdOf(10));
1691
+ var children = [];
1692
+ if (low) children.push(h("span", {
1693
+ key: "low",
1694
+ className: "dshh-balanceWarn"
1695
+ }, "⚠"));
1696
+ if (figure.kind === "used") children.push(h("span", {
1697
+ key: "label",
1698
+ className: "dshh-balanceUsed"
1699
+ }, t("balance.usedShort")));
1700
+ children.push(h("span", {
1701
+ key: "value",
1702
+ className: "dshh-balanceValue" + (low ? " dshh-balanceLow" : "")
1703
+ }, text));
1704
+ if (balance.bonus.length > 0) children.push(h("span", {
1705
+ key: "bonus",
1706
+ className: "dshh-balanceBonus"
1707
+ }, "+"));
1708
+ probe.log("balance-pill", {
1709
+ ledger: "provider",
1710
+ provider: balance.provider,
1711
+ kind: balance.kind,
1712
+ figure: figure.kind,
1713
+ placeholder: balance.placeholderLimit,
1714
+ low
1715
+ });
1716
+ return h("button", Object.assign({
1717
+ type: "button",
1718
+ className: "dshh-pill dshh-balance",
1719
+ "data-dsh-hud": "balance-pill",
1720
+ key: "balance",
1721
+ "aria-label": balance.provider,
1722
+ onClick: function() {
1723
+ onPillClick(ledger, options.store);
1724
+ },
1725
+ onContextMenu: function(event) {
1726
+ var url = usageUrl;
1727
+ if (url === null) return;
1728
+ if (event && typeof event.preventDefault === "function") event.preventDefault();
1729
+ if (event && typeof event.stopPropagation === "function") event.stopPropagation();
1730
+ openExternal(url);
1731
+ }
1732
+ }, options.store.tipHandlers("balance-pill", function() {
1733
+ return providerBalanceTitle(balance, t);
1734
+ })), children);
1735
+ }
866
1736
 
867
1737
  //#endregion
868
1738
  //#region src/client/hooks/use-store.ts
@@ -919,6 +1789,7 @@ function HudDockPill(props) {
919
1789
  if (store.loadSessionTime !== void 0) store.loadSessionTime(id);
920
1790
  if (store.loadPromptHistory !== void 0) store.loadPromptHistory(id);
921
1791
  };
1792
+ if (store.loadProviderBalance !== void 0) store.loadProviderBalance();
922
1793
  load();
923
1794
  var poll = window.setInterval(load, 3e3);
924
1795
  return function() {
@@ -1010,21 +1881,34 @@ function HudDockPill(props) {
1010
1881
  }
1011
1882
  }
1012
1883
  if (sessionSegment !== null) children.push(sessionSegment);
1013
- var titleLines = [t("pill.title")];
1014
- if (sessionView !== null) titleLines.push(t("session.pill", { n: String(sessionView.mineCount) }));
1015
- if (cwd) titleLines.push(cwd);
1884
+ var titleLines = [{
1885
+ text: t("pill.title"),
1886
+ tone: "muted"
1887
+ }];
1888
+ if (sessionView !== null) titleLines.push({ segments: emphasize(t("session.pill", { n: String(sessionView.mineCount) }), [String(sessionView.mineCount)]) });
1889
+ if (cwd) titleLines.push({
1890
+ text: cwd,
1891
+ tone: "muted"
1892
+ });
1016
1893
  if (state.status && state.status.repo) {
1017
1894
  var tip = branchLabel(state.status, t);
1018
1895
  if (state.status.upstream) tip += " · " + t("panel.upstream") + " " + state.status.upstream;
1019
- titleLines.push(tip);
1896
+ titleLines.push({ text: tip });
1020
1897
  var labels = [];
1898
+ var countReadings = [];
1021
1899
  var counts2 = state.status.counts || {};
1022
1900
  for (var i2 = 0; i2 < COUNT_ORDER.length; i2 += 1) {
1023
1901
  var k2 = COUNT_ORDER[i2];
1024
1902
  var v2 = counts2[k2] || 0;
1025
- if (v2 > 0) labels.push(t("count." + k2) + " " + String(v2));
1903
+ if (v2 > 0) {
1904
+ labels.push(t("count." + k2) + " " + String(v2));
1905
+ countReadings.push(String(v2));
1906
+ }
1907
+ }
1908
+ if (labels.length > 0) {
1909
+ var aheadBehind = aheadBehindReadings(state.status);
1910
+ titleLines.push({ segments: emphasize(labels.join(" · "), countReadings.concat(aheadBehind)) });
1026
1911
  }
1027
- if (labels.length > 0) titleLines.push(labels.join(" · "));
1028
1912
  }
1029
1913
  return h("div", {
1030
1914
  className: "dshh-dock",
@@ -1039,11 +1923,14 @@ function HudDockPill(props) {
1039
1923
  store.togglePanel();
1040
1924
  }
1041
1925
  }, store.tipHandlers("pill", function() {
1042
- return titleLines.join("\n");
1926
+ return titleLines;
1043
1927
  })), children), toolPillNode(state, {
1044
1928
  t,
1045
1929
  store,
1046
1930
  now: props.now === void 0 ? tick[0] : props.now
1931
+ }), balancePillNode(state, {
1932
+ t,
1933
+ store
1047
1934
  }), timerPillNode(state, {
1048
1935
  t,
1049
1936
  store,
@@ -1122,22 +2009,31 @@ function timerPillNode(state, options) {
1122
2009
  var createdAt = value.createdAt;
1123
2010
  if (typeof createdAt !== "number") return null;
1124
2011
  var age = Math.max(0, now - createdAt);
2012
+ var readings = [];
1125
2013
  var hints = [t("timer.title"), t("timer.created", { time: fullWhen(createdAt) })];
1126
- if (typeof value.llmMs === "number" && typeof value.toolMs === "number") hints.push(t("timer.work", {
1127
- llm: formatDuration(value.llmMs),
1128
- tool: formatDuration(value.toolMs)
1129
- }));
1130
- if (typeof value.turns === "number" && typeof value.steps === "number") hints.push(t("timer.turns", {
1131
- turns: String(value.turns),
1132
- steps: String(value.steps)
1133
- }));
2014
+ if (typeof value.llmMs === "number" && typeof value.toolMs === "number") {
2015
+ var llmText = formatDuration(value.llmMs);
2016
+ var toolText = formatDuration(value.toolMs);
2017
+ readings.push(llmText, toolText);
2018
+ hints.push(t("timer.work", {
2019
+ llm: llmText,
2020
+ tool: toolText
2021
+ }));
2022
+ }
2023
+ if (typeof value.turns === "number" && typeof value.steps === "number") {
2024
+ readings.push(String(value.turns), String(value.steps));
2025
+ hints.push(t("timer.turns", {
2026
+ turns: String(value.turns),
2027
+ steps: String(value.steps)
2028
+ }));
2029
+ }
1134
2030
  probe.log("timer-pill", { age: Math.round(age / 6e4) });
1135
2031
  return h("span", Object.assign({
1136
2032
  className: "dshh-pill dshh-timer",
1137
2033
  "data-dsh-hud": "session-timer",
1138
2034
  key: "timer"
1139
2035
  }, options.store.tipHandlers("session-timer", function() {
1140
- return hints.join(" · ");
2036
+ return [{ segments: emphasize(hints.join(" · "), readings) }];
1141
2037
  })), [h("span", {
1142
2038
  key: "glyph",
1143
2039
  className: "dshh-timerGlyph"
@@ -1147,29 +2043,65 @@ function timerPillNode(state, options) {
1147
2043
  }, formatDuration(age))]);
1148
2044
  }
1149
2045
  function toolsTitle(tools, t) {
2046
+ var readings = [];
2047
+ function countUnit() {
2048
+ readings.push(String(tools.total));
2049
+ }
1150
2050
  var lines = [];
1151
2051
  if (tools.active !== null) {
1152
- lines.push(t("tools.now", { name: String(tools.active.name) }));
1153
- if (typeof tools.active.summary === "string" && tools.active.summary.length > 0) lines.push(tools.active.summary);
1154
- }
1155
- lines.push(t("tools.total", { n: String(tools.total) }));
1156
- if (tools.counts.length > 0) lines.push(t("tools.byTool") + " " + tools.counts.map(function(item) {
1157
- return String(item.name) + " ×" + String(item.count);
1158
- }).join(" · "));
1159
- if (tools.skills && tools.skills.length > 0) lines.push(t("tools.skills") + " " + tools.skills.map(function(item) {
2052
+ lines.push({
2053
+ text: t("tools.now", { name: String(tools.active.name) }),
2054
+ tone: "muted"
2055
+ });
2056
+ if (typeof tools.active.summary === "string" && tools.active.summary.length > 0) lines.push({
2057
+ text: tools.active.summary,
2058
+ tone: "muted"
2059
+ });
2060
+ }
2061
+ var totalText = t("tools.total", { n: String(tools.total) });
2062
+ countUnit();
2063
+ lines.push({ segments: emphasize(totalText, readings) });
2064
+ if (tools.counts.length > 0) {
2065
+ var byTool = t("tools.byTool") + " ";
2066
+ lines.push({ segments: emphasize(byTool + tools.counts.map(function(item) {
2067
+ return String(item.name) + " ×" + String(item.count);
2068
+ }).join(" · "), tools.counts.map(function(item) {
2069
+ return "×" + String(item.count);
2070
+ })) });
2071
+ }
2072
+ if (tools.skills && tools.skills.length > 0) lines.push({ segments: emphasize(t("tools.skills") + " " + tools.skills.map(function(item) {
1160
2073
  return String(item.name) + " ×" + String(item.count) + (item.ok === false ? " (✗)" : "");
1161
- }).join(" · "));
2074
+ }).join(" · "), tools.skills.map(function(item) {
2075
+ return "×" + String(item.count);
2076
+ })) });
1162
2077
  var recent = tools.recent.slice(0, 3);
1163
2078
  if (recent.length > 0) {
1164
- lines.push(t("tools.recent"));
1165
- lines.push(recent.map(function(item) {
1166
- var mark = item.ok === false ? "✗" : "✓";
1167
- var ms = typeof item.ms === "number" ? " " + (item.ms / 1e3).toFixed(1) + "s" : "";
1168
- var summary = typeof item.summary === "string" && item.summary.length > 0 ? " · " + item.summary : "";
1169
- return mark + " " + String(item.name) + ms + summary;
1170
- }).join("\n"));
2079
+ lines.push({
2080
+ text: t("tools.recent"),
2081
+ tone: "muted"
2082
+ });
2083
+ var recentReadings = [];
2084
+ for (var at = 0; at < recent.length; at += 1) {
2085
+ var entry = recent[at];
2086
+ var mark = entry.ok === false ? "✗" : "✓";
2087
+ var ms = typeof entry.ms === "number" ? " " + (entry.ms / 1e3).toFixed(1) + "s" : "";
2088
+ if (ms !== "") recentReadings.push(ms.trim());
2089
+ var summary = typeof entry.summary === "string" && entry.summary.length > 0 ? " · " + entry.summary : "";
2090
+ lines.push({ segments: emphasize(mark + " " + String(entry.name) + ms + summary, recentReadings) });
2091
+ }
1171
2092
  }
1172
- return lines.join("\n");
2093
+ return lines;
2094
+ }
2095
+ /**
2096
+ * `↑2 ↓1` 这类领先/落后标记里的数字(给悬停高亮用)。
2097
+ *
2098
+ * 直接扫字符串而不是重算一遍 git 语义:这里要的只是"已经画出去的那几个数"。
2099
+ */
2100
+ function aheadBehindReadings(status) {
2101
+ var out = [];
2102
+ if (typeof status.ahead === "number" && status.ahead > 0) out.push(String(status.ahead));
2103
+ if (typeof status.behind === "number" && status.behind > 0) out.push(String(status.behind));
2104
+ return out;
1173
2105
  }
1174
2106
  function toolElapsedLabel(active, now) {
1175
2107
  if (active === null || typeof active !== "object") return null;
@@ -2486,7 +3418,176 @@ function PillTip(props) {
2486
3418
  left: String(left) + "px",
2487
3419
  bottom: String(bottom) + "px"
2488
3420
  }
2489
- }, tip.text);
3421
+ }, tipLines(tip));
3422
+ }
3423
+ /**
3424
+ * 把 `tip.lines` 画成一行一个元素。
3425
+ *
3426
+ * 为什么要拆元素:用户要「数字亮、标签灰」。一整段字符串在 DOM 里只有一个文本节点,
3427
+ * 一种颜色,做不到;拆成行以后标签与数字各自带类名,颜色由 CSS 决定。
3428
+ *
3429
+ * 三种行:`label + value`(两段)、整行 `text` 带 `tone`、以及只有 `text` 的普通行。
3430
+ */
3431
+ /** `tone` → 类名。默认(`primary`)不加类,走 `.dshh-tipValue` 的主文字色。 */
3432
+ function toneClass(tone) {
3433
+ if (tone === "muted") return " dshh-tipMuted";
3434
+ if (tone === "success") return " dshh-tipSuccess";
3435
+ if (tone === "warn") return " dshh-tipWarn";
3436
+ if (tone === "accent") return " dshh-tipAccent";
3437
+ return "";
3438
+ }
3439
+ function tipLines(tip) {
3440
+ var lines = Array.isArray(tip.lines) ? tip.lines : [];
3441
+ if (lines.length === 0) return [tip.text];
3442
+ return lines.map(function(line, index) {
3443
+ if (Array.isArray(line.segments)) return h("div", {
3444
+ key: "line-" + index,
3445
+ className: "dshh-tipLine"
3446
+ }, line.segments.map(function(segment, at) {
3447
+ var segmentTone = segment.tone === void 0 ? toneClass(line.tone) : toneClass(segment.tone);
3448
+ return h("span", {
3449
+ key: "seg-" + at,
3450
+ className: "dshh-tipSeg" + segmentTone
3451
+ }, segment.text);
3452
+ }));
3453
+ if (typeof line.label === "string" || typeof line.value === "string") return h("div", {
3454
+ key: "line-" + index,
3455
+ className: "dshh-tipLine"
3456
+ }, h("span", {
3457
+ key: "label",
3458
+ className: "dshh-tipLabel"
3459
+ }, line.label ?? ""), h("span", {
3460
+ key: "value",
3461
+ className: "dshh-tipValue" + toneClass(line.tone)
3462
+ }, line.value ?? ""));
3463
+ return h("div", {
3464
+ key: "line-" + index,
3465
+ className: "dshh-tipLine" + toneClass(line.tone)
3466
+ }, line.text ?? "");
3467
+ });
3468
+ }
3469
+
3470
+ //#endregion
3471
+ //#region src/client/components/token-panel.ts
3472
+ /**
3473
+ * 「给这个中转站配一枚后台凭据」的小面板。
3474
+ *
3475
+ * 为什么要有它:中转站的**账号余额**(后台那个"当前余额")挂在 new-api 的
3476
+ * `/api/user/self` 上,只认后台凭据(PAT / 登录会话),`sk-` API Key 读不到。
3477
+ * 没有这枚凭据,胶囊就只能报"本 Key 已用"。
3478
+ *
3479
+ * 而没有这个面板时,唯一的配法是让用户去手改 `~/.dsh/.credentials.yaml` ——
3480
+ * 那是 DSH 的凭据库,手滑一个缩进整份凭据就读不出来了。所以这里做三件事:
3481
+ *
3482
+ * 1. 只问**一样东西**:令牌。引用名(`UR_ACCESS_TOKEN`)由宿主从设置投影推导,
3483
+ * 用户不用知道它叫什么;
3484
+ * 2. 粘贴即存:`⌘V` 或按钮都行,存完立刻重取一次读数(`saveProviderToken` 里做);
3485
+ * 3. 令牌**不回显**:输入框 `type=password`,store 不持有它,宿主也不回传。
3486
+ *
3487
+ * 令牌本身从不进 localStorage、不进日志、不进快照 —— 它只走一次 RPC 到宿主的凭据服务。
3488
+ */
3489
+ /**
3490
+ * 后台凭据设置面板。
3491
+ *
3492
+ * 平时返回 `null`(不占位);`tokenPanel.open` 为真时才渲染。
3493
+ *
3494
+ * @param props - 取词与 store。
3495
+ * @returns 面板节点或 `null`。
3496
+ */
3497
+ function TokenPanel(props) {
3498
+ var store = props.store;
3499
+ var t = props.t || function(key) {
3500
+ return key;
3501
+ };
3502
+ var panel = useStore(store).tokenPanel;
3503
+ var draftRef = react.default.useState("");
3504
+ var draft = draftRef[0];
3505
+ var setDraft = draftRef[1];
3506
+ react.default.useEffect(function() {
3507
+ if (!panel.open) setDraft("");
3508
+ }, [panel.open]);
3509
+ if (!panel.open) return null;
3510
+ var provider = panel.provider ?? "";
3511
+ /** 存一次;失败原因由 store 写进 `tokenPanel.error`。 */
3512
+ function submit() {
3513
+ var value = draft.trim();
3514
+ if (value === "" || panel.saving) return;
3515
+ setDraft("");
3516
+ store.saveProviderToken(value);
3517
+ }
3518
+ var children = [h("div", {
3519
+ key: "head",
3520
+ className: "dshh-tokenHead"
3521
+ }, h("span", {
3522
+ key: "title",
3523
+ className: "dshh-tokenTitle"
3524
+ }, t("balance.tokenTitle")), h("span", {
3525
+ key: "provider",
3526
+ className: "dshh-tokenProvider"
3527
+ }, provider)), h("div", {
3528
+ key: "hint",
3529
+ className: "dshh-tokenHint"
3530
+ }, t("balance.tokenHint"))];
3531
+ children.push(h("input", {
3532
+ key: "input",
3533
+ className: "dshh-tokenInput",
3534
+ "data-dsh-hud": "token-input",
3535
+ type: "password",
3536
+ value: draft,
3537
+ placeholder: t("balance.tokenPlaceholder"),
3538
+ spellCheck: false,
3539
+ autoComplete: "off",
3540
+ disabled: panel.saving,
3541
+ onInput: function(event) {
3542
+ var next = event && event.target ? event.target.value : "";
3543
+ setDraft(typeof next === "string" ? next : "");
3544
+ },
3545
+ onKeyDown: function(event) {
3546
+ if (event && event.key === "Enter") {
3547
+ if (typeof event.preventDefault === "function") event.preventDefault();
3548
+ submit();
3549
+ }
3550
+ if (event && event.key === "Escape") store.closeTokenPanel();
3551
+ }
3552
+ }));
3553
+ var refTitle = typeof panel.ref === "string" && panel.ref !== "" ? (panel.provider ?? "") + " · " + panel.ref : void 0;
3554
+ children.push(h("div", {
3555
+ key: "ref",
3556
+ className: "dshh-tokenRef",
3557
+ "data-dsh-hud": "token-ref",
3558
+ title: refTitle
3559
+ }, t("balance.tokenRef")));
3560
+ children.push(h("div", {
3561
+ key: "actions",
3562
+ className: "dshh-tokenActions",
3563
+ "data-dsh-hud": "token-actions"
3564
+ }, h("button", {
3565
+ key: "cancel",
3566
+ type: "button",
3567
+ className: "dshh-tokenCancel",
3568
+ "data-dsh-hud": "token-cancel",
3569
+ onClick: function() {
3570
+ store.closeTokenPanel();
3571
+ }
3572
+ }, t("balance.tokenCancel")), h("button", {
3573
+ key: "save",
3574
+ type: "button",
3575
+ className: "dshh-tokenSave",
3576
+ "data-dsh-hud": "token-save",
3577
+ disabled: panel.saving || draft.trim() === "",
3578
+ onClick: submit
3579
+ }, panel.saving ? t("balance.tokenSaving") : t("balance.tokenSave"))));
3580
+ if (panel.error !== null) children.push(h("div", {
3581
+ key: "error",
3582
+ className: "dshh-tokenError",
3583
+ role: "alert"
3584
+ }, t("balance.tokenError." + panel.error)));
3585
+ return h("div", {
3586
+ className: "dshh-tokenPanel",
3587
+ "data-dsh-hud": "token-panel",
3588
+ role: "dialog",
3589
+ "aria-label": t("balance.tokenTitle")
3590
+ }, children);
2490
3591
  }
2491
3592
 
2492
3593
  //#endregion
@@ -3229,6 +4330,37 @@ const zh = {
3229
4330
  "timer.created": "创建于 {time}",
3230
4331
  "timer.work": "其中模型 {llm}、工具 {tool}",
3231
4332
  "timer.turns": "{turns} 轮 / {steps} 步",
4333
+ "balance.title": "DeepSeek 账号余额",
4334
+ "balance.bonus": "赠送余额",
4335
+ "balance.updated": "更新于 {time}",
4336
+ "balance.stale": "最近一次刷新失败,显示的是 {time} 的数",
4337
+ "balance.delta": "较上次刷新 {delta}({time})",
4338
+ "balance.providerRemaining": "余额 {amount}",
4339
+ "balance.providerUsedKey": "本 Key 已用 {amount}",
4340
+ "balance.providerUsedAccount": "账号已用 {amount}",
4341
+ "balance.providerRequests": "API 请求 {count}",
4342
+ "balance.tokenTitle": "添加访问令牌",
4343
+ "balance.tokenHint": "去站点「个人设置 → 访问令牌」新建,权限勾 profile:read,粘贴即可",
4344
+ "balance.tokenPlaceholder": "粘贴访问令牌",
4345
+ "balance.tokenSave": "保存",
4346
+ "balance.tokenSaving": "保存中…",
4347
+ "balance.tokenCancel": "取消",
4348
+ "balance.tokenRef": "只存储于本地",
4349
+ "balance.tokenError.unknown-provider": "不认识这个提供方,先确认会话用的模型来自哪个站",
4350
+ "balance.tokenError.not-supported": "这个站不支持后台令牌",
4351
+ "balance.tokenError.no-credentials": "凭据服务不可用",
4352
+ "balance.tokenError.bad-token": "令牌格式不对(不该有空格或换行)",
4353
+ "balance.tokenError.failed": "保存失败,稍后再试",
4354
+ "balance.tokenError.unsupported": "这个宿主不支持在这里保存",
4355
+ "balance.tokenError.reason": "保存失败",
4356
+ "balance.usedShort": "已用",
4357
+ "balance.providerUnknown": "--",
4358
+ "balance.reason.unsupported": "该站不提供余额接口",
4359
+ "balance.reason.no-key": "未配置密钥",
4360
+ "balance.reason.network": "网络失败",
4361
+ "balance.reason.unknown-provider": "模型来源不在设置里",
4362
+ "balance.reason.no-provider": "会话还没定下模型来源",
4363
+ "balance.reason.no-session": "会话还没就绪",
3232
4364
  "turnImages.title": "本轮图片改动",
3233
4365
  "turnImages.titleSession": "本会话图片改动",
3234
4366
  "turnImages.open": "在内置预览里打开",
@@ -3365,6 +4497,37 @@ const en = {
3365
4497
  "timer.created": "started {time}",
3366
4498
  "timer.work": "model {llm}, tools {tool}",
3367
4499
  "timer.turns": "{turns} turns / {steps} steps",
4500
+ "balance.title": "DeepSeek account balance",
4501
+ "balance.bonus": "Bonus balance",
4502
+ "balance.updated": "updated {time}",
4503
+ "balance.stale": "Last refresh failed; this is the balance from {time}",
4504
+ "balance.delta": "{delta} since last refresh ({time})",
4505
+ "balance.providerRemaining": "balance {amount}",
4506
+ "balance.providerUsedKey": "{amount} used on this key",
4507
+ "balance.providerUsedAccount": "{amount} used on this account",
4508
+ "balance.providerRequests": "API requests {count}",
4509
+ "balance.tokenTitle": "Add a dashboard access token",
4510
+ "balance.tokenHint": "This site only exposes its current balance to a dashboard session (API keys cannot read it). Create an access token with the profile:read scope in the site settings, then paste it here.",
4511
+ "balance.tokenPlaceholder": "Paste access token",
4512
+ "balance.tokenSave": "Save",
4513
+ "balance.tokenSaving": "Saving…",
4514
+ "balance.tokenCancel": "Cancel",
4515
+ "balance.tokenRef": "Stored locally only",
4516
+ "balance.tokenError.unknown-provider": "Unknown provider — check which site this session’s model comes from",
4517
+ "balance.tokenError.not-supported": "This site does not support dashboard tokens",
4518
+ "balance.tokenError.no-credentials": "Credentials service unavailable",
4519
+ "balance.tokenError.bad-token": "Malformed token (it must not contain spaces or newlines)",
4520
+ "balance.tokenError.failed": "Could not save; try again",
4521
+ "balance.tokenError.unsupported": "This host cannot save tokens here",
4522
+ "balance.tokenError.reason": "Could not save",
4523
+ "balance.usedShort": "used",
4524
+ "balance.providerUnknown": "--",
4525
+ "balance.reason.unsupported": "this site exposes no balance API",
4526
+ "balance.reason.no-key": "no key configured",
4527
+ "balance.reason.network": "network failure",
4528
+ "balance.reason.unknown-provider": "model provider not in settings",
4529
+ "balance.reason.no-provider": "session has no model provider yet",
4530
+ "balance.reason.no-session": "session not ready",
3368
4531
  "turnImages.title": "Images this turn",
3369
4532
  "turnImages.titleSession": "Images this session",
3370
4533
  "turnImages.open": "Open in the built-in preview",
@@ -3442,6 +4605,720 @@ const en = {
3442
4605
  "err.transport": "Cannot reach the host half"
3443
4606
  };
3444
4607
 
4608
+ //#endregion
4609
+ //#region src/client/model-watch.ts
4610
+ /** 多久检查一次"这个会话的投影挂上了没"(纯本地读取,不发请求)。 */
4611
+ const BIND_RETRY_MS = 2e3;
4612
+ /**
4613
+ * 从投影读数里取"当前属于哪个提供方"。
4614
+ *
4615
+ * `pending` 优先:菜单里刚点下的那个模型还没真正用过,但账本已经该换了 ——
4616
+ * 胶囊跟的是"接下来要花谁的钱",不是"上一次花了谁的钱"。
4617
+ */
4618
+ function providerOf(value) {
4619
+ if (value === null || typeof value !== "object") return null;
4620
+ const chosen = value.pending ?? value.lastUsed;
4621
+ if (chosen === null || typeof chosen !== "object") return null;
4622
+ const provider = chosen.provider;
4623
+ return typeof provider === "string" && provider !== "" ? provider : null;
4624
+ }
4625
+ /**
4626
+ * 盯着当前会话的 `modelSelection`:提供方一变就**立刻**重取一次余额。
4627
+ *
4628
+ * @param ctx - 客户端插件上下文(只用 `get`)。
4629
+ * @param store - 共享 store(`loadProviderBalance(true)` 会跳过 TTL)。
4630
+ * @returns 卸载函数(停掉定时器与订阅)。
4631
+ */
4632
+ function watchModelSelection(ctx, store) {
4633
+ const raw = ctx.get("sessions");
4634
+ if (raw === void 0 || raw === null || typeof raw.binding !== "function") return function() {};
4635
+ const sessions = raw;
4636
+ let disposed = false;
4637
+ let boundSession = null;
4638
+ let unsubscribe = null;
4639
+ let lastProvider = null;
4640
+ let retry = null;
4641
+ /** 投影还没就绪时才需要补挂;挂上了就把定时器停掉(不白跑)。 */
4642
+ function startRetry() {
4643
+ if (retry !== null || disposed) return;
4644
+ retry = window.setInterval(function() {
4645
+ sync(store.getSnapshot());
4646
+ }, BIND_RETRY_MS);
4647
+ }
4648
+ function stopRetry() {
4649
+ if (retry === null) return;
4650
+ window.clearInterval(retry);
4651
+ retry = null;
4652
+ }
4653
+ function detach() {
4654
+ if (unsubscribe !== null) {
4655
+ const stop = unsubscribe;
4656
+ unsubscribe = null;
4657
+ try {
4658
+ stop();
4659
+ } catch {}
4660
+ }
4661
+ boundSession = null;
4662
+ lastProvider = null;
4663
+ }
4664
+ /** 把订阅挂到当前会话的投影上(投影/绑定还没就绪就下次再试)。 */
4665
+ function attach(sessionId) {
4666
+ const faceOf = (sessions.binding?.(sessionId))?.session?.projections?.faceOf;
4667
+ if (typeof faceOf !== "function") return;
4668
+ let face;
4669
+ try {
4670
+ face = faceOf("modelSelection");
4671
+ } catch {
4672
+ return;
4673
+ }
4674
+ if (face === void 0 || face === null || typeof face.subscribe !== "function") return;
4675
+ boundSession = sessionId;
4676
+ stopRetry();
4677
+ lastProvider = providerOf(face.getSnapshot());
4678
+ unsubscribe = face.subscribe(function() {
4679
+ if (disposed) return;
4680
+ const next = providerOf(face.getSnapshot());
4681
+ if (next === null || next === lastProvider) return;
4682
+ lastProvider = next;
4683
+ store.loadProviderBalance(true);
4684
+ });
4685
+ }
4686
+ /** 看一眼当前会话,必要时重挂。 */
4687
+ function sync(state) {
4688
+ if (disposed) return;
4689
+ const sessionId = state.sessionId;
4690
+ if (typeof sessionId !== "string" || sessionId === "") {
4691
+ detach();
4692
+ return;
4693
+ }
4694
+ if (sessionId === boundSession) return;
4695
+ detach();
4696
+ attach(sessionId);
4697
+ if (boundSession === null) startRetry();
4698
+ }
4699
+ const stopStore = store.subscribe(function() {
4700
+ sync(store.getSnapshot());
4701
+ });
4702
+ sync(store.getSnapshot());
4703
+ return function() {
4704
+ disposed = true;
4705
+ stopRetry();
4706
+ stopStore();
4707
+ detach();
4708
+ };
4709
+ }
4710
+
4711
+ //#endregion
4712
+ //#region src/client/store/account.ts
4713
+ /**
4714
+ * 建「账号余额」这一片。
4715
+ * @param ctx - 客户端根上下文(用到 `effect` / `inject` / `locale`)。
4716
+ * @param getState - 读当前快照。
4717
+ * @param set - 浅合并写快照。
4718
+ * @returns 本片对外暴露的方法。
4719
+ */
4720
+ function createAccountSlice(ctx, getState, set) {
4721
+ /** 账号命名空间;没注入到就是 `null`(这个宿主没有账号插件,或还没起来)。 */
4722
+ var accountService = null;
4723
+ /** 启动清单服务:只借用它的 `manifest.release.version`。 */
4724
+ var modulesService = null;
4725
+ /** 平台链接(用量页 / 充值页):`getState()` 给,取到一次就一直用。 */
4726
+ var links = {
4727
+ usageUrl: null,
4728
+ topUpUrl: null
4729
+ };
4730
+ /** 在途请求:三次触发撞到一起时共用一次。 */
4731
+ var inflight = null;
4732
+ /** 上次真正发请求的时刻(TTL 的起点)。 */
4733
+ var lastAttemptAt = 0;
4734
+ /** 上次的结果档位:成功用长 TTL,其它用短 TTL。 */
4735
+ var lastReady = false;
4736
+ if (typeof ctx.inject === "function") {
4737
+ ctx.effect(function() {
4738
+ return ctx.inject(["remote", "remote.account"], function(scope) {
4739
+ accountService = scope.remote && scope.remote.account ? scope.remote.account : null;
4740
+ probe.log("account", { bound: accountService !== null });
4741
+ if (accountService !== null) loadBalance();
4742
+ return function() {
4743
+ accountService = null;
4744
+ };
4745
+ });
4746
+ }, "dsh-hud: account service");
4747
+ ctx.effect(function() {
4748
+ return ctx.inject(["modules"], function(scope) {
4749
+ modulesService = scope.modules || null;
4750
+ if (modulesService !== null) loadBalance();
4751
+ return function() {
4752
+ modulesService = null;
4753
+ };
4754
+ });
4755
+ }, "dsh-hud: boot manifest");
4756
+ }
4757
+ /**
4758
+ * 宿主这一版的版本号。
4759
+ *
4760
+ * 为什么非要它:账号接口的调用元数据要带 `x-client-version`,而 DSH 内置插件那半边
4761
+ * 是打包时**内联**了版本常量的(`"0.2.1-rc.3"`),外部插件的产物里没有这行字面量。
4762
+ * 所以从启动清单里现取:`window.__DSH_BOOT__.release.version` 是宿主自己发布在每份
4763
+ * 图里的发布身份,`ctx.modules.manifest.release.version` 是同一份数据的服务视角。
4764
+ * 两条都取不到就**不发请求**(宁可没有这一枚胶囊,也不要带一个假版本号去问平台)。
4765
+ *
4766
+ * @returns 版本字符串;都取不到回 `null`。
4767
+ */
4768
+ function releaseVersion() {
4769
+ var boot = globalThis.__DSH_BOOT__;
4770
+ var fromBoot = boot && boot.release ? boot.release.version : void 0;
4771
+ if (typeof fromBoot === "string" && fromBoot !== "") return fromBoot;
4772
+ var manifest = modulesService ? modulesService.manifest : null;
4773
+ var fromModules = manifest && manifest.release ? manifest.release.version : void 0;
4774
+ if (typeof fromModules === "string" && fromModules !== "") return fromModules;
4775
+ return null;
4776
+ }
4777
+ /** 当前界面语言(宿主再把它折成平台用的 `zh_CN` / `en_US`)。 */
4778
+ function activeLocale() {
4779
+ try {
4780
+ var locale = ctx.locale;
4781
+ var snapshot = locale && typeof locale.getSnapshot === "function" ? locale.getSnapshot() : null;
4782
+ var active = snapshot && snapshot.active;
4783
+ if (typeof active === "string" && active !== "") return active;
4784
+ } catch (error) {}
4785
+ return "en-US";
4786
+ }
4787
+ /**
4788
+ * 读账号投影里的两个平台链接。
4789
+ *
4790
+ * 为什么单独要它:余额接口只回钱包,**累计消耗 / 本月消耗只在平台用量页上**
4791
+ * (平台页还有「今天 / 本月 / 自定」那种时间维度,DSH 不代理那份数据)。
4792
+ * 这一枚胶囊因此提供一条直达那一页的路 —— 链接从账号投影里拿,不写死域名。
4793
+ *
4794
+ * `getState` 是直连方法(不碰网络),也不是所有宿主版本都有:缺了就安静地
4795
+ * 返回上一次的值,胶囊退化成"只有余额"。
4796
+ *
4797
+ * @param service - 账号命名空间。
4798
+ * @returns 两个链接(取不到就是上一次的、可能都是 `null`)。
4799
+ */
4800
+ function readLinks(service) {
4801
+ if (typeof service.getState !== "function") return Promise.resolve(links);
4802
+ return Promise.resolve().then(function() {
4803
+ return service.getState();
4804
+ }).then(function(result) {
4805
+ var value = result && result.ok === true ? result.value : null;
4806
+ var next = value && value.links ? value.links : null;
4807
+ if (next === null) return links;
4808
+ var usageUrl = typeof next.usageUrl === "string" ? next.usageUrl : null;
4809
+ var topUpUrl = typeof next.topUpUrl === "string" ? next.topUpUrl : null;
4810
+ if (usageUrl === null && topUpUrl === null) return links;
4811
+ return {
4812
+ usageUrl,
4813
+ topUpUrl
4814
+ };
4815
+ }).catch(function() {
4816
+ return links;
4817
+ });
4818
+ }
4819
+ /** 账号接口的调用元数据(时区偏移按"东为正"给,与 DSH 自己一致)。 */
4820
+ function clientMetadata(version) {
4821
+ return {
4822
+ version,
4823
+ locale: activeLocale(),
4824
+ timezoneOffsetSeconds: -(/* @__PURE__ */ new Date()).getTimezoneOffset() * 60
4825
+ };
4826
+ }
4827
+ /** 平台给的数组 → 本片的钱包列表(逐项校验;形状不对的项直接丢,不猜)。 */
4828
+ function walletsOf(value) {
4829
+ if (!Array.isArray(value)) return [];
4830
+ var out = [];
4831
+ for (var index = 0; index < value.length; index += 1) {
4832
+ var item = value[index];
4833
+ if (!item || typeof item !== "object") continue;
4834
+ if (typeof item.currency !== "string" || typeof item.balance !== "string") continue;
4835
+ out.push({
4836
+ currency: item.currency,
4837
+ balance: item.balance
4838
+ });
4839
+ }
4840
+ return out;
4841
+ }
4842
+ /** 两份快照的账目是否一致(一致就不 set,省一次重渲染)。 */
4843
+ function sameWallets(left, right) {
4844
+ if (left.length !== right.length) return false;
4845
+ for (var index = 0; index < left.length; index += 1) {
4846
+ if (left[index].currency !== right[index].currency) return false;
4847
+ if (left[index].balance !== right[index].balance) return false;
4848
+ }
4849
+ return true;
4850
+ }
4851
+ function publish(next) {
4852
+ var previous = getState().balance;
4853
+ if (next === null) {
4854
+ if (previous !== null) set({ balance: null });
4855
+ return null;
4856
+ }
4857
+ var previousPrev = previous !== null && previous.prev ? previous.prev.at : null;
4858
+ var nextPrev = next.prev ? next.prev.at : null;
4859
+ if (!(previous !== null && previous.failed === next.failed && previous.usageUrl === next.usageUrl && previousPrev === nextPrev && sameWallets(previous.wallets, next.wallets) && sameWallets(previous.bonus, next.bonus))) set({ balance: next });
4860
+ return next;
4861
+ }
4862
+ /** 读失败:留着上一次的数字、打上 `failed`,让界面能说"可能已过期"。 */
4863
+ function markFailed() {
4864
+ lastReady = false;
4865
+ var previous = getState().balance;
4866
+ if (previous === null) {
4867
+ probe.log("balance", {
4868
+ failed: true,
4869
+ had: false
4870
+ });
4871
+ return null;
4872
+ }
4873
+ probe.log("balance", {
4874
+ failed: true,
4875
+ had: true
4876
+ });
4877
+ return publish({
4878
+ wallets: previous.wallets,
4879
+ bonus: previous.bonus,
4880
+ at: previous.at,
4881
+ failed: true,
4882
+ usageUrl: previous.usageUrl,
4883
+ topUpUrl: previous.topUpUrl,
4884
+ prev: previous.prev || {
4885
+ wallets: previous.wallets,
4886
+ at: previous.at
4887
+ }
4888
+ });
4889
+ }
4890
+ /**
4891
+ * 取一次余额。
4892
+ * @param force - 跳过 TTL(点击胶囊时用)。
4893
+ * @returns 最新快照;没法取时回上一次的(可能为 `null`)。**从不 reject**。
4894
+ */
4895
+ function loadBalance(force) {
4896
+ var now = Date.now();
4897
+ if (force !== true && lastAttemptAt !== 0) {
4898
+ var ttl = lastReady ? BALANCE_TTL_MS : BALANCE_RETRY_MS;
4899
+ if (now - lastAttemptAt < ttl) return Promise.resolve(getState().balance);
4900
+ }
4901
+ if (accountService === null) return Promise.resolve(getState().balance);
4902
+ var version = releaseVersion();
4903
+ if (version === null) {
4904
+ probe.log("balance", { skipped: "no-release-version" });
4905
+ return Promise.resolve(getState().balance);
4906
+ }
4907
+ if (inflight !== null) return inflight;
4908
+ var service = accountService;
4909
+ lastAttemptAt = now;
4910
+ inflight = Promise.resolve().then(function() {
4911
+ return Promise.all([service.getBalance(clientMetadata(version)), readLinks(service)]);
4912
+ }).then(function(pair) {
4913
+ links = pair[1];
4914
+ inflight = null;
4915
+ var result = pair[0];
4916
+ if (!result || result.ok !== true) return markFailed();
4917
+ var payload = result.value;
4918
+ if (payload === null || payload === void 0) {
4919
+ lastReady = false;
4920
+ probe.log("balance", { signedOut: true });
4921
+ return publish(null);
4922
+ }
4923
+ if (payload.status !== "ready") return markFailed();
4924
+ var wallets = walletsOf(payload.value);
4925
+ var bonus = walletsOf(payload.bonusWallets).filter(function(wallet) {
4926
+ return isPositiveAmount(wallet.balance);
4927
+ });
4928
+ if (wallets.length === 0 && bonus.length === 0) {
4929
+ lastReady = false;
4930
+ probe.log("balance", { empty: true });
4931
+ return publish(null);
4932
+ }
4933
+ lastReady = true;
4934
+ var at = Date.now();
4935
+ var before = getState().balance;
4936
+ probe.log("balance", {
4937
+ wallets: wallets.length,
4938
+ bonus: bonus.length
4939
+ });
4940
+ return publish({
4941
+ wallets,
4942
+ bonus,
4943
+ at,
4944
+ failed: false,
4945
+ usageUrl: links.usageUrl,
4946
+ topUpUrl: links.topUpUrl,
4947
+ prev: before !== null ? {
4948
+ wallets: before.wallets,
4949
+ at: before.at
4950
+ } : null
4951
+ });
4952
+ }).catch(function(error) {
4953
+ inflight = null;
4954
+ probe.log("balance", { thrown: error instanceof Error ? error.message : String(error) });
4955
+ return markFailed();
4956
+ });
4957
+ return inflight;
4958
+ }
4959
+ return { loadBalance };
4960
+ }
4961
+
4962
+ //#endregion
4963
+ //#region src/client/store/provider.ts
4964
+ /** 同一会话两次取数的最小间隔(宿主侧缓存一分钟,这里配合它不空转)。 */
4965
+ const PROVIDER_TTL_MS = 6e4;
4966
+ /** 没取到时多久再试。 */
4967
+ const PROVIDER_RETRY_MS = 3e4;
4968
+ /**
4969
+ * 失败后的第一次重试间隔,之后逐次翻倍,到 `PROVIDER_RETRY_MS` 封顶。
4970
+ *
4971
+ * 为什么要退避而不是一上来就 30 秒:重启/刚开会话时**宿主常常还答不上来**
4972
+ *(模型选择投影还没就绪),第一次问必然落空。用固定 30 秒兜的话,胶囊要空半分钟 ——
4973
+ * 用户看到的就是"重启后没立即显示"。1 秒起、翻倍到 30 秒,正常情况一两秒内就出来了,
4974
+ * 而真出故障时也不会变成每秒一次的重试风暴。
4975
+ */
4976
+ const PROVIDER_RETRY_BASE_MS = 1e3;
4977
+ /** 端点名(宿主半 `rpc.ts` 里登记的那个)。 */
4978
+ const ENDPOINT = "provider-balance";
4979
+ /** 一个可展示的钱包列表(逐项校验,形状不对的项直接丢,不猜)。 */
4980
+ function walletsOf(value) {
4981
+ if (!Array.isArray(value)) return [];
4982
+ var out = [];
4983
+ for (var index = 0; index < value.length; index += 1) {
4984
+ var item = value[index];
4985
+ if (!item || typeof item !== "object") continue;
4986
+ if (typeof item.currency !== "string" || typeof item.balance !== "string") continue;
4987
+ out.push({
4988
+ currency: item.currency,
4989
+ balance: item.balance
4990
+ });
4991
+ }
4992
+ return out;
4993
+ }
4994
+ /** 十进制字符串校验(不合法就当没给)。 */
4995
+ function amountOf(value) {
4996
+ if (typeof value !== "string") return null;
4997
+ var raw = value.trim();
4998
+ return /^-?\d+(?:\.\d+)?$/.test(raw) ? raw : null;
4999
+ }
5000
+ /** 端点答复 → 快照;形状不对回 `null`(当作"读不到")。 */
5001
+ function balanceOf(payload) {
5002
+ if (payload.available !== true || payload.account === true) return null;
5003
+ var raw = payload.balance;
5004
+ if (raw === null || typeof raw !== "object") return null;
5005
+ var item = raw;
5006
+ if (typeof item.provider !== "string" || item.provider === "") return null;
5007
+ if (item.kind !== "official" && item.kind !== "relay") return null;
5008
+ var remaining = amountOf(item.remaining);
5009
+ var usedAccount = amountOf(item.usedAccount);
5010
+ var usedKey = amountOf(item.usedKey);
5011
+ var requests = typeof item.requests === "number" && Number.isFinite(item.requests) ? item.requests : null;
5012
+ var wallets = walletsOf(item.wallets);
5013
+ if (remaining === null && usedAccount === null && usedKey === null && wallets.length === 0) return null;
5014
+ return {
5015
+ provider: item.provider,
5016
+ kind: item.kind,
5017
+ currency: typeof item.currency === "string" && item.currency !== "" ? item.currency : null,
5018
+ remaining,
5019
+ usedAccount,
5020
+ usedKey,
5021
+ requests,
5022
+ placeholderLimit: item.placeholderLimit === true,
5023
+ wallets,
5024
+ bonus: walletsOf(item.bonus),
5025
+ at: typeof item.at === "number" && Number.isFinite(item.at) ? item.at : Date.now()
5026
+ };
5027
+ }
5028
+ /**
5029
+ * 失败答复里的"是哪个提供方 / 为什么读不到"。
5030
+ *
5031
+ * 有这两个字段,胶囊就能画成 `ur —`(并说清是"该站没有余额接口"还是"没配密钥"),
5032
+ * 而不是直接消失 —— 消失分不清"这个站真的没有"和"插件坏了"。
5033
+ *
5034
+ * @param result - 宿主的答复信封。
5035
+ * @returns `{provider, reason}`;读不出就给 `null`(调用方按无信息处理)。
5036
+ */
5037
+ function failureFieldsOf(result) {
5038
+ var payload = result?.value;
5039
+ if (payload === null || typeof payload !== "object") return {
5040
+ provider: null,
5041
+ reason: null
5042
+ };
5043
+ var item = payload;
5044
+ return {
5045
+ provider: typeof item.provider === "string" && item.provider !== "" ? item.provider : null,
5046
+ reason: typeof item.reason === "string" && item.reason !== "" ? item.reason : null
5047
+ };
5048
+ }
5049
+ /** 宿主给的「后台凭据配了没」;形状不对就当没有(老宿主不给这个字段)。 */
5050
+ function tokenOf(payload) {
5051
+ if (payload === null || typeof payload !== "object") return null;
5052
+ var item = payload.token;
5053
+ if (item === null || typeof item !== "object") return null;
5054
+ var token = item;
5055
+ if (typeof token.ref !== "string" || token.ref === "") return null;
5056
+ return {
5057
+ ref: token.ref,
5058
+ configured: token.configured === true
5059
+ };
5060
+ }
5061
+ /**
5062
+ * 建「提供方余额」这一片。
5063
+ * @param ctx - 客户端根上下文(只用 `connection.rpc`)。
5064
+ * @param getState - 读当前快照。
5065
+ * @param set - 浅合并写快照。
5066
+ * @returns 本片对外暴露的方法。
5067
+ */
5068
+ /**
5069
+ * 失败后的重试间隔:1s、2s、4s、8s、16s,然后封顶在 `PROVIDER_RETRY_MS`。
5070
+ *
5071
+ * @param failStreak - 连续失败次数(0 = 还没失败过)。
5072
+ * @returns 毫秒。
5073
+ */
5074
+ function providerRetryDelayMs(failStreak) {
5075
+ if (!(failStreak > 0)) return PROVIDER_RETRY_BASE_MS;
5076
+ var delay = PROVIDER_RETRY_BASE_MS * Math.pow(2, failStreak - 1);
5077
+ return delay > 3e4 ? PROVIDER_RETRY_MS : delay;
5078
+ }
5079
+ function createProviderSlice(ctx, getState, set) {
5080
+ /** 在途请求(一次只打一个)。 */
5081
+ var inflight = null;
5082
+ /** 请求代次:每发一次 +1,落地时对不上号就作废(见 `loadProviderBalance`)。 */
5083
+ var requestTicket = 0;
5084
+ /** 连续失败次数(成功即归零):只用来算退避间隔。 */
5085
+ var failStreak = 0;
5086
+ /** 上次真正发请求的时刻。 */
5087
+ var lastAttemptAt = 0;
5088
+ /** 上次取数针对哪个会话 —— 换了会话就必须重取,不能吃上一个会话的账本。 */
5089
+ var lastSessionId = null;
5090
+ /** 上次是成功(长 TTL)还是失败(短 TTL)。 */
5091
+ var lastReady = false;
5092
+ /** 写一份新结论(没变化就不 set,省一次重渲染)。 */
5093
+ function publish(mode, balance, extra, token) {
5094
+ var previous = getState().provider;
5095
+ var nextToken = token === void 0 ? previous?.token ?? null : token;
5096
+ var next = {
5097
+ mode,
5098
+ balance,
5099
+ provider: extra?.provider ?? balance?.provider ?? null,
5100
+ reason: extra?.reason ?? null,
5101
+ token: nextToken
5102
+ };
5103
+ if (previous === void 0 || previous.mode !== next.mode || previous.provider !== next.provider || previous.reason !== next.reason || (previous.token?.configured ?? null) !== (next.token?.configured ?? null) || (previous.token?.ref ?? null) !== (next.token?.ref ?? null) || previous.balance === null !== (next.balance === null) || previous.balance !== null && next.balance !== null && (previous.balance.provider !== next.balance.provider || previous.balance.remaining !== next.balance.remaining || previous.balance.usedAccount !== next.balance.usedAccount || previous.balance.usedKey !== next.balance.usedKey || previous.balance.requests !== next.balance.requests || previous.balance.at !== next.balance.at)) set({ provider: next });
5104
+ return next;
5105
+ }
5106
+ /**
5107
+ * 换会话时把账本清成"还不知道"。
5108
+ *
5109
+ * 为什么必须清:不清就会**在新会话里继续挂着上一个会话的读数和站名**,直到新答案回来。
5110
+ * 那段时间屏幕上写的是 `ur` 的钱,而这次会话可能花的是 `muyuan` 的钱 —— 正是
5111
+ * "绝不把别人的钱画成你的"那条约束要防的事。清成 `unknown` 后胶囊画 `--`,
5112
+ * 一眼就知道"还在查",比显示一个错的数强。
5113
+ */
5114
+ function resetForSession() {
5115
+ requestTicket += 1;
5116
+ inflight = null;
5117
+ lastAttemptAt = 0;
5118
+ lastSessionId = null;
5119
+ failStreak = 0;
5120
+ lastReady = false;
5121
+ set({ provider: {
5122
+ mode: "unknown",
5123
+ balance: null,
5124
+ provider: null,
5125
+ reason: null,
5126
+ token: null
5127
+ } });
5128
+ }
5129
+ /**
5130
+ * 取一次"当前会话模型来源"的余额。
5131
+ * @param force - 跳过 TTL(点击胶囊时用)。
5132
+ * @returns 最新结论(可能沿用上一次);**从不 reject**。
5133
+ */
5134
+ function loadProviderBalance(force) {
5135
+ var sessionId = getState().sessionId;
5136
+ var now = Date.now();
5137
+ var switched = sessionId !== lastSessionId;
5138
+ if (force !== true && !switched && lastAttemptAt !== 0) {
5139
+ var ttl = lastReady ? PROVIDER_TTL_MS : providerRetryDelayMs(failStreak);
5140
+ if (now - lastAttemptAt < ttl) return Promise.resolve(getState().provider);
5141
+ }
5142
+ if (inflight !== null && !switched && force !== true) return inflight;
5143
+ if (typeof sessionId !== "string" || sessionId === "") {
5144
+ lastSessionId = null;
5145
+ return Promise.resolve(publish("unknown", null));
5146
+ }
5147
+ if (switched) failStreak = 0;
5148
+ lastAttemptAt = now;
5149
+ lastSessionId = sessionId;
5150
+ var ticket = ++requestTicket;
5151
+ inflight = Promise.resolve().then(function() {
5152
+ return ctx.connection.rpc.call(CHANNEL, ENDPOINT, { args: {
5153
+ sessionId,
5154
+ force: force === true
5155
+ } });
5156
+ }).then(function(result) {
5157
+ if (ticket !== requestTicket) return getState().provider;
5158
+ inflight = null;
5159
+ if (getState().sessionId !== sessionId) return getState().provider;
5160
+ if (!result || result.ok !== true || !result.value) {
5161
+ lastReady = false;
5162
+ failStreak += 1;
5163
+ probe.log("provider-balance", { failed: true });
5164
+ return keepOr("unavailable", failureFieldsOf(result), tokenOf(result?.value));
5165
+ }
5166
+ var payload = result.value;
5167
+ var token = tokenOf(payload);
5168
+ if (payload.available === true && payload.account === true) {
5169
+ lastReady = true;
5170
+ failStreak = 0;
5171
+ probe.log("provider-balance", { account: true });
5172
+ return publish("account", null, void 0, token);
5173
+ }
5174
+ var balance = balanceOf(payload);
5175
+ if (balance === null) {
5176
+ lastReady = false;
5177
+ failStreak += 1;
5178
+ probe.log("provider-balance", {
5179
+ unavailable: payload.available !== true ? payload.reason : "empty",
5180
+ message: payload.message ?? null
5181
+ });
5182
+ return keepOr("unavailable", failureFieldsOf(result), token);
5183
+ }
5184
+ lastReady = true;
5185
+ failStreak = 0;
5186
+ probe.log("provider-balance", {
5187
+ provider: balance.provider,
5188
+ kind: balance.kind,
5189
+ remaining: balance.remaining === null,
5190
+ usedAccount: balance.usedAccount === null,
5191
+ usedKey: balance.usedKey === null,
5192
+ placeholder: balance.placeholderLimit
5193
+ });
5194
+ return publish("provider", balance, void 0, token);
5195
+ }).catch(function(error) {
5196
+ if (ticket !== requestTicket) return getState().provider;
5197
+ inflight = null;
5198
+ lastReady = false;
5199
+ failStreak += 1;
5200
+ probe.log("provider-balance", { thrown: error instanceof Error ? error.message : String(error) });
5201
+ return keepOr("unavailable");
5202
+ });
5203
+ return inflight;
5204
+ }
5205
+ /**
5206
+ * 读不到时的结论:**有**上一次的 provider 余额就继续用它(网络抖一下不该让胶囊消失),
5207
+ * 没有才落到 `unavailable`。
5208
+ *
5209
+ * @param fallbackMode - 没有上一次结果时用的模式。
5210
+ * @returns 写进快照的结论。
5211
+ */
5212
+ function keepOr(fallbackMode, extra, token) {
5213
+ var previous = getState().provider;
5214
+ var stale = previous !== void 0 && previous.mode === "provider" && previous.balance !== null ? previous.balance : null;
5215
+ if (stale !== null) return publish("provider", stale, void 0, token);
5216
+ return publish(fallbackMode, null, extra, token);
5217
+ }
5218
+ /**
5219
+ * 打开后台凭据设置面板。
5220
+ *
5221
+ * @param provider - 哪个提供方。
5222
+ * @param ref - 宿主给的凭据引用名(老宿主不给就 `null`,面板照样能存)。
5223
+ */
5224
+ function openTokenPanel(provider, ref) {
5225
+ set({ tokenPanel: {
5226
+ open: true,
5227
+ provider,
5228
+ ref,
5229
+ supported: true,
5230
+ saving: false,
5231
+ error: null
5232
+ } });
5233
+ }
5234
+ /** "关着"的面板状态:取消、存成功、初始值都从这里来(免得三份各自漂移)。 */
5235
+ function closedTokenPanel() {
5236
+ return {
5237
+ open: false,
5238
+ provider: null,
5239
+ ref: null,
5240
+ supported: true,
5241
+ saving: false,
5242
+ error: null
5243
+ };
5244
+ }
5245
+ /** 收起设置面板(取消 / 存完自动收起都走这里)。 */
5246
+ function closeTokenPanel() {
5247
+ set({ tokenPanel: closedTokenPanel() });
5248
+ }
5249
+ /**
5250
+ * 把用户粘贴的令牌交给宿主,写进凭据存储。
5251
+ *
5252
+ * 为什么由宿主写:令牌只该落在 DSH 自己的凭据库里(`~/.dsh/.credentials.yaml`),
5253
+ * 客户端不碰文件、不存 localStorage —— 存那里等于把密钥摊在一个任何脚本都读得到的地方。
5254
+ *
5255
+ * @param token - 用户粘贴的令牌(宿主会校验长度与形状)。
5256
+ * @returns 是否存成功(面板据此显示错误或自动收起)。
5257
+ */
5258
+ async function saveProviderToken(token) {
5259
+ var panel = getState().tokenPanel;
5260
+ var provider = panel.provider;
5261
+ if (provider === null || provider === "") {
5262
+ probe.log("provider-credential", { failed: "no-provider" });
5263
+ set({ tokenPanel: {
5264
+ ...getState().tokenPanel,
5265
+ saving: false,
5266
+ error: "unknown-provider"
5267
+ } });
5268
+ return false;
5269
+ }
5270
+ set({ tokenPanel: {
5271
+ ...panel,
5272
+ saving: true,
5273
+ error: null
5274
+ } });
5275
+ try {
5276
+ var result = await ctx.connection.rpc.call(CHANNEL, "provider-credential", { args: {
5277
+ provider,
5278
+ token
5279
+ } });
5280
+ var value = result && result.ok === true ? result.value : null;
5281
+ if (!value || value.saved !== true) {
5282
+ var reason = value?.reason ?? "failed";
5283
+ probe.log("provider-credential", {
5284
+ provider,
5285
+ reason,
5286
+ message: value?.message ?? null
5287
+ });
5288
+ set({ tokenPanel: {
5289
+ ...getState().tokenPanel,
5290
+ saving: false,
5291
+ error: reason
5292
+ } });
5293
+ return false;
5294
+ }
5295
+ probe.log("provider-credential", {
5296
+ provider,
5297
+ saved: true,
5298
+ ref: value.ref ?? null
5299
+ });
5300
+ set({ tokenPanel: closedTokenPanel() });
5301
+ loadProviderBalance(true);
5302
+ return true;
5303
+ } catch (error) {
5304
+ probe.log("provider-credential", { thrown: error instanceof Error ? error.message : String(error) });
5305
+ set({ tokenPanel: {
5306
+ ...getState().tokenPanel,
5307
+ saving: false,
5308
+ error: "failed"
5309
+ } });
5310
+ return false;
5311
+ }
5312
+ }
5313
+ return {
5314
+ loadProviderBalance,
5315
+ resetForSession,
5316
+ openTokenPanel,
5317
+ closeTokenPanel,
5318
+ saveProviderToken
5319
+ };
5320
+ }
5321
+
3445
5322
  //#endregion
3446
5323
  //#region src/client/store/attribution.ts
3447
5324
  function createAttributionSlice(ctx, getState, set, helpers) {
@@ -4180,20 +6057,23 @@ function createSessionInfoSlice(ctx, getState, set) {
4180
6057
  //#region src/client/store/tip.ts
4181
6058
  function createTipSlice(getState, set) {
4182
6059
  /** 显示某枚胶囊的悬停提示(`rect` 由调用方从 `getBoundingClientRect()` 给)。 */
4183
- function showTip(key, text, rect) {
4184
- if (typeof text !== "string" || text.length === 0) return;
6060
+ function showTip(key, content, rect) {
6061
+ var normalized = normalizeTipContent(content);
6062
+ if (normalized.text.length === 0) return;
6063
+ var at = rect && typeof rect === "object" ? {
6064
+ left: rect.left,
6065
+ top: rect.top,
6066
+ right: rect.right
6067
+ } : {
6068
+ left: 0,
6069
+ top: 0,
6070
+ right: 0
6071
+ };
4185
6072
  set({ tip: {
4186
6073
  key,
4187
- text,
4188
- rect: rect && typeof rect === "object" ? {
4189
- left: rect.left,
4190
- top: rect.top,
4191
- right: rect.right
4192
- } : {
4193
- left: 0,
4194
- top: 0,
4195
- right: 0
4196
- }
6074
+ text: normalized.text,
6075
+ lines: normalized.lines,
6076
+ rect: at
4197
6077
  } });
4198
6078
  }
4199
6079
  /** 收起悬停提示。带 `key` 时只在"还是这一枚"的情况下收 —— 免得旧的 leave 把新的提示关掉。 */
@@ -4209,12 +6089,12 @@ function createTipSlice(getState, set) {
4209
6089
  * 为什么走 store 而不是就地渲染气泡:气泡必须**逃出行内盒**(不被 dock 那行的裁切与
4210
6090
  * 堆叠上下文限制),所以它由 `shell.overlay` 里一枚单独的座位画,位置用 `position:fixed`。
4211
6091
  */
4212
- function tipHandlers(key, textOf) {
6092
+ function tipHandlers(key, contentOf) {
4213
6093
  return {
4214
6094
  onPointerEnter: function(event) {
4215
6095
  var target = event && event.currentTarget ? event.currentTarget : null;
4216
6096
  var rect = target && typeof target.getBoundingClientRect === "function" ? target.getBoundingClientRect() : null;
4217
- showTip(key, textOf(), rect);
6097
+ showTip(key, contentOf(), rect);
4218
6098
  },
4219
6099
  onPointerLeave: function() {
4220
6100
  hideTip(key);
@@ -4547,6 +6427,19 @@ function createStore(ctx) {
4547
6427
  sessionId: null,
4548
6428
  value: null
4549
6429
  },
6430
+ balance: null,
6431
+ provider: {
6432
+ mode: "unknown",
6433
+ balance: null
6434
+ },
6435
+ tokenPanel: {
6436
+ open: false,
6437
+ provider: null,
6438
+ ref: null,
6439
+ supported: true,
6440
+ saving: false,
6441
+ error: null
6442
+ },
4550
6443
  tip: null,
4551
6444
  /** 侧栏行内 chip 要的摘要:一次批量取回,按 sessionId 索引。 */
4552
6445
  rows: {
@@ -4600,6 +6493,12 @@ function createStore(ctx) {
4600
6493
  var sessionInfoSlice = createSessionInfoSlice(ctx, function() {
4601
6494
  return state;
4602
6495
  }, set);
6496
+ var accountSlice = createAccountSlice(ctx, function() {
6497
+ return state;
6498
+ }, set);
6499
+ var providerSlice = createProviderSlice(ctx, function() {
6500
+ return state;
6501
+ }, set);
4603
6502
  var toolsSlice = createToolsSlice(ctx, function() {
4604
6503
  return state;
4605
6504
  }, set);
@@ -4798,7 +6697,21 @@ function createStore(ctx) {
4798
6697
  return {
4799
6698
  getSnapshot,
4800
6699
  subscribe,
4801
- bindSession: attributionSlice.bindSession,
6700
+ /**
6701
+ * 绑定当前会话(胶囊/turn-images 的 effect 调它)。
6702
+ *
6703
+ * 绑完之后**立刻**去问"这个会话的模型来源是哪个账本":早先这一步挂在另一个 effect 里,
6704
+ * 而那个 effect 的依赖只有 `sessionId` —— 它先于 `bindSession` 跑,此时 store 里的
6705
+ * `sessionId` 还是 `null`,于是 provider 侧永远是 `unknown`(胶囊一直画平台账号余额),
6706
+ * 要等五分钟那一次轮询才会纠正。会话一换就顺手问,才是对的时机。
6707
+ */
6708
+ bindSession: function(sessionId, cwd) {
6709
+ var previous = state.sessionId;
6710
+ attributionSlice.bindSession(sessionId, cwd);
6711
+ if (state.sessionId === previous) return;
6712
+ providerSlice.resetForSession();
6713
+ if (state.sessionId !== null) providerSlice.loadProviderBalance();
6714
+ },
4802
6715
  refresh,
4803
6716
  openPanel,
4804
6717
  togglePanel,
@@ -4815,6 +6728,12 @@ function createStore(ctx) {
4815
6728
  loadSessionChanges: attributionSlice.loadSessionChanges,
4816
6729
  loadSessionTime: sessionInfoSlice.loadSessionTime,
4817
6730
  loadPromptHistory: sessionInfoSlice.loadPromptHistory,
6731
+ loadBalance: accountSlice.loadBalance,
6732
+ resetForSession: providerSlice.resetForSession,
6733
+ loadProviderBalance: providerSlice.loadProviderBalance,
6734
+ openTokenPanel: providerSlice.openTokenPanel,
6735
+ closeTokenPanel: providerSlice.closeTokenPanel,
6736
+ saveProviderToken: providerSlice.saveProviderToken,
4818
6737
  showTip: tipSlice.showTip,
4819
6738
  hideTip: tipSlice.hideTip,
4820
6739
  tipHandlers: tipSlice.tipHandlers,
@@ -4961,6 +6880,53 @@ function apply(ctx) {
4961
6880
  window.removeEventListener("focus", onFocus);
4962
6881
  };
4963
6882
  }, "dsh-hud: focus refresh");
6883
+ ctx.effect(function() {
6884
+ store.loadBalance();
6885
+ var id = window.setInterval(function() {
6886
+ if (document.visibilityState === "hidden") return;
6887
+ store.loadBalance();
6888
+ }, BALANCE_POLL_MS);
6889
+ return function() {
6890
+ window.clearInterval(id);
6891
+ };
6892
+ }, "dsh-hud: account balance poll");
6893
+ /**
6894
+ * 「当前会话模型来源」的余额:自己的节拍,比账号那一路快。
6895
+ *
6896
+ * 为什么是 30 秒而不是 5 分钟:第一次取数常常赶在投影就绪之前(刚开会话、刚换模型),
6897
+ * 那时宿主答不上来;如果下一次机会在五分钟之后,胶囊就有五分钟是空白的。
6898
+ * 真实请求次数由 store 自己的 TTL 兜着(成功 60s、失败 30s),这里只是"多久问一次"。
6899
+ */
6900
+ ctx.effect(function() {
6901
+ var stopped = false;
6902
+ var handle = null;
6903
+ /**
6904
+ * 下一次隔多久再问。
6905
+ *
6906
+ * 稳态是 `PROVIDER_POLL_MS`(30 秒);**还没问出来**的时候用 1 秒重问 ——
6907
+ * 这才是"重启后胶囊迟迟不出现"的症结:宿主在刚启动那一下常常还读不到模型选择投影,
6908
+ * 第一次问必然落空,而原来的固定 30 秒节拍意味着胶囊要空半分钟。
6909
+ * 真实请求次数仍由 store 的 TTL 兜着(成功 60s、失败按 1s 起指数退避到 30s),
6910
+ * 这里只是"多久看一眼",看的时候多半是空转。
6911
+ */
6912
+ function nextDelay() {
6913
+ var ledger = store.getSnapshot().provider;
6914
+ return ledger !== void 0 && ledger !== null && ledger.mode !== "unknown" && typeof ledger.provider === "string" && ledger.provider !== "" ? PROVIDER_POLL_MS : PROVIDER_RETRY_TICK_MS;
6915
+ }
6916
+ function tick() {
6917
+ if (stopped) return;
6918
+ if (document.visibilityState !== "hidden") store.loadProviderBalance();
6919
+ handle = window.setTimeout(tick, nextDelay());
6920
+ }
6921
+ tick();
6922
+ return function() {
6923
+ stopped = true;
6924
+ if (handle !== null) window.clearTimeout(handle);
6925
+ };
6926
+ }, "dsh-hud: provider balance poll");
6927
+ ctx.effect(function() {
6928
+ return watchModelSelection(ctx, store);
6929
+ }, "dsh-hud: model selection -> provider balance");
4964
6930
  ctx.slots.inject("shell.overlay", function() {
4965
6931
  return ctx.slots.register({
4966
6932
  name: "shell.overlay",
@@ -4972,6 +6938,17 @@ function apply(ctx) {
4972
6938
  }
4973
6939
  }, PillTip);
4974
6940
  });
6941
+ ctx.slots.inject("shell.overlay", function() {
6942
+ return ctx.slots.register({
6943
+ name: "shell.overlay",
6944
+ id: "dsh-hud-token-panel",
6945
+ order: 15,
6946
+ locale: NS,
6947
+ inject: function() {
6948
+ return { store };
6949
+ }
6950
+ }, TokenPanel);
6951
+ });
4975
6952
  ctx.slots.inject("shell.overlay", function() {
4976
6953
  return ctx.slots.register({
4977
6954
  name: "shell.overlay",