opencode-visual-cache 1.6.2 → 1.7.0-beta.1
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/LICENSE +21 -21
- package/README.md +247 -247
- package/README_EN.md +247 -247
- package/dist/_version.d.ts +1 -1
- package/dist/_version.js +1 -1
- package/dist/core/color.d.ts +37 -0
- package/dist/core/color.js +108 -0
- package/dist/core/currency.d.ts +17 -0
- package/dist/core/currency.js +43 -0
- package/dist/core/estimate.d.ts +1 -0
- package/dist/core/estimate.js +40 -0
- package/dist/core/format.d.ts +15 -0
- package/dist/core/format.js +90 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +5 -0
- package/dist/core/types.d.ts +17 -0
- package/dist/core/types.js +1 -0
- package/dist/index.js +12 -818
- package/dist/panel/TokenCachePanel.d.ts +10 -0
- package/dist/panel/TokenCachePanel.js +549 -0
- package/dist/panel/panel-api.d.ts +119 -0
- package/dist/panel/panel-api.js +1 -0
- package/dist/tui.js +223 -209
- package/dist/v2/commands.d.ts +10 -0
- package/dist/v2/commands.js +490 -0
- package/dist/v2/data.d.ts +27 -0
- package/dist/v2/data.js +103 -0
- package/dist/v2/index.d.ts +4 -0
- package/dist/v2/index.js +163 -0
- package/dist/v2/sidebar.d.ts +6 -0
- package/dist/v2/sidebar.js +36 -0
- package/dist/v2/status.d.ts +14 -0
- package/dist/v2/status.js +83 -0
- package/dist/v2/theme.d.ts +8 -0
- package/dist/v2/theme.js +16 -0
- package/dist/v2/types.d.ts +219 -0
- package/dist/v2/types.js +6 -0
- package/dist/v2/v2-panel-api.d.ts +8 -0
- package/dist/v2/v2-panel-api.js +176 -0
- package/dist/v2.js +2941 -0
- package/package.json +72 -67
- package/src/_version.ts +1 -1
- package/src/balance-providers.ts +153 -153
- package/src/core/color.ts +108 -0
- package/src/core/currency.ts +46 -0
- package/src/core/estimate.ts +36 -0
- package/src/core/format.ts +81 -0
- package/src/core/index.ts +5 -0
- package/src/core/types.ts +17 -0
- package/src/i18n.ts +380 -380
- package/src/index.tsx +1035 -2120
- package/src/panel/TokenCachePanel.tsx +776 -0
- package/src/panel/panel-api.ts +104 -0
- package/src/server.ts +10 -10
- package/src/v2/commands.ts +471 -0
- package/src/v2/data.ts +112 -0
- package/src/v2/index.tsx +184 -0
- package/src/v2/sidebar.tsx +69 -0
- package/src/v2/status.tsx +96 -0
- package/src/v2/theme.ts +19 -0
- package/src/v2/types.ts +159 -0
- package/src/v2/v2-panel-api.ts +177 -0
package/dist/index.js
CHANGED
|
@@ -1,58 +1,9 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/solid/jsx-runtime";
|
|
2
2
|
import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, For, untrack } from "solid-js";
|
|
3
|
-
import { PLUGIN_VERSION } from "./_version";
|
|
4
3
|
import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider } from "./balance-providers";
|
|
5
4
|
import { LANG_META, createT, detectLang } from "./i18n";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
// string length (=1 per char), which breaks alignment with mixed text.
|
|
9
|
-
function charColumns(c) {
|
|
10
|
-
const code = c.codePointAt(0) ?? 0;
|
|
11
|
-
if (code < 0x20)
|
|
12
|
-
return 0; // control
|
|
13
|
-
if (code < 0x7F)
|
|
14
|
-
return 1; // ASCII
|
|
15
|
-
if (code < 0xA0)
|
|
16
|
-
return 0; // C1 controls
|
|
17
|
-
// East-Asian wide / fullwidth ranges
|
|
18
|
-
if ((code >= 0x1100 && code <= 0x115F) || // Hangul Jamo
|
|
19
|
-
(code >= 0x2E80 && code <= 0xA4CF) || // CJK Radicals … Yi
|
|
20
|
-
(code >= 0xAC00 && code <= 0xD7A3) || // Hangul
|
|
21
|
-
(code >= 0xF900 && code <= 0xFAFF) || // CJK Compat
|
|
22
|
-
(code >= 0xFE10 && code <= 0xFE6F) || // Vertical / Compat
|
|
23
|
-
(code >= 0xFF01 && code <= 0xFF60) || // Fullwidth
|
|
24
|
-
(code >= 0xFFE0 && code <= 0xFFE6) || // Fullwidth signs
|
|
25
|
-
(code >= 0x1F300 && code <= 0x1F64F) || // Misc Symbols (emoji)
|
|
26
|
-
(code >= 0x20000 && code <= 0x3FFFD)) // SIP / TIP
|
|
27
|
-
return 2;
|
|
28
|
-
return 1;
|
|
29
|
-
}
|
|
30
|
-
function visualWidth(s) {
|
|
31
|
-
let w = 0;
|
|
32
|
-
for (const c of s)
|
|
33
|
-
w += charColumns(c);
|
|
34
|
-
return w;
|
|
35
|
-
}
|
|
36
|
-
function visualPadEnd(s, cols) {
|
|
37
|
-
const pad = cols - visualWidth(s);
|
|
38
|
-
return pad > 0 ? s + " ".repeat(pad) : s;
|
|
39
|
-
}
|
|
40
|
-
/** Truncate `s` to fit within `maxCols` visual columns, appending "…" when cut. */
|
|
41
|
-
function truncateVisual(s, maxCols) {
|
|
42
|
-
if (visualWidth(s) <= maxCols)
|
|
43
|
-
return s;
|
|
44
|
-
let result = "", w = 0;
|
|
45
|
-
for (const c of s) {
|
|
46
|
-
const cw = charColumns(c);
|
|
47
|
-
if (w + cw > maxCols - 1) {
|
|
48
|
-
result += "\u2026";
|
|
49
|
-
break;
|
|
50
|
-
}
|
|
51
|
-
result += c;
|
|
52
|
-
w += cw;
|
|
53
|
-
}
|
|
54
|
-
return result;
|
|
55
|
-
}
|
|
5
|
+
import { MAX_SAT, FALLBACK, CURRENCIES, DEFAULT_RATES, visualWidth, visualPadEnd, truncateVisual, num, fmtCost, fmtCompact, formatBalanceText, desaturateTo, } from "./core";
|
|
6
|
+
import { TokenCachePanel } from "./panel/TokenCachePanel";
|
|
56
7
|
// ── language ──────────────────────────────────────────────────────
|
|
57
8
|
// 语言初始化:环境变量 CACHE_TUI_LANG 覆盖 → 否则按系统 locale 自动检测。
|
|
58
9
|
// 用户通过 /cache-lang 设置的偏好会在 KV 就绪后优先覆盖(见 tui() 内恢复逻辑)。
|
|
@@ -60,191 +11,10 @@ const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG
|
|
|
60
11
|
const INIT_LANG = DEBUG_LANG !== undefined && LANG_META.some((m) => m.code === DEBUG_LANG)
|
|
61
12
|
? DEBUG_LANG
|
|
62
13
|
: detectLang();
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (typeof raw === "string" && raw.startsWith("#")) {
|
|
67
|
-
const h = raw.slice(1);
|
|
68
|
-
return {
|
|
69
|
-
r: parseInt(h.slice(0, 2), 16),
|
|
70
|
-
g: parseInt(h.slice(2, 4), 16),
|
|
71
|
-
b: parseInt(h.slice(4, 6), 16),
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
if (raw && typeof raw === "object") {
|
|
75
|
-
const o = raw;
|
|
76
|
-
if (typeof o.r === "number" && typeof o.g === "number" && typeof o.b === "number") {
|
|
77
|
-
// RGBA channels may be 0-1 floats; detect and upscale.
|
|
78
|
-
const scale = o.r > 1 || o.g > 1 || o.b > 1 ? 1 : 255;
|
|
79
|
-
return {
|
|
80
|
-
r: Math.round(o.r * scale),
|
|
81
|
-
g: Math.round(o.g * scale),
|
|
82
|
-
b: Math.round(o.b * scale),
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
/** HSL saturation of an RGB color (0–1). */
|
|
89
|
-
function saturation(r, g, b) {
|
|
90
|
-
const max = Math.max(r, g, b) / 255;
|
|
91
|
-
const min = Math.min(r, g, b) / 255;
|
|
92
|
-
const delta = max - min;
|
|
93
|
-
if (delta === 0)
|
|
94
|
-
return 0;
|
|
95
|
-
const L = (max + min) / 2;
|
|
96
|
-
return L <= 0.5 ? delta / (max + min) : delta / (2 - max - min);
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* If the colour's saturation exceeds `maxSat`, pull it toward grey
|
|
100
|
-
* until saturation drops to maxSat. Returns a hex string.
|
|
101
|
-
*/
|
|
102
|
-
function desaturateTo(raw, maxSat, fallback) {
|
|
103
|
-
const c = rgb(raw);
|
|
104
|
-
if (!c)
|
|
105
|
-
return fallback;
|
|
106
|
-
const sat = saturation(c.r, c.g, c.b);
|
|
107
|
-
if (sat <= maxSat) {
|
|
108
|
-
// already muted — return as hex
|
|
109
|
-
return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* Binary search for the optimal grey-mix ratio α (0…1).
|
|
113
|
-
*
|
|
114
|
-
* 12 iterations → 1/2^12 ≈ 1/4096 resolution. The downstream RGB
|
|
115
|
-
* channels are only 0–255 (8 bit), so 8 iterations (1/256) would
|
|
116
|
-
* technically suffice; 12 is intentionally over-budget — the extra
|
|
117
|
-
* precision costs almost nothing and guarantees the saturation probe
|
|
118
|
-
* converges to within a fraction of an 8‑bit step, eliminating
|
|
119
|
-
* colour banding in edge cases.
|
|
120
|
-
*/
|
|
121
|
-
// Bt.601 luma (perceptual brightness used as the grey anchor)
|
|
122
|
-
const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114;
|
|
123
|
-
let lo = 0, hi = 1;
|
|
124
|
-
for (let i = 0; i < 12; i++) {
|
|
125
|
-
const mid = (lo + hi) / 2;
|
|
126
|
-
const nr = Math.round(c.r + (luma - c.r) * mid);
|
|
127
|
-
const ng = Math.round(c.g + (luma - c.g) * mid);
|
|
128
|
-
const nb = Math.round(c.b + (luma - c.b) * mid);
|
|
129
|
-
if (saturation(nr, ng, nb) > maxSat)
|
|
130
|
-
lo = mid;
|
|
131
|
-
else
|
|
132
|
-
hi = mid;
|
|
133
|
-
}
|
|
134
|
-
const nr = Math.round(c.r + (luma - c.r) * hi);
|
|
135
|
-
const ng = Math.round(c.g + (luma - c.g) * hi);
|
|
136
|
-
const nb = Math.round(c.b + (luma - c.b) * hi);
|
|
137
|
-
return "#" + [nr, ng, nb].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("");
|
|
138
|
-
}
|
|
139
|
-
/** Darken a hex colour by multiplying each channel by `factor` (0–1). */
|
|
140
|
-
function dimColor(hex, factor = 0.5) {
|
|
141
|
-
const c = rgb(hex);
|
|
142
|
-
if (!c)
|
|
143
|
-
return hex;
|
|
144
|
-
const r = Math.round(c.r * factor);
|
|
145
|
-
const g = Math.round(c.g * factor);
|
|
146
|
-
const b = Math.round(c.b * factor);
|
|
147
|
-
return "#" + [r, g, b].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("");
|
|
148
|
-
}
|
|
149
|
-
// Morandi fallbacks — used when a theme colour cannot be resolved
|
|
150
|
-
const FALLBACK = {
|
|
151
|
-
primary: "#8B9DAF",
|
|
152
|
-
text: "#C5C5BB",
|
|
153
|
-
muted: "#7A7A72",
|
|
154
|
-
success: "#9CAF8B",
|
|
155
|
-
warning: "#C5B88D",
|
|
156
|
-
error: "#B08A8A",
|
|
157
|
-
border: "#6B6B63",
|
|
158
|
-
};
|
|
159
|
-
/**
|
|
160
|
-
* Desaturation ceiling for the Morandi-style palette.
|
|
161
|
-
*
|
|
162
|
-
* Morandi colours float around 0.15–0.30 saturation in HSL space.
|
|
163
|
-
* 0.28 sits near the upper end of that range: it strips the aggressive
|
|
164
|
-
* punch from high-saturation themes (Dracula, Solarized …) while
|
|
165
|
-
* preserving enough colour identity that green / orange / red hit-rate
|
|
166
|
-
* coding stays distinguishable.
|
|
167
|
-
*
|
|
168
|
-
* Lower → more grey, harder to tell colours apart.
|
|
169
|
-
* Higher → bright themes bleed through and defeat the muted look.
|
|
170
|
-
*/
|
|
171
|
-
const MAX_SAT = 0.28;
|
|
172
|
-
function progressBar(percent, width) {
|
|
173
|
-
const clamped = Math.max(0, Math.min(100, percent));
|
|
174
|
-
const filled = Math.round((clamped / 100) * width);
|
|
175
|
-
const empty = Math.max(0, width - filled);
|
|
176
|
-
return "\u2588".repeat(filled) + "\u2591".repeat(empty);
|
|
177
|
-
}
|
|
178
|
-
function fmt(n) {
|
|
179
|
-
if (n >= 1_000_000)
|
|
180
|
-
return (n / 1_000_000).toFixed(1) + "M";
|
|
181
|
-
if (n >= 10_000)
|
|
182
|
-
return (n / 1_000).toFixed(1) + "K";
|
|
183
|
-
return n.toLocaleString("en-US");
|
|
184
|
-
}
|
|
185
|
-
function num(v) {
|
|
186
|
-
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
187
|
-
}
|
|
188
|
-
function fmtCost(n, symbol = "$", rate = 1) {
|
|
189
|
-
const v = n * rate;
|
|
190
|
-
if (v >= 1)
|
|
191
|
-
return symbol + v.toFixed(2);
|
|
192
|
-
if (v >= 0.01)
|
|
193
|
-
return symbol + v.toFixed(3);
|
|
194
|
-
return symbol + v.toFixed(4);
|
|
195
|
-
}
|
|
196
|
-
// ── token estimation ──
|
|
197
|
-
// Character-based BPE approximation. Default ratios (~4 ASCII or ~1.5 CJK
|
|
198
|
-
// chars per token) work well for natural language but systematically
|
|
199
|
-
// under-count tokens in JSON and source code where every punctuation mark
|
|
200
|
-
// tends to be its own token. Detect these cases and tighten the ratio.
|
|
201
|
-
// See: GPT-4 / Claude tokenizer behaviour with structured text.
|
|
202
|
-
function estimateTokens(text) {
|
|
203
|
-
if (!text || text.length === 0)
|
|
204
|
-
return 0;
|
|
205
|
-
let ascii = 0;
|
|
206
|
-
let cjk = 0;
|
|
207
|
-
for (const c of text) {
|
|
208
|
-
const code = c.codePointAt(0) ?? 0;
|
|
209
|
-
if (code >= 0x4E00 && code <= 0x9FFF)
|
|
210
|
-
cjk++; // CJK Unified
|
|
211
|
-
else if (code >= 0x3040 && code <= 0x30FF)
|
|
212
|
-
cjk++; // Hiragana/Katakana
|
|
213
|
-
else if (code >= 0xAC00 && code <= 0xD7A3)
|
|
214
|
-
cjk++; // Hangul
|
|
215
|
-
else if (code >= 0x1100 && code <= 0x11FF)
|
|
216
|
-
cjk++; // Hangul Jamo
|
|
217
|
-
else if (code >= 0x2E80 && code <= 0x2EFF)
|
|
218
|
-
cjk++; // CJK Radicals
|
|
219
|
-
else
|
|
220
|
-
ascii++;
|
|
221
|
-
}
|
|
222
|
-
// Real BPE tokenizers (cl100k_base, o200k_base) average ~3.5-4.0
|
|
223
|
-
// ASCII chars/token for both JSON and source code — close to prose.
|
|
224
|
-
// The old 2.0 / 2.5 ratios matched minified-JS extremes, not typical
|
|
225
|
-
// payloads, and systematically over-estimated token counts.
|
|
226
|
-
const trimmed = text.trimStart();
|
|
227
|
-
// Strip markdown code-fence prefix so that ```json … is detected as JSON
|
|
228
|
-
const strippedFence = trimmed.replace(/^\x60{3}\w*\s*\n?/, "");
|
|
229
|
-
const jsonLike = (strippedFence.startsWith("{") || strippedFence.startsWith("["))
|
|
230
|
-
&& /"[^"]+"\s*:/.test(text);
|
|
231
|
-
const codeLike = !jsonLike
|
|
232
|
-
&& /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text);
|
|
233
|
-
const asciiPerToken = jsonLike ? 3.5 : codeLike ? 3.5 : 4;
|
|
234
|
-
return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.0));
|
|
235
|
-
}
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Balance state
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
236
17
|
const BALANCE_POLL_MS = 5 * 60 * 1000; // 5 minutes
|
|
237
|
-
/**
|
|
238
|
-
* 将余额从来源币种换算为目标币种。
|
|
239
|
-
* DEFAULT_RATES 以 USD=1 为基准:先折算为 USD,再换算到目标币种。
|
|
240
|
-
*/
|
|
241
|
-
function convertBalance(target, targetRate, amount, from) {
|
|
242
|
-
if (from === target)
|
|
243
|
-
return amount;
|
|
244
|
-
const fromRate = DEFAULT_RATES[from] ?? 1;
|
|
245
|
-
const usd = from === "USD" ? amount : amount / fromRate;
|
|
246
|
-
return target === "USD" ? usd : usd * targetRate;
|
|
247
|
-
}
|
|
248
18
|
/**
|
|
249
19
|
* 从 OpenCode 已认证的 provider 读取 API key 作为余额查询的自动兜底。
|
|
250
20
|
* 匹配复用前缀逻辑:先精确匹配 id,再前缀匹配(如 moonshotai-cn → moonshot)。
|
|
@@ -268,54 +38,12 @@ function findOpencodeKey(api, provider) {
|
|
|
268
38
|
return "";
|
|
269
39
|
}
|
|
270
40
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
function fmtCompact(n) {
|
|
278
|
-
if (n >= 1e6)
|
|
279
|
-
return (n / 1e6).toFixed(1) + "M";
|
|
280
|
-
if (n >= 1e3)
|
|
281
|
-
return (n / 1e3).toFixed(1) + "K";
|
|
282
|
-
return String(Math.round(n));
|
|
283
|
-
}
|
|
284
|
-
/** 余额数值格式化:≥1 或 0 显示固定 2 位小数;小额(<1)保留精度(最多 6 位),避免抹成 0.00。 */
|
|
285
|
-
function formatBalanceAmount(total) {
|
|
286
|
-
const n = parseFloat(total);
|
|
287
|
-
if (!Number.isFinite(n))
|
|
288
|
-
return total;
|
|
289
|
-
if (n === 0 || n >= 1)
|
|
290
|
-
return n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
291
|
-
return n.toLocaleString("en-US", { maximumFractionDigits: 6 });
|
|
292
|
-
}
|
|
293
|
-
/**
|
|
294
|
-
* 将余额列表格式化为单行文本。
|
|
295
|
-
* 优先直接显示偏好币种(CNY/USD…);偏好币种为换算币种时按汇率折算第一条余额。
|
|
296
|
-
*/
|
|
297
|
-
function formatBalanceText(list, pref, rate) {
|
|
298
|
-
const native = pref ? list.find((x) => x.currency === pref) : undefined;
|
|
299
|
-
if (native)
|
|
300
|
-
return balanceSymbol(native.currency) + formatBalanceAmount(native.total);
|
|
301
|
-
const base = list[0];
|
|
302
|
-
const baseAmt = parseFloat(base.total);
|
|
303
|
-
const converted = Number.isFinite(baseAmt)
|
|
304
|
-
? convertBalance(pref || base.currency, rate, baseAmt, base.currency)
|
|
305
|
-
: baseAmt;
|
|
306
|
-
const shown = pref && base.currency !== pref
|
|
307
|
-
? converted.toLocaleString("en-US", { maximumFractionDigits: 2 })
|
|
308
|
-
: formatBalanceAmount(base.total);
|
|
309
|
-
return balanceSymbol(pref || base.currency) + shown;
|
|
310
|
-
}
|
|
311
|
-
const CURRENCIES = {
|
|
312
|
-
USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
|
|
313
|
-
};
|
|
314
|
-
/** Approximate USD exchange rates — used as defaults when switching currency.
|
|
315
|
-
* Users can override via /cache-rate. Last updated 2026-05. */
|
|
316
|
-
const DEFAULT_RATES = {
|
|
317
|
-
USD: 1, CNY: 7.2, EUR: 0.92, JPY: 150, GBP: 0.79, KRW: 1350,
|
|
318
|
-
};
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Sidebar component
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
/** Signals shared between the TUI component and slash commands.
|
|
45
|
+
* Created in the `tui` function scope so they do not survive module reload —
|
|
46
|
+
* the component re-creates them on mount and restores user config from kv. */
|
|
319
47
|
const MIN_PANEL_WIDTH = 20;
|
|
320
48
|
const DEFAULT_PANEL_WIDTH = 26;
|
|
321
49
|
/** ── layout measurement constants (visual columns) ── */
|
|
@@ -325,540 +53,6 @@ const BAR_GAP = 1; // "]" 后面的空格
|
|
|
325
53
|
const PCT_FIXED_WIDTH = 5; // "XX.X%" 固定 5 字符宽度
|
|
326
54
|
const HEADER_PREFIX = 2; // 折叠态标题行:▶/▼ 图标 + 后面的空格
|
|
327
55
|
const UNIT_GAP = 1; // 计量单位前的空格(如 "tok")
|
|
328
|
-
function TokenCachePanel(props) {
|
|
329
|
-
const [panelWidth, setPanelWidth] = createSignal(DEFAULT_PANEL_WIDTH);
|
|
330
|
-
const [open, setOpen] = createSignal(true);
|
|
331
|
-
const [detailOpen, setDetailOpen] = createSignal(true);
|
|
332
|
-
const [modelOpen, setModelOpen] = createSignal(true);
|
|
333
|
-
const [distOpen, setDistOpen] = createSignal(false);
|
|
334
|
-
const [skillsOpen, setSkillsOpen] = createSignal(true);
|
|
335
|
-
let boxEl;
|
|
336
|
-
// 侧边栏可见性通知:本面板挂载 ⇒ 宿主侧边栏可见(固定占用 42 列输入框宽度)
|
|
337
|
-
createEffect(() => {
|
|
338
|
-
props.signals.setSidebarVisible(true);
|
|
339
|
-
onCleanup(() => props.signals.setSidebarVisible(false));
|
|
340
|
-
});
|
|
341
|
-
// ── shared signals (de-structured so internal code is unchanged) ──
|
|
342
|
-
const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, langCode, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, sectionSkills, setSectionSkills, sectionBalance, setSectionBalance, balanceRefresh, balanceProviderId, setBalanceProviderId, autoBalance, setAutoBalance, balanceUnsupported, setBalanceUnsupported, balanceState, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, } = props.signals;
|
|
343
|
-
// ── reactive translation (follows langCode signal) ──
|
|
344
|
-
const t = createT(() => langCode());
|
|
345
|
-
// ── scan session messages reactively ──
|
|
346
|
-
// SolidJS createMemo re-evaluates whenever the underlying
|
|
347
|
-
// api.state.session state changes — no event listener needed.
|
|
348
|
-
// ── distribution cache ────────────────────────────────────────
|
|
349
|
-
// When data() re-computes before api.state.part() is warm (e.g. after
|
|
350
|
-
// a view switch), hasDistData flips to false and the distribution
|
|
351
|
-
// block disappears. Keep the last valid snapshot so the UI stays
|
|
352
|
-
// stable until the next successful computation arrives.
|
|
353
|
-
const [lastDist, setLastDist] = createSignal({
|
|
354
|
-
system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0,
|
|
355
|
-
output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0,
|
|
356
|
-
});
|
|
357
|
-
const [lastHasDist, setLastHasDist] = createSignal(false);
|
|
358
|
-
const [dataSignal, setDataSignal] = createSignal({
|
|
359
|
-
hitRate: 0, read: 0, write: 0, freshInput: 0, output: 0,
|
|
360
|
-
cost: 0, saved: 0, model: "", inputRate: 0, cacheReadRate: 0, cacheWriteRate: 0,
|
|
361
|
-
hasPricing: false, hasData: false, trend: 0, hasTrendData: false,
|
|
362
|
-
providerName: "", sessionHitRate: 0,
|
|
363
|
-
dist: { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0 },
|
|
364
|
-
hasDistData: false,
|
|
365
|
-
skills: [],
|
|
366
|
-
hasSkills: false,
|
|
367
|
-
});
|
|
368
|
-
const [refreshTick, setRefreshTick] = createSignal(0);
|
|
369
|
-
// 当前 provider 显示名(余额查询状态为共享信号,见 PanelSignals.balanceState)
|
|
370
|
-
const providerName = createMemo(() => getBalanceProvider(balanceProviderId()).name);
|
|
371
|
-
// 自动切换当前会话的 provider(前缀匹配)。手动切换会关闭此行为。
|
|
372
|
-
// 直接追踪 messages 取最后一条 assistant 消息的 providerID——
|
|
373
|
-
// 不依赖 session.model 的响应式更新(模型切换时该链路可能不触发重算)。
|
|
374
|
-
createEffect(() => {
|
|
375
|
-
if (!autoBalance())
|
|
376
|
-
return;
|
|
377
|
-
const sid = props.signals.overrideSessionId() ?? props.sessionId;
|
|
378
|
-
const msgs = props.api.state.session.messages(sid);
|
|
379
|
-
let pid = "";
|
|
380
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
381
|
-
const m = msgs[i];
|
|
382
|
-
if (m.role === "assistant" && m.providerID) {
|
|
383
|
-
pid = m.providerID;
|
|
384
|
-
break;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
// 会话尚无 assistant 消息(新会话 / 刚切换模型未对话 / 消息未加载)
|
|
388
|
-
// → 回退到会话级模型元数据,反映当前正在使用的 provider
|
|
389
|
-
if (!pid) {
|
|
390
|
-
try {
|
|
391
|
-
const session = props.api.state.session.get(sid);
|
|
392
|
-
pid = session?.model?.providerID ?? "";
|
|
393
|
-
}
|
|
394
|
-
catch { /* ignore */ }
|
|
395
|
-
}
|
|
396
|
-
if (!pid)
|
|
397
|
-
return;
|
|
398
|
-
const hit = matchBalanceProvider(pid);
|
|
399
|
-
if (hit) {
|
|
400
|
-
setBalanceUnsupported(false);
|
|
401
|
-
if (hit.id !== balanceProviderId()) {
|
|
402
|
-
setBalanceProviderId(hit.id);
|
|
403
|
-
props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
else {
|
|
407
|
-
// 当前提供商没有余额适配器 → 标记不支持,余额显示 N/A 并停止轮询
|
|
408
|
-
setBalanceUnsupported(true);
|
|
409
|
-
}
|
|
410
|
-
});
|
|
411
|
-
// ── auto-clear override when the user navigates to a different main session ──
|
|
412
|
-
let lastMainSid = props.sessionId;
|
|
413
|
-
createEffect(() => {
|
|
414
|
-
const sid = props.sessionId;
|
|
415
|
-
if (sid !== lastMainSid) {
|
|
416
|
-
lastMainSid = sid;
|
|
417
|
-
if (props.signals.overrideSessionId()) {
|
|
418
|
-
props.signals.setOverrideSessionId(undefined);
|
|
419
|
-
props.api.kv.set(`${KV_PREFIX}.session`, "");
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
});
|
|
423
|
-
createEffect(() => {
|
|
424
|
-
const sid = props.signals.overrideSessionId() ?? props.sessionId;
|
|
425
|
-
void refreshTick();
|
|
426
|
-
void partVersion();
|
|
427
|
-
// 自然追踪 messages 和 provider(SDK 数据就绪时自动重新执行)
|
|
428
|
-
const msgs = props.api.state.session.messages(sid);
|
|
429
|
-
const session = typeof props.api.state.session.get === "function"
|
|
430
|
-
? props.api.state.session.get(sid)
|
|
431
|
-
: undefined;
|
|
432
|
-
// 累计值优先使用 Session 聚合字段(数据库级,不受 sync 层 limit:100 截断)
|
|
433
|
-
// 若字段不存在(旧版本 SDK),降级到消息遍历累加
|
|
434
|
-
let input = session?.tokens?.input ?? 0;
|
|
435
|
-
let read = session?.tokens?.cache?.read ?? 0;
|
|
436
|
-
let write = session?.tokens?.cache?.write ?? 0;
|
|
437
|
-
let output = session?.tokens?.output ?? 0;
|
|
438
|
-
let cost = session?.cost ?? 0;
|
|
439
|
-
let pid = session?.model?.providerID ?? "";
|
|
440
|
-
let mid = session?.model?.id ?? "";
|
|
441
|
-
const fallbackTokens = session?.tokens == null;
|
|
442
|
-
const fallbackCost = session?.cost == null;
|
|
443
|
-
const fallbackModel = !pid || !mid;
|
|
444
|
-
let prevMsgHitRate = -1, lastMsgHitRate = -1;
|
|
445
|
-
for (const msg of msgs) {
|
|
446
|
-
if (msg.role !== "assistant")
|
|
447
|
-
continue;
|
|
448
|
-
const tok = msg.tokens;
|
|
449
|
-
if (!tok)
|
|
450
|
-
continue;
|
|
451
|
-
const mit = num(tok.input) + num(tok.cache?.read) + num(tok.cache?.write), mrt = num(tok.cache?.read);
|
|
452
|
-
if (mit > 0) {
|
|
453
|
-
prevMsgHitRate = lastMsgHitRate;
|
|
454
|
-
lastMsgHitRate = (mrt / mit) * 100;
|
|
455
|
-
}
|
|
456
|
-
if (fallbackTokens) {
|
|
457
|
-
input += num(tok.input);
|
|
458
|
-
read += num(tok.cache?.read);
|
|
459
|
-
write += num(tok.cache?.write);
|
|
460
|
-
output += num(tok.output);
|
|
461
|
-
}
|
|
462
|
-
if (fallbackCost) {
|
|
463
|
-
cost += num(msg.cost);
|
|
464
|
-
}
|
|
465
|
-
if (fallbackModel && msg.providerID && msg.modelID) {
|
|
466
|
-
pid = msg.providerID;
|
|
467
|
-
mid = msg.modelID;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
let saved = 0, inputRate = 0, cacheReadRate = 0, cacheWriteRate = 0;
|
|
471
|
-
if (read > 0 && pid && mid && Array.isArray(props.api.state.provider))
|
|
472
|
-
for (const provider of props.api.state.provider) {
|
|
473
|
-
if (provider.id !== pid)
|
|
474
|
-
continue;
|
|
475
|
-
const model = provider.models[mid];
|
|
476
|
-
if (!model?.cost)
|
|
477
|
-
continue;
|
|
478
|
-
inputRate = num(model.cost.input);
|
|
479
|
-
cacheReadRate = num(model.cost.cache?.read);
|
|
480
|
-
cacheWriteRate = num(model.cost.cache?.write);
|
|
481
|
-
if (inputRate > cacheReadRate)
|
|
482
|
-
saved = (read * (inputRate - cacheReadRate)) / 1_000_000;
|
|
483
|
-
break;
|
|
484
|
-
}
|
|
485
|
-
const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0;
|
|
486
|
-
// 总命中率分母含缓存写(业界口径:read / (input+read+write))
|
|
487
|
-
const freshTotal = input + read + write, sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0;
|
|
488
|
-
const model = mid.split("/").pop() ?? mid, hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0;
|
|
489
|
-
const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0;
|
|
490
|
-
const trend = hasTrendData ? lastMsgHitRate - prevMsgHitRate : 0, providerName = pid || "";
|
|
491
|
-
// untrack 只包裹已知触发死锁的 API
|
|
492
|
-
const distData = untrack(() => {
|
|
493
|
-
let dist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0 };
|
|
494
|
-
let hasDistData = false;
|
|
495
|
-
const loadedSkills = new Map();
|
|
496
|
-
try {
|
|
497
|
-
const cfg = props.api.state.config;
|
|
498
|
-
const agentName = String(session?.agent ?? cfg?.default_agent ?? "build");
|
|
499
|
-
const agents = cfg?.agent;
|
|
500
|
-
const agentCfg = agents?.[agentName];
|
|
501
|
-
const sysPrompt = typeof agentCfg?.prompt === "string" ? agentCfg.prompt : "";
|
|
502
|
-
if (sysPrompt)
|
|
503
|
-
dist.system = estimateTokens(sysPrompt);
|
|
504
|
-
let lastAssMsg;
|
|
505
|
-
for (const msg of msgs) {
|
|
506
|
-
if (msg.role === "user") {
|
|
507
|
-
const um = msg;
|
|
508
|
-
if (um.system)
|
|
509
|
-
dist.system += estimateTokens(um.system);
|
|
510
|
-
let parts = [];
|
|
511
|
-
try {
|
|
512
|
-
parts = props.api.state.part(msg.id);
|
|
513
|
-
}
|
|
514
|
-
catch { }
|
|
515
|
-
for (const p of parts) {
|
|
516
|
-
if (p.type === "text" && !p.synthetic && !p.ignored)
|
|
517
|
-
dist.user += estimateTokens(p.text);
|
|
518
|
-
else if (p.type === "file") {
|
|
519
|
-
const fp = p;
|
|
520
|
-
if (fp.source?.text?.value)
|
|
521
|
-
dist.user += estimateTokens(fp.source.text.value);
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
else if (msg.role === "assistant") {
|
|
526
|
-
const am = msg;
|
|
527
|
-
dist.output += num(am.tokens?.output);
|
|
528
|
-
dist.reasoning += num(am.tokens?.reasoning);
|
|
529
|
-
let parts = [];
|
|
530
|
-
try {
|
|
531
|
-
parts = props.api.state.part(msg.id);
|
|
532
|
-
}
|
|
533
|
-
catch { }
|
|
534
|
-
for (const p of parts) {
|
|
535
|
-
if (p.type === "tool") {
|
|
536
|
-
const tp = p;
|
|
537
|
-
let rawInput = "";
|
|
538
|
-
try {
|
|
539
|
-
rawInput = tp.state.raw ?? (tp.state.input != null ? JSON.stringify(tp.state.input) : "");
|
|
540
|
-
}
|
|
541
|
-
catch { }
|
|
542
|
-
if (rawInput)
|
|
543
|
-
dist.toolCall += estimateTokens(rawInput);
|
|
544
|
-
// 子代理委托(task 工具):任务描述计入子代理指令(1.15.x 无 subtask part)
|
|
545
|
-
if (tp.tool === "task" && tp.state?.input) {
|
|
546
|
-
const ti = tp.state.input;
|
|
547
|
-
const prompt = typeof ti.prompt === "string" ? ti.prompt : "";
|
|
548
|
-
const desc = typeof ti.description === "string" ? ti.description : "";
|
|
549
|
-
dist.agent += estimateTokens(prompt || desc);
|
|
550
|
-
}
|
|
551
|
-
if (tp.state.status === "completed") {
|
|
552
|
-
const c = tp.state;
|
|
553
|
-
if (c.output)
|
|
554
|
-
dist.toolResult += estimateTokens(c.output);
|
|
555
|
-
}
|
|
556
|
-
else if (tp.state.status === "error") {
|
|
557
|
-
const e = tp.state;
|
|
558
|
-
if (e.error)
|
|
559
|
-
dist.toolResult += estimateTokens(e.error);
|
|
560
|
-
}
|
|
561
|
-
if (tp.tool === "skill" && tp.state.status === "completed") {
|
|
562
|
-
// TUI SDK strips tool metadata — extract skill name from well-known output format.
|
|
563
|
-
// Cross-validated against api.client.app.skills() when available.
|
|
564
|
-
let name = tp.state.metadata?.name;
|
|
565
|
-
if (typeof name !== "string") {
|
|
566
|
-
const m = typeof tp.state.output === "string"
|
|
567
|
-
? tp.state.output.match(/^#{1,2}\s*Skill:\s*(.+)/m)
|
|
568
|
-
: null;
|
|
569
|
-
if (m)
|
|
570
|
-
name = m[1].trim();
|
|
571
|
-
}
|
|
572
|
-
if (typeof name === "string") {
|
|
573
|
-
const tokens = typeof tp.state.output === "string" ? estimateTokens(tp.state.output) : 0;
|
|
574
|
-
const existing = loadedSkills.get(name);
|
|
575
|
-
if (!existing || existing.tokens < tokens) {
|
|
576
|
-
loadedSkills.set(name, { name, tokens });
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
else if (p.type === "subtask") {
|
|
582
|
-
const sub = p;
|
|
583
|
-
dist.agent += estimateTokens(sub.prompt || sub.description || "");
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
// 从后往前找最后一条有 token 数据的 assistant 消息(避免取到 streaming 中未填充的消息)
|
|
589
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
590
|
-
if (msgs[i].role !== "assistant")
|
|
591
|
-
continue;
|
|
592
|
-
const tok = msgs[i].tokens;
|
|
593
|
-
if (tok && ((tok.input ?? 0) > 0 || (tok.cache?.read ?? 0) > 0 || (tok.cache?.write ?? 0) > 0)) {
|
|
594
|
-
lastAssMsg = msgs[i];
|
|
595
|
-
break;
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
// 取最后一条有数据消息的总输入(含缓存读/写)作为当前 context 大小
|
|
599
|
-
dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read) + num(lastAssMsg?.tokens?.cache?.write);
|
|
600
|
-
dist.apiOutput = num(lastAssMsg?.tokens?.output);
|
|
601
|
-
// 本回合(最后一条有数据消息所在的 parentID 链)的 API 调用次数与末次成本。
|
|
602
|
-
// opencode 将回合内每次工具调用循环拆为独立 assistant 消息(各含 1 个 step-finish),
|
|
603
|
-
// 故按 parentID 链聚合统计,而非单条消息。
|
|
604
|
-
if (lastAssMsg) {
|
|
605
|
-
const roundParent = lastAssMsg.parentID;
|
|
606
|
-
let lastCost;
|
|
607
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
608
|
-
const m = msgs[i];
|
|
609
|
-
if (m.role !== "assistant")
|
|
610
|
-
continue;
|
|
611
|
-
if (m.parentID !== roundParent)
|
|
612
|
-
break;
|
|
613
|
-
let parts = [];
|
|
614
|
-
try {
|
|
615
|
-
parts = props.api.state.part(m.id);
|
|
616
|
-
}
|
|
617
|
-
catch { }
|
|
618
|
-
for (const p of parts) {
|
|
619
|
-
if (p.type !== "step-finish")
|
|
620
|
-
continue;
|
|
621
|
-
dist.stepCount++;
|
|
622
|
-
const sc = p.cost;
|
|
623
|
-
if (lastCost === undefined && typeof sc === "number" && Number.isFinite(sc))
|
|
624
|
-
lastCost = sc;
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
if (lastCost !== undefined)
|
|
628
|
-
dist.stepCost = lastCost;
|
|
629
|
-
}
|
|
630
|
-
hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0 || dist.reasoning > 0;
|
|
631
|
-
}
|
|
632
|
-
catch { }
|
|
633
|
-
const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist();
|
|
634
|
-
const skills = [...loadedSkills.values()];
|
|
635
|
-
return { finalDist, finalHasDist, skills };
|
|
636
|
-
});
|
|
637
|
-
setDataSignal({
|
|
638
|
-
hitRate, read, write, freshInput: input, output, cost, saved, model,
|
|
639
|
-
inputRate, cacheReadRate, cacheWriteRate, hasPricing,
|
|
640
|
-
hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0,
|
|
641
|
-
trend, hasTrendData, providerName, sessionHitRate,
|
|
642
|
-
dist: distData.finalDist, hasDistData: distData.finalHasDist,
|
|
643
|
-
skills: distData.skills, hasSkills: distData.skills.length > 0,
|
|
644
|
-
});
|
|
645
|
-
});
|
|
646
|
-
const data = createMemo(() => {
|
|
647
|
-
return dataSignal();
|
|
648
|
-
});
|
|
649
|
-
// Persist the last valid distribution so that data() can fall back
|
|
650
|
-
// to it while api.state.part() is re-hydrating after a view switch.
|
|
651
|
-
createEffect(() => {
|
|
652
|
-
const d = data();
|
|
653
|
-
if (d.hasDistData) {
|
|
654
|
-
setLastDist({ ...d.dist });
|
|
655
|
-
setLastHasDist(true);
|
|
656
|
-
// Also persist across component remounts (view switches)
|
|
657
|
-
try {
|
|
658
|
-
props.api.kv.set(`${KV_PREFIX}.dist_snapshot`, { ...d.dist });
|
|
659
|
-
}
|
|
660
|
-
catch { }
|
|
661
|
-
}
|
|
662
|
-
});
|
|
663
|
-
// ── token distribution (in-process via api.state.part) ──
|
|
664
|
-
const [partVersion, setPartVersion] = createSignal(0);
|
|
665
|
-
// Persist fold state to api.kv
|
|
666
|
-
const KV_PREFIX = "cache_panel";
|
|
667
|
-
const persistFold = (key, val) => {
|
|
668
|
-
try {
|
|
669
|
-
props.api.kv.set(`${KV_PREFIX}.${key}`, val);
|
|
670
|
-
}
|
|
671
|
-
catch { }
|
|
672
|
-
};
|
|
673
|
-
onMount(() => {
|
|
674
|
-
// Reset panelWidth on (re)mount so the layout uses a clean
|
|
675
|
-
// default until onSizeChange measures the live box dimensions.
|
|
676
|
-
setPanelWidth(DEFAULT_PANEL_WIDTH);
|
|
677
|
-
// Restore fold state from persisted storage (non-critical — fire and forget)
|
|
678
|
-
try {
|
|
679
|
-
setOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.open`, false)));
|
|
680
|
-
setDetailOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.detail`, true)));
|
|
681
|
-
setModelOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.model`, true)));
|
|
682
|
-
setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)));
|
|
683
|
-
setSkillsOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.skills`, true)));
|
|
684
|
-
}
|
|
685
|
-
catch { }
|
|
686
|
-
// Restore user config (currency, rate, section visibility).
|
|
687
|
-
// Try synchronously first (kv is usually ready on mount), fall back to
|
|
688
|
-
// polling if the module was reloaded and kv hasn't initialised yet.
|
|
689
|
-
const doRestore = () => {
|
|
690
|
-
try {
|
|
691
|
-
const sym = props.api.kv.get(`${KV_PREFIX}.currency`);
|
|
692
|
-
const rate = props.api.kv.get(`${KV_PREFIX}.rate`);
|
|
693
|
-
if (typeof sym === "string")
|
|
694
|
-
setCurrencySymbol(sym);
|
|
695
|
-
if (typeof rate === "number" && rate > 0)
|
|
696
|
-
setExchangeRate(rate);
|
|
697
|
-
const balCur = props.api.kv.get(`${KV_PREFIX}.balance_currency`);
|
|
698
|
-
if (typeof balCur === "string")
|
|
699
|
-
setBalanceCurrency(balCur);
|
|
700
|
-
// Restore balance provider (fall back to default when unknown)
|
|
701
|
-
const savedProvider = props.api.kv.get(`${KV_PREFIX}.balance.provider`);
|
|
702
|
-
if (typeof savedProvider === "string" && balanceProviders.some((p) => p.id === savedProvider)) {
|
|
703
|
-
setBalanceProviderId(savedProvider);
|
|
704
|
-
setBalanceUnsupported(false);
|
|
705
|
-
}
|
|
706
|
-
// Restore auto-switch (default on)
|
|
707
|
-
const savedAuto = props.api.kv.get(`${KV_PREFIX}.balance.auto`);
|
|
708
|
-
if (typeof savedAuto === "boolean")
|
|
709
|
-
setAutoBalance(savedAuto);
|
|
710
|
-
// Migrate legacy DeepSeek key (cache_panel.ds_key → cache_panel.balance.deepseek.key)
|
|
711
|
-
const legacyKey = props.api.kv.get(`${KV_PREFIX}.ds_key`, "");
|
|
712
|
-
if (legacyKey) {
|
|
713
|
-
const dsKey = props.api.kv.get(`${KV_PREFIX}.balance.deepseek.key`, "");
|
|
714
|
-
if (!dsKey)
|
|
715
|
-
props.api.kv.set(`${KV_PREFIX}.balance.deepseek.key`, legacyKey);
|
|
716
|
-
props.api.kv.set(`${KV_PREFIX}.ds_key`, "");
|
|
717
|
-
}
|
|
718
|
-
// 恢复的 provider 可能与默认值不同,强制重新查询
|
|
719
|
-
props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1);
|
|
720
|
-
setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)));
|
|
721
|
-
setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)));
|
|
722
|
-
setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)));
|
|
723
|
-
setSectionSkills(Boolean(props.api.kv.get(`${KV_PREFIX}.section.skills`, true)));
|
|
724
|
-
setSectionBalance(Boolean(props.api.kv.get(`${KV_PREFIX}.section.balance`, true)));
|
|
725
|
-
const bv = props.api.kv.get(`${KV_PREFIX}.border`, true);
|
|
726
|
-
setBorderVisible(bv !== false);
|
|
727
|
-
// Restore distribution snapshot so the token distribution block
|
|
728
|
-
// doesn't blank out while api.state.part() re-hydrates.
|
|
729
|
-
const cachedDist = props.api.kv.get(`${KV_PREFIX}.dist_snapshot`);
|
|
730
|
-
if (cachedDist) {
|
|
731
|
-
setLastDist(cachedDist);
|
|
732
|
-
setLastHasDist(true);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
catch {
|
|
736
|
-
// kv read failed — signals stay at defaults
|
|
737
|
-
}
|
|
738
|
-
// Re-measure panel width after config signals have settled
|
|
739
|
-
if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
|
|
740
|
-
setPanelWidth(Math.max(MIN_PANEL_WIDTH, boxEl.width));
|
|
741
|
-
}
|
|
742
|
-
};
|
|
743
|
-
if (props.api.kv.ready) {
|
|
744
|
-
doRestore();
|
|
745
|
-
}
|
|
746
|
-
else {
|
|
747
|
-
// Poll kv.ready with a 1-second timeout to avoid infinite busy-wait
|
|
748
|
-
// on platforms where kv initialisation may be delayed (Linux single-thread
|
|
749
|
-
// mode, session switch storms, etc.).
|
|
750
|
-
const MAX_POLL = 100;
|
|
751
|
-
let tries = 0;
|
|
752
|
-
const pollRestore = () => {
|
|
753
|
-
if (!props.api.kv.ready) {
|
|
754
|
-
if (++tries > MAX_POLL) {
|
|
755
|
-
doRestore();
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
setTimeout(pollRestore, 10);
|
|
759
|
-
return;
|
|
760
|
-
}
|
|
761
|
-
doRestore();
|
|
762
|
-
};
|
|
763
|
-
pollRestore();
|
|
764
|
-
}
|
|
765
|
-
// Debounce partVersion updates so that event bursts during session
|
|
766
|
-
// switching / streaming don't cause data() to re-compute on every
|
|
767
|
-
// single event (up to hundreds per second on Linux single-thread).
|
|
768
|
-
let partTimer;
|
|
769
|
-
const bumpPartVersion = () => {
|
|
770
|
-
clearTimeout(partTimer);
|
|
771
|
-
partTimer = setTimeout(() => setPartVersion((v) => v + 1), 100);
|
|
772
|
-
};
|
|
773
|
-
const unsubPart = props.api.event.on("message.part.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1); });
|
|
774
|
-
const unsubMsg = props.api.event.on("message.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1); });
|
|
775
|
-
const unsubSession = props.api.event.on("session.updated", () => { setRefreshTick(v => v + 1); });
|
|
776
|
-
setRefreshTick(v => v + 1);
|
|
777
|
-
onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); unsubSession(); });
|
|
778
|
-
});
|
|
779
|
-
// ── colours ──
|
|
780
|
-
// Pull from the current theme, auto-desaturate if too punchy,
|
|
781
|
-
// fall back to Morandi when a key is missing from the theme.
|
|
782
|
-
const pal = createMemo(() => {
|
|
783
|
-
const t = props.theme;
|
|
784
|
-
const sat = (k, fb) => desaturateTo(t[k], MAX_SAT, fb);
|
|
785
|
-
return {
|
|
786
|
-
primary: sat("primary", FALLBACK.primary),
|
|
787
|
-
text: sat("text", FALLBACK.text),
|
|
788
|
-
muted: sat("textMuted", FALLBACK.muted),
|
|
789
|
-
success: sat("success", FALLBACK.success),
|
|
790
|
-
warning: sat("warning", FALLBACK.warning),
|
|
791
|
-
error: sat("error", FALLBACK.error),
|
|
792
|
-
border: sat("border", FALLBACK.border),
|
|
793
|
-
};
|
|
794
|
-
});
|
|
795
|
-
const hitColor = createMemo(() => {
|
|
796
|
-
const r = data().hitRate;
|
|
797
|
-
if (r >= 85)
|
|
798
|
-
return pal().success;
|
|
799
|
-
if (r >= 70)
|
|
800
|
-
return pal().warning;
|
|
801
|
-
return pal().error;
|
|
802
|
-
});
|
|
803
|
-
/** Horizontal space eaten by border (1+1 when visible) + padding (2+2 when visible). */
|
|
804
|
-
const gutter = createMemo(() => borderVisible() ? 6 : 0);
|
|
805
|
-
const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter())));
|
|
806
|
-
function trendLabel(t) {
|
|
807
|
-
// |t| < 0.05 视为无变化:避免显示 "↑0.0%" 的矛盾(箭头存在但数值截断为零)
|
|
808
|
-
if (Math.abs(t) < 0.05)
|
|
809
|
-
return "-";
|
|
810
|
-
return (t > 0 ? "\u2191" : "\u2193") + Math.abs(t).toFixed(1) + "%";
|
|
811
|
-
}
|
|
812
|
-
const barW = createMemo(() => {
|
|
813
|
-
const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0;
|
|
814
|
-
const overhead = visualWidth(t("hit")) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter();
|
|
815
|
-
return Math.max(3, panelWidth() - overhead);
|
|
816
|
-
});
|
|
817
|
-
const bar = createMemo(() => progressBar(data().hitRate, barW()));
|
|
818
|
-
const pct = createMemo(() => (Math.floor(data().hitRate * 10) / 10).toFixed(1) + "%");
|
|
819
|
-
// When border visibility changes the box dimensions shift, which
|
|
820
|
-
// may not reliably trigger onSizeChange across (re)mount cycles.
|
|
821
|
-
// Force panelWidth to resync with the live box after every change.
|
|
822
|
-
createEffect(() => {
|
|
823
|
-
borderVisible();
|
|
824
|
-
if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
|
|
825
|
-
const w = Math.max(MIN_PANEL_WIDTH, boxEl.width);
|
|
826
|
-
setPanelWidth((prev) => (prev === w ? prev : w));
|
|
827
|
-
}
|
|
828
|
-
});
|
|
829
|
-
// left-align label, right-align value — auto-fill space between
|
|
830
|
-
const justify = (label, value, unit = "") => {
|
|
831
|
-
const gauge = panelWidth() - gutter();
|
|
832
|
-
const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0);
|
|
833
|
-
const gap = Math.max(1, gauge - used);
|
|
834
|
-
return label + " ".repeat(gap) + value + (unit ? " " + unit : "");
|
|
835
|
-
};
|
|
836
|
-
return (_jsxs("box", { border: borderVisible(), ...(borderVisible() ? { borderColor: pal().border } : {}), paddingTop: 0, paddingBottom: 0, paddingLeft: borderVisible() ? 2 : 0, paddingRight: borderVisible() ? 2 : 0, flexDirection: "column", gap: 0, ref: boxEl, onSizeChange: () => {
|
|
837
|
-
// boxEl.width may be undefined before the first measurement — guard with 0
|
|
838
|
-
const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width ?? 0) : DEFAULT_PANEL_WIDTH;
|
|
839
|
-
setPanelWidth((prev) => (prev === w ? prev : w));
|
|
840
|
-
}, children: [_jsxs("text", { onMouseUp: () => setOpen((o) => { const n = !o; persistFold("open", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: open() ? "\u25bc " : "\u25b6 " }), _jsxs("span", { style: { fg: pal().primary }, children: [_jsx("b", { children: t("title") }), _jsx(Show, { when: open(), children: _jsxs("span", { style: { fg: dimColor(pal().muted, 0.75) }, children: [" v", PLUGIN_VERSION] }) })] }), _jsxs(Show, { when: !open() && data().hasData, children: [_jsxs(Show, { when: data().hasTrendData, children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t("title")) - visualWidth(pct() + " " + t("hitFolded") + " " + trendLabel(data().trend)))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", t("hitFolded")] }), _jsxs("span", { style: { fg: Math.abs(data().trend) >= 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }, children: [" ", trendLabel(data().trend)] })] }), _jsxs(Show, { when: !data().hasTrendData, children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t("title")) - visualWidth(pct() + " " + t("hitFolded")))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", t("hitFolded")] })] })] })] }), _jsxs(Show, { when: open(), children: [_jsx(Show, { when: props.signals.overrideSessionId(), children: (() => {
|
|
841
|
-
const prefix = " \u21b3 " + t("subPrefix");
|
|
842
|
-
const maxSidW = Math.max(6, panelWidth() - visualWidth(prefix));
|
|
843
|
-
return (_jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: prefix }), _jsx("span", { style: { fg: pal().text }, children: truncateVisual(props.signals.overrideSessionId(), maxSidW) })] }));
|
|
844
|
-
})() }), _jsxs(Show, { when: data().hasData, fallback: _jsxs(_Fragment, { children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { style: { fg: pal().muted }, children: t("noData") })] })] }), children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsxs("text", { children: [_jsxs("span", { style: { fg: pal().text }, children: [t("hit"), " "] }), _jsxs("span", { style: { fg: hitColor() }, children: ["[", bar(), "] "] }), _jsx("span", { style: { fg: pal().text }, children: pct() }), _jsx(Show, { when: data().hasTrendData, children: _jsxs("span", { style: { fg: Math.abs(data().trend) >= 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }, children: [" ", trendLabel(data().trend)] }) })] }), _jsx("text", { fg: pal().muted, children: justify(t("totalHit"), (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%") }), _jsxs(Show, { when: sectionDetail(), children: [_jsxs("text", { onMouseUp: () => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: detailOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: t("secDetail") }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + t("secDetail"))) })] }), _jsxs(Show, { when: detailOpen(), children: [_jsx(Show, { when: data().read > 0, children: _jsx("text", { fg: pal().muted, children: justify(t("read"), fmt(data().read), t("tok")) }) }), _jsx(Show, { when: data().write > 0, children: _jsx("text", { fg: pal().muted, children: justify(t("write"), fmt(data().write), t("tok")) }) }), _jsx("text", { fg: pal().muted, children: justify(t("miss"), fmt(data().freshInput + data().write), t("tok")) }), _jsx("text", { fg: pal().muted, children: justify(t("out"), fmt(data().output), t("tok")) }), _jsx(Show, { when: data().dist.stepCount >= 2, children: _jsx("text", { fg: pal().muted, children: justify(t("stepsCount", { n: data().dist.stepCount }), fmtCost(data().dist.stepCost, currencySymbol(), exchangeRate())) }) }), _jsx(Show, { when: data().saved > 0, children: _jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: t("saved") }), _jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(t("saved")) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate())))) }), _jsxs("span", { style: { fg: pal().success }, children: ["~", fmtCost(data().saved, currencySymbol(), exchangeRate())] })] }) })] })] }), _jsxs(Show, { when: sectionModel(), children: [_jsxs("text", { onMouseUp: () => setModelOpen((o) => { const n = !o; persistFold("model", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: modelOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: t("secModel") }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + t("secModel"))) })] }), _jsxs(Show, { when: modelOpen(), children: [_jsx("text", { fg: pal().text, children: justify(t("cost"), fmtCost(data().cost, currencySymbol(), exchangeRate())) }), _jsx(Show, { when: data().providerName, children: _jsx("text", { fg: pal().muted, children: justify(t("provider"), data().providerName) }) }), _jsx("text", { fg: pal().muted, children: justify(t("model"), data().model) }), _jsxs(Show, { when: data().hasPricing, children: [_jsx("text", { fg: pal().muted, children: justify(t("rate"), currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + t("inputRate")) }), _jsx(Show, { when: data().cacheReadRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t("cacheRate")) }) }), _jsx(Show, { when: data().cacheWriteRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t("writeRate")) }) })] })] })] }), _jsx(Show, { when: sectionDist(), children: _jsxs(Show, { when: data().hasDistData, children: [_jsxs("text", { onMouseUp: () => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: distOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: t("distTitle") }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + t("distTitle"))) })] }), _jsxs(Show, { when: distOpen(), children: [_jsx(Show, { when: data().dist.system > 0, children: _jsx("text", { fg: pal().muted, children: justify(t("distSys"), fmt(data().dist.system), t("tok")) }) }), _jsx(Show, { when: data().dist.user > 0, children: _jsx("text", { fg: pal().muted, children: justify(t("distUser"), fmt(data().dist.user), t("tok")) }) }), _jsx(Show, { when: data().dist.agent > 0, children: _jsx("text", { fg: pal().muted, children: justify(t("distAgent"), fmt(data().dist.agent), t("tok")) }) }), _jsx(Show, { when: data().dist.toolCall > 0, children: _jsx("text", { fg: pal().muted, children: justify(t("distTool"), fmt(data().dist.toolCall), t("tok")) }) }), _jsx(Show, { when: data().dist.toolResult > 0, children: _jsx("text", { fg: pal().muted, children: justify(t("distRes"), fmt(data().dist.toolResult), t("tok")) }) }), _jsx(Show, { when: data().dist.reasoning > 0, children: _jsx("text", { fg: pal().muted, children: justify(t("distReason"), fmt(data().dist.reasoning), t("tok")) }) })] })] }) }), _jsx(Show, { when: sectionSkills(), children: _jsxs(Show, { when: data().hasSkills, children: [_jsxs("text", { onMouseUp: () => setSkillsOpen((o) => { const n = !o; persistFold("skills", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: skillsOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: t("secSkills") }) }), _jsxs("span", { style: { fg: pal().muted }, children: [" (", data().skills.length, ")"] }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t("secSkills") + ` (${data().skills.length})`)) })] }), _jsx(Show, { when: skillsOpen(), children: data().skills.map((sk) => {
|
|
845
|
-
const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t("tok"));
|
|
846
|
-
const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1);
|
|
847
|
-
const label = truncateVisual(sk.name, maxLabel);
|
|
848
|
-
return (_jsx("text", { fg: pal().muted, children: justify(label, fmt(sk.tokens), t("tok")) }));
|
|
849
|
-
}) })] }) }), _jsxs(Show, { when: sectionBalance(), children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsx(Show, { when: balanceUnsupported(), children: _jsxs("text", { fg: pal().muted, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: t("balUnsupported") })] }) }), _jsxs(Show, { when: !balanceUnsupported(), children: [_jsx(Show, { when: balanceState().status === "idle", children: _jsxs("text", { fg: pal().muted, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: t("balNoKey", { p: providerName() }) })] }) }), _jsx(Show, { when: balanceState().status === "loading", children: _jsxs("text", { fg: pal().muted, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: t("balLoading") })] }) }), _jsx(Show, { when: balanceState().status === "error", children: _jsxs("text", { fg: pal().error, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: (() => {
|
|
850
|
-
const code = balanceState().error;
|
|
851
|
-
if (code === "401")
|
|
852
|
-
return t("balErr401");
|
|
853
|
-
if (code === "403")
|
|
854
|
-
return t("balErr403");
|
|
855
|
-
if (code === "EMPTY")
|
|
856
|
-
return t("balErrEmpty");
|
|
857
|
-
if (code === "TIMEOUT")
|
|
858
|
-
return t("balErrTimeout");
|
|
859
|
-
return t("balError") + (code ? ` (${code})` : "");
|
|
860
|
-
})() })] }) }), _jsx(Show, { when: balanceState().status === "ok" && balanceState().data, children: _jsx("text", { fg: pal().text, children: justify(t("balTotal"), formatBalanceText(balanceState().data, balanceCurrency(), exchangeRate())) }) })] })] })] })] })] }));
|
|
861
|
-
}
|
|
862
56
|
// ---------------------------------------------------------------------------
|
|
863
57
|
// Plugin entry
|
|
864
58
|
// ---------------------------------------------------------------------------
|