opencode-visual-cache 1.4.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 +37 -4
- package/README_EN.md +37 -4
- package/dist/_version.d.ts +1 -1
- package/dist/_version.js +1 -1
- package/dist/balance-providers.d.ts +27 -0
- package/dist/balance-providers.js +131 -0
- package/dist/i18n.d.ts +102 -0
- package/dist/i18n.js +368 -0
- package/dist/index.js +585 -283
- package/dist/tui.js +1350 -453
- package/package.json +1 -1
- package/src/_version.ts +1 -1
- package/src/balance-providers.ts +153 -0
- package/src/i18n.ts +380 -0
- package/src/index.tsx +687 -343
package/src/index.tsx
CHANGED
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
TuiSlotPlugin,
|
|
9
9
|
TuiPluginModule,
|
|
10
10
|
TuiThemeCurrent,
|
|
11
|
+
TuiDialogStack,
|
|
12
|
+
TuiPromptRef,
|
|
11
13
|
} from "@opencode-ai/plugin/tui"
|
|
12
14
|
import type { UserMessage, AssistantMessage, Message } from "@opencode-ai/sdk"
|
|
13
15
|
import type {
|
|
@@ -19,6 +21,8 @@ import type {
|
|
|
19
21
|
} from "@opencode-ai/sdk/v2"
|
|
20
22
|
import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, untrack } from "solid-js"
|
|
21
23
|
import { PLUGIN_VERSION } from "./_version"
|
|
24
|
+
import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry, type BalanceProvider } from "./balance-providers"
|
|
25
|
+
import { LANG_META, createT, detectLang, type LangCode } from "./i18n"
|
|
22
26
|
|
|
23
27
|
// ---------------------------------------------------------------------------
|
|
24
28
|
// Helpers
|
|
@@ -71,97 +75,14 @@ function truncateVisual(s: string, maxCols: number): string {
|
|
|
71
75
|
return result
|
|
72
76
|
}
|
|
73
77
|
|
|
74
|
-
// ── language override (env: CACHE_TUI_LANG) ──
|
|
75
|
-
const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined
|
|
76
|
-
|
|
77
78
|
// ── language ──────────────────────────────────────────────────────
|
|
79
|
+
// 语言初始化:环境变量 CACHE_TUI_LANG 覆盖 → 否则按系统 locale 自动检测。
|
|
80
|
+
// 用户通过 /cache-lang 设置的偏好会在 KV 就绪后优先覆盖(见 tui() 内恢复逻辑)。
|
|
78
81
|
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
catch { return false }
|
|
84
|
-
})()
|
|
85
|
-
|
|
86
|
-
const ZH_T = {
|
|
87
|
-
title: "缓存统计",
|
|
88
|
-
hit: "命中率",
|
|
89
|
-
totalHit: "总命中:",
|
|
90
|
-
read: "缓存读:",
|
|
91
|
-
write: "缓存写:",
|
|
92
|
-
miss: "未命中:",
|
|
93
|
-
out: "输出:",
|
|
94
|
-
cost: "费用:",
|
|
95
|
-
saved: "累计节省:",
|
|
96
|
-
model: "模型:",
|
|
97
|
-
provider: "提供商:",
|
|
98
|
-
rate: "单价:",
|
|
99
|
-
hitFolded: "命中",
|
|
100
|
-
inputRate: "输入",
|
|
101
|
-
cacheRate: "缓存",
|
|
102
|
-
writeRate: "写入",
|
|
103
|
-
noData: "等待缓存数据...",
|
|
104
|
-
tok: "tok",
|
|
105
|
-
distTitle: "估算 Token 分布",
|
|
106
|
-
distSys: "系统提示:",
|
|
107
|
-
distUser: "用户:",
|
|
108
|
-
distAgent: "Agent 指令:",
|
|
109
|
-
distTool: "Tool 调用:",
|
|
110
|
-
distRes: "Tool 结果:",
|
|
111
|
-
distTotal: "总计:",
|
|
112
|
-
distOut: "输出:",
|
|
113
|
-
secDetail: "明细",
|
|
114
|
-
secModel: "模型",
|
|
115
|
-
secSkills: "已加载技能",
|
|
116
|
-
balTotal: "总余额:",
|
|
117
|
-
balNoKey: "未配置 API Key",
|
|
118
|
-
balLoading: "查询中...",
|
|
119
|
-
balError: "查询失败",
|
|
120
|
-
balErr401: "API Key 无效",
|
|
121
|
-
balErr403: "余额查询被拒绝",
|
|
122
|
-
balErrEmpty:"未获取到余额数据",
|
|
123
|
-
balErrTimeout: "查询超时",
|
|
124
|
-
} as const
|
|
125
|
-
|
|
126
|
-
const EN_T = {
|
|
127
|
-
title: "Token Cache",
|
|
128
|
-
hit: "Hit",
|
|
129
|
-
totalHit: "Total Hit:",
|
|
130
|
-
read: "Read:",
|
|
131
|
-
write: "Write:",
|
|
132
|
-
miss: "Miss:",
|
|
133
|
-
out: "Out:",
|
|
134
|
-
cost: "Cost:",
|
|
135
|
-
saved: "Total Saved:",
|
|
136
|
-
model: "Model:",
|
|
137
|
-
provider: "Provider:",
|
|
138
|
-
rate: "Rate:",
|
|
139
|
-
hitFolded: "hit",
|
|
140
|
-
inputRate: "in",
|
|
141
|
-
cacheRate: "cache",
|
|
142
|
-
writeRate: "write",
|
|
143
|
-
noData: "Waiting for cache data...",
|
|
144
|
-
tok: "tok",
|
|
145
|
-
distTitle: "Estimated Token Dist.",
|
|
146
|
-
distSys: "System:",
|
|
147
|
-
distUser: "User:",
|
|
148
|
-
distAgent: "Agent Instr:",
|
|
149
|
-
distTool: "Tool Call:",
|
|
150
|
-
distRes: "Tool Result:",
|
|
151
|
-
distTotal: "Total:",
|
|
152
|
-
distOut: "Output:",
|
|
153
|
-
secDetail: "Detail",
|
|
154
|
-
secModel: "Model",
|
|
155
|
-
secSkills: "Loaded Skills",
|
|
156
|
-
balTotal: "Total:",
|
|
157
|
-
balNoKey: "No API Key set",
|
|
158
|
-
balLoading: "Fetching...",
|
|
159
|
-
balError: "Fetch failed",
|
|
160
|
-
balErr401: "Invalid API Key",
|
|
161
|
-
balErr403: "Balance request rejected",
|
|
162
|
-
balErrEmpty:"No balance data",
|
|
163
|
-
balErrTimeout: "Request timed out",
|
|
164
|
-
} 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()
|
|
165
86
|
|
|
166
87
|
// ── color helpers ────────────────────────────────────────────────
|
|
167
88
|
|
|
@@ -222,7 +143,7 @@ function desaturateTo(raw: unknown, maxSat: number, fallback: string): string {
|
|
|
222
143
|
* converges to within a fraction of an 8‑bit step, eliminating
|
|
223
144
|
* colour banding in edge cases.
|
|
224
145
|
*/
|
|
225
|
-
//
|
|
146
|
+
// Bt.601 luma (perceptual brightness used as the grey anchor)
|
|
226
147
|
const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114
|
|
227
148
|
let lo = 0, hi = 1
|
|
228
149
|
for (let i = 0; i < 12; i++) {
|
|
@@ -343,22 +264,17 @@ interface TokenDist {
|
|
|
343
264
|
toolResult: number // ToolPart completed output / error
|
|
344
265
|
output: number // AssistantMessage.tokens.output (fallback)
|
|
345
266
|
apiOutput: number // StepFinishPart.tokens.output (API exact, preferred)
|
|
346
|
-
apiInput: number //
|
|
267
|
+
apiInput: number // API exact total input context (input + cache read + cache write)
|
|
347
268
|
stepCost: number
|
|
348
269
|
}
|
|
349
270
|
|
|
350
271
|
// ---------------------------------------------------------------------------
|
|
351
|
-
//
|
|
272
|
+
// Balance state
|
|
352
273
|
// ---------------------------------------------------------------------------
|
|
353
274
|
|
|
354
|
-
interface DeepSeekBalance {
|
|
355
|
-
currency: string
|
|
356
|
-
total: string
|
|
357
|
-
}
|
|
358
|
-
|
|
359
275
|
interface BalanceState {
|
|
360
276
|
status: "idle" | "loading" | "ok" | "error"
|
|
361
|
-
data:
|
|
277
|
+
data: BalanceEntry[] | null
|
|
362
278
|
lastFetch: number
|
|
363
279
|
error?: string
|
|
364
280
|
key?: string // 上次成功/尝试查询所用的 key,用于检测 key 是否更换
|
|
@@ -366,28 +282,6 @@ interface BalanceState {
|
|
|
366
282
|
|
|
367
283
|
const BALANCE_POLL_MS = 5 * 60 * 1000 // 5 minutes
|
|
368
284
|
|
|
369
|
-
async function fetchDeepSeekBalance(apiKey: string, signal?: AbortSignal): Promise<DeepSeekBalance[]> {
|
|
370
|
-
const res = await fetch("https://api.deepseek.com/user/balance", {
|
|
371
|
-
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
372
|
-
signal,
|
|
373
|
-
})
|
|
374
|
-
if (!res.ok) {
|
|
375
|
-
if (res.status === 401) throw new Error("401")
|
|
376
|
-
if (res.status === 402 || res.status === 403) throw new Error("403")
|
|
377
|
-
throw new Error(String(res.status))
|
|
378
|
-
}
|
|
379
|
-
const json = await res.json() as {
|
|
380
|
-
is_available?: boolean
|
|
381
|
-
balance_infos?: { currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }[]
|
|
382
|
-
}
|
|
383
|
-
const infos = json.balance_infos ?? []
|
|
384
|
-
if (infos.length === 0) throw new Error("EMPTY")
|
|
385
|
-
return infos.map((info) => ({
|
|
386
|
-
currency: info.currency ?? "CNY",
|
|
387
|
-
total: info.total_balance ?? "0",
|
|
388
|
-
}))
|
|
389
|
-
}
|
|
390
|
-
|
|
391
285
|
/**
|
|
392
286
|
* 将余额从来源币种换算为目标币种。
|
|
393
287
|
* DEFAULT_RATES 以 USD=1 为基准:先折算为 USD,再换算到目标币种。
|
|
@@ -399,12 +293,66 @@ function convertBalance(target: string, targetRate: number, amount: number, from
|
|
|
399
293
|
return target === "USD" ? usd : usd * targetRate
|
|
400
294
|
}
|
|
401
295
|
|
|
296
|
+
/**
|
|
297
|
+
* 从 OpenCode 已认证的 provider 读取 API key 作为余额查询的自动兜底。
|
|
298
|
+
* 匹配复用前缀逻辑:先精确匹配 id,再前缀匹配(如 moonshotai-cn → moonshot)。
|
|
299
|
+
* key 来源:auth.json(provider.key)或配置(provider.options.apiKey)。
|
|
300
|
+
* 仅当手动配置的 key 缺失时使用;读取失败或未匹配返回空串。
|
|
301
|
+
*/
|
|
302
|
+
function findOpencodeKey(api: TuiPluginApi, provider: BalanceProvider): string {
|
|
303
|
+
try {
|
|
304
|
+
const provs = api.state.provider as unknown as Array<{ id: string; key?: string; options?: { apiKey?: string } }>
|
|
305
|
+
// 大小写不敏感:精确匹配 id,否则前缀匹配(如 moonshotai-cn → moonshot)
|
|
306
|
+
const id = provider.id.toLowerCase()
|
|
307
|
+
const hit = provs.find((p) => p.id.toLowerCase() === id) ?? provs.find((p) => p.id.toLowerCase().startsWith(id))
|
|
308
|
+
if (!hit) return ""
|
|
309
|
+
const k = typeof hit.key === "string" ? hit.key : ""
|
|
310
|
+
if (k) return k
|
|
311
|
+
return typeof hit.options?.apiKey === "string" ? hit.options.apiKey : ""
|
|
312
|
+
} catch {
|
|
313
|
+
return ""
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
402
317
|
/** 货币符号:优先取 /cache-currency 内置映射,未知币种回退为代码。 */
|
|
403
318
|
function balanceSymbol(currency: string): string {
|
|
404
319
|
const sym = CURRENCIES[currency]
|
|
405
320
|
return sym ?? currency + " "
|
|
406
321
|
}
|
|
407
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
|
+
|
|
408
356
|
// ---------------------------------------------------------------------------
|
|
409
357
|
// Sidebar component
|
|
410
358
|
// ---------------------------------------------------------------------------
|
|
@@ -417,8 +365,8 @@ interface PanelSignals {
|
|
|
417
365
|
setCurrencySymbol: (v: string) => void
|
|
418
366
|
exchangeRate: () => number
|
|
419
367
|
setExchangeRate: (v: number) => void
|
|
420
|
-
|
|
421
|
-
|
|
368
|
+
langCode: () => LangCode
|
|
369
|
+
setLangCode: (v: LangCode) => void
|
|
422
370
|
sectionDetail: () => boolean
|
|
423
371
|
setSectionDetail: (v: boolean) => void
|
|
424
372
|
sectionModel: () => boolean
|
|
@@ -429,9 +377,23 @@ interface PanelSignals {
|
|
|
429
377
|
setSectionSkills: (v: boolean) => void
|
|
430
378
|
sectionBalance: () => boolean
|
|
431
379
|
setSectionBalance: (v: boolean) => void
|
|
432
|
-
/**
|
|
380
|
+
/** Bottom status bar (prompt hint line) visibility. */
|
|
381
|
+
sectionBottom: () => boolean
|
|
382
|
+
setSectionBottom: (v: boolean) => void
|
|
383
|
+
/** Increment to force a balance re-fetch. */
|
|
433
384
|
balanceRefresh: () => number
|
|
434
385
|
setBalanceRefresh: (v: number) => void
|
|
386
|
+
/** Currently selected balance provider id (e.g. "deepseek"). */
|
|
387
|
+
balanceProviderId: () => string
|
|
388
|
+
setBalanceProviderId: (v: string) => void
|
|
389
|
+
/** Auto-switch to the session's provider for balance display. Manual switch disables it. */
|
|
390
|
+
autoBalance: () => boolean
|
|
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
|
|
435
397
|
/** Preferred currency code for balance display (CNY / USD / …). Empty = first entry. */
|
|
436
398
|
balanceCurrency: () => string
|
|
437
399
|
setBalanceCurrency: (v: string) => void
|
|
@@ -481,19 +443,23 @@ function TokenCachePanel(props: {
|
|
|
481
443
|
const {
|
|
482
444
|
currencySymbol, setCurrencySymbol,
|
|
483
445
|
exchangeRate, setExchangeRate,
|
|
484
|
-
|
|
446
|
+
langCode,
|
|
485
447
|
sectionDetail, setSectionDetail,
|
|
486
448
|
sectionModel, setSectionModel,
|
|
487
449
|
sectionDist, setSectionDist,
|
|
488
450
|
sectionSkills, setSectionSkills,
|
|
489
451
|
sectionBalance, setSectionBalance,
|
|
490
452
|
balanceRefresh,
|
|
453
|
+
balanceProviderId, setBalanceProviderId,
|
|
454
|
+
autoBalance, setAutoBalance,
|
|
455
|
+
balanceUnsupported, setBalanceUnsupported,
|
|
456
|
+
balanceState,
|
|
491
457
|
balanceCurrency, setBalanceCurrency,
|
|
492
458
|
borderVisible, setBorderVisible,
|
|
493
459
|
} = props.signals
|
|
494
460
|
|
|
495
|
-
// ── reactive translation (follows
|
|
496
|
-
const t =
|
|
461
|
+
// ── reactive translation (follows langCode signal) ──
|
|
462
|
+
const t = createT(() => langCode())
|
|
497
463
|
|
|
498
464
|
// ── scan session messages reactively ──
|
|
499
465
|
// SolidJS createMemo re-evaluates whenever the underlying
|
|
@@ -522,46 +488,44 @@ function TokenCachePanel(props: {
|
|
|
522
488
|
})
|
|
523
489
|
const [refreshTick, setRefreshTick] = createSignal(0)
|
|
524
490
|
|
|
525
|
-
//
|
|
526
|
-
const
|
|
527
|
-
status: "idle", data: null, lastFetch: 0,
|
|
528
|
-
})
|
|
529
|
-
// 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果
|
|
530
|
-
let balanceSeq = 0
|
|
491
|
+
// 当前 provider 显示名(余额查询状态为共享信号,见 PanelSignals.balanceState)
|
|
492
|
+
const providerName = createMemo(() => getBalanceProvider(balanceProviderId()).name)
|
|
531
493
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const now = Date.now()
|
|
536
|
-
const prev = balanceState()
|
|
537
|
-
// key 已更换(重新输入)→ 强制重新查询,绕过缓存
|
|
538
|
-
if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS) return // cache still fresh
|
|
539
|
-
const seq = ++balanceSeq
|
|
540
|
-
setBalanceState({ ...prev, status: "loading", error: undefined, key })
|
|
541
|
-
const controller = new AbortController()
|
|
542
|
-
let timedOut = false
|
|
543
|
-
const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000)
|
|
544
|
-
try {
|
|
545
|
-
const data = await fetchDeepSeekBalance(key, controller.signal)
|
|
546
|
-
clearTimeout(timer)
|
|
547
|
-
if (seq !== balanceSeq) return // 已被更新的请求取代,丢弃过期结果
|
|
548
|
-
setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key })
|
|
549
|
-
} catch (err) {
|
|
550
|
-
clearTimeout(timer)
|
|
551
|
-
if (seq !== balanceSeq) return
|
|
552
|
-
const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "")
|
|
553
|
-
// 失败时清空旧数据,避免显示过期余额
|
|
554
|
-
setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key })
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// Re-fetch when the API key is (re)configured via /cache-balance-key.
|
|
559
|
-
// 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹,
|
|
560
|
-
// effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState
|
|
561
|
-
// 形成无限循环(每次重跑都发起新的 fetch 请求)。
|
|
494
|
+
// 自动切换当前会话的 provider(前缀匹配)。手动切换会关闭此行为。
|
|
495
|
+
// 直接追踪 messages 取最后一条 assistant 消息的 providerID——
|
|
496
|
+
// 不依赖 session.model 的响应式更新(模型切换时该链路可能不触发重算)。
|
|
562
497
|
createEffect(() => {
|
|
563
|
-
|
|
564
|
-
|
|
498
|
+
if (!autoBalance()) return
|
|
499
|
+
const sid = props.signals.overrideSessionId() ?? props.sessionId
|
|
500
|
+
const msgs = props.api.state.session.messages(sid) as Message[]
|
|
501
|
+
let pid = ""
|
|
502
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
503
|
+
const m = msgs[i]
|
|
504
|
+
if (m.role === "assistant" && (m as AssistantMessage).providerID) {
|
|
505
|
+
pid = (m as AssistantMessage).providerID
|
|
506
|
+
break
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// 会话尚无 assistant 消息(新会话 / 刚切换模型未对话 / 消息未加载)
|
|
510
|
+
// → 回退到会话级模型元数据,反映当前正在使用的 provider
|
|
511
|
+
if (!pid) {
|
|
512
|
+
try {
|
|
513
|
+
const session = props.api.state.session.get(sid)
|
|
514
|
+
pid = session?.model?.providerID ?? ""
|
|
515
|
+
} catch { /* ignore */ }
|
|
516
|
+
}
|
|
517
|
+
if (!pid) return
|
|
518
|
+
const hit = matchBalanceProvider(pid)
|
|
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)
|
|
528
|
+
}
|
|
565
529
|
})
|
|
566
530
|
|
|
567
531
|
// ── auto-clear override when the user navigates to a different main session ──
|
|
@@ -605,11 +569,11 @@ function TokenCachePanel(props: {
|
|
|
605
569
|
let prevMsgHitRate = -1, lastMsgHitRate = -1
|
|
606
570
|
for (const msg of msgs) {
|
|
607
571
|
if (msg.role !== "assistant") continue
|
|
608
|
-
const
|
|
609
|
-
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)
|
|
610
574
|
if (mit > 0) { prevMsgHitRate = lastMsgHitRate; lastMsgHitRate = (mrt / mit) * 100 }
|
|
611
575
|
if (fallbackTokens) {
|
|
612
|
-
input += num(
|
|
576
|
+
input += num(tok.input); read += num(tok.cache?.read); write += num(tok.cache?.write); output += num(tok.output)
|
|
613
577
|
}
|
|
614
578
|
if (fallbackCost) {
|
|
615
579
|
cost += num((msg as AssistantMessage).cost)
|
|
@@ -627,7 +591,8 @@ function TokenCachePanel(props: {
|
|
|
627
591
|
break
|
|
628
592
|
}
|
|
629
593
|
const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0
|
|
630
|
-
|
|
594
|
+
// 总命中率分母含缓存写(业界口径:read / (input+read+write))
|
|
595
|
+
const freshTotal = input + read + write, sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0
|
|
631
596
|
const model = mid.split("/").pop() ?? mid, hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0
|
|
632
597
|
const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0
|
|
633
598
|
const trend = hasTrendData ? lastMsgHitRate - prevMsgHitRate : 0, providerName = pid || ""
|
|
@@ -690,11 +655,11 @@ function TokenCachePanel(props: {
|
|
|
690
655
|
// 从后往前找最后一条有 token 数据的 assistant 消息(避免取到 streaming 中未填充的消息)
|
|
691
656
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
692
657
|
if (msgs[i].role !== "assistant") continue
|
|
693
|
-
const
|
|
694
|
-
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 }
|
|
695
660
|
}
|
|
696
|
-
//
|
|
697
|
-
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)
|
|
698
663
|
dist.apiOutput = num(lastAssMsg?.tokens?.output)
|
|
699
664
|
hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0
|
|
700
665
|
} catch {}
|
|
@@ -763,6 +728,24 @@ function TokenCachePanel(props: {
|
|
|
763
728
|
if (typeof rate === "number" && rate > 0) setExchangeRate(rate)
|
|
764
729
|
const balCur = props.api.kv.get<string>(`${KV_PREFIX}.balance_currency`)
|
|
765
730
|
if (typeof balCur === "string") setBalanceCurrency(balCur)
|
|
731
|
+
// Restore balance provider (fall back to default when unknown)
|
|
732
|
+
const savedProvider = props.api.kv.get<string>(`${KV_PREFIX}.balance.provider`)
|
|
733
|
+
if (typeof savedProvider === "string" && balanceProviders.some((p) => p.id === savedProvider)) {
|
|
734
|
+
setBalanceProviderId(savedProvider)
|
|
735
|
+
setBalanceUnsupported(false)
|
|
736
|
+
}
|
|
737
|
+
// Restore auto-switch (default on)
|
|
738
|
+
const savedAuto = props.api.kv.get<boolean>(`${KV_PREFIX}.balance.auto`)
|
|
739
|
+
if (typeof savedAuto === "boolean") setAutoBalance(savedAuto)
|
|
740
|
+
// Migrate legacy DeepSeek key (cache_panel.ds_key → cache_panel.balance.deepseek.key)
|
|
741
|
+
const legacyKey = props.api.kv.get<string>(`${KV_PREFIX}.ds_key`, "")
|
|
742
|
+
if (legacyKey) {
|
|
743
|
+
const dsKey = props.api.kv.get<string>(`${KV_PREFIX}.balance.deepseek.key`, "")
|
|
744
|
+
if (!dsKey) props.api.kv.set(`${KV_PREFIX}.balance.deepseek.key`, legacyKey)
|
|
745
|
+
props.api.kv.set(`${KV_PREFIX}.ds_key`, "")
|
|
746
|
+
}
|
|
747
|
+
// 恢复的 provider 可能与默认值不同,强制重新查询
|
|
748
|
+
props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
|
|
766
749
|
setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)))
|
|
767
750
|
setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)))
|
|
768
751
|
setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)))
|
|
@@ -770,11 +753,6 @@ function TokenCachePanel(props: {
|
|
|
770
753
|
setSectionBalance(Boolean(props.api.kv.get(`${KV_PREFIX}.section.balance`, true)))
|
|
771
754
|
const bv = props.api.kv.get<boolean>(`${KV_PREFIX}.border`, true)
|
|
772
755
|
setBorderVisible(bv !== false)
|
|
773
|
-
// Restore language preference
|
|
774
|
-
const savedLang = props.api.kv.get<string>(`${KV_PREFIX}.lang`)
|
|
775
|
-
if (savedLang === "zh" || savedLang === "en") {
|
|
776
|
-
setLangZH(savedLang === "zh")
|
|
777
|
-
}
|
|
778
756
|
// Restore distribution snapshot so the token distribution block
|
|
779
757
|
// doesn't blank out while api.state.part() re-hydrates.
|
|
780
758
|
const cachedDist = props.api.kv.get<TokenDist>(`${KV_PREFIX}.dist_snapshot`)
|
|
@@ -822,8 +800,7 @@ function TokenCachePanel(props: {
|
|
|
822
800
|
const unsubMsg = props.api.event.on("message.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) })
|
|
823
801
|
const unsubSession = props.api.event.on("session.updated", () => { setRefreshTick(v => v + 1) })
|
|
824
802
|
setRefreshTick(v => v + 1)
|
|
825
|
-
|
|
826
|
-
onCleanup(() => { clearTimeout(partTimer); clearInterval(balanceTimer); unsubPart(); unsubMsg(); unsubSession() })
|
|
803
|
+
onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); unsubSession() })
|
|
827
804
|
})
|
|
828
805
|
|
|
829
806
|
// ── colours ──
|
|
@@ -855,12 +832,14 @@ function TokenCachePanel(props: {
|
|
|
855
832
|
|
|
856
833
|
const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter())))
|
|
857
834
|
function trendLabel(t: number): string {
|
|
858
|
-
|
|
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) + "%"
|
|
859
838
|
}
|
|
860
839
|
|
|
861
840
|
const barW = createMemo(() => {
|
|
862
841
|
const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0
|
|
863
|
-
const overhead = visualWidth(t(
|
|
842
|
+
const overhead = visualWidth(t("hit")) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
|
|
864
843
|
return Math.max(3, panelWidth() - overhead)
|
|
865
844
|
})
|
|
866
845
|
const bar = createMemo(() => progressBar(data().hitRate, barW()))
|
|
@@ -906,7 +885,7 @@ function TokenCachePanel(props: {
|
|
|
906
885
|
<text onMouseUp={() => setOpen((o) => { const n = !o; persistFold("open", n); return n })}>
|
|
907
886
|
<span style={{ fg: pal().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
|
|
908
887
|
<span style={{ fg: pal().primary }}>
|
|
909
|
-
<b>{t()
|
|
888
|
+
<b>{t("title")}</b>
|
|
910
889
|
<Show when={open()}>
|
|
911
890
|
<span style={{ fg: dimColor(pal().muted, 0.75) }}> v{PLUGIN_VERSION}</span>
|
|
912
891
|
</Show>
|
|
@@ -914,18 +893,18 @@ function TokenCachePanel(props: {
|
|
|
914
893
|
<Show when={!open() && data().hasData}>
|
|
915
894
|
<Show when={data().hasTrendData}>
|
|
916
895
|
<span>
|
|
917
|
-
{" ".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))))}
|
|
918
897
|
</span>
|
|
919
|
-
<span style={{ fg: hitColor() }}>{pct()} {t()
|
|
920
|
-
<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 }}>
|
|
921
900
|
{" "}{trendLabel(data().trend)}
|
|
922
901
|
</span>
|
|
923
902
|
</Show>
|
|
924
903
|
<Show when={!data().hasTrendData}>
|
|
925
904
|
<span>
|
|
926
|
-
{" ".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"))))}
|
|
927
906
|
</span>
|
|
928
|
-
<span style={{ fg: hitColor() }}>{pct()} {t()
|
|
907
|
+
<span style={{ fg: hitColor() }}>{pct()} {t("hitFolded")}</span>
|
|
929
908
|
</Show>
|
|
930
909
|
</Show>
|
|
931
910
|
</text>
|
|
@@ -933,7 +912,7 @@ function TokenCachePanel(props: {
|
|
|
933
912
|
<Show when={open()}>
|
|
934
913
|
<Show when={props.signals.overrideSessionId()}>
|
|
935
914
|
{(() => {
|
|
936
|
-
const prefix = " \u21b3 " + (
|
|
915
|
+
const prefix = " \u21b3 " + t("subPrefix")
|
|
937
916
|
const maxSidW = Math.max(6, panelWidth() - visualWidth(prefix))
|
|
938
917
|
return (
|
|
939
918
|
<text>
|
|
@@ -948,7 +927,7 @@ function TokenCachePanel(props: {
|
|
|
948
927
|
<text fg={pal().muted}>{sep()}</text>
|
|
949
928
|
<text>
|
|
950
929
|
<span style={{ fg: pal().muted }}>{"> "}</span>
|
|
951
|
-
<span style={{ fg: pal().muted }}>{t()
|
|
930
|
+
<span style={{ fg: pal().muted }}>{t("noData")}</span>
|
|
952
931
|
</text>
|
|
953
932
|
</>
|
|
954
933
|
}>
|
|
@@ -956,11 +935,11 @@ function TokenCachePanel(props: {
|
|
|
956
935
|
|
|
957
936
|
{/* hit rate + bar — inline to avoid box spacing */}
|
|
958
937
|
<text>
|
|
959
|
-
<span style={{ fg: pal().text }}>{t()
|
|
938
|
+
<span style={{ fg: pal().text }}>{t("hit")} </span>
|
|
960
939
|
<span style={{ fg: hitColor() }}>[{bar()}] </span>
|
|
961
940
|
<span style={{ fg: pal().text }}>{pct()}</span>
|
|
962
941
|
<Show when={data().hasTrendData}>
|
|
963
|
-
<span style={{ fg: data().trend
|
|
942
|
+
<span style={{ fg: Math.abs(data().trend) >= 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
|
|
964
943
|
{" "}{trendLabel(data().trend)}
|
|
965
944
|
</span>
|
|
966
945
|
</Show>
|
|
@@ -968,38 +947,39 @@ function TokenCachePanel(props: {
|
|
|
968
947
|
|
|
969
948
|
{/* session cumulative hit rate */}
|
|
970
949
|
<text fg={pal().muted}>
|
|
971
|
-
{justify(t()
|
|
950
|
+
{justify(t("totalHit"), (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")}
|
|
972
951
|
</text>
|
|
973
952
|
|
|
974
953
|
{/* ── detail section (collapsible, default open) ── */}
|
|
975
954
|
<Show when={sectionDetail()}>
|
|
976
955
|
<text onMouseUp={() => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n })}>
|
|
977
956
|
<span style={{ fg: pal().muted }}>{detailOpen() ? "\u25bc " : "\u25b6 "}</span>
|
|
978
|
-
<span style={{ fg: pal().primary }}><b>{t()
|
|
979
|
-
<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>
|
|
980
959
|
</text>
|
|
981
960
|
|
|
982
961
|
<Show when={detailOpen()}>
|
|
983
962
|
<Show when={data().read > 0}>
|
|
984
963
|
<text fg={pal().muted}>
|
|
985
|
-
{justify(t()
|
|
964
|
+
{justify(t("read"), fmt(data().read), t("tok"))}
|
|
986
965
|
</text>
|
|
987
966
|
</Show>
|
|
988
967
|
<Show when={data().write > 0}>
|
|
989
968
|
<text fg={pal().muted}>
|
|
990
|
-
{justify(t()
|
|
969
|
+
{justify(t("write"), fmt(data().write), t("tok"))}
|
|
991
970
|
</text>
|
|
992
971
|
</Show>
|
|
972
|
+
{/* 未命中 = 新鲜输入 + 缓存写(两者都未从缓存命中) */}
|
|
993
973
|
<text fg={pal().muted}>
|
|
994
|
-
{justify(t()
|
|
974
|
+
{justify(t("miss"), fmt(data().freshInput + data().write), t("tok"))}
|
|
995
975
|
</text>
|
|
996
976
|
<text fg={pal().muted}>
|
|
997
|
-
{justify(t()
|
|
977
|
+
{justify(t("out"), fmt(data().output), t("tok"))}
|
|
998
978
|
</text>
|
|
999
979
|
<Show when={data().saved > 0}>
|
|
1000
980
|
<text>
|
|
1001
|
-
<span style={{ fg: pal().muted }}>{t()
|
|
1002
|
-
<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>
|
|
1003
983
|
<span style={{ fg: pal().success }}>~{fmtCost(data().saved, currencySymbol(), exchangeRate())}</span>
|
|
1004
984
|
</text>
|
|
1005
985
|
</Show>
|
|
@@ -1010,34 +990,34 @@ function TokenCachePanel(props: {
|
|
|
1010
990
|
<Show when={sectionModel()}>
|
|
1011
991
|
{<text onMouseUp={() => setModelOpen((o) => { const n = !o; persistFold("model", n); return n })}>
|
|
1012
992
|
<span style={{ fg: pal().muted }}>{modelOpen() ? "\u25bc " : "\u25b6 "}</span>
|
|
1013
|
-
<span style={{ fg: pal().primary }}><b>{t()
|
|
1014
|
-
<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>
|
|
1015
995
|
</text>}
|
|
1016
996
|
|
|
1017
997
|
<Show when={modelOpen()}>
|
|
1018
998
|
<text fg={pal().text}>
|
|
1019
|
-
{justify(t()
|
|
999
|
+
{justify(t("cost"), fmtCost(data().cost, currencySymbol(), exchangeRate()))}
|
|
1020
1000
|
</text>
|
|
1021
1001
|
<Show when={data().providerName}>
|
|
1022
1002
|
<text fg={pal().muted}>
|
|
1023
|
-
{justify(t()
|
|
1003
|
+
{justify(t("provider"), data().providerName)}
|
|
1024
1004
|
</text>
|
|
1025
1005
|
</Show>
|
|
1026
1006
|
<text fg={pal().muted}>
|
|
1027
|
-
{justify(t()
|
|
1007
|
+
{justify(t("model"), data().model)}
|
|
1028
1008
|
</text>
|
|
1029
1009
|
<Show when={data().hasPricing}>
|
|
1030
1010
|
<text fg={pal().muted}>
|
|
1031
|
-
{justify(t()
|
|
1011
|
+
{justify(t("rate"), currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + t("inputRate"))}
|
|
1032
1012
|
</text>
|
|
1033
1013
|
<Show when={data().cacheReadRate > 0}>
|
|
1034
1014
|
<text fg={pal().muted}>
|
|
1035
|
-
{justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t(
|
|
1015
|
+
{justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t("cacheRate"))}
|
|
1036
1016
|
</text>
|
|
1037
1017
|
</Show>
|
|
1038
1018
|
<Show when={data().cacheWriteRate > 0}>
|
|
1039
1019
|
<text fg={pal().muted}>
|
|
1040
|
-
{justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t(
|
|
1020
|
+
{justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t("writeRate"))}
|
|
1041
1021
|
</text>
|
|
1042
1022
|
</Show>
|
|
1043
1023
|
</Show>
|
|
@@ -1049,37 +1029,37 @@ function TokenCachePanel(props: {
|
|
|
1049
1029
|
<Show when={data().hasDistData}>
|
|
1050
1030
|
{<text onMouseUp={() => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n })}>
|
|
1051
1031
|
<span style={{ fg: pal().muted }}>{distOpen() ? "\u25bc " : "\u25b6 "}</span>
|
|
1052
|
-
<span style={{ fg: pal().primary }}><b>{t()
|
|
1053
|
-
<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>
|
|
1054
1034
|
</text>}
|
|
1055
1035
|
<Show when={distOpen()}>
|
|
1056
1036
|
<Show when={data().dist.system > 0}>
|
|
1057
1037
|
<text fg={pal().muted}>
|
|
1058
|
-
{justify(t()
|
|
1038
|
+
{justify(t("distSys"), fmt(data().dist.system), t("tok"))}
|
|
1059
1039
|
</text>
|
|
1060
1040
|
</Show>
|
|
1061
1041
|
<Show when={data().dist.user > 0}>
|
|
1062
1042
|
<text fg={pal().muted}>
|
|
1063
|
-
{justify(t()
|
|
1043
|
+
{justify(t("distUser"), fmt(data().dist.user), t("tok"))}
|
|
1064
1044
|
</text>
|
|
1065
1045
|
</Show>
|
|
1066
1046
|
<Show when={data().dist.agent > 0}>
|
|
1067
1047
|
<text fg={pal().muted}>
|
|
1068
|
-
{justify(t()
|
|
1048
|
+
{justify(t("distAgent"), fmt(data().dist.agent), t("tok"))}
|
|
1069
1049
|
</text>
|
|
1070
1050
|
</Show>
|
|
1071
1051
|
<Show when={data().dist.toolCall > 0}>
|
|
1072
1052
|
<text fg={pal().muted}>
|
|
1073
|
-
{justify(t()
|
|
1053
|
+
{justify(t("distTool"), fmt(data().dist.toolCall), t("tok"))}
|
|
1074
1054
|
</text>
|
|
1075
1055
|
</Show>
|
|
1076
1056
|
<Show when={data().dist.toolResult > 0}>
|
|
1077
1057
|
<text fg={pal().muted}>
|
|
1078
|
-
{justify(t()
|
|
1058
|
+
{justify(t("distRes"), fmt(data().dist.toolResult), t("tok"))}
|
|
1079
1059
|
</text>
|
|
1080
1060
|
</Show>
|
|
1081
1061
|
<text fg={pal().text}>
|
|
1082
|
-
{justify(t()
|
|
1062
|
+
{justify(t("distTotal"), fmt(data().dist.apiInput), t("tok"))}
|
|
1083
1063
|
</text>
|
|
1084
1064
|
</Show>
|
|
1085
1065
|
</Show>
|
|
@@ -1090,18 +1070,18 @@ function TokenCachePanel(props: {
|
|
|
1090
1070
|
<Show when={data().hasSkills}>
|
|
1091
1071
|
{<text onMouseUp={() => setSkillsOpen((o) => { const n = !o; persistFold("skills", n); return n })}>
|
|
1092
1072
|
<span style={{ fg: pal().muted }}>{skillsOpen() ? "\u25bc " : "\u25b6 "}</span>
|
|
1093
|
-
<span style={{ fg: pal().primary }}><b>{t()
|
|
1073
|
+
<span style={{ fg: pal().primary }}><b>{t("secSkills")}</b></span>
|
|
1094
1074
|
<span style={{ fg: pal().muted }}> ({data().skills.length})</span>
|
|
1095
|
-
<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>
|
|
1096
1076
|
</text>}
|
|
1097
1077
|
<Show when={skillsOpen()}>
|
|
1098
1078
|
{data().skills.map((sk: { name: string; tokens: number }) => {
|
|
1099
|
-
const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t(
|
|
1079
|
+
const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t("tok"))
|
|
1100
1080
|
const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1)
|
|
1101
1081
|
const label = truncateVisual(sk.name, maxLabel)
|
|
1102
1082
|
return (
|
|
1103
1083
|
<text fg={pal().muted}>
|
|
1104
|
-
{justify(label, fmt(sk.tokens), t(
|
|
1084
|
+
{justify(label, fmt(sk.tokens), t("tok"))}
|
|
1105
1085
|
</text>
|
|
1106
1086
|
)
|
|
1107
1087
|
})}
|
|
@@ -1109,62 +1089,46 @@ function TokenCachePanel(props: {
|
|
|
1109
1089
|
</Show>
|
|
1110
1090
|
</Show>
|
|
1111
1091
|
|
|
1112
|
-
{/* ──
|
|
1092
|
+
{/* ── provider balance (single line) ── */}
|
|
1113
1093
|
<Show when={sectionBalance()}>
|
|
1114
1094
|
<text fg={pal().muted}>{sep()}</text>
|
|
1115
|
-
<Show when={
|
|
1116
|
-
<text fg={pal().muted}>
|
|
1117
|
-
<span style={{ fg: pal().muted }}>{"> "}</span>
|
|
1118
|
-
<span>{t().balNoKey}</span>
|
|
1119
|
-
</text>
|
|
1120
|
-
</Show>
|
|
1121
|
-
<Show when={balanceState().status === "loading"}>
|
|
1095
|
+
<Show when={balanceUnsupported()}>
|
|
1122
1096
|
<text fg={pal().muted}>
|
|
1123
1097
|
<span style={{ fg: pal().muted }}>{"> "}</span>
|
|
1124
|
-
<span>{t()
|
|
1125
|
-
</text>
|
|
1126
|
-
</Show>
|
|
1127
|
-
<Show when={balanceState().status === "error"}>
|
|
1128
|
-
<text fg={pal().error}>
|
|
1129
|
-
<span style={{ fg: pal().muted }}>{"> "}</span>
|
|
1130
|
-
<span>{(() => {
|
|
1131
|
-
const code = balanceState().error
|
|
1132
|
-
if (code === "401") return t().balErr401
|
|
1133
|
-
if (code === "403") return t().balErr403
|
|
1134
|
-
if (code === "EMPTY") return t().balErrEmpty
|
|
1135
|
-
if (code === "TIMEOUT") return t().balErrTimeout
|
|
1136
|
-
return t().balError + (code ? ` (${code})` : "")
|
|
1137
|
-
})()}</span>
|
|
1098
|
+
<span>{t("balUnsupported")}</span>
|
|
1138
1099
|
</text>
|
|
1139
1100
|
</Show>
|
|
1140
|
-
<Show when={
|
|
1141
|
-
{(
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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>
|
|
1168
1132
|
</Show>
|
|
1169
1133
|
</Show>
|
|
1170
1134
|
</Show>
|
|
@@ -1177,6 +1141,166 @@ function TokenCachePanel(props: {
|
|
|
1177
1141
|
// Plugin entry
|
|
1178
1142
|
// ---------------------------------------------------------------------------
|
|
1179
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
|
+
|
|
1180
1304
|
function createSidebarSlot(api: TuiPluginApi, signals: PanelSignals): TuiSlotPlugin {
|
|
1181
1305
|
let lastSlotSid = ""
|
|
1182
1306
|
return {
|
|
@@ -1213,22 +1337,39 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1213
1337
|
const [sectionDist, setSectionDist] = createSignal(true)
|
|
1214
1338
|
const [sectionSkills, setSectionSkills] = createSignal(true)
|
|
1215
1339
|
const [sectionBalance, setSectionBalance] = createSignal(true)
|
|
1340
|
+
const [sectionBottom, setSectionBottom] = createSignal(true)
|
|
1216
1341
|
const [balanceRefresh, setBalanceRefresh] = createSignal(0)
|
|
1342
|
+
const [balanceProviderId, setBalanceProviderId] = createSignal("deepseek")
|
|
1343
|
+
const [autoBalance, setAutoBalance] = createSignal(true)
|
|
1344
|
+
const [balanceUnsupported, setBalanceUnsupported] = createSignal(false)
|
|
1217
1345
|
const [balanceCurrency, setBalanceCurrency] = createSignal("")
|
|
1218
1346
|
const [borderVisible, setBorderVisible] = createSignal(true)
|
|
1219
|
-
const [
|
|
1347
|
+
const [langCode, setLangCode] = createSignal<LangCode>(INIT_LANG)
|
|
1220
1348
|
const [overrideSessionId, setOverrideSessionId] = createSignal<string | undefined>(undefined)
|
|
1221
1349
|
|
|
1350
|
+
// ── 余额查询状态(共享):侧边栏与底部栏读同一份数据,
|
|
1351
|
+
// 避免重复请求导致两处余额不一致 ──
|
|
1352
|
+
const [balanceState, setBalanceState] = createSignal<BalanceState>({
|
|
1353
|
+
status: "idle", data: null, lastFetch: 0,
|
|
1354
|
+
})
|
|
1355
|
+
// 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果
|
|
1356
|
+
let balanceSeq = 0
|
|
1357
|
+
|
|
1222
1358
|
const signals: PanelSignals = {
|
|
1223
1359
|
currencySymbol, setCurrencySymbol,
|
|
1224
1360
|
exchangeRate, setExchangeRate,
|
|
1225
|
-
|
|
1361
|
+
langCode, setLangCode,
|
|
1226
1362
|
sectionDetail, setSectionDetail,
|
|
1227
1363
|
sectionModel, setSectionModel,
|
|
1228
1364
|
sectionDist, setSectionDist,
|
|
1229
1365
|
sectionSkills, setSectionSkills,
|
|
1230
1366
|
sectionBalance, setSectionBalance,
|
|
1367
|
+
sectionBottom, setSectionBottom,
|
|
1231
1368
|
balanceRefresh, setBalanceRefresh,
|
|
1369
|
+
balanceProviderId, setBalanceProviderId,
|
|
1370
|
+
autoBalance, setAutoBalance,
|
|
1371
|
+
balanceUnsupported, setBalanceUnsupported,
|
|
1372
|
+
balanceState,
|
|
1232
1373
|
balanceCurrency, setBalanceCurrency,
|
|
1233
1374
|
borderVisible, setBorderVisible,
|
|
1234
1375
|
overrideSessionId, setOverrideSessionId,
|
|
@@ -1236,8 +1377,146 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1236
1377
|
|
|
1237
1378
|
api.slots.register(createSidebarSlot(api, signals))
|
|
1238
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
|
+
|
|
1239
1410
|
// ── slash commands for runtime config ──
|
|
1240
1411
|
const KV_PREFIX = "cache_panel"
|
|
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
|
+
|
|
1472
|
+
/** 菜单中 provider 选项标题:标注 key 来源(手动配置 / OpenCode 自动复用 / 未配置)。 */
|
|
1473
|
+
const providerOptionTitle = (p: BalanceProvider, current?: string) => {
|
|
1474
|
+
const t = createT(() => langCode())
|
|
1475
|
+
const hasManual = !!api.kv.get<string>(`${KV_PREFIX}.balance.${p.id}.key`, "")
|
|
1476
|
+
const hasAuto = !hasManual && !!findOpencodeKey(api, p)
|
|
1477
|
+
const mark = hasManual
|
|
1478
|
+
? t("keyUser")
|
|
1479
|
+
: hasAuto
|
|
1480
|
+
? t("keyOpenCode")
|
|
1481
|
+
: t("keyNotSet")
|
|
1482
|
+
return p.name + mark + (current && p.id === current ? " *" : "")
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/** 弹出指定 provider 的 API Key 输入框(脱敏预填;空清除 / 含 * 保留原 key / 新 key 实时刷新)。 */
|
|
1486
|
+
const promptBalanceKey = (dialog: TuiDialogStack | undefined, provider: BalanceProvider) => {
|
|
1487
|
+
const t = createT(() => langCode())
|
|
1488
|
+
const current = api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
|
|
1489
|
+
const masked = maskKey(current)
|
|
1490
|
+
dialog?.replace(() => (
|
|
1491
|
+
<api.ui.DialogPrompt
|
|
1492
|
+
title={provider.name}
|
|
1493
|
+
description={() => <text>{t("balKeyPrompt", { p: provider.name })}</text>}
|
|
1494
|
+
placeholder={provider.keyPlaceholder ?? "sk-..."}
|
|
1495
|
+
value={masked}
|
|
1496
|
+
onConfirm={(val) => {
|
|
1497
|
+
const input = val.trim()
|
|
1498
|
+
let key: string
|
|
1499
|
+
if (input === "") {
|
|
1500
|
+
key = ""
|
|
1501
|
+
} else if (input.includes("*")) {
|
|
1502
|
+
key = current
|
|
1503
|
+
} else {
|
|
1504
|
+
key = input
|
|
1505
|
+
}
|
|
1506
|
+
api.kv.set(`${KV_PREFIX}.balance.${provider.id}.key`, key)
|
|
1507
|
+
setBalanceRefresh(v => v + 1)
|
|
1508
|
+
if (key) {
|
|
1509
|
+
api.ui.toast({ message: t("keySaved") })
|
|
1510
|
+
} else {
|
|
1511
|
+
api.ui.toast({ message: t("keyCleared") })
|
|
1512
|
+
}
|
|
1513
|
+
dialog?.clear()
|
|
1514
|
+
}}
|
|
1515
|
+
onCancel={() => dialog?.clear()}
|
|
1516
|
+
/>
|
|
1517
|
+
))
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1241
1520
|
api.command?.register(() => [
|
|
1242
1521
|
{
|
|
1243
1522
|
title: "Cache: Set Currency",
|
|
@@ -1253,6 +1532,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1253
1532
|
value: code,
|
|
1254
1533
|
}))}
|
|
1255
1534
|
onSelect={(opt) => {
|
|
1535
|
+
const t = createT(() => langCode())
|
|
1256
1536
|
const sym = CURRENCIES[opt.value] ?? "$"
|
|
1257
1537
|
const defRate = DEFAULT_RATES[opt.value] ?? 1
|
|
1258
1538
|
api.kv.set(`${KV_PREFIX}.currency`, sym)
|
|
@@ -1262,7 +1542,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1262
1542
|
signals.setBalanceCurrency(opt.value)
|
|
1263
1543
|
signals.setCurrencySymbol(sym)
|
|
1264
1544
|
signals.setExchangeRate(defRate)
|
|
1265
|
-
api.ui.toast({ message:
|
|
1545
|
+
api.ui.toast({ message: t("currencySet", { v: opt.value, s: sym, r: defRate }) })
|
|
1266
1546
|
dialog?.clear()
|
|
1267
1547
|
}}
|
|
1268
1548
|
/>
|
|
@@ -1282,11 +1562,12 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1282
1562
|
placeholder="1.0"
|
|
1283
1563
|
value={String(api.kv.get<number>(`${KV_PREFIX}.rate`, 1))}
|
|
1284
1564
|
onConfirm={(val) => {
|
|
1565
|
+
const t = createT(() => langCode())
|
|
1285
1566
|
const n = parseFloat(val)
|
|
1286
1567
|
if (n > 0) {
|
|
1287
1568
|
api.kv.set(`${KV_PREFIX}.rate`, n)
|
|
1288
1569
|
signals.setExchangeRate(n)
|
|
1289
|
-
api.ui.toast({ message:
|
|
1570
|
+
api.ui.toast({ message: t("rateSet", { r: n }) })
|
|
1290
1571
|
}
|
|
1291
1572
|
dialog?.clear()
|
|
1292
1573
|
}}
|
|
@@ -1300,29 +1581,42 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1300
1581
|
description: "Show or hide a sidebar section",
|
|
1301
1582
|
slash: { name: "cache-section" },
|
|
1302
1583
|
onSelect: (dialog) => {
|
|
1584
|
+
const t = createT(() => langCode())
|
|
1303
1585
|
const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
|
|
1304
1586
|
const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
|
|
1305
1587
|
const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
|
|
1306
1588
|
const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
|
|
1307
1589
|
const balanceOn = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true))
|
|
1590
|
+
const bottomOn = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true))
|
|
1308
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"}]`
|
|
1309
1602
|
dialog?.replace(() => (
|
|
1310
1603
|
<api.ui.DialogSelect
|
|
1311
|
-
title="
|
|
1604
|
+
title={t("secToggle")}
|
|
1312
1605
|
options={[
|
|
1313
|
-
{ title:
|
|
1314
|
-
{ title:
|
|
1315
|
-
{ title:
|
|
1316
|
-
{ title:
|
|
1317
|
-
{ title:
|
|
1318
|
-
{ 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" },
|
|
1319
1613
|
]}
|
|
1320
1614
|
onSelect={(opt) => {
|
|
1321
1615
|
if (opt.value === "border") {
|
|
1322
1616
|
const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
|
|
1323
1617
|
api.kv.set(`${KV_PREFIX}.border`, !cur)
|
|
1324
1618
|
signals.setBorderVisible(!cur)
|
|
1325
|
-
api.ui.toast({ message:
|
|
1619
|
+
api.ui.toast({ message: !cur ? t("borderShown") : t("borderHidden") })
|
|
1326
1620
|
} else {
|
|
1327
1621
|
const key = `${KV_PREFIX}.section.${opt.value}`
|
|
1328
1622
|
const cur = Boolean(api.kv.get(key, true))
|
|
@@ -1332,7 +1626,9 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1332
1626
|
if (opt.value === "dist") signals.setSectionDist(!cur)
|
|
1333
1627
|
if (opt.value === "skills") signals.setSectionSkills(!cur)
|
|
1334
1628
|
if (opt.value === "balance") signals.setSectionBalance(!cur)
|
|
1335
|
-
|
|
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 }) })
|
|
1336
1632
|
}
|
|
1337
1633
|
dialog?.clear()
|
|
1338
1634
|
}}
|
|
@@ -1346,6 +1642,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1346
1642
|
description: "Display the current plugin configuration",
|
|
1347
1643
|
slash: { name: "cache-config" },
|
|
1348
1644
|
onSelect: (dialog) => {
|
|
1645
|
+
const t = createT(() => langCode())
|
|
1349
1646
|
const sym = api.kv.get<string>(`${KV_PREFIX}.currency`) ?? "$"
|
|
1350
1647
|
const rate = api.kv.get<number>(`${KV_PREFIX}.rate`) ?? 1
|
|
1351
1648
|
const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
|
|
@@ -1353,9 +1650,16 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1353
1650
|
const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
|
|
1354
1651
|
const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
|
|
1355
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"
|
|
1356
1655
|
api.ui.toast({
|
|
1357
|
-
title: "
|
|
1358
|
-
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
|
+
}),
|
|
1359
1663
|
duration: 8000,
|
|
1360
1664
|
})
|
|
1361
1665
|
dialog?.clear()
|
|
@@ -1367,69 +1671,107 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1367
1671
|
description: "Switch between Chinese and English display",
|
|
1368
1672
|
slash: { name: "cache-lang" },
|
|
1369
1673
|
onSelect: (dialog) => {
|
|
1370
|
-
const
|
|
1674
|
+
const t = createT(() => langCode())
|
|
1675
|
+
const cur = langCode()
|
|
1371
1676
|
dialog?.replace(() => (
|
|
1372
1677
|
<api.ui.DialogSelect
|
|
1373
|
-
title="
|
|
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
|
+
}))}
|
|
1683
|
+
onSelect={(opt) => {
|
|
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") })
|
|
1688
|
+
dialog?.clear()
|
|
1689
|
+
}}
|
|
1690
|
+
/>
|
|
1691
|
+
))
|
|
1692
|
+
},
|
|
1693
|
+
},
|
|
1694
|
+
{
|
|
1695
|
+
title: "Cache: Switch Balance Provider",
|
|
1696
|
+
value: "cache.balance",
|
|
1697
|
+
description: "切换余额提供商 / 自动切换当前会话提供商 | Switch balance provider / auto-switch session provider",
|
|
1698
|
+
slash: { name: "cache-balance" },
|
|
1699
|
+
onSelect: (dialog) => {
|
|
1700
|
+
const t = createT(() => langCode())
|
|
1701
|
+
const current = signals.balanceProviderId()
|
|
1702
|
+
const auto = signals.autoBalance()
|
|
1703
|
+
const autoLabel = `${t("autoSwitchOpt")} [${auto ? "ON" : "OFF"}]`
|
|
1704
|
+
dialog?.replace(() => (
|
|
1705
|
+
<api.ui.DialogSelect
|
|
1706
|
+
title={t("balProvTitle")}
|
|
1374
1707
|
options={[
|
|
1375
|
-
{
|
|
1376
|
-
|
|
1708
|
+
{
|
|
1709
|
+
title: autoLabel,
|
|
1710
|
+
value: "__auto__",
|
|
1711
|
+
},
|
|
1712
|
+
...balanceProviders.map((p) => ({
|
|
1713
|
+
title: providerOptionTitle(p, current),
|
|
1714
|
+
value: p.id,
|
|
1715
|
+
})),
|
|
1377
1716
|
]}
|
|
1378
1717
|
onSelect={(opt) => {
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1718
|
+
if (opt.value === "__auto__") {
|
|
1719
|
+
const next = !auto
|
|
1720
|
+
api.kv.set(`${KV_PREFIX}.balance.auto`, next)
|
|
1721
|
+
signals.setAutoBalance(next)
|
|
1722
|
+
api.ui.toast({ message: next ? t("autoSwitchOn") : t("autoSwitchOff") })
|
|
1723
|
+
dialog?.clear()
|
|
1724
|
+
} else {
|
|
1725
|
+
const provider = getBalanceProvider(opt.value)
|
|
1726
|
+
// 手动切换会关闭自动切换
|
|
1727
|
+
api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id)
|
|
1728
|
+
api.kv.set(`${KV_PREFIX}.balance.auto`, false)
|
|
1729
|
+
signals.setBalanceProviderId(provider.id)
|
|
1730
|
+
signals.setAutoBalance(false)
|
|
1731
|
+
signals.setBalanceUnsupported(false)
|
|
1732
|
+
// 切换后立即按新 provider 刷新显示(无 key 时显示 idle,避免残留上一 provider 余额)
|
|
1733
|
+
signals.setBalanceRefresh(signals.balanceRefresh() + 1)
|
|
1734
|
+
const hasKey = !!api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
|
|
1735
|
+
if (!hasKey) {
|
|
1736
|
+
// 未配置 key → 进入设置流程(对话框保持打开等待输入)
|
|
1737
|
+
promptBalanceKey(dialog, provider)
|
|
1738
|
+
} else {
|
|
1739
|
+
api.ui.toast({ message: t("providerManual", { p: provider.name }) })
|
|
1740
|
+
dialog?.clear()
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1384
1743
|
}}
|
|
1385
1744
|
/>
|
|
1386
1745
|
))
|
|
1387
1746
|
},
|
|
1388
1747
|
},
|
|
1389
1748
|
{
|
|
1390
|
-
title: "Cache: Set
|
|
1749
|
+
title: "Cache: Set Balance API Key",
|
|
1391
1750
|
value: "cache.balance.key",
|
|
1392
|
-
description: "
|
|
1751
|
+
description: "Select a provider and set its API key for balance display",
|
|
1393
1752
|
slash: { name: "cache-balance-key" },
|
|
1394
1753
|
onSelect: (dialog) => {
|
|
1395
|
-
const
|
|
1396
|
-
|
|
1397
|
-
// 已保存的 key 以脱敏形式预填:保留 "sk-" 前缀 + 头 5 尾 5 字符,中间用 * 填充
|
|
1398
|
-
const maskKey = (k: string): string => {
|
|
1399
|
-
if (!k) return ""
|
|
1400
|
-
const prefix = k.startsWith("sk-") ? "sk-" : ""
|
|
1401
|
-
const body = prefix ? k.slice(3) : k
|
|
1402
|
-
if (body.length <= 10) return prefix + body.slice(0, 5) + "*".repeat(Math.max(3, body.length - 5))
|
|
1403
|
-
return prefix + body.slice(0, 5) + "*".repeat(Math.max(3, body.length - 10)) + body.slice(-5)
|
|
1404
|
-
}
|
|
1405
|
-
const masked = maskKey(current)
|
|
1754
|
+
const t = createT(() => langCode())
|
|
1755
|
+
// 步骤 1:选择 provider
|
|
1406
1756
|
dialog?.replace(() => (
|
|
1407
|
-
<api.ui.
|
|
1408
|
-
title={
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
setBalanceRefresh(v => v + 1)
|
|
1425
|
-
if (key) {
|
|
1426
|
-
api.ui.toast({ message: zh ? "API Key 已保存,正在查询余额..." : "API Key saved, fetching balance..." })
|
|
1427
|
-
} else {
|
|
1428
|
-
api.ui.toast({ message: zh ? "API Key 已清除" : "API Key cleared" })
|
|
1429
|
-
}
|
|
1430
|
-
dialog?.clear()
|
|
1757
|
+
<api.ui.DialogSelect
|
|
1758
|
+
title={t("balSelectTitle")}
|
|
1759
|
+
options={balanceProviders.map((p) => ({
|
|
1760
|
+
title: providerOptionTitle(p),
|
|
1761
|
+
value: p.id,
|
|
1762
|
+
}))}
|
|
1763
|
+
onSelect={(opt) => {
|
|
1764
|
+
const provider = getBalanceProvider(opt.value)
|
|
1765
|
+
// 手动指定 provider 会关闭自动切换
|
|
1766
|
+
api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id)
|
|
1767
|
+
api.kv.set(`${KV_PREFIX}.balance.auto`, false)
|
|
1768
|
+
signals.setBalanceProviderId(provider.id)
|
|
1769
|
+
signals.setAutoBalance(false)
|
|
1770
|
+
// 切换后立即刷新显示(防止取消输入时残留上一 provider 的余额)
|
|
1771
|
+
signals.setBalanceRefresh(signals.balanceRefresh() + 1)
|
|
1772
|
+
// 步骤 2:输入 key
|
|
1773
|
+
promptBalanceKey(dialog, provider)
|
|
1431
1774
|
}}
|
|
1432
|
-
onCancel={() => dialog?.clear()}
|
|
1433
1775
|
/>
|
|
1434
1776
|
))
|
|
1435
1777
|
},
|
|
@@ -1440,9 +1782,10 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1440
1782
|
description: "Dump all tool parts found in the current session for skill detection debugging",
|
|
1441
1783
|
slash: { name: "cache-debug-skills" },
|
|
1442
1784
|
onSelect: () => {
|
|
1785
|
+
const t = createT(() => langCode())
|
|
1443
1786
|
const rt = api.route.current
|
|
1444
1787
|
if (rt.name !== "session" || !rt.params) {
|
|
1445
|
-
api.ui.toast({ message: "
|
|
1788
|
+
api.ui.toast({ message: t("runInSession"), variant: "warning" })
|
|
1446
1789
|
return
|
|
1447
1790
|
}
|
|
1448
1791
|
const sid = String(rt.params.sessionID)
|
|
@@ -1520,7 +1863,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1520
1863
|
|
|
1521
1864
|
if (unique.length > 0) {
|
|
1522
1865
|
// ── 有子代理 → DialogSelect 列表选择 ──
|
|
1523
|
-
const
|
|
1866
|
+
const t = createT(() => langCode())
|
|
1524
1867
|
const currentSid = signals.overrideSessionId() ?? api.kv.get<string>(`${KV_PREFIX}.session`, "")
|
|
1525
1868
|
const options = unique.map((c, i) => ({
|
|
1526
1869
|
title: `${i + 1}. ${c.title}`,
|
|
@@ -1529,24 +1872,24 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1529
1872
|
}))
|
|
1530
1873
|
// 首尾各放一个"回到主会话",长列表时顶部底部均可直达
|
|
1531
1874
|
const backValue = "__main__"
|
|
1532
|
-
const backTitle = `\u2500 ${
|
|
1875
|
+
const backTitle = `\u2500 ${t("backToMainTitle")}`
|
|
1533
1876
|
options.unshift({ title: backTitle, value: backValue, description: "" })
|
|
1534
1877
|
options.push({ title: backTitle, value: backValue, description: "" })
|
|
1535
1878
|
const currentIdx = currentSid ? options.findIndex(o => o.value === currentSid) : -1
|
|
1536
1879
|
dialog?.replace(() => (
|
|
1537
1880
|
<api.ui.DialogSelect
|
|
1538
|
-
title={
|
|
1881
|
+
title={t("subSelectTitle")}
|
|
1539
1882
|
options={options}
|
|
1540
1883
|
current={currentIdx >= 0 ? options[currentIdx].value : undefined}
|
|
1541
1884
|
onSelect={(opt) => {
|
|
1542
1885
|
if (opt.value === backValue) {
|
|
1543
1886
|
signals.setOverrideSessionId(undefined)
|
|
1544
1887
|
api.kv.set(`${KV_PREFIX}.session`, "")
|
|
1545
|
-
api.ui.toast({ message:
|
|
1888
|
+
api.ui.toast({ message: t("backToMain") })
|
|
1546
1889
|
} else {
|
|
1547
1890
|
signals.setOverrideSessionId(opt.value)
|
|
1548
1891
|
api.kv.set(`${KV_PREFIX}.session`, opt.value)
|
|
1549
|
-
api.ui.toast({ message: (
|
|
1892
|
+
api.ui.toast({ message: t("subAgentSwitched", { s: opt.value.slice(0, 24) + "\u2026" }) })
|
|
1550
1893
|
}
|
|
1551
1894
|
dialog?.clear()
|
|
1552
1895
|
}}
|
|
@@ -1554,11 +1897,11 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1554
1897
|
))
|
|
1555
1898
|
} else {
|
|
1556
1899
|
// ── 无子代理 → DialogPrompt 手动粘贴 ──
|
|
1557
|
-
const
|
|
1900
|
+
const t = createT(() => langCode())
|
|
1558
1901
|
dialog?.replace(() => (
|
|
1559
1902
|
<api.ui.DialogPrompt
|
|
1560
|
-
title={signals.overrideSessionId() ?
|
|
1561
|
-
description={() => <text>{
|
|
1903
|
+
title={signals.overrideSessionId() ? t("subSwitchTitle") : t("subViewTitle")}
|
|
1904
|
+
description={() => <text>{t("subNoFound")}</text>}
|
|
1562
1905
|
placeholder="ses_..."
|
|
1563
1906
|
value={signals.overrideSessionId() ?? api.kv.get<string>(`${KV_PREFIX}.session`, "") ?? ""}
|
|
1564
1907
|
onConfirm={(val) => {
|
|
@@ -1566,7 +1909,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1566
1909
|
if (sid) {
|
|
1567
1910
|
signals.setOverrideSessionId(sid)
|
|
1568
1911
|
api.kv.set(`${KV_PREFIX}.session`, sid)
|
|
1569
|
-
api.ui.toast({ message: (
|
|
1912
|
+
api.ui.toast({ message: t("subAgentSwitched", { s: sid.slice(0, 24) + "\u2026" }) })
|
|
1570
1913
|
}
|
|
1571
1914
|
dialog?.clear()
|
|
1572
1915
|
}}
|
|
@@ -1582,9 +1925,10 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1582
1925
|
description: "Return to main session stats",
|
|
1583
1926
|
slash: { name: "cache-session-back" },
|
|
1584
1927
|
onSelect: (dialog) => {
|
|
1928
|
+
const t = createT(() => langCode())
|
|
1585
1929
|
signals.setOverrideSessionId(undefined)
|
|
1586
1930
|
api.kv.set(`${KV_PREFIX}.session`, "")
|
|
1587
|
-
api.ui.toast({ message:
|
|
1931
|
+
api.ui.toast({ message: t("backToMain") })
|
|
1588
1932
|
dialog?.clear()
|
|
1589
1933
|
},
|
|
1590
1934
|
},
|