opencode-go-usage-tui 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -6
- package/README_EN.md +39 -6
- package/dist/tui.js +907 -58
- package/package.json +1 -1
- package/src/i18n.ts +88 -0
- package/src/index.tsx +433 -45
- 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
|
|
@@ -177,7 +213,7 @@ async function fetchUsage(workspaceId: string, authCookie: string): Promise<Usag
|
|
|
177
213
|
return { error: "cookie_expired" } as Usage
|
|
178
214
|
}
|
|
179
215
|
const extract = (key: string): { resetInSec: number; usagePercent: number } | null => {
|
|
180
|
-
const m = text.match(new RegExp(`${key}:[^}]*?\\{status:"
|
|
216
|
+
const m = text.match(new RegExp(`${key}:[^}]*?\\{status:"[^"]*",resetInSec:(\\d+),usagePercent:(\\d+)\\}`))
|
|
181
217
|
if (!m) return null
|
|
182
218
|
return { resetInSec: Number(m[1]), usagePercent: Number(m[2]) }
|
|
183
219
|
}
|
|
@@ -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,188 @@ 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
|
+
// 强制同步 panelWidth 与 box 真实宽度(visual-cache 模式,避免首次不触发 onSizeChange)
|
|
454
|
+
createEffect(() => {
|
|
455
|
+
if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
|
|
456
|
+
setPanelWidth(Math.max(20, boxEl.width))
|
|
457
|
+
}
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
async function refreshPricing() {
|
|
461
|
+
if (priceLoading()) return
|
|
462
|
+
setPriceLoading(true)
|
|
463
|
+
setPriceError("")
|
|
464
|
+
priceController?.abort()
|
|
465
|
+
priceController = new AbortController()
|
|
466
|
+
try {
|
|
467
|
+
const data = await fetchPricing()
|
|
468
|
+
const store = updatePricingStore(data)
|
|
469
|
+
setSharedPricing(data)
|
|
470
|
+
setPricingStore(store)
|
|
471
|
+
const prev = store.previous
|
|
472
|
+
if (prev && prev.fetchTime !== data.fetchTime) {
|
|
473
|
+
const diff = comparePricing(prev, data)
|
|
474
|
+
if (diff.hasChanges) {
|
|
475
|
+
props.api.toast({
|
|
476
|
+
variant: "info",
|
|
477
|
+
title: t("priceChanged"),
|
|
478
|
+
message: t("priceChangesSummary", {
|
|
479
|
+
t: diff.summary.total,
|
|
480
|
+
a: diff.summary.added,
|
|
481
|
+
r: diff.summary.removed,
|
|
482
|
+
p: diff.summary.priceChanges,
|
|
483
|
+
l: diff.summary.limitChanges,
|
|
484
|
+
}),
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
} catch {
|
|
489
|
+
setPriceError(t("priceError"))
|
|
490
|
+
} finally {
|
|
491
|
+
setPriceLoading(false)
|
|
492
|
+
priceController = null
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function formatCount(n: number): string {
|
|
497
|
+
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k"
|
|
498
|
+
return String(n)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function visualPadEnd(s: string, w: number): string {
|
|
502
|
+
const cur = visualWidth(s)
|
|
503
|
+
if (cur >= w) return s
|
|
504
|
+
return s + " ".repeat(w - cur)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function abbreviateName(name: string, maxLen: number): string {
|
|
508
|
+
if (visualWidth(name) <= maxLen) return name
|
|
509
|
+
let out = ""
|
|
510
|
+
let w = 0
|
|
511
|
+
for (const c of name) {
|
|
512
|
+
const cw = visualWidth(c)
|
|
513
|
+
if (w + cw > maxLen - 1) break
|
|
514
|
+
out += c
|
|
515
|
+
w += cw
|
|
516
|
+
}
|
|
517
|
+
return out + "…"
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// visual-cache 风格:左标签 + 自动填充 + 右值
|
|
521
|
+
// 直接读取 boxEl 实时宽度(避免 panelWidth 信号滞后)
|
|
522
|
+
function justify(label: string, value: string): string {
|
|
523
|
+
const outer = boxEl && typeof boxEl.width === "number" && boxEl.width > 0 ? boxEl.width : panelWidth()
|
|
524
|
+
const gauge = Math.max(10, outer - 4)
|
|
525
|
+
const used = visualWidth(label) + visualWidth(value)
|
|
526
|
+
const gap = Math.max(1, gauge - used)
|
|
527
|
+
return label + " ".repeat(gap) + value
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function renderLimitRow(m: ModelPrice) {
|
|
531
|
+
const name = abbreviateName(m.name, 18)
|
|
532
|
+
const req = `${formatCount(m.limits.fiveHour)}/${formatCount(m.limits.weekly)}/${formatCount(m.limits.monthly)}`
|
|
533
|
+
return (
|
|
534
|
+
<text>
|
|
535
|
+
<span style={{ fg: colors().text }}>{justify(name, req)}</span>
|
|
536
|
+
</text>
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const [pal, setPal] = createSignal<Record<string, string>>({ ...FALLBACK })
|
|
541
|
+
createEffect(() => {
|
|
542
|
+
const th = props.theme as any
|
|
543
|
+
const p: Record<string, string> = { ...FALLBACK }
|
|
544
|
+
for (const k of Object.keys(FALLBACK)) {
|
|
545
|
+
const h = hex(th?.[k])
|
|
546
|
+
if (h) p[k] = h
|
|
547
|
+
}
|
|
548
|
+
setPal(p)
|
|
549
|
+
})
|
|
550
|
+
const colors = () => pal()
|
|
551
|
+
|
|
552
|
+
const sep = () => {
|
|
553
|
+
const outer = boxEl && typeof boxEl.width === "number" && boxEl.width > 0 ? boxEl.width : panelWidth()
|
|
554
|
+
return "\u2500".repeat(Math.max(1, outer - 4))
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
setTimeout(() => {
|
|
558
|
+
const store = loadPricingStore()
|
|
559
|
+
if (store.current.models.length > 0) {
|
|
560
|
+
setSharedPricing(store.current)
|
|
561
|
+
setPricingStore(store)
|
|
562
|
+
}
|
|
563
|
+
refreshPricing()
|
|
564
|
+
setInterval(() => refreshPricing(), loadConfig().pricingIntervalMs)
|
|
565
|
+
}, 2000)
|
|
566
|
+
|
|
322
567
|
return (
|
|
323
568
|
<box
|
|
324
569
|
border
|
|
@@ -329,50 +574,59 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
|
|
|
329
574
|
gap={0}
|
|
330
575
|
ref={boxEl}
|
|
331
576
|
onSizeChange={() => {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
577
|
+
if (resizeTimeout) clearTimeout(resizeTimeout)
|
|
578
|
+
resizeTimeout = setTimeout(() => {
|
|
579
|
+
const w = boxEl ? Math.max(20, boxEl.width ?? 24) : 24
|
|
580
|
+
setPanelWidth(w)
|
|
581
|
+
}, 100)
|
|
582
|
+
}}
|
|
583
|
+
>
|
|
336
584
|
<text onMouseUp={() => setOpen(o => !o)}>
|
|
337
585
|
<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>
|
|
586
|
+
<span style={{ fg: colors().primary }}><b>{t("priceTitle")}</b></span>
|
|
587
|
+
<Show when={sharedPricing()}>
|
|
588
|
+
<span style={{ fg: colors().muted }}> ({sharedPricing()!.models.length})</span>
|
|
344
589
|
</Show>
|
|
590
|
+
<span style={{ fg: colors().muted }}>{sep().slice(visualWidth((open() ? "\u25bc " : "\u25b6 ") + t("priceTitle") + (sharedPricing() ? ` (${sharedPricing()!.models.length})` : "")))}</span>
|
|
345
591
|
</text>
|
|
346
592
|
|
|
347
593
|
<Show when={open()}>
|
|
594
|
+
<text fg={colors().muted}>
|
|
595
|
+
{justify(t("priceModelCol"), t("priceReqs"))}
|
|
596
|
+
</text>
|
|
348
597
|
<text fg={colors().muted}>{sep()}</text>
|
|
349
598
|
|
|
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>
|
|
599
|
+
<Show when={priceLoading() || !sharedPricing()} fallback={
|
|
600
|
+
<Show when={sharedPricing()} fallback={
|
|
601
|
+
<text fg={colors().muted}>{t("priceLoading")}</text>
|
|
358
602
|
}>
|
|
359
|
-
{
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
<text>
|
|
363
|
-
|
|
364
|
-
<span style={{ fg: colors().muted }}>{lastUpdated()}</span>
|
|
603
|
+
{sharedPricing()!.models.filter((m) => {
|
|
604
|
+
return cfg().focusModels.length === 0 || cfg().focusModels.includes(m.id)
|
|
605
|
+
}).map((m) => renderLimitRow(m))}
|
|
606
|
+
<text fg={colors().muted}>
|
|
607
|
+
{justify(t("priceUpdated"), new Date(sharedPricing()!.fetchTime).toLocaleTimeString())}
|
|
365
608
|
</text>
|
|
609
|
+
<Show when={pricingStore() && pricingStore()!.history.length > 0}>
|
|
610
|
+
<text fg={colors().warning}>
|
|
611
|
+
{t("priceChangesSummary", {
|
|
612
|
+
t: pricingStore()!.history[0].summary.total,
|
|
613
|
+
a: pricingStore()!.history[0].summary.added,
|
|
614
|
+
r: pricingStore()!.history[0].summary.removed,
|
|
615
|
+
p: pricingStore()!.history[0].summary.priceChanges,
|
|
616
|
+
l: pricingStore()!.history[0].summary.limitChanges,
|
|
617
|
+
})}
|
|
618
|
+
</text>
|
|
619
|
+
</Show>
|
|
366
620
|
</Show>
|
|
367
621
|
}>
|
|
368
|
-
<text fg={colors().
|
|
369
|
-
<text fg={colors().muted}>{t("reconfigureHint")}</text>
|
|
622
|
+
<text fg={colors().muted}>{t("priceLoading")}</text>
|
|
370
623
|
</Show>
|
|
371
624
|
</Show>
|
|
372
625
|
</box>
|
|
373
626
|
)
|
|
374
627
|
}
|
|
375
628
|
|
|
629
|
+
|
|
376
630
|
function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
|
|
377
631
|
return {
|
|
378
632
|
order: 60,
|
|
@@ -384,6 +638,17 @@ function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
|
|
|
384
638
|
}
|
|
385
639
|
}
|
|
386
640
|
|
|
641
|
+
function createPricingSlot(api: TuiPluginApi): TuiSlotPlugin {
|
|
642
|
+
return {
|
|
643
|
+
order: 61,
|
|
644
|
+
slots: {
|
|
645
|
+
sidebar_content(ctx: TuiSlotContext, _input: { session_id: string }): JSX.Element {
|
|
646
|
+
return <PricePanel theme={ctx.theme.current} api={api} />
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
387
652
|
function runConfigDialog(api: TuiPluginApi, dialog: any): void {
|
|
388
653
|
dialog?.replace(() => (
|
|
389
654
|
<api.ui.DialogPrompt
|
|
@@ -446,8 +711,124 @@ function runLangDialog(api: TuiPluginApi, dialog: any): void {
|
|
|
446
711
|
))
|
|
447
712
|
}
|
|
448
713
|
|
|
714
|
+
function runSettingsDialog(api: TuiPluginApi, dialog: any): void {
|
|
715
|
+
let lastSelected: string | undefined
|
|
716
|
+
const renderSettingsDialog = (dialog2: any) => {
|
|
717
|
+
const cfg = loadConfig()
|
|
718
|
+
const options = [
|
|
719
|
+
{
|
|
720
|
+
title: `${t("settingsFocus")} (${cfg.focusModels.length} 个)`,
|
|
721
|
+
value: "focus-models",
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
title: `${t("settingsRefreshUsage")} (${Math.round(cfg.refreshIntervalMs / 1000)}s)`,
|
|
725
|
+
value: "refresh-usage",
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
title: `${t("settingsRefreshPricing")} (${Math.round(cfg.pricingIntervalMs / 1000)}s)`,
|
|
729
|
+
value: "refresh-pricing",
|
|
730
|
+
},
|
|
731
|
+
]
|
|
732
|
+
dialog2?.replace(() => (
|
|
733
|
+
<api.ui.DialogSelect
|
|
734
|
+
title={t("settingsTitle")}
|
|
735
|
+
options={options}
|
|
736
|
+
onSelect={(opt) => {
|
|
737
|
+
const value = opt.value
|
|
738
|
+
if (value === "focus-models") {
|
|
739
|
+
const renderFocusDialog = (dialog3: any) => {
|
|
740
|
+
const cur = loadConfig().focusModels
|
|
741
|
+
const allModels = sharedPricing()?.models ?? []
|
|
742
|
+
const modelOptions = allModels.map((m) => ({
|
|
743
|
+
title: `${cur.includes(m.id) ? "\u2611" : "\u2610"} ${m.name} (${m.pricing.input ?? "-"}/${m.pricing.output ?? "-"})`,
|
|
744
|
+
value: m.id,
|
|
745
|
+
}))
|
|
746
|
+
const focusOptions = [
|
|
747
|
+
...modelOptions,
|
|
748
|
+
{ title: `${t("focusAll")} (${cur.length} 个已选)`, value: "select-all" },
|
|
749
|
+
{ title: t("focusNone"), value: "clear-all" },
|
|
750
|
+
{ title: `${t("focusDone")} (${t("focusSelected", { n: cur.length })})`, value: "done" },
|
|
751
|
+
]
|
|
752
|
+
dialog3?.replace(() => (
|
|
753
|
+
<api.ui.DialogSelect
|
|
754
|
+
title={t("settingsFocus")}
|
|
755
|
+
options={focusOptions}
|
|
756
|
+
current={lastSelected}
|
|
757
|
+
onSelect={(opt2) => {
|
|
758
|
+
const v2 = opt2.value
|
|
759
|
+
if (v2 === "done") {
|
|
760
|
+
dialog3?.clear()
|
|
761
|
+
renderSettingsDialog(dialog2)
|
|
762
|
+
} else if (v2 === "select-all") {
|
|
763
|
+
const all = allModels.map((m) => m.id)
|
|
764
|
+
saveConfig({ focus_models: all })
|
|
765
|
+
setConfigTick((v) => v + 1)
|
|
766
|
+
api.ui.toast({ variant: "success", message: t("settingsSaved") })
|
|
767
|
+
renderFocusDialog(dialog3)
|
|
768
|
+
} else if (v2 === "clear-all") {
|
|
769
|
+
saveConfig({ focus_models: [] })
|
|
770
|
+
setConfigTick((v) => v + 1)
|
|
771
|
+
api.ui.toast({ variant: "success", message: t("settingsSaved") })
|
|
772
|
+
renderFocusDialog(dialog3)
|
|
773
|
+
} else {
|
|
774
|
+
const next = cur.includes(v2) ? cur.filter((id) => id !== v2) : [...cur, v2]
|
|
775
|
+
saveConfig({ focus_models: next })
|
|
776
|
+
setConfigTick((v) => v + 1)
|
|
777
|
+
lastSelected = v2
|
|
778
|
+
renderFocusDialog(dialog3)
|
|
779
|
+
}
|
|
780
|
+
}}
|
|
781
|
+
onCancel={() => dialog3?.clear()}
|
|
782
|
+
/>
|
|
783
|
+
))
|
|
784
|
+
}
|
|
785
|
+
renderFocusDialog(dialog2)
|
|
786
|
+
} else if (value === "refresh-usage") {
|
|
787
|
+
dialog2?.replace(() => (
|
|
788
|
+
<api.ui.DialogPrompt
|
|
789
|
+
title={t("settingsRefreshUsage")}
|
|
790
|
+
description={() => <text>{"输入秒数(默认 60)"}</text>}
|
|
791
|
+
placeholder="60"
|
|
792
|
+
onConfirm={(val) => {
|
|
793
|
+
const sec = Number(val.trim())
|
|
794
|
+
if (!sec || sec <= 0) { dialog2?.clear(); return }
|
|
795
|
+
saveConfig({ refresh_interval_sec: sec })
|
|
796
|
+
setConfigTick((v) => v + 1)
|
|
797
|
+
api.ui.toast({ variant: "success", message: t("settingsSaved") })
|
|
798
|
+
renderSettingsDialog(dialog2)
|
|
799
|
+
}}
|
|
800
|
+
onCancel={() => dialog2?.clear()}
|
|
801
|
+
/>
|
|
802
|
+
))
|
|
803
|
+
} else if (value === "refresh-pricing") {
|
|
804
|
+
dialog2?.replace(() => (
|
|
805
|
+
<api.ui.DialogPrompt
|
|
806
|
+
title={t("settingsRefreshPricing")}
|
|
807
|
+
description={() => <text>{"输入秒数(默认 1800)"}</text>}
|
|
808
|
+
placeholder="1800"
|
|
809
|
+
onConfirm={(val) => {
|
|
810
|
+
const sec = Number(val.trim())
|
|
811
|
+
if (!sec || sec <= 0) { dialog2?.clear(); return }
|
|
812
|
+
saveConfig({ pricing_interval_sec: sec })
|
|
813
|
+
setConfigTick((v) => v + 1)
|
|
814
|
+
api.ui.toast({ variant: "success", message: t("settingsSaved") })
|
|
815
|
+
renderSettingsDialog(dialog2)
|
|
816
|
+
}}
|
|
817
|
+
onCancel={() => dialog2?.clear()}
|
|
818
|
+
/>
|
|
819
|
+
))
|
|
820
|
+
}
|
|
821
|
+
}}
|
|
822
|
+
onCancel={() => dialog2?.clear()}
|
|
823
|
+
/>
|
|
824
|
+
))
|
|
825
|
+
}
|
|
826
|
+
renderSettingsDialog(dialog)
|
|
827
|
+
}
|
|
828
|
+
|
|
449
829
|
const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
450
830
|
api.slots.register(createSidebarSlot(api))
|
|
831
|
+
api.slots.register(createPricingSlot(api))
|
|
451
832
|
|
|
452
833
|
// 恢复已保存的语言;若无则按系统检测
|
|
453
834
|
const savedLang = kvTryGet(api, `${KV_PREFIX}.lang`) as LangCode | undefined
|
|
@@ -479,6 +860,13 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
479
860
|
slash: { name: "go-lang" },
|
|
480
861
|
onSelect: (dialog) => runLangDialog(api, dialog),
|
|
481
862
|
},
|
|
863
|
+
{
|
|
864
|
+
title: t("settingsTitle"),
|
|
865
|
+
value: "go-usage.settings",
|
|
866
|
+
description: t("settingsDesc"),
|
|
867
|
+
slash: { name: "go-settings" },
|
|
868
|
+
onSelect: (dialog) => runSettingsDialog(api, dialog),
|
|
869
|
+
},
|
|
482
870
|
])
|
|
483
871
|
}
|
|
484
872
|
|