opencode-visual-cache 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/README_EN.md +10 -4
- package/dist/_version.d.ts +1 -1
- package/dist/_version.js +1 -1
- package/dist/i18n.d.ts +102 -0
- package/dist/i18n.js +368 -0
- package/dist/index.js +420 -254
- package/dist/tui.js +1086 -411
- package/package.json +1 -1
- package/src/_version.ts +1 -1
- package/src/i18n.ts +380 -0
- package/src/index.tsx +499 -304
package/src/index.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
TuiPluginModule,
|
|
10
10
|
TuiThemeCurrent,
|
|
11
11
|
TuiDialogStack,
|
|
12
|
+
TuiPromptRef,
|
|
12
13
|
} from "@opencode-ai/plugin/tui"
|
|
13
14
|
import type { UserMessage, AssistantMessage, Message } from "@opencode-ai/sdk"
|
|
14
15
|
import type {
|
|
@@ -21,6 +22,7 @@ import type {
|
|
|
21
22
|
import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, untrack } from "solid-js"
|
|
22
23
|
import { PLUGIN_VERSION } from "./_version"
|
|
23
24
|
import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry, type BalanceProvider } from "./balance-providers"
|
|
25
|
+
import { LANG_META, createT, detectLang, type LangCode } from "./i18n"
|
|
24
26
|
|
|
25
27
|
// ---------------------------------------------------------------------------
|
|
26
28
|
// Helpers
|
|
@@ -73,97 +75,14 @@ function truncateVisual(s: string, maxCols: number): string {
|
|
|
73
75
|
return result
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
// ── language override (env: CACHE_TUI_LANG) ──
|
|
77
|
-
const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined
|
|
78
|
-
|
|
79
78
|
// ── language ──────────────────────────────────────────────────────
|
|
79
|
+
// 语言初始化:环境变量 CACHE_TUI_LANG 覆盖 → 否则按系统 locale 自动检测。
|
|
80
|
+
// 用户通过 /cache-lang 设置的偏好会在 KV 就绪后优先覆盖(见 tui() 内恢复逻辑)。
|
|
80
81
|
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
catch { return false }
|
|
86
|
-
})()
|
|
87
|
-
|
|
88
|
-
const ZH_T = {
|
|
89
|
-
title: "缓存统计",
|
|
90
|
-
hit: "命中率",
|
|
91
|
-
totalHit: "总命中:",
|
|
92
|
-
read: "缓存读:",
|
|
93
|
-
write: "缓存写:",
|
|
94
|
-
miss: "未命中:",
|
|
95
|
-
out: "输出:",
|
|
96
|
-
cost: "费用:",
|
|
97
|
-
saved: "累计节省:",
|
|
98
|
-
model: "模型:",
|
|
99
|
-
provider: "提供商:",
|
|
100
|
-
rate: "单价:",
|
|
101
|
-
hitFolded: "命中",
|
|
102
|
-
inputRate: "输入",
|
|
103
|
-
cacheRate: "缓存",
|
|
104
|
-
writeRate: "写入",
|
|
105
|
-
noData: "等待缓存数据...",
|
|
106
|
-
tok: "tok",
|
|
107
|
-
distTitle: "估算 Token 分布",
|
|
108
|
-
distSys: "系统提示:",
|
|
109
|
-
distUser: "用户:",
|
|
110
|
-
distAgent: "Agent 指令:",
|
|
111
|
-
distTool: "Tool 调用:",
|
|
112
|
-
distRes: "Tool 结果:",
|
|
113
|
-
distTotal: "总计:",
|
|
114
|
-
distOut: "输出:",
|
|
115
|
-
secDetail: "明细",
|
|
116
|
-
secModel: "模型",
|
|
117
|
-
secSkills: "已加载技能",
|
|
118
|
-
balTotal: "总余额:",
|
|
119
|
-
balNoKey: "未配置 {p} API Key",
|
|
120
|
-
balLoading: "查询中...",
|
|
121
|
-
balError: "查询失败",
|
|
122
|
-
balErr401: "API Key 无效",
|
|
123
|
-
balErr403: "余额查询被拒绝",
|
|
124
|
-
balErrEmpty:"未获取到余额数据",
|
|
125
|
-
balErrTimeout: "查询超时",
|
|
126
|
-
} as const
|
|
127
|
-
|
|
128
|
-
const EN_T = {
|
|
129
|
-
title: "Token Cache",
|
|
130
|
-
hit: "Hit",
|
|
131
|
-
totalHit: "Total Hit:",
|
|
132
|
-
read: "Read:",
|
|
133
|
-
write: "Write:",
|
|
134
|
-
miss: "Miss:",
|
|
135
|
-
out: "Out:",
|
|
136
|
-
cost: "Cost:",
|
|
137
|
-
saved: "Total Saved:",
|
|
138
|
-
model: "Model:",
|
|
139
|
-
provider: "Provider:",
|
|
140
|
-
rate: "Rate:",
|
|
141
|
-
hitFolded: "hit",
|
|
142
|
-
inputRate: "in",
|
|
143
|
-
cacheRate: "cache",
|
|
144
|
-
writeRate: "write",
|
|
145
|
-
noData: "Waiting for cache data...",
|
|
146
|
-
tok: "tok",
|
|
147
|
-
distTitle: "Estimated Token Dist.",
|
|
148
|
-
distSys: "System:",
|
|
149
|
-
distUser: "User:",
|
|
150
|
-
distAgent: "Agent Instr:",
|
|
151
|
-
distTool: "Tool Call:",
|
|
152
|
-
distRes: "Tool Result:",
|
|
153
|
-
distTotal: "Total:",
|
|
154
|
-
distOut: "Output:",
|
|
155
|
-
secDetail: "Detail",
|
|
156
|
-
secModel: "Model",
|
|
157
|
-
secSkills: "Loaded Skills",
|
|
158
|
-
balTotal: "Total:",
|
|
159
|
-
balNoKey: "{p} API Key not set",
|
|
160
|
-
balLoading: "Fetching...",
|
|
161
|
-
balError: "Fetch failed",
|
|
162
|
-
balErr401: "Invalid API Key",
|
|
163
|
-
balErr403: "Balance request rejected",
|
|
164
|
-
balErrEmpty:"No balance data",
|
|
165
|
-
balErrTimeout: "Request timed out",
|
|
166
|
-
} as const
|
|
82
|
+
const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined
|
|
83
|
+
const INIT_LANG: LangCode = DEBUG_LANG !== undefined && LANG_META.some((m) => m.code === DEBUG_LANG)
|
|
84
|
+
? (DEBUG_LANG as LangCode)
|
|
85
|
+
: detectLang()
|
|
167
86
|
|
|
168
87
|
// ── color helpers ────────────────────────────────────────────────
|
|
169
88
|
|
|
@@ -224,7 +143,7 @@ function desaturateTo(raw: unknown, maxSat: number, fallback: string): string {
|
|
|
224
143
|
* converges to within a fraction of an 8‑bit step, eliminating
|
|
225
144
|
* colour banding in edge cases.
|
|
226
145
|
*/
|
|
227
|
-
//
|
|
146
|
+
// Bt.601 luma (perceptual brightness used as the grey anchor)
|
|
228
147
|
const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114
|
|
229
148
|
let lo = 0, hi = 1
|
|
230
149
|
for (let i = 0; i < 12; i++) {
|
|
@@ -345,7 +264,7 @@ interface TokenDist {
|
|
|
345
264
|
toolResult: number // ToolPart completed output / error
|
|
346
265
|
output: number // AssistantMessage.tokens.output (fallback)
|
|
347
266
|
apiOutput: number // StepFinishPart.tokens.output (API exact, preferred)
|
|
348
|
-
apiInput: number //
|
|
267
|
+
apiInput: number // API exact total input context (input + cache read + cache write)
|
|
349
268
|
stepCost: number
|
|
350
269
|
}
|
|
351
270
|
|
|
@@ -401,6 +320,39 @@ function balanceSymbol(currency: string): string {
|
|
|
401
320
|
return sym ?? currency + " "
|
|
402
321
|
}
|
|
403
322
|
|
|
323
|
+
/** 紧凑数字缩写(底部状态栏用):1234 → "1.2K",1234567 → "1.2M"。 */
|
|
324
|
+
function fmtCompact(n: number): string {
|
|
325
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"
|
|
326
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"
|
|
327
|
+
return String(Math.round(n))
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 余额数值格式化:≥1 或 0 显示固定 2 位小数;小额(<1)保留精度(最多 6 位),避免抹成 0.00。 */
|
|
331
|
+
function formatBalanceAmount(total: string): string {
|
|
332
|
+
const n = parseFloat(total)
|
|
333
|
+
if (!Number.isFinite(n)) return total
|
|
334
|
+
if (n === 0 || n >= 1) return n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
335
|
+
return n.toLocaleString("en-US", { maximumFractionDigits: 6 })
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* 将余额列表格式化为单行文本。
|
|
340
|
+
* 优先直接显示偏好币种(CNY/USD…);偏好币种为换算币种时按汇率折算第一条余额。
|
|
341
|
+
*/
|
|
342
|
+
function formatBalanceText(list: BalanceEntry[], pref: string, rate: number): string {
|
|
343
|
+
const native = pref ? list.find((x) => x.currency === pref) : undefined
|
|
344
|
+
if (native) return balanceSymbol(native.currency) + formatBalanceAmount(native.total)
|
|
345
|
+
const base = list[0]
|
|
346
|
+
const baseAmt = parseFloat(base.total)
|
|
347
|
+
const converted = Number.isFinite(baseAmt)
|
|
348
|
+
? convertBalance(pref || base.currency, rate, baseAmt, base.currency)
|
|
349
|
+
: baseAmt
|
|
350
|
+
const shown = pref && base.currency !== pref
|
|
351
|
+
? converted.toLocaleString("en-US", { maximumFractionDigits: 2 })
|
|
352
|
+
: formatBalanceAmount(base.total)
|
|
353
|
+
return balanceSymbol(pref || base.currency) + shown
|
|
354
|
+
}
|
|
355
|
+
|
|
404
356
|
// ---------------------------------------------------------------------------
|
|
405
357
|
// Sidebar component
|
|
406
358
|
// ---------------------------------------------------------------------------
|
|
@@ -413,8 +365,8 @@ interface PanelSignals {
|
|
|
413
365
|
setCurrencySymbol: (v: string) => void
|
|
414
366
|
exchangeRate: () => number
|
|
415
367
|
setExchangeRate: (v: number) => void
|
|
416
|
-
|
|
417
|
-
|
|
368
|
+
langCode: () => LangCode
|
|
369
|
+
setLangCode: (v: LangCode) => void
|
|
418
370
|
sectionDetail: () => boolean
|
|
419
371
|
setSectionDetail: (v: boolean) => void
|
|
420
372
|
sectionModel: () => boolean
|
|
@@ -425,6 +377,9 @@ interface PanelSignals {
|
|
|
425
377
|
setSectionSkills: (v: boolean) => void
|
|
426
378
|
sectionBalance: () => boolean
|
|
427
379
|
setSectionBalance: (v: boolean) => void
|
|
380
|
+
/** Bottom status bar (prompt hint line) visibility. */
|
|
381
|
+
sectionBottom: () => boolean
|
|
382
|
+
setSectionBottom: (v: boolean) => void
|
|
428
383
|
/** Increment to force a balance re-fetch. */
|
|
429
384
|
balanceRefresh: () => number
|
|
430
385
|
setBalanceRefresh: (v: number) => void
|
|
@@ -434,6 +389,11 @@ interface PanelSignals {
|
|
|
434
389
|
/** Auto-switch to the session's provider for balance display. Manual switch disables it. */
|
|
435
390
|
autoBalance: () => boolean
|
|
436
391
|
setAutoBalance: (v: boolean) => void
|
|
392
|
+
/** True when the session's provider has no balance adapter (auto mode). Suppresses balance polling. */
|
|
393
|
+
balanceUnsupported: () => boolean
|
|
394
|
+
setBalanceUnsupported: (v: boolean) => void
|
|
395
|
+
/** Shared balance query state — single source of truth for sidebar and bottom bar. */
|
|
396
|
+
balanceState: () => BalanceState
|
|
437
397
|
/** Preferred currency code for balance display (CNY / USD / …). Empty = first entry. */
|
|
438
398
|
balanceCurrency: () => string
|
|
439
399
|
setBalanceCurrency: (v: string) => void
|
|
@@ -483,7 +443,7 @@ function TokenCachePanel(props: {
|
|
|
483
443
|
const {
|
|
484
444
|
currencySymbol, setCurrencySymbol,
|
|
485
445
|
exchangeRate, setExchangeRate,
|
|
486
|
-
|
|
446
|
+
langCode,
|
|
487
447
|
sectionDetail, setSectionDetail,
|
|
488
448
|
sectionModel, setSectionModel,
|
|
489
449
|
sectionDist, setSectionDist,
|
|
@@ -492,12 +452,14 @@ function TokenCachePanel(props: {
|
|
|
492
452
|
balanceRefresh,
|
|
493
453
|
balanceProviderId, setBalanceProviderId,
|
|
494
454
|
autoBalance, setAutoBalance,
|
|
455
|
+
balanceUnsupported, setBalanceUnsupported,
|
|
456
|
+
balanceState,
|
|
495
457
|
balanceCurrency, setBalanceCurrency,
|
|
496
458
|
borderVisible, setBorderVisible,
|
|
497
459
|
} = props.signals
|
|
498
460
|
|
|
499
|
-
// ── reactive translation (follows
|
|
500
|
-
const t =
|
|
461
|
+
// ── reactive translation (follows langCode signal) ──
|
|
462
|
+
const t = createT(() => langCode())
|
|
501
463
|
|
|
502
464
|
// ── scan session messages reactively ──
|
|
503
465
|
// SolidJS createMemo re-evaluates whenever the underlying
|
|
@@ -526,54 +488,9 @@ function TokenCachePanel(props: {
|
|
|
526
488
|
})
|
|
527
489
|
const [refreshTick, setRefreshTick] = createSignal(0)
|
|
528
490
|
|
|
529
|
-
//
|
|
530
|
-
const [balanceState, setBalanceState] = createSignal<BalanceState>({
|
|
531
|
-
status: "idle", data: null, lastFetch: 0,
|
|
532
|
-
})
|
|
533
|
-
// 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果
|
|
534
|
-
let balanceSeq = 0
|
|
535
|
-
|
|
536
|
-
// 当前 provider 显示名
|
|
491
|
+
// 当前 provider 显示名(余额查询状态为共享信号,见 PanelSignals.balanceState)
|
|
537
492
|
const providerName = createMemo(() => getBalanceProvider(balanceProviderId()).name)
|
|
538
493
|
|
|
539
|
-
const pollBalance = async () => {
|
|
540
|
-
const provider = getBalanceProvider(balanceProviderId())
|
|
541
|
-
// 手动配置的 key 优先;缺失时自动复用 OpenCode 已认证的 key(auth.json / config)
|
|
542
|
-
const key = props.api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
|
|
543
|
-
|| findOpencodeKey(props.api, provider)
|
|
544
|
-
if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
|
|
545
|
-
const now = Date.now()
|
|
546
|
-
const prev = balanceState()
|
|
547
|
-
// key 已更换(重新输入)→ 强制重新查询,绕过缓存
|
|
548
|
-
if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS) return // cache still fresh
|
|
549
|
-
const seq = ++balanceSeq
|
|
550
|
-
setBalanceState({ ...prev, status: "loading", error: undefined, key })
|
|
551
|
-
const controller = new AbortController()
|
|
552
|
-
let timedOut = false
|
|
553
|
-
const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000)
|
|
554
|
-
try {
|
|
555
|
-
const data = await provider.fetchBalance(key, controller.signal)
|
|
556
|
-
clearTimeout(timer)
|
|
557
|
-
if (seq !== balanceSeq) return // 已被更新的请求取代,丢弃过期结果
|
|
558
|
-
setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key })
|
|
559
|
-
} catch (err) {
|
|
560
|
-
clearTimeout(timer)
|
|
561
|
-
if (seq !== balanceSeq) return
|
|
562
|
-
const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "")
|
|
563
|
-
// 失败时清空旧数据,避免显示过期余额
|
|
564
|
-
setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key })
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// Re-fetch when the API key is (re)configured via /cache-balance-key.
|
|
569
|
-
// 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹,
|
|
570
|
-
// effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState
|
|
571
|
-
// 形成无限循环(每次重跑都发起新的 fetch 请求)。
|
|
572
|
-
createEffect(() => {
|
|
573
|
-
void balanceRefresh()
|
|
574
|
-
untrack(() => { void pollBalance() })
|
|
575
|
-
})
|
|
576
|
-
|
|
577
494
|
// 自动切换当前会话的 provider(前缀匹配)。手动切换会关闭此行为。
|
|
578
495
|
// 直接追踪 messages 取最后一条 assistant 消息的 providerID——
|
|
579
496
|
// 不依赖 session.model 的响应式更新(模型切换时该链路可能不触发重算)。
|
|
@@ -599,9 +516,15 @@ function TokenCachePanel(props: {
|
|
|
599
516
|
}
|
|
600
517
|
if (!pid) return
|
|
601
518
|
const hit = matchBalanceProvider(pid)
|
|
602
|
-
if (hit
|
|
603
|
-
|
|
604
|
-
|
|
519
|
+
if (hit) {
|
|
520
|
+
setBalanceUnsupported(false)
|
|
521
|
+
if (hit.id !== balanceProviderId()) {
|
|
522
|
+
setBalanceProviderId(hit.id)
|
|
523
|
+
props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
|
|
524
|
+
}
|
|
525
|
+
} else {
|
|
526
|
+
// 当前提供商没有余额适配器 → 标记不支持,余额显示 N/A 并停止轮询
|
|
527
|
+
setBalanceUnsupported(true)
|
|
605
528
|
}
|
|
606
529
|
})
|
|
607
530
|
|
|
@@ -646,11 +569,11 @@ function TokenCachePanel(props: {
|
|
|
646
569
|
let prevMsgHitRate = -1, lastMsgHitRate = -1
|
|
647
570
|
for (const msg of msgs) {
|
|
648
571
|
if (msg.role !== "assistant") continue
|
|
649
|
-
const
|
|
650
|
-
const mit = num(
|
|
572
|
+
const tok = (msg as AssistantMessage).tokens; if (!tok) continue
|
|
573
|
+
const mit = num(tok.input) + num(tok.cache?.read) + num(tok.cache?.write), mrt = num(tok.cache?.read)
|
|
651
574
|
if (mit > 0) { prevMsgHitRate = lastMsgHitRate; lastMsgHitRate = (mrt / mit) * 100 }
|
|
652
575
|
if (fallbackTokens) {
|
|
653
|
-
input += num(
|
|
576
|
+
input += num(tok.input); read += num(tok.cache?.read); write += num(tok.cache?.write); output += num(tok.output)
|
|
654
577
|
}
|
|
655
578
|
if (fallbackCost) {
|
|
656
579
|
cost += num((msg as AssistantMessage).cost)
|
|
@@ -668,7 +591,8 @@ function TokenCachePanel(props: {
|
|
|
668
591
|
break
|
|
669
592
|
}
|
|
670
593
|
const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0
|
|
671
|
-
|
|
594
|
+
// 总命中率分母含缓存写(业界口径:read / (input+read+write))
|
|
595
|
+
const freshTotal = input + read + write, sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0
|
|
672
596
|
const model = mid.split("/").pop() ?? mid, hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0
|
|
673
597
|
const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0
|
|
674
598
|
const trend = hasTrendData ? lastMsgHitRate - prevMsgHitRate : 0, providerName = pid || ""
|
|
@@ -731,11 +655,11 @@ function TokenCachePanel(props: {
|
|
|
731
655
|
// 从后往前找最后一条有 token 数据的 assistant 消息(避免取到 streaming 中未填充的消息)
|
|
732
656
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
733
657
|
if (msgs[i].role !== "assistant") continue
|
|
734
|
-
const
|
|
735
|
-
if (
|
|
658
|
+
const tok = (msgs[i] as AssistantMessage).tokens
|
|
659
|
+
if (tok && ((tok.input ?? 0) > 0 || (tok.cache?.read ?? 0) > 0 || (tok.cache?.write ?? 0) > 0)) { lastAssMsg = msgs[i] as AssistantMessage; break }
|
|
736
660
|
}
|
|
737
|
-
//
|
|
738
|
-
dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read)
|
|
661
|
+
// 取最后一条有数据消息的总输入(含缓存读/写)作为当前 context 大小
|
|
662
|
+
dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read) + num(lastAssMsg?.tokens?.cache?.write)
|
|
739
663
|
dist.apiOutput = num(lastAssMsg?.tokens?.output)
|
|
740
664
|
hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0
|
|
741
665
|
} catch {}
|
|
@@ -808,6 +732,7 @@ function TokenCachePanel(props: {
|
|
|
808
732
|
const savedProvider = props.api.kv.get<string>(`${KV_PREFIX}.balance.provider`)
|
|
809
733
|
if (typeof savedProvider === "string" && balanceProviders.some((p) => p.id === savedProvider)) {
|
|
810
734
|
setBalanceProviderId(savedProvider)
|
|
735
|
+
setBalanceUnsupported(false)
|
|
811
736
|
}
|
|
812
737
|
// Restore auto-switch (default on)
|
|
813
738
|
const savedAuto = props.api.kv.get<boolean>(`${KV_PREFIX}.balance.auto`)
|
|
@@ -828,11 +753,6 @@ function TokenCachePanel(props: {
|
|
|
828
753
|
setSectionBalance(Boolean(props.api.kv.get(`${KV_PREFIX}.section.balance`, true)))
|
|
829
754
|
const bv = props.api.kv.get<boolean>(`${KV_PREFIX}.border`, true)
|
|
830
755
|
setBorderVisible(bv !== false)
|
|
831
|
-
// Restore language preference
|
|
832
|
-
const savedLang = props.api.kv.get<string>(`${KV_PREFIX}.lang`)
|
|
833
|
-
if (savedLang === "zh" || savedLang === "en") {
|
|
834
|
-
setLangZH(savedLang === "zh")
|
|
835
|
-
}
|
|
836
756
|
// Restore distribution snapshot so the token distribution block
|
|
837
757
|
// doesn't blank out while api.state.part() re-hydrates.
|
|
838
758
|
const cachedDist = props.api.kv.get<TokenDist>(`${KV_PREFIX}.dist_snapshot`)
|
|
@@ -880,8 +800,7 @@ function TokenCachePanel(props: {
|
|
|
880
800
|
const unsubMsg = props.api.event.on("message.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) })
|
|
881
801
|
const unsubSession = props.api.event.on("session.updated", () => { setRefreshTick(v => v + 1) })
|
|
882
802
|
setRefreshTick(v => v + 1)
|
|
883
|
-
|
|
884
|
-
onCleanup(() => { clearTimeout(partTimer); clearInterval(balanceTimer); unsubPart(); unsubMsg(); unsubSession() })
|
|
803
|
+
onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); unsubSession() })
|
|
885
804
|
})
|
|
886
805
|
|
|
887
806
|
// ── colours ──
|
|
@@ -913,12 +832,14 @@ function TokenCachePanel(props: {
|
|
|
913
832
|
|
|
914
833
|
const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter())))
|
|
915
834
|
function trendLabel(t: number): string {
|
|
916
|
-
|
|
835
|
+
// |t| < 0.05 视为无变化:避免显示 "↑0.0%" 的矛盾(箭头存在但数值截断为零)
|
|
836
|
+
if (Math.abs(t) < 0.05) return "-"
|
|
837
|
+
return (t > 0 ? "\u2191" : "\u2193") + Math.abs(t).toFixed(1) + "%"
|
|
917
838
|
}
|
|
918
839
|
|
|
919
840
|
const barW = createMemo(() => {
|
|
920
841
|
const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0
|
|
921
|
-
const overhead = visualWidth(t(
|
|
842
|
+
const overhead = visualWidth(t("hit")) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
|
|
922
843
|
return Math.max(3, panelWidth() - overhead)
|
|
923
844
|
})
|
|
924
845
|
const bar = createMemo(() => progressBar(data().hitRate, barW()))
|
|
@@ -964,7 +885,7 @@ function TokenCachePanel(props: {
|
|
|
964
885
|
<text onMouseUp={() => setOpen((o) => { const n = !o; persistFold("open", n); return n })}>
|
|
965
886
|
<span style={{ fg: pal().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
|
|
966
887
|
<span style={{ fg: pal().primary }}>
|
|
967
|
-
<b>{t()
|
|
888
|
+
<b>{t("title")}</b>
|
|
968
889
|
<Show when={open()}>
|
|
969
890
|
<span style={{ fg: dimColor(pal().muted, 0.75) }}> v{PLUGIN_VERSION}</span>
|
|
970
891
|
</Show>
|
|
@@ -972,18 +893,18 @@ function TokenCachePanel(props: {
|
|
|
972
893
|
<Show when={!open() && data().hasData}>
|
|
973
894
|
<Show when={data().hasTrendData}>
|
|
974
895
|
<span>
|
|
975
|
-
{" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t(
|
|
896
|
+
{" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t("title")) - visualWidth(pct() + " " + t("hitFolded") + " " + trendLabel(data().trend))))}
|
|
976
897
|
</span>
|
|
977
|
-
<span style={{ fg: hitColor() }}>{pct()} {t()
|
|
978
|
-
<span style={{ fg: data().trend
|
|
898
|
+
<span style={{ fg: hitColor() }}>{pct()} {t("hitFolded")}</span>
|
|
899
|
+
<span style={{ fg: Math.abs(data().trend) >= 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
|
|
979
900
|
{" "}{trendLabel(data().trend)}
|
|
980
901
|
</span>
|
|
981
902
|
</Show>
|
|
982
903
|
<Show when={!data().hasTrendData}>
|
|
983
904
|
<span>
|
|
984
|
-
{" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t(
|
|
905
|
+
{" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t("title")) - visualWidth(pct() + " " + t("hitFolded"))))}
|
|
985
906
|
</span>
|
|
986
|
-
<span style={{ fg: hitColor() }}>{pct()} {t()
|
|
907
|
+
<span style={{ fg: hitColor() }}>{pct()} {t("hitFolded")}</span>
|
|
987
908
|
</Show>
|
|
988
909
|
</Show>
|
|
989
910
|
</text>
|
|
@@ -991,7 +912,7 @@ function TokenCachePanel(props: {
|
|
|
991
912
|
<Show when={open()}>
|
|
992
913
|
<Show when={props.signals.overrideSessionId()}>
|
|
993
914
|
{(() => {
|
|
994
|
-
const prefix = " \u21b3 " + (
|
|
915
|
+
const prefix = " \u21b3 " + t("subPrefix")
|
|
995
916
|
const maxSidW = Math.max(6, panelWidth() - visualWidth(prefix))
|
|
996
917
|
return (
|
|
997
918
|
<text>
|
|
@@ -1006,7 +927,7 @@ function TokenCachePanel(props: {
|
|
|
1006
927
|
<text fg={pal().muted}>{sep()}</text>
|
|
1007
928
|
<text>
|
|
1008
929
|
<span style={{ fg: pal().muted }}>{"> "}</span>
|
|
1009
|
-
<span style={{ fg: pal().muted }}>{t()
|
|
930
|
+
<span style={{ fg: pal().muted }}>{t("noData")}</span>
|
|
1010
931
|
</text>
|
|
1011
932
|
</>
|
|
1012
933
|
}>
|
|
@@ -1014,11 +935,11 @@ function TokenCachePanel(props: {
|
|
|
1014
935
|
|
|
1015
936
|
{/* hit rate + bar — inline to avoid box spacing */}
|
|
1016
937
|
<text>
|
|
1017
|
-
<span style={{ fg: pal().text }}>{t()
|
|
938
|
+
<span style={{ fg: pal().text }}>{t("hit")} </span>
|
|
1018
939
|
<span style={{ fg: hitColor() }}>[{bar()}] </span>
|
|
1019
940
|
<span style={{ fg: pal().text }}>{pct()}</span>
|
|
1020
941
|
<Show when={data().hasTrendData}>
|
|
1021
|
-
<span style={{ fg: data().trend
|
|
942
|
+
<span style={{ fg: Math.abs(data().trend) >= 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
|
|
1022
943
|
{" "}{trendLabel(data().trend)}
|
|
1023
944
|
</span>
|
|
1024
945
|
</Show>
|
|
@@ -1026,38 +947,39 @@ function TokenCachePanel(props: {
|
|
|
1026
947
|
|
|
1027
948
|
{/* session cumulative hit rate */}
|
|
1028
949
|
<text fg={pal().muted}>
|
|
1029
|
-
{justify(t()
|
|
950
|
+
{justify(t("totalHit"), (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")}
|
|
1030
951
|
</text>
|
|
1031
952
|
|
|
1032
953
|
{/* ── detail section (collapsible, default open) ── */}
|
|
1033
954
|
<Show when={sectionDetail()}>
|
|
1034
955
|
<text onMouseUp={() => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n })}>
|
|
1035
956
|
<span style={{ fg: pal().muted }}>{detailOpen() ? "\u25bc " : "\u25b6 "}</span>
|
|
1036
|
-
<span style={{ fg: pal().primary }}><b>{t()
|
|
1037
|
-
<span style={{ fg: pal().muted }}>{sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + t(
|
|
957
|
+
<span style={{ fg: pal().primary }}><b>{t("secDetail")}</b></span>
|
|
958
|
+
<span style={{ fg: pal().muted }}>{sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + t("secDetail")))}</span>
|
|
1038
959
|
</text>
|
|
1039
960
|
|
|
1040
961
|
<Show when={detailOpen()}>
|
|
1041
962
|
<Show when={data().read > 0}>
|
|
1042
963
|
<text fg={pal().muted}>
|
|
1043
|
-
{justify(t()
|
|
964
|
+
{justify(t("read"), fmt(data().read), t("tok"))}
|
|
1044
965
|
</text>
|
|
1045
966
|
</Show>
|
|
1046
967
|
<Show when={data().write > 0}>
|
|
1047
968
|
<text fg={pal().muted}>
|
|
1048
|
-
{justify(t()
|
|
969
|
+
{justify(t("write"), fmt(data().write), t("tok"))}
|
|
1049
970
|
</text>
|
|
1050
971
|
</Show>
|
|
972
|
+
{/* 未命中 = 新鲜输入 + 缓存写(两者都未从缓存命中) */}
|
|
1051
973
|
<text fg={pal().muted}>
|
|
1052
|
-
{justify(t()
|
|
974
|
+
{justify(t("miss"), fmt(data().freshInput + data().write), t("tok"))}
|
|
1053
975
|
</text>
|
|
1054
976
|
<text fg={pal().muted}>
|
|
1055
|
-
{justify(t()
|
|
977
|
+
{justify(t("out"), fmt(data().output), t("tok"))}
|
|
1056
978
|
</text>
|
|
1057
979
|
<Show when={data().saved > 0}>
|
|
1058
980
|
<text>
|
|
1059
|
-
<span style={{ fg: pal().muted }}>{t()
|
|
1060
|
-
<span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(t(
|
|
981
|
+
<span style={{ fg: pal().muted }}>{t("saved")}</span>
|
|
982
|
+
<span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(t("saved")) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
|
|
1061
983
|
<span style={{ fg: pal().success }}>~{fmtCost(data().saved, currencySymbol(), exchangeRate())}</span>
|
|
1062
984
|
</text>
|
|
1063
985
|
</Show>
|
|
@@ -1068,34 +990,34 @@ function TokenCachePanel(props: {
|
|
|
1068
990
|
<Show when={sectionModel()}>
|
|
1069
991
|
{<text onMouseUp={() => setModelOpen((o) => { const n = !o; persistFold("model", n); return n })}>
|
|
1070
992
|
<span style={{ fg: pal().muted }}>{modelOpen() ? "\u25bc " : "\u25b6 "}</span>
|
|
1071
|
-
<span style={{ fg: pal().primary }}><b>{t()
|
|
1072
|
-
<span style={{ fg: pal().muted }}>{sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + t(
|
|
993
|
+
<span style={{ fg: pal().primary }}><b>{t("secModel")}</b></span>
|
|
994
|
+
<span style={{ fg: pal().muted }}>{sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + t("secModel")))}</span>
|
|
1073
995
|
</text>}
|
|
1074
996
|
|
|
1075
997
|
<Show when={modelOpen()}>
|
|
1076
998
|
<text fg={pal().text}>
|
|
1077
|
-
{justify(t()
|
|
999
|
+
{justify(t("cost"), fmtCost(data().cost, currencySymbol(), exchangeRate()))}
|
|
1078
1000
|
</text>
|
|
1079
1001
|
<Show when={data().providerName}>
|
|
1080
1002
|
<text fg={pal().muted}>
|
|
1081
|
-
{justify(t()
|
|
1003
|
+
{justify(t("provider"), data().providerName)}
|
|
1082
1004
|
</text>
|
|
1083
1005
|
</Show>
|
|
1084
1006
|
<text fg={pal().muted}>
|
|
1085
|
-
{justify(t()
|
|
1007
|
+
{justify(t("model"), data().model)}
|
|
1086
1008
|
</text>
|
|
1087
1009
|
<Show when={data().hasPricing}>
|
|
1088
1010
|
<text fg={pal().muted}>
|
|
1089
|
-
{justify(t()
|
|
1011
|
+
{justify(t("rate"), currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + t("inputRate"))}
|
|
1090
1012
|
</text>
|
|
1091
1013
|
<Show when={data().cacheReadRate > 0}>
|
|
1092
1014
|
<text fg={pal().muted}>
|
|
1093
|
-
{justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t(
|
|
1015
|
+
{justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t("cacheRate"))}
|
|
1094
1016
|
</text>
|
|
1095
1017
|
</Show>
|
|
1096
1018
|
<Show when={data().cacheWriteRate > 0}>
|
|
1097
1019
|
<text fg={pal().muted}>
|
|
1098
|
-
{justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t(
|
|
1020
|
+
{justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t("writeRate"))}
|
|
1099
1021
|
</text>
|
|
1100
1022
|
</Show>
|
|
1101
1023
|
</Show>
|
|
@@ -1107,37 +1029,37 @@ function TokenCachePanel(props: {
|
|
|
1107
1029
|
<Show when={data().hasDistData}>
|
|
1108
1030
|
{<text onMouseUp={() => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n })}>
|
|
1109
1031
|
<span style={{ fg: pal().muted }}>{distOpen() ? "\u25bc " : "\u25b6 "}</span>
|
|
1110
|
-
<span style={{ fg: pal().primary }}><b>{t()
|
|
1111
|
-
<span style={{ fg: pal().muted }}>{sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + t(
|
|
1032
|
+
<span style={{ fg: pal().primary }}><b>{t("distTitle")}</b></span>
|
|
1033
|
+
<span style={{ fg: pal().muted }}>{sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + t("distTitle")))}</span>
|
|
1112
1034
|
</text>}
|
|
1113
1035
|
<Show when={distOpen()}>
|
|
1114
1036
|
<Show when={data().dist.system > 0}>
|
|
1115
1037
|
<text fg={pal().muted}>
|
|
1116
|
-
{justify(t()
|
|
1038
|
+
{justify(t("distSys"), fmt(data().dist.system), t("tok"))}
|
|
1117
1039
|
</text>
|
|
1118
1040
|
</Show>
|
|
1119
1041
|
<Show when={data().dist.user > 0}>
|
|
1120
1042
|
<text fg={pal().muted}>
|
|
1121
|
-
{justify(t()
|
|
1043
|
+
{justify(t("distUser"), fmt(data().dist.user), t("tok"))}
|
|
1122
1044
|
</text>
|
|
1123
1045
|
</Show>
|
|
1124
1046
|
<Show when={data().dist.agent > 0}>
|
|
1125
1047
|
<text fg={pal().muted}>
|
|
1126
|
-
{justify(t()
|
|
1048
|
+
{justify(t("distAgent"), fmt(data().dist.agent), t("tok"))}
|
|
1127
1049
|
</text>
|
|
1128
1050
|
</Show>
|
|
1129
1051
|
<Show when={data().dist.toolCall > 0}>
|
|
1130
1052
|
<text fg={pal().muted}>
|
|
1131
|
-
{justify(t()
|
|
1053
|
+
{justify(t("distTool"), fmt(data().dist.toolCall), t("tok"))}
|
|
1132
1054
|
</text>
|
|
1133
1055
|
</Show>
|
|
1134
1056
|
<Show when={data().dist.toolResult > 0}>
|
|
1135
1057
|
<text fg={pal().muted}>
|
|
1136
|
-
{justify(t()
|
|
1058
|
+
{justify(t("distRes"), fmt(data().dist.toolResult), t("tok"))}
|
|
1137
1059
|
</text>
|
|
1138
1060
|
</Show>
|
|
1139
1061
|
<text fg={pal().text}>
|
|
1140
|
-
{justify(t()
|
|
1062
|
+
{justify(t("distTotal"), fmt(data().dist.apiInput), t("tok"))}
|
|
1141
1063
|
</text>
|
|
1142
1064
|
</Show>
|
|
1143
1065
|
</Show>
|
|
@@ -1148,18 +1070,18 @@ function TokenCachePanel(props: {
|
|
|
1148
1070
|
<Show when={data().hasSkills}>
|
|
1149
1071
|
{<text onMouseUp={() => setSkillsOpen((o) => { const n = !o; persistFold("skills", n); return n })}>
|
|
1150
1072
|
<span style={{ fg: pal().muted }}>{skillsOpen() ? "\u25bc " : "\u25b6 "}</span>
|
|
1151
|
-
<span style={{ fg: pal().primary }}><b>{t()
|
|
1073
|
+
<span style={{ fg: pal().primary }}><b>{t("secSkills")}</b></span>
|
|
1152
1074
|
<span style={{ fg: pal().muted }}> ({data().skills.length})</span>
|
|
1153
|
-
<span style={{ fg: pal().muted }}>{sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t()
|
|
1075
|
+
<span style={{ fg: pal().muted }}>{sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t("secSkills") + ` (${data().skills.length})`))}</span>
|
|
1154
1076
|
</text>}
|
|
1155
1077
|
<Show when={skillsOpen()}>
|
|
1156
1078
|
{data().skills.map((sk: { name: string; tokens: number }) => {
|
|
1157
|
-
const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t(
|
|
1079
|
+
const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t("tok"))
|
|
1158
1080
|
const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1)
|
|
1159
1081
|
const label = truncateVisual(sk.name, maxLabel)
|
|
1160
1082
|
return (
|
|
1161
1083
|
<text fg={pal().muted}>
|
|
1162
|
-
{justify(label, fmt(sk.tokens), t(
|
|
1084
|
+
{justify(label, fmt(sk.tokens), t("tok"))}
|
|
1163
1085
|
</text>
|
|
1164
1086
|
)
|
|
1165
1087
|
})}
|
|
@@ -1167,62 +1089,46 @@ function TokenCachePanel(props: {
|
|
|
1167
1089
|
</Show>
|
|
1168
1090
|
</Show>
|
|
1169
1091
|
|
|
1170
|
-
{/* ──
|
|
1092
|
+
{/* ── provider balance (single line) ── */}
|
|
1171
1093
|
<Show when={sectionBalance()}>
|
|
1172
1094
|
<text fg={pal().muted}>{sep()}</text>
|
|
1173
|
-
<Show when={
|
|
1174
|
-
<text fg={pal().muted}>
|
|
1175
|
-
<span style={{ fg: pal().muted }}>{"> "}</span>
|
|
1176
|
-
<span>{t().balNoKey.replace("{p}", providerName())}</span>
|
|
1177
|
-
</text>
|
|
1178
|
-
</Show>
|
|
1179
|
-
<Show when={balanceState().status === "loading"}>
|
|
1095
|
+
<Show when={balanceUnsupported()}>
|
|
1180
1096
|
<text fg={pal().muted}>
|
|
1181
1097
|
<span style={{ fg: pal().muted }}>{"> "}</span>
|
|
1182
|
-
<span>{t()
|
|
1098
|
+
<span>{t("balUnsupported")}</span>
|
|
1183
1099
|
</text>
|
|
1184
1100
|
</Show>
|
|
1185
|
-
<Show when={
|
|
1186
|
-
<
|
|
1187
|
-
<
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
: baseAmt
|
|
1217
|
-
const shown = pref && base.currency !== pref
|
|
1218
|
-
? converted.toLocaleString("en-US", { maximumFractionDigits: 2 })
|
|
1219
|
-
: base.total
|
|
1220
|
-
return (
|
|
1221
|
-
<text fg={pal().text}>
|
|
1222
|
-
{justify(t().balTotal, balanceSymbol(pref || base.currency) + shown)}
|
|
1223
|
-
</text>
|
|
1224
|
-
)
|
|
1225
|
-
})()}
|
|
1101
|
+
<Show when={!balanceUnsupported()}>
|
|
1102
|
+
<Show when={balanceState().status === "idle"}>
|
|
1103
|
+
<text fg={pal().muted}>
|
|
1104
|
+
<span style={{ fg: pal().muted }}>{"> "}</span>
|
|
1105
|
+
<span>{t("balNoKey", { p: providerName() })}</span>
|
|
1106
|
+
</text>
|
|
1107
|
+
</Show>
|
|
1108
|
+
<Show when={balanceState().status === "loading"}>
|
|
1109
|
+
<text fg={pal().muted}>
|
|
1110
|
+
<span style={{ fg: pal().muted }}>{"> "}</span>
|
|
1111
|
+
<span>{t("balLoading")}</span>
|
|
1112
|
+
</text>
|
|
1113
|
+
</Show>
|
|
1114
|
+
<Show when={balanceState().status === "error"}>
|
|
1115
|
+
<text fg={pal().error}>
|
|
1116
|
+
<span style={{ fg: pal().muted }}>{"> "}</span>
|
|
1117
|
+
<span>{(() => {
|
|
1118
|
+
const code = balanceState().error
|
|
1119
|
+
if (code === "401") return t("balErr401")
|
|
1120
|
+
if (code === "403") return t("balErr403")
|
|
1121
|
+
if (code === "EMPTY") return t("balErrEmpty")
|
|
1122
|
+
if (code === "TIMEOUT") return t("balErrTimeout")
|
|
1123
|
+
return t("balError") + (code ? ` (${code})` : "")
|
|
1124
|
+
})()}</span>
|
|
1125
|
+
</text>
|
|
1126
|
+
</Show>
|
|
1127
|
+
<Show when={balanceState().status === "ok" && balanceState().data}>
|
|
1128
|
+
<text fg={pal().text}>
|
|
1129
|
+
{justify(t("balTotal"), formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()))}
|
|
1130
|
+
</text>
|
|
1131
|
+
</Show>
|
|
1226
1132
|
</Show>
|
|
1227
1133
|
</Show>
|
|
1228
1134
|
</Show>
|
|
@@ -1235,6 +1141,166 @@ function TokenCachePanel(props: {
|
|
|
1235
1141
|
// Plugin entry
|
|
1236
1142
|
// ---------------------------------------------------------------------------
|
|
1237
1143
|
|
|
1144
|
+
/**
|
|
1145
|
+
* 输入框 hint 行(session_prompt slot 的 hint):单行显示 路径 · 命中率 · 余额 · Tokens。
|
|
1146
|
+
* 通过 ui.Prompt 的 hint prop 注入——宿主右侧的 token/commands 提示自动保留,
|
|
1147
|
+
* 三合一信息与路径同行显示在中间位置。
|
|
1148
|
+
*/
|
|
1149
|
+
function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sessionId: string }): JSX.Element {
|
|
1150
|
+
const KV_PREFIX = "cache_panel"
|
|
1151
|
+
const t = createT(() => props.signals.langCode())
|
|
1152
|
+
|
|
1153
|
+
const sid = props.sessionId
|
|
1154
|
+
|
|
1155
|
+
// ── 命中率(单条口径:最后一条有 token 的 assistant 消息)+ token 汇总 ──
|
|
1156
|
+
const stats = createMemo(() => {
|
|
1157
|
+
const id = sid
|
|
1158
|
+
if (!id) return null
|
|
1159
|
+
const msgs = props.api.state.session.messages(id) as Message[]
|
|
1160
|
+
const session = typeof props.api.state.session.get === "function"
|
|
1161
|
+
? props.api.state.session.get(id)
|
|
1162
|
+
: undefined
|
|
1163
|
+
let input = session?.tokens?.input ?? 0
|
|
1164
|
+
let read = session?.tokens?.cache?.read ?? 0
|
|
1165
|
+
let write = session?.tokens?.cache?.write ?? 0
|
|
1166
|
+
// 旧 SDK 无 session 聚合字段 → 遍历消息累加(与侧边栏 fallback 一致)
|
|
1167
|
+
if (session?.tokens == null) {
|
|
1168
|
+
for (const m of msgs) {
|
|
1169
|
+
if (m.role !== "assistant") continue
|
|
1170
|
+
const tk = (m as AssistantMessage).tokens
|
|
1171
|
+
if (!tk) continue
|
|
1172
|
+
input += num(tk.input)
|
|
1173
|
+
read += num(tk.cache?.read)
|
|
1174
|
+
write += num(tk.cache?.write)
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
// 从后往前取最后两条有 token 数据的 assistant 消息 → 单条命中率 + 趋势
|
|
1178
|
+
// 分母含缓存写(业界口径:read / (input+read+write))
|
|
1179
|
+
let hitRate = -1, prevHitRate = -1
|
|
1180
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
1181
|
+
const m = msgs[i]
|
|
1182
|
+
if (m.role !== "assistant") continue
|
|
1183
|
+
const tk = (m as AssistantMessage).tokens
|
|
1184
|
+
if (!tk) continue
|
|
1185
|
+
const mit = num(tk.input) + num(tk.cache?.read) + num(tk.cache?.write)
|
|
1186
|
+
const mrt = num(tk.cache?.read)
|
|
1187
|
+
if (mit <= 0) continue
|
|
1188
|
+
const rate = (mrt / mit) * 100
|
|
1189
|
+
if (hitRate < 0) { hitRate = rate; continue }
|
|
1190
|
+
prevHitRate = rate
|
|
1191
|
+
break
|
|
1192
|
+
}
|
|
1193
|
+
return { hitRate, prevHitRate, input, read, write }
|
|
1194
|
+
})
|
|
1195
|
+
|
|
1196
|
+
// 余额查询状态为共享信号(PanelSignals.balanceState),由 tui() 统一轮询
|
|
1197
|
+
|
|
1198
|
+
// 自动切换 provider(跟随当前会话模型;幂等,与侧边栏共享信号)
|
|
1199
|
+
createEffect(() => {
|
|
1200
|
+
if (!props.signals.autoBalance()) return
|
|
1201
|
+
const id = sid
|
|
1202
|
+
if (!id) return
|
|
1203
|
+
const msgs = props.api.state.session.messages(id) as Message[]
|
|
1204
|
+
let pid = ""
|
|
1205
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
1206
|
+
const m = msgs[i]
|
|
1207
|
+
if (m.role === "assistant" && (m as AssistantMessage).providerID) { pid = (m as AssistantMessage).providerID; break }
|
|
1208
|
+
}
|
|
1209
|
+
if (!pid) {
|
|
1210
|
+
try { pid = props.api.state.session.get(id)?.model?.providerID ?? "" } catch {}
|
|
1211
|
+
}
|
|
1212
|
+
if (!pid) return
|
|
1213
|
+
const hit = matchBalanceProvider(pid)
|
|
1214
|
+
if (hit) {
|
|
1215
|
+
props.signals.setBalanceUnsupported(false)
|
|
1216
|
+
if (hit.id !== props.signals.balanceProviderId()) {
|
|
1217
|
+
props.signals.setBalanceProviderId(hit.id)
|
|
1218
|
+
props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
|
|
1219
|
+
}
|
|
1220
|
+
} else {
|
|
1221
|
+
// 当前提供商没有余额适配器 → 标记不支持,余额显示 N/A 并停止轮询
|
|
1222
|
+
props.signals.setBalanceUnsupported(true)
|
|
1223
|
+
}
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1226
|
+
// ── 主题色(与侧边栏同口径)──
|
|
1227
|
+
const pal = createMemo(() => {
|
|
1228
|
+
const th = props.api.theme.current as Record<string, unknown>
|
|
1229
|
+
const sat = (k: string, fb: string) => desaturateTo(th[k], MAX_SAT, fb)
|
|
1230
|
+
return {
|
|
1231
|
+
text: sat("text", FALLBACK.text),
|
|
1232
|
+
muted: sat("textMuted", FALLBACK.muted),
|
|
1233
|
+
success: sat("success", FALLBACK.success),
|
|
1234
|
+
warning: sat("warning", FALLBACK.warning),
|
|
1235
|
+
error: sat("error", FALLBACK.error),
|
|
1236
|
+
}
|
|
1237
|
+
})
|
|
1238
|
+
|
|
1239
|
+
const hitColor = createMemo(() => {
|
|
1240
|
+
const r = stats()?.hitRate ?? -1
|
|
1241
|
+
if (r >= 85) return pal().success
|
|
1242
|
+
if (r >= 70) return pal().warning
|
|
1243
|
+
return pal().error
|
|
1244
|
+
})
|
|
1245
|
+
|
|
1246
|
+
// 命中率趋势:最后一条与上一条的差值;|Δ| < 0.05 视为无变化(null = 不显示)
|
|
1247
|
+
const trend = createMemo(() => {
|
|
1248
|
+
const s = stats()
|
|
1249
|
+
if (!s || s.prevHitRate < 0 || s.hitRate < 0) return null
|
|
1250
|
+
const d = s.hitRate - s.prevHitRate
|
|
1251
|
+
return Math.abs(d) < 0.05 ? null : d
|
|
1252
|
+
})
|
|
1253
|
+
|
|
1254
|
+
const balanceText = createMemo(() => {
|
|
1255
|
+
const s = props.signals.balanceState()
|
|
1256
|
+
if (s.status === "ok" && s.data) return formatBalanceText(s.data, props.signals.balanceCurrency(), props.signals.exchangeRate())
|
|
1257
|
+
if (s.status === "loading") return "\u2026"
|
|
1258
|
+
if (s.status === "error") return "\u26a0"
|
|
1259
|
+
return "-"
|
|
1260
|
+
})
|
|
1261
|
+
|
|
1262
|
+
// 路径显示(替换宿主默认 hint 左侧的 cwd 文本)
|
|
1263
|
+
const directory = createMemo(() => {
|
|
1264
|
+
try { return props.api.state.path.directory } catch { return "" }
|
|
1265
|
+
})
|
|
1266
|
+
|
|
1267
|
+
// 恢复显隐偏好(默认显示);关闭时回退为仅显示路径,与宿主默认 hint 行一致
|
|
1268
|
+
onMount(() => {
|
|
1269
|
+
try {
|
|
1270
|
+
const v = props.api.kv.get<boolean>(`${KV_PREFIX}.section.bottom`, true)
|
|
1271
|
+
props.signals.setSectionBottom(v !== false)
|
|
1272
|
+
} catch {}
|
|
1273
|
+
})
|
|
1274
|
+
|
|
1275
|
+
return (
|
|
1276
|
+
<Show when={props.signals.sectionBottom()} fallback={<text fg={pal().muted}>{directory()}</text>}>
|
|
1277
|
+
<box marginLeft={1} flexGrow={1} flexShrink={0} flexDirection="row" justifyContent="space-between">
|
|
1278
|
+
<text fg={pal().muted}>{directory()}</text>
|
|
1279
|
+
<box flexDirection="row">
|
|
1280
|
+
<text>
|
|
1281
|
+
<span style={{ fg: pal().muted }}>{t("barHit")} </span>
|
|
1282
|
+
<span style={{ fg: hitColor() }}>{(stats()?.hitRate ?? -1) >= 0 ? (Math.floor(stats()!.hitRate * 10) / 10).toFixed(1) + "%" : "--"}</span>
|
|
1283
|
+
<Show when={trend() !== null}>
|
|
1284
|
+
<span style={{ fg: trend()! > 0 ? pal().success : pal().error }}>
|
|
1285
|
+
{" " + (trend()! > 0 ? "\u2191" : "\u2193") + Math.abs(trend()!).toFixed(1) + "%"}
|
|
1286
|
+
</span>
|
|
1287
|
+
</Show>
|
|
1288
|
+
<span style={{ fg: pal().muted }}>{" \u00b7 " + t("barTok") + " "}</span>
|
|
1289
|
+
<span style={{ fg: pal().text }}>
|
|
1290
|
+
{stats() ? fmtCompact(stats()!.input + stats()!.read + stats()!.write) : "--"}
|
|
1291
|
+
</span>
|
|
1292
|
+
<Show when={!props.signals.balanceUnsupported()}>
|
|
1293
|
+
<span style={{ fg: pal().muted }}>{" \u00b7 " + t("barBal") + " "}</span>
|
|
1294
|
+
<span style={{ fg: pal().text }}>{balanceText()}</span>
|
|
1295
|
+
</Show>
|
|
1296
|
+
</text>
|
|
1297
|
+
<text fg={pal().muted}>{" \u00b7 "}</text>
|
|
1298
|
+
</box>
|
|
1299
|
+
</box>
|
|
1300
|
+
</Show>
|
|
1301
|
+
)
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1238
1304
|
function createSidebarSlot(api: TuiPluginApi, signals: PanelSignals): TuiSlotPlugin {
|
|
1239
1305
|
let lastSlotSid = ""
|
|
1240
1306
|
return {
|
|
@@ -1271,26 +1337,39 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1271
1337
|
const [sectionDist, setSectionDist] = createSignal(true)
|
|
1272
1338
|
const [sectionSkills, setSectionSkills] = createSignal(true)
|
|
1273
1339
|
const [sectionBalance, setSectionBalance] = createSignal(true)
|
|
1340
|
+
const [sectionBottom, setSectionBottom] = createSignal(true)
|
|
1274
1341
|
const [balanceRefresh, setBalanceRefresh] = createSignal(0)
|
|
1275
1342
|
const [balanceProviderId, setBalanceProviderId] = createSignal("deepseek")
|
|
1276
1343
|
const [autoBalance, setAutoBalance] = createSignal(true)
|
|
1344
|
+
const [balanceUnsupported, setBalanceUnsupported] = createSignal(false)
|
|
1277
1345
|
const [balanceCurrency, setBalanceCurrency] = createSignal("")
|
|
1278
1346
|
const [borderVisible, setBorderVisible] = createSignal(true)
|
|
1279
|
-
const [
|
|
1347
|
+
const [langCode, setLangCode] = createSignal<LangCode>(INIT_LANG)
|
|
1280
1348
|
const [overrideSessionId, setOverrideSessionId] = createSignal<string | undefined>(undefined)
|
|
1281
1349
|
|
|
1350
|
+
// ── 余额查询状态(共享):侧边栏与底部栏读同一份数据,
|
|
1351
|
+
// 避免重复请求导致两处余额不一致 ──
|
|
1352
|
+
const [balanceState, setBalanceState] = createSignal<BalanceState>({
|
|
1353
|
+
status: "idle", data: null, lastFetch: 0,
|
|
1354
|
+
})
|
|
1355
|
+
// 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果
|
|
1356
|
+
let balanceSeq = 0
|
|
1357
|
+
|
|
1282
1358
|
const signals: PanelSignals = {
|
|
1283
1359
|
currencySymbol, setCurrencySymbol,
|
|
1284
1360
|
exchangeRate, setExchangeRate,
|
|
1285
|
-
|
|
1361
|
+
langCode, setLangCode,
|
|
1286
1362
|
sectionDetail, setSectionDetail,
|
|
1287
1363
|
sectionModel, setSectionModel,
|
|
1288
1364
|
sectionDist, setSectionDist,
|
|
1289
1365
|
sectionSkills, setSectionSkills,
|
|
1290
1366
|
sectionBalance, setSectionBalance,
|
|
1367
|
+
sectionBottom, setSectionBottom,
|
|
1291
1368
|
balanceRefresh, setBalanceRefresh,
|
|
1292
1369
|
balanceProviderId, setBalanceProviderId,
|
|
1293
1370
|
autoBalance, setAutoBalance,
|
|
1371
|
+
balanceUnsupported, setBalanceUnsupported,
|
|
1372
|
+
balanceState,
|
|
1294
1373
|
balanceCurrency, setBalanceCurrency,
|
|
1295
1374
|
borderVisible, setBorderVisible,
|
|
1296
1375
|
overrideSessionId, setOverrideSessionId,
|
|
@@ -1298,31 +1377,120 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1298
1377
|
|
|
1299
1378
|
api.slots.register(createSidebarSlot(api, signals))
|
|
1300
1379
|
|
|
1380
|
+
// 输入框 hint 行(session_prompt slot,replace 模式):
|
|
1381
|
+
// 用宿主同一 Prompt 组件重渲染输入框,仅替换 hint 行左侧——
|
|
1382
|
+
// 在路径与右侧 token/commands 提示之间插入 命中率 · 余额 · Tokens。
|
|
1383
|
+
api.slots.register({
|
|
1384
|
+
order: 55,
|
|
1385
|
+
slots: {
|
|
1386
|
+
session_prompt(
|
|
1387
|
+
_ctx: TuiSlotContext,
|
|
1388
|
+
input: {
|
|
1389
|
+
session_id: string
|
|
1390
|
+
visible?: boolean
|
|
1391
|
+
disabled?: boolean
|
|
1392
|
+
on_submit?: () => void
|
|
1393
|
+
ref?: (ref: TuiPromptRef | undefined) => void
|
|
1394
|
+
},
|
|
1395
|
+
): JSX.Element {
|
|
1396
|
+
return (
|
|
1397
|
+
<api.ui.Prompt
|
|
1398
|
+
sessionID={input.session_id}
|
|
1399
|
+
visible={input.visible}
|
|
1400
|
+
disabled={input.disabled}
|
|
1401
|
+
onSubmit={input.on_submit}
|
|
1402
|
+
ref={input.ref}
|
|
1403
|
+
hint={<BottomStatusBar api={api} signals={signals} sessionId={input.session_id} />}
|
|
1404
|
+
/>
|
|
1405
|
+
)
|
|
1406
|
+
},
|
|
1407
|
+
},
|
|
1408
|
+
})
|
|
1409
|
+
|
|
1301
1410
|
// ── slash commands for runtime config ──
|
|
1302
1411
|
const KV_PREFIX = "cache_panel"
|
|
1303
1412
|
|
|
1413
|
+
// ── 语言偏好恢复:KV 就绪后优先用户设置(/cache-lang),覆盖自动识别 ──
|
|
1414
|
+
const restoreLang = () => {
|
|
1415
|
+
try {
|
|
1416
|
+
const saved = api.kv.get<string>(`${KV_PREFIX}.lang`)
|
|
1417
|
+
if (saved && LANG_META.some((m) => m.code === saved)) setLangCode(saved as LangCode)
|
|
1418
|
+
} catch {}
|
|
1419
|
+
}
|
|
1420
|
+
if (api.kv.ready) {
|
|
1421
|
+
restoreLang()
|
|
1422
|
+
} else {
|
|
1423
|
+
const langTimer = setInterval(() => {
|
|
1424
|
+
if (api.kv.ready) { clearInterval(langTimer); restoreLang() }
|
|
1425
|
+
}, 10)
|
|
1426
|
+
api.lifecycle.onDispose(() => clearInterval(langTimer))
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const pollBalance = async () => {
|
|
1430
|
+
const provider = getBalanceProvider(balanceProviderId())
|
|
1431
|
+
// 手动配置的 key 优先;缺失时自动复用 OpenCode 已认证的 key(auth.json / config)
|
|
1432
|
+
const key = api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
|
|
1433
|
+
|| findOpencodeKey(api, provider)
|
|
1434
|
+
if (balanceUnsupported()) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
|
|
1435
|
+
if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
|
|
1436
|
+
const now = Date.now()
|
|
1437
|
+
const prev = balanceState()
|
|
1438
|
+
// key 已更换(重新输入)→ 强制重新查询,绕过缓存
|
|
1439
|
+
if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS) return // cache still fresh
|
|
1440
|
+
const seq = ++balanceSeq
|
|
1441
|
+
setBalanceState({ ...prev, status: "loading", error: undefined, key })
|
|
1442
|
+
const controller = new AbortController()
|
|
1443
|
+
let timedOut = false
|
|
1444
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000)
|
|
1445
|
+
try {
|
|
1446
|
+
const data = await provider.fetchBalance(key, controller.signal)
|
|
1447
|
+
clearTimeout(timer)
|
|
1448
|
+
if (seq !== balanceSeq) return // 已被更新的请求取代,丢弃过期结果
|
|
1449
|
+
setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key })
|
|
1450
|
+
} catch (err) {
|
|
1451
|
+
clearTimeout(timer)
|
|
1452
|
+
if (seq !== balanceSeq) return
|
|
1453
|
+
const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "")
|
|
1454
|
+
// 失败时清空旧数据,避免显示过期余额
|
|
1455
|
+
setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key })
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// Re-fetch when the API key is (re)configured via /cache-balance-key.
|
|
1460
|
+
// 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹,
|
|
1461
|
+
// effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState
|
|
1462
|
+
// 形成无限循环(每次重跑都发起新的 fetch 请求)。
|
|
1463
|
+
createEffect(() => {
|
|
1464
|
+
void balanceRefresh()
|
|
1465
|
+
untrack(() => { void pollBalance() })
|
|
1466
|
+
})
|
|
1467
|
+
|
|
1468
|
+
// 定时轮询(5 分钟);随插件生命周期清理
|
|
1469
|
+
const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS)
|
|
1470
|
+
api.lifecycle.onDispose(() => clearInterval(balanceTimer))
|
|
1471
|
+
|
|
1304
1472
|
/** 菜单中 provider 选项标题:标注 key 来源(手动配置 / OpenCode 自动复用 / 未配置)。 */
|
|
1305
1473
|
const providerOptionTitle = (p: BalanceProvider, current?: string) => {
|
|
1306
|
-
const
|
|
1474
|
+
const t = createT(() => langCode())
|
|
1307
1475
|
const hasManual = !!api.kv.get<string>(`${KV_PREFIX}.balance.${p.id}.key`, "")
|
|
1308
1476
|
const hasAuto = !hasManual && !!findOpencodeKey(api, p)
|
|
1309
1477
|
const mark = hasManual
|
|
1310
|
-
? (
|
|
1478
|
+
? t("keyUser")
|
|
1311
1479
|
: hasAuto
|
|
1312
|
-
? (
|
|
1313
|
-
: (
|
|
1480
|
+
? t("keyOpenCode")
|
|
1481
|
+
: t("keyNotSet")
|
|
1314
1482
|
return p.name + mark + (current && p.id === current ? " *" : "")
|
|
1315
1483
|
}
|
|
1316
1484
|
|
|
1317
1485
|
/** 弹出指定 provider 的 API Key 输入框(脱敏预填;空清除 / 含 * 保留原 key / 新 key 实时刷新)。 */
|
|
1318
1486
|
const promptBalanceKey = (dialog: TuiDialogStack | undefined, provider: BalanceProvider) => {
|
|
1319
|
-
const
|
|
1487
|
+
const t = createT(() => langCode())
|
|
1320
1488
|
const current = api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
|
|
1321
1489
|
const masked = maskKey(current)
|
|
1322
1490
|
dialog?.replace(() => (
|
|
1323
1491
|
<api.ui.DialogPrompt
|
|
1324
1492
|
title={provider.name}
|
|
1325
|
-
description={() => <text>{
|
|
1493
|
+
description={() => <text>{t("balKeyPrompt", { p: provider.name })}</text>}
|
|
1326
1494
|
placeholder={provider.keyPlaceholder ?? "sk-..."}
|
|
1327
1495
|
value={masked}
|
|
1328
1496
|
onConfirm={(val) => {
|
|
@@ -1338,9 +1506,9 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1338
1506
|
api.kv.set(`${KV_PREFIX}.balance.${provider.id}.key`, key)
|
|
1339
1507
|
setBalanceRefresh(v => v + 1)
|
|
1340
1508
|
if (key) {
|
|
1341
|
-
api.ui.toast({ message:
|
|
1509
|
+
api.ui.toast({ message: t("keySaved") })
|
|
1342
1510
|
} else {
|
|
1343
|
-
api.ui.toast({ message:
|
|
1511
|
+
api.ui.toast({ message: t("keyCleared") })
|
|
1344
1512
|
}
|
|
1345
1513
|
dialog?.clear()
|
|
1346
1514
|
}}
|
|
@@ -1364,6 +1532,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1364
1532
|
value: code,
|
|
1365
1533
|
}))}
|
|
1366
1534
|
onSelect={(opt) => {
|
|
1535
|
+
const t = createT(() => langCode())
|
|
1367
1536
|
const sym = CURRENCIES[opt.value] ?? "$"
|
|
1368
1537
|
const defRate = DEFAULT_RATES[opt.value] ?? 1
|
|
1369
1538
|
api.kv.set(`${KV_PREFIX}.currency`, sym)
|
|
@@ -1373,7 +1542,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1373
1542
|
signals.setBalanceCurrency(opt.value)
|
|
1374
1543
|
signals.setCurrencySymbol(sym)
|
|
1375
1544
|
signals.setExchangeRate(defRate)
|
|
1376
|
-
api.ui.toast({ message:
|
|
1545
|
+
api.ui.toast({ message: t("currencySet", { v: opt.value, s: sym, r: defRate }) })
|
|
1377
1546
|
dialog?.clear()
|
|
1378
1547
|
}}
|
|
1379
1548
|
/>
|
|
@@ -1393,11 +1562,12 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1393
1562
|
placeholder="1.0"
|
|
1394
1563
|
value={String(api.kv.get<number>(`${KV_PREFIX}.rate`, 1))}
|
|
1395
1564
|
onConfirm={(val) => {
|
|
1565
|
+
const t = createT(() => langCode())
|
|
1396
1566
|
const n = parseFloat(val)
|
|
1397
1567
|
if (n > 0) {
|
|
1398
1568
|
api.kv.set(`${KV_PREFIX}.rate`, n)
|
|
1399
1569
|
signals.setExchangeRate(n)
|
|
1400
|
-
api.ui.toast({ message:
|
|
1570
|
+
api.ui.toast({ message: t("rateSet", { r: n }) })
|
|
1401
1571
|
}
|
|
1402
1572
|
dialog?.clear()
|
|
1403
1573
|
}}
|
|
@@ -1411,29 +1581,42 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1411
1581
|
description: "Show or hide a sidebar section",
|
|
1412
1582
|
slash: { name: "cache-section" },
|
|
1413
1583
|
onSelect: (dialog) => {
|
|
1584
|
+
const t = createT(() => langCode())
|
|
1414
1585
|
const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
|
|
1415
1586
|
const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
|
|
1416
1587
|
const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
|
|
1417
1588
|
const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
|
|
1418
1589
|
const balanceOn = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true))
|
|
1590
|
+
const bottomOn = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true))
|
|
1419
1591
|
const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
|
|
1592
|
+
const labels: Record<string, string> = {
|
|
1593
|
+
detail: t("secDetail"),
|
|
1594
|
+
model: t("secModel"),
|
|
1595
|
+
dist: t("distTitle"),
|
|
1596
|
+
skills: t("secSkills"),
|
|
1597
|
+
balance: t("secBalance"),
|
|
1598
|
+
bottom: t("secBottom"),
|
|
1599
|
+
border: t("secBorder"),
|
|
1600
|
+
}
|
|
1601
|
+
const optTitle = (label: string, on: boolean) => `${visualPadEnd(label, 15)}[${on ? "ON" : "OFF"}]`
|
|
1420
1602
|
dialog?.replace(() => (
|
|
1421
1603
|
<api.ui.DialogSelect
|
|
1422
|
-
title="
|
|
1604
|
+
title={t("secToggle")}
|
|
1423
1605
|
options={[
|
|
1424
|
-
{ title:
|
|
1425
|
-
{ title:
|
|
1426
|
-
{ title:
|
|
1427
|
-
{ title:
|
|
1428
|
-
{ title:
|
|
1429
|
-
{ title:
|
|
1606
|
+
{ title: optTitle(labels.detail, detailOn), value: "detail" },
|
|
1607
|
+
{ title: optTitle(labels.model, modelOn), value: "model" },
|
|
1608
|
+
{ title: optTitle(labels.dist, distOn), value: "dist" },
|
|
1609
|
+
{ title: optTitle(labels.skills, skillsOn), value: "skills" },
|
|
1610
|
+
{ title: optTitle(labels.balance, balanceOn), value: "balance" },
|
|
1611
|
+
{ title: optTitle(labels.bottom, bottomOn), value: "bottom" },
|
|
1612
|
+
{ title: optTitle(labels.border, borderOn), value: "border" },
|
|
1430
1613
|
]}
|
|
1431
1614
|
onSelect={(opt) => {
|
|
1432
1615
|
if (opt.value === "border") {
|
|
1433
1616
|
const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
|
|
1434
1617
|
api.kv.set(`${KV_PREFIX}.border`, !cur)
|
|
1435
1618
|
signals.setBorderVisible(!cur)
|
|
1436
|
-
api.ui.toast({ message:
|
|
1619
|
+
api.ui.toast({ message: !cur ? t("borderShown") : t("borderHidden") })
|
|
1437
1620
|
} else {
|
|
1438
1621
|
const key = `${KV_PREFIX}.section.${opt.value}`
|
|
1439
1622
|
const cur = Boolean(api.kv.get(key, true))
|
|
@@ -1443,7 +1626,9 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1443
1626
|
if (opt.value === "dist") signals.setSectionDist(!cur)
|
|
1444
1627
|
if (opt.value === "skills") signals.setSectionSkills(!cur)
|
|
1445
1628
|
if (opt.value === "balance") signals.setSectionBalance(!cur)
|
|
1446
|
-
|
|
1629
|
+
if (opt.value === "bottom") signals.setSectionBottom(!cur)
|
|
1630
|
+
const name = labels[opt.value] ?? opt.value
|
|
1631
|
+
api.ui.toast({ message: t(!cur ? "sectionShown" : "sectionHidden", { s: name }) })
|
|
1447
1632
|
}
|
|
1448
1633
|
dialog?.clear()
|
|
1449
1634
|
}}
|
|
@@ -1457,6 +1642,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1457
1642
|
description: "Display the current plugin configuration",
|
|
1458
1643
|
slash: { name: "cache-config" },
|
|
1459
1644
|
onSelect: (dialog) => {
|
|
1645
|
+
const t = createT(() => langCode())
|
|
1460
1646
|
const sym = api.kv.get<string>(`${KV_PREFIX}.currency`) ?? "$"
|
|
1461
1647
|
const rate = api.kv.get<number>(`${KV_PREFIX}.rate`) ?? 1
|
|
1462
1648
|
const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
|
|
@@ -1464,9 +1650,16 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1464
1650
|
const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
|
|
1465
1651
|
const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
|
|
1466
1652
|
const balance = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true))
|
|
1653
|
+
const bottom = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true))
|
|
1654
|
+
const on = (v: boolean) => v ? "ON" : "OFF"
|
|
1467
1655
|
api.ui.toast({
|
|
1468
|
-
title: "
|
|
1469
|
-
message:
|
|
1656
|
+
title: t("panelConfigTitle"),
|
|
1657
|
+
message: t("panelConfigMsg", {
|
|
1658
|
+
c: sym, r: rate,
|
|
1659
|
+
d: on(detail), m: on(model),
|
|
1660
|
+
t: on(dist), k: on(skills),
|
|
1661
|
+
b: on(balance), f: on(bottom),
|
|
1662
|
+
}),
|
|
1470
1663
|
duration: 8000,
|
|
1471
1664
|
})
|
|
1472
1665
|
dialog?.clear()
|
|
@@ -1478,19 +1671,20 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1478
1671
|
description: "Switch between Chinese and English display",
|
|
1479
1672
|
slash: { name: "cache-lang" },
|
|
1480
1673
|
onSelect: (dialog) => {
|
|
1481
|
-
const
|
|
1674
|
+
const t = createT(() => langCode())
|
|
1675
|
+
const cur = langCode()
|
|
1482
1676
|
dialog?.replace(() => (
|
|
1483
1677
|
<api.ui.DialogSelect
|
|
1484
|
-
title="
|
|
1485
|
-
options={
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1678
|
+
title={t("langTitle")}
|
|
1679
|
+
options={LANG_META.map((m) => ({
|
|
1680
|
+
title: `${visualPadEnd(m.label, 9)}${cur === m.code ? "\u2713" : ""}`,
|
|
1681
|
+
value: m.code,
|
|
1682
|
+
}))}
|
|
1489
1683
|
onSelect={(opt) => {
|
|
1490
|
-
const
|
|
1491
|
-
api.kv.set(`${KV_PREFIX}.lang`,
|
|
1492
|
-
|
|
1493
|
-
api.ui.toast({ message:
|
|
1684
|
+
const code = opt.value as LangCode
|
|
1685
|
+
api.kv.set(`${KV_PREFIX}.lang`, code)
|
|
1686
|
+
setLangCode(code)
|
|
1687
|
+
api.ui.toast({ message: t("langSwitched") })
|
|
1494
1688
|
dialog?.clear()
|
|
1495
1689
|
}}
|
|
1496
1690
|
/>
|
|
@@ -1503,15 +1697,13 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1503
1697
|
description: "切换余额提供商 / 自动切换当前会话提供商 | Switch balance provider / auto-switch session provider",
|
|
1504
1698
|
slash: { name: "cache-balance" },
|
|
1505
1699
|
onSelect: (dialog) => {
|
|
1506
|
-
const
|
|
1700
|
+
const t = createT(() => langCode())
|
|
1507
1701
|
const current = signals.balanceProviderId()
|
|
1508
1702
|
const auto = signals.autoBalance()
|
|
1509
|
-
const autoLabel = auto
|
|
1510
|
-
? (zh ? "自动切换提供商 [开]" : "Auto-switch provider [ON]")
|
|
1511
|
-
: (zh ? "自动切换提供商 [关]" : "Auto-switch provider [OFF]")
|
|
1703
|
+
const autoLabel = `${t("autoSwitchOpt")} [${auto ? "ON" : "OFF"}]`
|
|
1512
1704
|
dialog?.replace(() => (
|
|
1513
1705
|
<api.ui.DialogSelect
|
|
1514
|
-
title={
|
|
1706
|
+
title={t("balProvTitle")}
|
|
1515
1707
|
options={[
|
|
1516
1708
|
{
|
|
1517
1709
|
title: autoLabel,
|
|
@@ -1527,7 +1719,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1527
1719
|
const next = !auto
|
|
1528
1720
|
api.kv.set(`${KV_PREFIX}.balance.auto`, next)
|
|
1529
1721
|
signals.setAutoBalance(next)
|
|
1530
|
-
api.ui.toast({ message:
|
|
1722
|
+
api.ui.toast({ message: next ? t("autoSwitchOn") : t("autoSwitchOff") })
|
|
1531
1723
|
dialog?.clear()
|
|
1532
1724
|
} else {
|
|
1533
1725
|
const provider = getBalanceProvider(opt.value)
|
|
@@ -1536,6 +1728,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1536
1728
|
api.kv.set(`${KV_PREFIX}.balance.auto`, false)
|
|
1537
1729
|
signals.setBalanceProviderId(provider.id)
|
|
1538
1730
|
signals.setAutoBalance(false)
|
|
1731
|
+
signals.setBalanceUnsupported(false)
|
|
1539
1732
|
// 切换后立即按新 provider 刷新显示(无 key 时显示 idle,避免残留上一 provider 余额)
|
|
1540
1733
|
signals.setBalanceRefresh(signals.balanceRefresh() + 1)
|
|
1541
1734
|
const hasKey = !!api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
|
|
@@ -1543,7 +1736,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1543
1736
|
// 未配置 key → 进入设置流程(对话框保持打开等待输入)
|
|
1544
1737
|
promptBalanceKey(dialog, provider)
|
|
1545
1738
|
} else {
|
|
1546
|
-
api.ui.toast({ message:
|
|
1739
|
+
api.ui.toast({ message: t("providerManual", { p: provider.name }) })
|
|
1547
1740
|
dialog?.clear()
|
|
1548
1741
|
}
|
|
1549
1742
|
}
|
|
@@ -1558,11 +1751,11 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1558
1751
|
description: "Select a provider and set its API key for balance display",
|
|
1559
1752
|
slash: { name: "cache-balance-key" },
|
|
1560
1753
|
onSelect: (dialog) => {
|
|
1561
|
-
const
|
|
1754
|
+
const t = createT(() => langCode())
|
|
1562
1755
|
// 步骤 1:选择 provider
|
|
1563
1756
|
dialog?.replace(() => (
|
|
1564
1757
|
<api.ui.DialogSelect
|
|
1565
|
-
title={
|
|
1758
|
+
title={t("balSelectTitle")}
|
|
1566
1759
|
options={balanceProviders.map((p) => ({
|
|
1567
1760
|
title: providerOptionTitle(p),
|
|
1568
1761
|
value: p.id,
|
|
@@ -1589,9 +1782,10 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1589
1782
|
description: "Dump all tool parts found in the current session for skill detection debugging",
|
|
1590
1783
|
slash: { name: "cache-debug-skills" },
|
|
1591
1784
|
onSelect: () => {
|
|
1785
|
+
const t = createT(() => langCode())
|
|
1592
1786
|
const rt = api.route.current
|
|
1593
1787
|
if (rt.name !== "session" || !rt.params) {
|
|
1594
|
-
api.ui.toast({ message: "
|
|
1788
|
+
api.ui.toast({ message: t("runInSession"), variant: "warning" })
|
|
1595
1789
|
return
|
|
1596
1790
|
}
|
|
1597
1791
|
const sid = String(rt.params.sessionID)
|
|
@@ -1669,7 +1863,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1669
1863
|
|
|
1670
1864
|
if (unique.length > 0) {
|
|
1671
1865
|
// ── 有子代理 → DialogSelect 列表选择 ──
|
|
1672
|
-
const
|
|
1866
|
+
const t = createT(() => langCode())
|
|
1673
1867
|
const currentSid = signals.overrideSessionId() ?? api.kv.get<string>(`${KV_PREFIX}.session`, "")
|
|
1674
1868
|
const options = unique.map((c, i) => ({
|
|
1675
1869
|
title: `${i + 1}. ${c.title}`,
|
|
@@ -1678,24 +1872,24 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1678
1872
|
}))
|
|
1679
1873
|
// 首尾各放一个"回到主会话",长列表时顶部底部均可直达
|
|
1680
1874
|
const backValue = "__main__"
|
|
1681
|
-
const backTitle = `\u2500 ${
|
|
1875
|
+
const backTitle = `\u2500 ${t("backToMainTitle")}`
|
|
1682
1876
|
options.unshift({ title: backTitle, value: backValue, description: "" })
|
|
1683
1877
|
options.push({ title: backTitle, value: backValue, description: "" })
|
|
1684
1878
|
const currentIdx = currentSid ? options.findIndex(o => o.value === currentSid) : -1
|
|
1685
1879
|
dialog?.replace(() => (
|
|
1686
1880
|
<api.ui.DialogSelect
|
|
1687
|
-
title={
|
|
1881
|
+
title={t("subSelectTitle")}
|
|
1688
1882
|
options={options}
|
|
1689
1883
|
current={currentIdx >= 0 ? options[currentIdx].value : undefined}
|
|
1690
1884
|
onSelect={(opt) => {
|
|
1691
1885
|
if (opt.value === backValue) {
|
|
1692
1886
|
signals.setOverrideSessionId(undefined)
|
|
1693
1887
|
api.kv.set(`${KV_PREFIX}.session`, "")
|
|
1694
|
-
api.ui.toast({ message:
|
|
1888
|
+
api.ui.toast({ message: t("backToMain") })
|
|
1695
1889
|
} else {
|
|
1696
1890
|
signals.setOverrideSessionId(opt.value)
|
|
1697
1891
|
api.kv.set(`${KV_PREFIX}.session`, opt.value)
|
|
1698
|
-
api.ui.toast({ message: (
|
|
1892
|
+
api.ui.toast({ message: t("subAgentSwitched", { s: opt.value.slice(0, 24) + "\u2026" }) })
|
|
1699
1893
|
}
|
|
1700
1894
|
dialog?.clear()
|
|
1701
1895
|
}}
|
|
@@ -1703,11 +1897,11 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1703
1897
|
))
|
|
1704
1898
|
} else {
|
|
1705
1899
|
// ── 无子代理 → DialogPrompt 手动粘贴 ──
|
|
1706
|
-
const
|
|
1900
|
+
const t = createT(() => langCode())
|
|
1707
1901
|
dialog?.replace(() => (
|
|
1708
1902
|
<api.ui.DialogPrompt
|
|
1709
|
-
title={signals.overrideSessionId() ?
|
|
1710
|
-
description={() => <text>{
|
|
1903
|
+
title={signals.overrideSessionId() ? t("subSwitchTitle") : t("subViewTitle")}
|
|
1904
|
+
description={() => <text>{t("subNoFound")}</text>}
|
|
1711
1905
|
placeholder="ses_..."
|
|
1712
1906
|
value={signals.overrideSessionId() ?? api.kv.get<string>(`${KV_PREFIX}.session`, "") ?? ""}
|
|
1713
1907
|
onConfirm={(val) => {
|
|
@@ -1715,7 +1909,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1715
1909
|
if (sid) {
|
|
1716
1910
|
signals.setOverrideSessionId(sid)
|
|
1717
1911
|
api.kv.set(`${KV_PREFIX}.session`, sid)
|
|
1718
|
-
api.ui.toast({ message: (
|
|
1912
|
+
api.ui.toast({ message: t("subAgentSwitched", { s: sid.slice(0, 24) + "\u2026" }) })
|
|
1719
1913
|
}
|
|
1720
1914
|
dialog?.clear()
|
|
1721
1915
|
}}
|
|
@@ -1731,9 +1925,10 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1731
1925
|
description: "Return to main session stats",
|
|
1732
1926
|
slash: { name: "cache-session-back" },
|
|
1733
1927
|
onSelect: (dialog) => {
|
|
1928
|
+
const t = createT(() => langCode())
|
|
1734
1929
|
signals.setOverrideSessionId(undefined)
|
|
1735
1930
|
api.kv.set(`${KV_PREFIX}.session`, "")
|
|
1736
|
-
api.ui.toast({ message:
|
|
1931
|
+
api.ui.toast({ message: t("backToMain") })
|
|
1737
1932
|
dialog?.clear()
|
|
1738
1933
|
},
|
|
1739
1934
|
},
|