opencode-go-usage-tui 1.2.0 → 1.3.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 +38 -6
- package/README_EN.md +38 -6
- package/dist/tui.js +897 -57
- package/package.json +1 -1
- package/src/i18n.ts +88 -0
- package/src/index.tsx +420 -44
- package/src/pricing.ts +348 -0
package/src/index.tsx
CHANGED
|
@@ -15,6 +15,8 @@ import { join, dirname } from "node:path"
|
|
|
15
15
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
|
|
16
16
|
import { createT, detectLang, LANG_META } from "./i18n"
|
|
17
17
|
import type { LangCode } from "./i18n"
|
|
18
|
+
import { fetchPricing, comparePricing, loadPricingStore, updatePricingStore } from "./pricing"
|
|
19
|
+
import type { ModelPrice, PricingData, PricingStore } from "./pricing"
|
|
18
20
|
|
|
19
21
|
declare const process: { env: Record<string, string | undefined> } | undefined
|
|
20
22
|
|
|
@@ -66,15 +68,22 @@ function readJsonFile<T>(file: string): T | null {
|
|
|
66
68
|
try { return JSON.parse(readFileSync(file, "utf8")) as T } catch { return null }
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
interface ConfigFile { workspace_id?: string; cookie?: string; auth_cookie?: string; cookie_file?: string }
|
|
71
|
+
interface ConfigFile { workspace_id?: string; cookie?: string; auth_cookie?: string; cookie_file?: string; ui?: { usage_panel?: boolean; price_panel?: boolean }; focus_models?: string[]; refresh_interval_sec?: number; pricing_interval_sec?: number }
|
|
70
72
|
|
|
71
|
-
|
|
73
|
+
interface LoadedConfig { workspaceId: string; authCookie: string; usagePanel: boolean; pricePanel: boolean; focusModels: string[]; refreshIntervalMs: number; pricingIntervalMs: number }
|
|
74
|
+
|
|
75
|
+
function loadConfig(): LoadedConfig {
|
|
72
76
|
const env = process?.env ?? {}
|
|
73
77
|
const fileCfg = readJsonFile<ConfigFile>(CONFIG_FILE) ?? {}
|
|
74
78
|
|
|
75
79
|
// 1. 环境变量优先
|
|
76
80
|
if (env.OPENCODE_GO_AUTH_COOKIE) {
|
|
77
|
-
return {
|
|
81
|
+
return {
|
|
82
|
+
workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: env.OPENCODE_GO_AUTH_COOKIE,
|
|
83
|
+
usagePanel: fileCfg.ui?.usage_panel ?? true, pricePanel: fileCfg.ui?.price_panel ?? true,
|
|
84
|
+
focusModels: fileCfg.focus_models ?? [], refreshIntervalMs: (fileCfg.refresh_interval_sec ?? Math.round(CHECK_INTERVAL / 1000)) * 1000,
|
|
85
|
+
pricingIntervalMs: (fileCfg.pricing_interval_sec ?? Math.round((CHECK_INTERVAL / 1000) * 30)) * 1000,
|
|
86
|
+
}
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
// 2. 加密存储
|
|
@@ -82,7 +91,12 @@ function loadConfig(): { workspaceId: string; authCookie: string } {
|
|
|
82
91
|
if (existsSync(COOKIE_ENC_FILE)) {
|
|
83
92
|
const cookie = decryptText(readFileSync(COOKIE_ENC_FILE, "utf8"))
|
|
84
93
|
if (cookie) {
|
|
85
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: cookie,
|
|
96
|
+
usagePanel: fileCfg.ui?.usage_panel ?? true, pricePanel: fileCfg.ui?.price_panel ?? true,
|
|
97
|
+
focusModels: fileCfg.focus_models ?? [], refreshIntervalMs: (fileCfg.refresh_interval_sec ?? Math.round(CHECK_INTERVAL / 1000)) * 1000,
|
|
98
|
+
pricingIntervalMs: (fileCfg.pricing_interval_sec ?? Math.round((CHECK_INTERVAL / 1000) * 30)) * 1000,
|
|
99
|
+
}
|
|
86
100
|
}
|
|
87
101
|
}
|
|
88
102
|
} catch { /* 密文损坏时回退到明文迁移 */ }
|
|
@@ -101,13 +115,23 @@ function loadConfig(): { workspaceId: string; authCookie: string } {
|
|
|
101
115
|
delete cleanCfg.auth_cookie
|
|
102
116
|
try { writeFileSync(CONFIG_FILE, JSON.stringify(cleanCfg, null, 2), "utf8") } catch { /* 忽略 */ }
|
|
103
117
|
try { if (existsSync(COOKIE_PLAIN_FILE)) renameSync(COOKIE_PLAIN_FILE, COOKIE_PLAIN_FILE + ".bak") } catch { /* 忽略 */ }
|
|
104
|
-
return {
|
|
118
|
+
return {
|
|
119
|
+
workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: legacyCookie,
|
|
120
|
+
usagePanel: fileCfg.ui?.usage_panel ?? true, pricePanel: fileCfg.ui?.price_panel ?? true,
|
|
121
|
+
focusModels: fileCfg.focus_models ?? [], refreshIntervalMs: (fileCfg.refresh_interval_sec ?? Math.round(CHECK_INTERVAL / 1000)) * 1000,
|
|
122
|
+
pricingIntervalMs: (fileCfg.pricing_interval_sec ?? Math.round((CHECK_INTERVAL / 1000) * 30)) * 1000,
|
|
123
|
+
}
|
|
105
124
|
}
|
|
106
125
|
|
|
107
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: "",
|
|
128
|
+
usagePanel: fileCfg.ui?.usage_panel ?? true, pricePanel: fileCfg.ui?.price_panel ?? true,
|
|
129
|
+
focusModels: fileCfg.focus_models ?? [], refreshIntervalMs: (fileCfg.refresh_interval_sec ?? Math.round(CHECK_INTERVAL / 1000)) * 1000,
|
|
130
|
+
pricingIntervalMs: (fileCfg.pricing_interval_sec ?? Math.round((CHECK_INTERVAL / 1000) * 30)) * 1000,
|
|
131
|
+
}
|
|
108
132
|
}
|
|
109
133
|
|
|
110
|
-
function saveConfig(patch: { workspace_id?: string; cookie?: string }): void {
|
|
134
|
+
function saveConfig(patch: { workspace_id?: string; cookie?: string; ui?: { usage_panel?: boolean; price_panel?: boolean }; focus_models?: string[]; refresh_interval_sec?: number; pricing_interval_sec?: number }): void {
|
|
111
135
|
const fileCfg = readJsonFile<ConfigFile>(CONFIG_FILE) ?? {}
|
|
112
136
|
if (patch.workspace_id !== undefined) fileCfg.workspace_id = patch.workspace_id
|
|
113
137
|
if (patch.cookie !== undefined) {
|
|
@@ -123,6 +147,12 @@ function saveConfig(patch: { workspace_id?: string; cookie?: string }): void {
|
|
|
123
147
|
try { if (existsSync(COOKIE_ENC_FILE)) renameSync(COOKIE_ENC_FILE, COOKIE_ENC_FILE + ".bak") } catch { /* 忽略 */ }
|
|
124
148
|
}
|
|
125
149
|
}
|
|
150
|
+
if (patch.ui !== undefined) {
|
|
151
|
+
fileCfg.ui = { ...(fileCfg.ui ?? {}), ...patch.ui }
|
|
152
|
+
}
|
|
153
|
+
if (patch.focus_models !== undefined) fileCfg.focus_models = patch.focus_models
|
|
154
|
+
if (patch.refresh_interval_sec !== undefined) fileCfg.refresh_interval_sec = patch.refresh_interval_sec
|
|
155
|
+
if (patch.pricing_interval_sec !== undefined) fileCfg.pricing_interval_sec = patch.pricing_interval_sec
|
|
126
156
|
try {
|
|
127
157
|
mkdirSync(dirname(CONFIG_FILE), { recursive: true })
|
|
128
158
|
writeFileSync(CONFIG_FILE, JSON.stringify(fileCfg, null, 2), "utf8")
|
|
@@ -136,6 +166,9 @@ const [configTick, setConfigTick] = createSignal(0)
|
|
|
136
166
|
const [langCode, setLangCode] = createSignal<LangCode>(detectLang())
|
|
137
167
|
const t = createT(() => langCode())
|
|
138
168
|
|
|
169
|
+
// 模块级共享价格数据:PricePanel 抓取后写入,/go-settings 设置关注模型时读取
|
|
170
|
+
const [sharedPricing, setSharedPricing] = createSignal<PricingData | null>(null)
|
|
171
|
+
|
|
139
172
|
function kvTryGet(api: TuiPluginApi, key: string): string | undefined {
|
|
140
173
|
try { return api.kv.get<string>(key) } catch { return undefined }
|
|
141
174
|
}
|
|
@@ -150,7 +183,9 @@ interface Usage {
|
|
|
150
183
|
error?: string
|
|
151
184
|
}
|
|
152
185
|
|
|
153
|
-
|
|
186
|
+
const REQUEST_TIMEOUT = 10000
|
|
187
|
+
|
|
188
|
+
async function fetchUsage(workspaceId: string, authCookie: string, signal?: AbortSignal): Promise<Usage | null> {
|
|
154
189
|
if (!workspaceId || !authCookie) return { error: "not_configured" } as Usage
|
|
155
190
|
try {
|
|
156
191
|
const res = await fetch("https://opencode.ai/_server", {
|
|
@@ -167,6 +202,7 @@ async function fetchUsage(workspaceId: string, authCookie: string): Promise<Usag
|
|
|
167
202
|
f: 31,
|
|
168
203
|
m: [],
|
|
169
204
|
}),
|
|
205
|
+
signal,
|
|
170
206
|
})
|
|
171
207
|
if (!res.ok) {
|
|
172
208
|
if (res.status === 302) return { error: "cookie_expired" } as Usage
|
|
@@ -248,9 +284,21 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
|
|
|
248
284
|
const [configured, setConfigured] = createSignal(true)
|
|
249
285
|
const [lastUpdated, setLastUpdated] = createSignal("")
|
|
250
286
|
const [panelWidth, setPanelWidth] = createSignal(24)
|
|
287
|
+
const [cfg, setCfg] = createSignal<LoadedConfig>(loadConfig())
|
|
288
|
+
createEffect(() => { void configTick(); setCfg(loadConfig()) })
|
|
251
289
|
let boxEl: any
|
|
290
|
+
let abortController: AbortController | null = null
|
|
291
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
|
292
|
+
let isRefreshing = false
|
|
293
|
+
let resizeTimeout: ReturnType<typeof setTimeout> | null = null
|
|
294
|
+
|
|
295
|
+
function cleanupPendingRequest() {
|
|
296
|
+
if (timeoutId) { clearTimeout(timeoutId); timeoutId = null }
|
|
297
|
+
if (abortController) { abortController.abort(); abortController = null }
|
|
298
|
+
}
|
|
252
299
|
|
|
253
300
|
async function refresh() {
|
|
301
|
+
if (isRefreshing) return
|
|
254
302
|
const cfg = loadConfig()
|
|
255
303
|
if (!cfg.workspaceId || !cfg.authCookie) {
|
|
256
304
|
setConfigured(false)
|
|
@@ -259,24 +307,39 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
|
|
|
259
307
|
return
|
|
260
308
|
}
|
|
261
309
|
setConfigured(true)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
310
|
+
cleanupPendingRequest()
|
|
311
|
+
abortController = new AbortController()
|
|
312
|
+
const signal = abortController.signal
|
|
313
|
+
timeoutId = setTimeout(() => abortController?.abort(), REQUEST_TIMEOUT)
|
|
314
|
+
isRefreshing = true
|
|
315
|
+
try {
|
|
316
|
+
const u = await fetchUsage(cfg.workspaceId, cfg.authCookie, signal)
|
|
317
|
+
if (signal.aborted) return
|
|
318
|
+
if (u === null) { setError(t("queryFailed")); return }
|
|
319
|
+
if (u.error === "cookie_expired") { setError(t("cookieExpired")); return }
|
|
320
|
+
if (u.error === "not_configured") { setConfigured(false); return }
|
|
321
|
+
setUsage(u)
|
|
322
|
+
setLastUpdated(new Date().toLocaleTimeString())
|
|
323
|
+
setError("")
|
|
324
|
+
} finally {
|
|
325
|
+
if (timeoutId) { clearTimeout(timeoutId); timeoutId = null }
|
|
326
|
+
isRefreshing = false
|
|
327
|
+
}
|
|
269
328
|
}
|
|
270
329
|
|
|
271
330
|
onMount(() => {
|
|
272
331
|
refresh()
|
|
273
|
-
const timer = setInterval(refresh,
|
|
274
|
-
// 监听配置变更(/go-config 保存后)立即刷新
|
|
332
|
+
const timer = setInterval(refresh, loadConfig().refreshIntervalMs)
|
|
275
333
|
const stopWatch = createEffect(() => {
|
|
276
334
|
void configTick()
|
|
277
335
|
refresh()
|
|
278
336
|
})
|
|
279
|
-
onCleanup(() => {
|
|
337
|
+
onCleanup(() => {
|
|
338
|
+
clearInterval(timer)
|
|
339
|
+
stopWatch()
|
|
340
|
+
cleanupPendingRequest()
|
|
341
|
+
if (resizeTimeout) { clearTimeout(resizeTimeout); resizeTimeout = null }
|
|
342
|
+
})
|
|
280
343
|
})
|
|
281
344
|
|
|
282
345
|
const [pal, setPal] = createSignal<Record<string, string>>({ ...FALLBACK })
|
|
@@ -319,6 +382,176 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
|
|
|
319
382
|
|
|
320
383
|
const sep = () => "\u2500".repeat(Math.max(1, panelWidth() - 4))
|
|
321
384
|
|
|
385
|
+
return (
|
|
386
|
+
<box
|
|
387
|
+
border
|
|
388
|
+
borderColor={colors().border}
|
|
389
|
+
paddingLeft={1}
|
|
390
|
+
paddingRight={1}
|
|
391
|
+
flexDirection="column"
|
|
392
|
+
gap={0}
|
|
393
|
+
ref={boxEl}
|
|
394
|
+
onSizeChange={() => {
|
|
395
|
+
if (resizeTimeout) clearTimeout(resizeTimeout)
|
|
396
|
+
resizeTimeout = setTimeout(() => {
|
|
397
|
+
const w = boxEl ? Math.max(20, boxEl.width ?? 24) : 24
|
|
398
|
+
setPanelWidth(w)
|
|
399
|
+
}, 100)
|
|
400
|
+
}}
|
|
401
|
+
>
|
|
402
|
+
<text onMouseUp={() => setOpen(o => !o)}>
|
|
403
|
+
<span style={{ fg: colors().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
|
|
404
|
+
<span style={{ fg: colors().primary }}><b>{t("panelTitle")}</b></span>
|
|
405
|
+
<span style={{ fg: colors().muted }}> v{VERSION}</span>
|
|
406
|
+
<Show when={!open() && usage()}>
|
|
407
|
+
<span style={{ fg: colorFor(usage()!.weeklyUsage.usagePercent) }}>
|
|
408
|
+
{" ".repeat(2)}{t("weeklyShort")} {usage()!.weeklyUsage.usagePercent}%
|
|
409
|
+
</span>
|
|
410
|
+
</Show>
|
|
411
|
+
</text>
|
|
412
|
+
|
|
413
|
+
<Show when={open()}>
|
|
414
|
+
<text fg={colors().muted}>{sep()}</text>
|
|
415
|
+
|
|
416
|
+
<Show when={!configured()}>
|
|
417
|
+
<text fg={colors().warning}>{t("notConfigured")}</text>
|
|
418
|
+
<text fg={colors().muted}>{t("notConfiguredHint")}</text>
|
|
419
|
+
</Show>
|
|
420
|
+
|
|
421
|
+
<Show when={configured() && error()} fallback={
|
|
422
|
+
<Show when={configured() && usage()} fallback={
|
|
423
|
+
<Show when={configured()}><text fg={colors().muted}>{t("loading")}</text></Show>
|
|
424
|
+
}>
|
|
425
|
+
{renderRow(t("rowRolling"), usage()!.rollingUsage)}
|
|
426
|
+
{renderRow(t("rowWeekly"), usage()!.weeklyUsage)}
|
|
427
|
+
{renderRow(t("rowMonthly"), usage()!.monthlyUsage)}
|
|
428
|
+
<text>
|
|
429
|
+
<span style={{ fg: colors().muted }}>{t("updatedAt")} </span>
|
|
430
|
+
<span style={{ fg: colors().muted }}>{lastUpdated()}</span>
|
|
431
|
+
</text>
|
|
432
|
+
</Show>
|
|
433
|
+
}>
|
|
434
|
+
<text fg={colors().error}>{error()}</text>
|
|
435
|
+
<text fg={colors().muted}>{t("reconfigureHint")}</text>
|
|
436
|
+
</Show>
|
|
437
|
+
</Show>
|
|
438
|
+
</box>
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function PricePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX.Element {
|
|
443
|
+
const [open, setOpen] = createSignal(true)
|
|
444
|
+
const [pricingStore, setPricingStore] = createSignal<PricingStore | null>(null)
|
|
445
|
+
const [priceError, setPriceError] = createSignal("")
|
|
446
|
+
const [priceLoading, setPriceLoading] = createSignal(false)
|
|
447
|
+
const [panelWidth, setPanelWidth] = createSignal(24)
|
|
448
|
+
const [cfg, setCfg] = createSignal<LoadedConfig>(loadConfig())
|
|
449
|
+
createEffect(() => { void configTick(); setCfg(loadConfig()) })
|
|
450
|
+
let boxEl: any
|
|
451
|
+
let priceController: AbortController | null = null
|
|
452
|
+
|
|
453
|
+
async function refreshPricing() {
|
|
454
|
+
if (priceLoading()) return
|
|
455
|
+
setPriceLoading(true)
|
|
456
|
+
setPriceError("")
|
|
457
|
+
priceController?.abort()
|
|
458
|
+
priceController = new AbortController()
|
|
459
|
+
try {
|
|
460
|
+
const data = await fetchPricing()
|
|
461
|
+
const store = updatePricingStore(data)
|
|
462
|
+
setSharedPricing(data)
|
|
463
|
+
setPricingStore(store)
|
|
464
|
+
const prev = store.previous
|
|
465
|
+
if (prev && prev.fetchTime !== data.fetchTime) {
|
|
466
|
+
const diff = comparePricing(prev, data)
|
|
467
|
+
if (diff.hasChanges) {
|
|
468
|
+
props.api.toast({
|
|
469
|
+
variant: "info",
|
|
470
|
+
title: t("priceChanged"),
|
|
471
|
+
message: t("priceChangesSummary", {
|
|
472
|
+
t: diff.summary.total,
|
|
473
|
+
a: diff.summary.added,
|
|
474
|
+
r: diff.summary.removed,
|
|
475
|
+
p: diff.summary.priceChanges,
|
|
476
|
+
l: diff.summary.limitChanges,
|
|
477
|
+
}),
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
} catch {
|
|
482
|
+
setPriceError(t("priceError"))
|
|
483
|
+
} finally {
|
|
484
|
+
setPriceLoading(false)
|
|
485
|
+
priceController = null
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function formatCount(n: number): string {
|
|
490
|
+
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k"
|
|
491
|
+
return String(n)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function visualPadEnd(s: string, w: number): string {
|
|
495
|
+
const cur = visualWidth(s)
|
|
496
|
+
if (cur >= w) return s
|
|
497
|
+
return s + " ".repeat(w - cur)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function abbreviateName(name: string, maxLen: number): string {
|
|
501
|
+
if (visualWidth(name) <= maxLen) return name
|
|
502
|
+
let out = ""
|
|
503
|
+
let w = 0
|
|
504
|
+
for (const c of name) {
|
|
505
|
+
const cw = visualWidth(c)
|
|
506
|
+
if (w + cw > maxLen - 1) break
|
|
507
|
+
out += c
|
|
508
|
+
w += cw
|
|
509
|
+
}
|
|
510
|
+
return out + "…"
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// visual-cache 风格:左标签 + 自动填充 + 右值
|
|
514
|
+
function justify(label: string, value: string): string {
|
|
515
|
+
const gauge = panelWidth() - 4
|
|
516
|
+
const used = visualWidth(label) + visualWidth(value)
|
|
517
|
+
const gap = Math.max(1, gauge - used)
|
|
518
|
+
return label + " ".repeat(gap) + value
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function renderLimitRow(m: ModelPrice) {
|
|
522
|
+
const name = abbreviateName(m.name, 18)
|
|
523
|
+
const req = `${formatCount(m.limits.fiveHour)}/${formatCount(m.limits.weekly)}/${formatCount(m.limits.monthly)}`
|
|
524
|
+
return (
|
|
525
|
+
<text>
|
|
526
|
+
<span style={{ fg: colors().text }}>{justify(name, req)}</span>
|
|
527
|
+
</text>
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const [pal, setPal] = createSignal<Record<string, string>>({ ...FALLBACK })
|
|
532
|
+
createEffect(() => {
|
|
533
|
+
const th = props.theme as any
|
|
534
|
+
const p: Record<string, string> = { ...FALLBACK }
|
|
535
|
+
for (const k of Object.keys(FALLBACK)) {
|
|
536
|
+
const h = hex(th?.[k])
|
|
537
|
+
if (h) p[k] = h
|
|
538
|
+
}
|
|
539
|
+
setPal(p)
|
|
540
|
+
})
|
|
541
|
+
const colors = () => pal()
|
|
542
|
+
|
|
543
|
+
const sep = () => "\u2500".repeat(Math.max(1, panelWidth() - 4))
|
|
544
|
+
|
|
545
|
+
setTimeout(() => {
|
|
546
|
+
const store = loadPricingStore()
|
|
547
|
+
if (store.current.models.length > 0) {
|
|
548
|
+
setSharedPricing(store.current)
|
|
549
|
+
setPricingStore(store)
|
|
550
|
+
}
|
|
551
|
+
refreshPricing()
|
|
552
|
+
setInterval(() => refreshPricing(), loadConfig().pricingIntervalMs)
|
|
553
|
+
}, 2000)
|
|
554
|
+
|
|
322
555
|
return (
|
|
323
556
|
<box
|
|
324
557
|
border
|
|
@@ -329,50 +562,59 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
|
|
|
329
562
|
gap={0}
|
|
330
563
|
ref={boxEl}
|
|
331
564
|
onSizeChange={() => {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
565
|
+
if (resizeTimeout) clearTimeout(resizeTimeout)
|
|
566
|
+
resizeTimeout = setTimeout(() => {
|
|
567
|
+
const w = boxEl ? Math.max(20, boxEl.width ?? 24) : 24
|
|
568
|
+
setPanelWidth(w)
|
|
569
|
+
}, 100)
|
|
570
|
+
}}
|
|
571
|
+
>
|
|
336
572
|
<text onMouseUp={() => setOpen(o => !o)}>
|
|
337
573
|
<span style={{ fg: colors().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
|
|
338
|
-
<span style={{ fg: colors().primary }}><b>{t("
|
|
339
|
-
<
|
|
340
|
-
|
|
341
|
-
<span style={{ fg: colorFor(usage()!.weeklyUsage.usagePercent) }}>
|
|
342
|
-
{" ".repeat(2)}{t("weeklyShort")} {usage()!.weeklyUsage.usagePercent}%
|
|
343
|
-
</span>
|
|
574
|
+
<span style={{ fg: colors().primary }}><b>{t("priceTitle")}</b></span>
|
|
575
|
+
<Show when={sharedPricing()}>
|
|
576
|
+
<span style={{ fg: colors().muted }}> ({sharedPricing()!.models.length})</span>
|
|
344
577
|
</Show>
|
|
578
|
+
<span style={{ fg: colors().muted }}>{sep().slice(visualWidth((open() ? "\u25bc " : "\u25b6 ") + t("priceTitle") + (sharedPricing() ? ` (${sharedPricing()!.models.length})` : "")))}</span>
|
|
345
579
|
</text>
|
|
346
580
|
|
|
347
581
|
<Show when={open()}>
|
|
582
|
+
<text fg={colors().muted}>
|
|
583
|
+
{justify(t("priceModelCol"), t("priceReqs"))}
|
|
584
|
+
</text>
|
|
348
585
|
<text fg={colors().muted}>{sep()}</text>
|
|
349
586
|
|
|
350
|
-
<Show when={!
|
|
351
|
-
<
|
|
352
|
-
|
|
353
|
-
</Show>
|
|
354
|
-
|
|
355
|
-
<Show when={configured() && error()} fallback={
|
|
356
|
-
<Show when={configured() && usage()} fallback={
|
|
357
|
-
<Show when={configured()}><text fg={colors().muted}>{t("loading")}</text></Show>
|
|
587
|
+
<Show when={priceLoading() || !sharedPricing()} fallback={
|
|
588
|
+
<Show when={sharedPricing()} fallback={
|
|
589
|
+
<text fg={colors().muted}>{t("priceLoading")}</text>
|
|
358
590
|
}>
|
|
359
|
-
{
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
<text>
|
|
363
|
-
|
|
364
|
-
<span style={{ fg: colors().muted }}>{lastUpdated()}</span>
|
|
591
|
+
{sharedPricing()!.models.filter((m) => {
|
|
592
|
+
return cfg().focusModels.length === 0 || cfg().focusModels.includes(m.id)
|
|
593
|
+
}).map((m) => renderLimitRow(m))}
|
|
594
|
+
<text fg={colors().muted}>
|
|
595
|
+
{justify(t("priceUpdated"), new Date(sharedPricing()!.fetchTime).toLocaleTimeString())}
|
|
365
596
|
</text>
|
|
597
|
+
<Show when={pricingStore() && pricingStore()!.history.length > 0}>
|
|
598
|
+
<text fg={colors().warning}>
|
|
599
|
+
{t("priceChangesSummary", {
|
|
600
|
+
t: pricingStore()!.history[0].summary.total,
|
|
601
|
+
a: pricingStore()!.history[0].summary.added,
|
|
602
|
+
r: pricingStore()!.history[0].summary.removed,
|
|
603
|
+
p: pricingStore()!.history[0].summary.priceChanges,
|
|
604
|
+
l: pricingStore()!.history[0].summary.limitChanges,
|
|
605
|
+
})}
|
|
606
|
+
</text>
|
|
607
|
+
</Show>
|
|
366
608
|
</Show>
|
|
367
609
|
}>
|
|
368
|
-
<text fg={colors().
|
|
369
|
-
<text fg={colors().muted}>{t("reconfigureHint")}</text>
|
|
610
|
+
<text fg={colors().muted}>{t("priceLoading")}</text>
|
|
370
611
|
</Show>
|
|
371
612
|
</Show>
|
|
372
613
|
</box>
|
|
373
614
|
)
|
|
374
615
|
}
|
|
375
616
|
|
|
617
|
+
|
|
376
618
|
function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
|
|
377
619
|
return {
|
|
378
620
|
order: 60,
|
|
@@ -384,6 +626,17 @@ function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
|
|
|
384
626
|
}
|
|
385
627
|
}
|
|
386
628
|
|
|
629
|
+
function createPricingSlot(api: TuiPluginApi): TuiSlotPlugin {
|
|
630
|
+
return {
|
|
631
|
+
order: 61,
|
|
632
|
+
slots: {
|
|
633
|
+
sidebar_content(ctx: TuiSlotContext, _input: { session_id: string }): JSX.Element {
|
|
634
|
+
return <PricePanel theme={ctx.theme.current} api={api} />
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
387
640
|
function runConfigDialog(api: TuiPluginApi, dialog: any): void {
|
|
388
641
|
dialog?.replace(() => (
|
|
389
642
|
<api.ui.DialogPrompt
|
|
@@ -446,8 +699,124 @@ function runLangDialog(api: TuiPluginApi, dialog: any): void {
|
|
|
446
699
|
))
|
|
447
700
|
}
|
|
448
701
|
|
|
702
|
+
function runSettingsDialog(api: TuiPluginApi, dialog: any): void {
|
|
703
|
+
let lastSelected: string | undefined
|
|
704
|
+
const renderSettingsDialog = (dialog2: any) => {
|
|
705
|
+
const cfg = loadConfig()
|
|
706
|
+
const options = [
|
|
707
|
+
{
|
|
708
|
+
title: `${t("settingsFocus")} (${cfg.focusModels.length} 个)`,
|
|
709
|
+
value: "focus-models",
|
|
710
|
+
},
|
|
711
|
+
{
|
|
712
|
+
title: `${t("settingsRefreshUsage")} (${Math.round(cfg.refreshIntervalMs / 1000)}s)`,
|
|
713
|
+
value: "refresh-usage",
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
title: `${t("settingsRefreshPricing")} (${Math.round(cfg.pricingIntervalMs / 1000)}s)`,
|
|
717
|
+
value: "refresh-pricing",
|
|
718
|
+
},
|
|
719
|
+
]
|
|
720
|
+
dialog2?.replace(() => (
|
|
721
|
+
<api.ui.DialogSelect
|
|
722
|
+
title={t("settingsTitle")}
|
|
723
|
+
options={options}
|
|
724
|
+
onSelect={(opt) => {
|
|
725
|
+
const value = opt.value
|
|
726
|
+
if (value === "focus-models") {
|
|
727
|
+
const renderFocusDialog = (dialog3: any) => {
|
|
728
|
+
const cur = loadConfig().focusModels
|
|
729
|
+
const allModels = sharedPricing()?.models ?? []
|
|
730
|
+
const modelOptions = allModels.map((m) => ({
|
|
731
|
+
title: `${cur.includes(m.id) ? "\u2611" : "\u2610"} ${m.name} (${m.pricing.input ?? "-"}/${m.pricing.output ?? "-"})`,
|
|
732
|
+
value: m.id,
|
|
733
|
+
}))
|
|
734
|
+
const focusOptions = [
|
|
735
|
+
...modelOptions,
|
|
736
|
+
{ title: `${t("focusAll")} (${cur.length} 个已选)`, value: "select-all" },
|
|
737
|
+
{ title: t("focusNone"), value: "clear-all" },
|
|
738
|
+
{ title: `${t("focusDone")} (${t("focusSelected", { n: cur.length })})`, value: "done" },
|
|
739
|
+
]
|
|
740
|
+
dialog3?.replace(() => (
|
|
741
|
+
<api.ui.DialogSelect
|
|
742
|
+
title={t("settingsFocus")}
|
|
743
|
+
options={focusOptions}
|
|
744
|
+
current={lastSelected}
|
|
745
|
+
onSelect={(opt2) => {
|
|
746
|
+
const v2 = opt2.value
|
|
747
|
+
if (v2 === "done") {
|
|
748
|
+
dialog3?.clear()
|
|
749
|
+
renderSettingsDialog(dialog2)
|
|
750
|
+
} else if (v2 === "select-all") {
|
|
751
|
+
const all = allModels.map((m) => m.id)
|
|
752
|
+
saveConfig({ focus_models: all })
|
|
753
|
+
setConfigTick((v) => v + 1)
|
|
754
|
+
api.ui.toast({ variant: "success", message: t("settingsSaved") })
|
|
755
|
+
renderFocusDialog(dialog3)
|
|
756
|
+
} else if (v2 === "clear-all") {
|
|
757
|
+
saveConfig({ focus_models: [] })
|
|
758
|
+
setConfigTick((v) => v + 1)
|
|
759
|
+
api.ui.toast({ variant: "success", message: t("settingsSaved") })
|
|
760
|
+
renderFocusDialog(dialog3)
|
|
761
|
+
} else {
|
|
762
|
+
const next = cur.includes(v2) ? cur.filter((id) => id !== v2) : [...cur, v2]
|
|
763
|
+
saveConfig({ focus_models: next })
|
|
764
|
+
setConfigTick((v) => v + 1)
|
|
765
|
+
lastSelected = v2
|
|
766
|
+
renderFocusDialog(dialog3)
|
|
767
|
+
}
|
|
768
|
+
}}
|
|
769
|
+
onCancel={() => dialog3?.clear()}
|
|
770
|
+
/>
|
|
771
|
+
))
|
|
772
|
+
}
|
|
773
|
+
renderFocusDialog(dialog2)
|
|
774
|
+
} else if (value === "refresh-usage") {
|
|
775
|
+
dialog2?.replace(() => (
|
|
776
|
+
<api.ui.DialogPrompt
|
|
777
|
+
title={t("settingsRefreshUsage")}
|
|
778
|
+
description={() => <text>{"输入秒数(默认 60)"}</text>}
|
|
779
|
+
placeholder="60"
|
|
780
|
+
onConfirm={(val) => {
|
|
781
|
+
const sec = Number(val.trim())
|
|
782
|
+
if (!sec || sec <= 0) { dialog2?.clear(); return }
|
|
783
|
+
saveConfig({ refresh_interval_sec: sec })
|
|
784
|
+
setConfigTick((v) => v + 1)
|
|
785
|
+
api.ui.toast({ variant: "success", message: t("settingsSaved") })
|
|
786
|
+
renderSettingsDialog(dialog2)
|
|
787
|
+
}}
|
|
788
|
+
onCancel={() => dialog2?.clear()}
|
|
789
|
+
/>
|
|
790
|
+
))
|
|
791
|
+
} else if (value === "refresh-pricing") {
|
|
792
|
+
dialog2?.replace(() => (
|
|
793
|
+
<api.ui.DialogPrompt
|
|
794
|
+
title={t("settingsRefreshPricing")}
|
|
795
|
+
description={() => <text>{"输入秒数(默认 1800)"}</text>}
|
|
796
|
+
placeholder="1800"
|
|
797
|
+
onConfirm={(val) => {
|
|
798
|
+
const sec = Number(val.trim())
|
|
799
|
+
if (!sec || sec <= 0) { dialog2?.clear(); return }
|
|
800
|
+
saveConfig({ pricing_interval_sec: sec })
|
|
801
|
+
setConfigTick((v) => v + 1)
|
|
802
|
+
api.ui.toast({ variant: "success", message: t("settingsSaved") })
|
|
803
|
+
renderSettingsDialog(dialog2)
|
|
804
|
+
}}
|
|
805
|
+
onCancel={() => dialog2?.clear()}
|
|
806
|
+
/>
|
|
807
|
+
))
|
|
808
|
+
}
|
|
809
|
+
}}
|
|
810
|
+
onCancel={() => dialog2?.clear()}
|
|
811
|
+
/>
|
|
812
|
+
))
|
|
813
|
+
}
|
|
814
|
+
renderSettingsDialog(dialog)
|
|
815
|
+
}
|
|
816
|
+
|
|
449
817
|
const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
450
818
|
api.slots.register(createSidebarSlot(api))
|
|
819
|
+
api.slots.register(createPricingSlot(api))
|
|
451
820
|
|
|
452
821
|
// 恢复已保存的语言;若无则按系统检测
|
|
453
822
|
const savedLang = kvTryGet(api, `${KV_PREFIX}.lang`) as LangCode | undefined
|
|
@@ -479,6 +848,13 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
479
848
|
slash: { name: "go-lang" },
|
|
480
849
|
onSelect: (dialog) => runLangDialog(api, dialog),
|
|
481
850
|
},
|
|
851
|
+
{
|
|
852
|
+
title: t("settingsTitle"),
|
|
853
|
+
value: "go-usage.settings",
|
|
854
|
+
description: t("settingsDesc"),
|
|
855
|
+
slash: { name: "go-settings" },
|
|
856
|
+
onSelect: (dialog) => runSettingsDialog(api, dialog),
|
|
857
|
+
},
|
|
482
858
|
])
|
|
483
859
|
}
|
|
484
860
|
|