opencode-go-usage-tui 1.1.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 +53 -7
- package/README_EN.md +53 -7
- package/build.tui.mjs +1 -1
- package/dist/tui.js +993 -56
- package/package.json +1 -1
- package/src/i18n.ts +88 -0
- package/src/index.tsx +504 -58
- package/src/pricing.ts +348 -0
package/src/index.tsx
CHANGED
|
@@ -10,10 +10,13 @@ import type {
|
|
|
10
10
|
} from "@opencode-ai/plugin/tui"
|
|
11
11
|
import { createSignal, createEffect, onMount, onCleanup, Show } from "solid-js"
|
|
12
12
|
import type { JSX } from "@opentui/solid"
|
|
13
|
-
import { readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
|
13
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from "node:fs"
|
|
14
14
|
import { join, dirname } from "node:path"
|
|
15
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
|
|
15
16
|
import { createT, detectLang, LANG_META } from "./i18n"
|
|
16
17
|
import type { LangCode } from "./i18n"
|
|
18
|
+
import { fetchPricing, comparePricing, loadPricingStore, updatePricingStore } from "./pricing"
|
|
19
|
+
import type { ModelPrice, PricingData, PricingStore } from "./pricing"
|
|
17
20
|
|
|
18
21
|
declare const process: { env: Record<string, string | undefined> } | undefined
|
|
19
22
|
|
|
@@ -24,35 +27,132 @@ const CONFIG_DIR = process?.env?.OPENCODE_CONFIG_DIR
|
|
|
24
27
|
|| (process?.env?.USERPROFILE ? `${process.env.USERPROFILE}\\.config\\opencode` : "")
|
|
25
28
|
|| process?.env?.HOME + "/.config/opencode"
|
|
26
29
|
const CONFIG_FILE = join(CONFIG_DIR, "go-usage-config.json")
|
|
30
|
+
const COOKIE_ENC_FILE = join(CONFIG_DIR, "go-auth-cookie.enc")
|
|
31
|
+
const KEY_FILE = join(CONFIG_DIR, ".encryption-key")
|
|
32
|
+
const COOKIE_PLAIN_FILE = `${CONFIG_DIR}\\go-auth-cookie.txt`
|
|
27
33
|
const KV_PREFIX = "go-usage"
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
// ── AES-256-GCM 加密存储 ────────────────────────────────
|
|
36
|
+
// cookie 以密文存 go-auth-cookie.enc,密钥存 .encryption-key。
|
|
37
|
+
// 注意:Windows 无 Unix 权限位,mode 0o600 仅对 macOS/Linux 生效;
|
|
38
|
+
// Windows 上密钥与密文同目录,防护重点是"被动泄露"(备份/云同步/日志),
|
|
39
|
+
// 无法抵御能读取该目录的本地恶意程序。
|
|
40
|
+
function loadEncryptionKey(): Buffer {
|
|
32
41
|
try {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
const hex = readFileSync(KEY_FILE, "utf8").trim()
|
|
43
|
+
if (hex) return Buffer.from(hex, "hex")
|
|
44
|
+
} catch { /* 无密钥则新建 */ }
|
|
45
|
+
const key = randomBytes(32)
|
|
46
|
+
mkdirSync(dirname(KEY_FILE), { recursive: true })
|
|
47
|
+
writeFileSync(KEY_FILE, key.toString("hex"), { encoding: "utf8", mode: 0o600 })
|
|
48
|
+
return key
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function encryptText(text: string): string {
|
|
52
|
+
const key = loadEncryptionKey()
|
|
53
|
+
const iv = randomBytes(16)
|
|
54
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv)
|
|
55
|
+
const data = cipher.update(text, "utf8", "hex") + cipher.final("hex")
|
|
56
|
+
return JSON.stringify({ v: 1, iv: iv.toString("hex"), data, tag: cipher.getAuthTag().toString("hex") })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function decryptText(encJson: string): string {
|
|
60
|
+
const { iv, data, tag } = JSON.parse(encJson)
|
|
61
|
+
const key = loadEncryptionKey()
|
|
62
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "hex"))
|
|
63
|
+
decipher.setAuthTag(Buffer.from(tag, "hex"))
|
|
64
|
+
return decipher.update(data, "hex", "utf8") + decipher.final("utf8")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readJsonFile<T>(file: string): T | null {
|
|
68
|
+
try { return JSON.parse(readFileSync(file, "utf8")) as T } catch { return null }
|
|
69
|
+
}
|
|
70
|
+
|
|
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 }
|
|
72
|
+
|
|
73
|
+
interface LoadedConfig { workspaceId: string; authCookie: string; usagePanel: boolean; pricePanel: boolean; focusModels: string[]; refreshIntervalMs: number; pricingIntervalMs: number }
|
|
74
|
+
|
|
75
|
+
function loadConfig(): LoadedConfig {
|
|
76
|
+
const env = process?.env ?? {}
|
|
77
|
+
const fileCfg = readJsonFile<ConfigFile>(CONFIG_FILE) ?? {}
|
|
78
|
+
|
|
79
|
+
// 1. 环境变量优先
|
|
80
|
+
if (env.OPENCODE_GO_AUTH_COOKIE) {
|
|
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
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 2. 加密存储
|
|
37
90
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
91
|
+
if (existsSync(COOKIE_ENC_FILE)) {
|
|
92
|
+
const cookie = decryptText(readFileSync(COOKIE_ENC_FILE, "utf8"))
|
|
93
|
+
if (cookie) {
|
|
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
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch { /* 密文损坏时回退到明文迁移 */ }
|
|
103
|
+
|
|
104
|
+
// 3. 兼容旧明文(config.json 内嵌 cookie / auth_cookie / 独立 txt 文件)——自动迁移
|
|
105
|
+
const legacyCookie = fileCfg.auth_cookie || fileCfg.cookie || (() => {
|
|
106
|
+
try { return readFileSync(COOKIE_PLAIN_FILE, "utf8").trim() } catch { return "" }
|
|
107
|
+
})()
|
|
108
|
+
if (legacyCookie) {
|
|
109
|
+
const enc = encryptText(legacyCookie)
|
|
110
|
+
mkdirSync(CONFIG_DIR, { recursive: true })
|
|
111
|
+
writeFileSync(COOKIE_ENC_FILE, enc, "utf8")
|
|
112
|
+
// 清理旧明文
|
|
113
|
+
const cleanCfg: ConfigFile = { ...fileCfg }
|
|
114
|
+
delete cleanCfg.cookie
|
|
115
|
+
delete cleanCfg.auth_cookie
|
|
116
|
+
try { writeFileSync(CONFIG_FILE, JSON.stringify(cleanCfg, null, 2), "utf8") } catch { /* 忽略 */ }
|
|
117
|
+
try { if (existsSync(COOKIE_PLAIN_FILE)) renameSync(COOKIE_PLAIN_FILE, COOKIE_PLAIN_FILE + ".bak") } catch { /* 忽略 */ }
|
|
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
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
43
126
|
return {
|
|
44
|
-
workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "",
|
|
45
|
-
|
|
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,
|
|
46
131
|
}
|
|
47
132
|
}
|
|
48
133
|
|
|
49
|
-
function saveConfig(patch: { workspace_id?: string; cookie?: string }): void {
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"))
|
|
53
|
-
} catch { fileCfg = {} }
|
|
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 {
|
|
135
|
+
const fileCfg = readJsonFile<ConfigFile>(CONFIG_FILE) ?? {}
|
|
54
136
|
if (patch.workspace_id !== undefined) fileCfg.workspace_id = patch.workspace_id
|
|
55
|
-
if (patch.cookie !== undefined)
|
|
137
|
+
if (patch.cookie !== undefined) {
|
|
138
|
+
// cookie 改为加密存储,不再写明文到 config.json
|
|
139
|
+
if (patch.cookie) {
|
|
140
|
+
mkdirSync(CONFIG_DIR, { recursive: true })
|
|
141
|
+
writeFileSync(COOKIE_ENC_FILE, encryptText(patch.cookie), "utf8")
|
|
142
|
+
delete fileCfg.cookie
|
|
143
|
+
delete fileCfg.auth_cookie
|
|
144
|
+
// 清理可能存在的旧明文 txt
|
|
145
|
+
try { if (existsSync(COOKIE_PLAIN_FILE)) renameSync(COOKIE_PLAIN_FILE, COOKIE_PLAIN_FILE + ".bak") } catch { /* 忽略 */ }
|
|
146
|
+
} else {
|
|
147
|
+
try { if (existsSync(COOKIE_ENC_FILE)) renameSync(COOKIE_ENC_FILE, COOKIE_ENC_FILE + ".bak") } catch { /* 忽略 */ }
|
|
148
|
+
}
|
|
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
|
|
56
156
|
try {
|
|
57
157
|
mkdirSync(dirname(CONFIG_FILE), { recursive: true })
|
|
58
158
|
writeFileSync(CONFIG_FILE, JSON.stringify(fileCfg, null, 2), "utf8")
|
|
@@ -66,6 +166,9 @@ const [configTick, setConfigTick] = createSignal(0)
|
|
|
66
166
|
const [langCode, setLangCode] = createSignal<LangCode>(detectLang())
|
|
67
167
|
const t = createT(() => langCode())
|
|
68
168
|
|
|
169
|
+
// 模块级共享价格数据:PricePanel 抓取后写入,/go-settings 设置关注模型时读取
|
|
170
|
+
const [sharedPricing, setSharedPricing] = createSignal<PricingData | null>(null)
|
|
171
|
+
|
|
69
172
|
function kvTryGet(api: TuiPluginApi, key: string): string | undefined {
|
|
70
173
|
try { return api.kv.get<string>(key) } catch { return undefined }
|
|
71
174
|
}
|
|
@@ -80,7 +183,9 @@ interface Usage {
|
|
|
80
183
|
error?: string
|
|
81
184
|
}
|
|
82
185
|
|
|
83
|
-
|
|
186
|
+
const REQUEST_TIMEOUT = 10000
|
|
187
|
+
|
|
188
|
+
async function fetchUsage(workspaceId: string, authCookie: string, signal?: AbortSignal): Promise<Usage | null> {
|
|
84
189
|
if (!workspaceId || !authCookie) return { error: "not_configured" } as Usage
|
|
85
190
|
try {
|
|
86
191
|
const res = await fetch("https://opencode.ai/_server", {
|
|
@@ -97,6 +202,7 @@ async function fetchUsage(workspaceId: string, authCookie: string): Promise<Usag
|
|
|
97
202
|
f: 31,
|
|
98
203
|
m: [],
|
|
99
204
|
}),
|
|
205
|
+
signal,
|
|
100
206
|
})
|
|
101
207
|
if (!res.ok) {
|
|
102
208
|
if (res.status === 302) return { error: "cookie_expired" } as Usage
|
|
@@ -178,9 +284,21 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
|
|
|
178
284
|
const [configured, setConfigured] = createSignal(true)
|
|
179
285
|
const [lastUpdated, setLastUpdated] = createSignal("")
|
|
180
286
|
const [panelWidth, setPanelWidth] = createSignal(24)
|
|
287
|
+
const [cfg, setCfg] = createSignal<LoadedConfig>(loadConfig())
|
|
288
|
+
createEffect(() => { void configTick(); setCfg(loadConfig()) })
|
|
181
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
|
+
}
|
|
182
299
|
|
|
183
300
|
async function refresh() {
|
|
301
|
+
if (isRefreshing) return
|
|
184
302
|
const cfg = loadConfig()
|
|
185
303
|
if (!cfg.workspaceId || !cfg.authCookie) {
|
|
186
304
|
setConfigured(false)
|
|
@@ -189,24 +307,39 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
|
|
|
189
307
|
return
|
|
190
308
|
}
|
|
191
309
|
setConfigured(true)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
+
}
|
|
199
328
|
}
|
|
200
329
|
|
|
201
330
|
onMount(() => {
|
|
202
331
|
refresh()
|
|
203
|
-
const timer = setInterval(refresh,
|
|
204
|
-
// 监听配置变更(/go-config 保存后)立即刷新
|
|
332
|
+
const timer = setInterval(refresh, loadConfig().refreshIntervalMs)
|
|
205
333
|
const stopWatch = createEffect(() => {
|
|
206
334
|
void configTick()
|
|
207
335
|
refresh()
|
|
208
336
|
})
|
|
209
|
-
onCleanup(() => {
|
|
337
|
+
onCleanup(() => {
|
|
338
|
+
clearInterval(timer)
|
|
339
|
+
stopWatch()
|
|
340
|
+
cleanupPendingRequest()
|
|
341
|
+
if (resizeTimeout) { clearTimeout(resizeTimeout); resizeTimeout = null }
|
|
342
|
+
})
|
|
210
343
|
})
|
|
211
344
|
|
|
212
345
|
const [pal, setPal] = createSignal<Record<string, string>>({ ...FALLBACK })
|
|
@@ -249,6 +382,176 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
|
|
|
249
382
|
|
|
250
383
|
const sep = () => "\u2500".repeat(Math.max(1, panelWidth() - 4))
|
|
251
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
|
+
|
|
252
555
|
return (
|
|
253
556
|
<box
|
|
254
557
|
border
|
|
@@ -259,50 +562,59 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
|
|
|
259
562
|
gap={0}
|
|
260
563
|
ref={boxEl}
|
|
261
564
|
onSizeChange={() => {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
+
>
|
|
266
572
|
<text onMouseUp={() => setOpen(o => !o)}>
|
|
267
573
|
<span style={{ fg: colors().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
|
|
268
|
-
<span style={{ fg: colors().primary }}><b>{t("
|
|
269
|
-
<
|
|
270
|
-
|
|
271
|
-
<span style={{ fg: colorFor(usage()!.weeklyUsage.usagePercent) }}>
|
|
272
|
-
{" ".repeat(2)}{t("weeklyShort")} {usage()!.weeklyUsage.usagePercent}%
|
|
273
|
-
</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>
|
|
274
577
|
</Show>
|
|
578
|
+
<span style={{ fg: colors().muted }}>{sep().slice(visualWidth((open() ? "\u25bc " : "\u25b6 ") + t("priceTitle") + (sharedPricing() ? ` (${sharedPricing()!.models.length})` : "")))}</span>
|
|
275
579
|
</text>
|
|
276
580
|
|
|
277
581
|
<Show when={open()}>
|
|
582
|
+
<text fg={colors().muted}>
|
|
583
|
+
{justify(t("priceModelCol"), t("priceReqs"))}
|
|
584
|
+
</text>
|
|
278
585
|
<text fg={colors().muted}>{sep()}</text>
|
|
279
586
|
|
|
280
|
-
<Show when={!
|
|
281
|
-
<
|
|
282
|
-
|
|
283
|
-
</Show>
|
|
284
|
-
|
|
285
|
-
<Show when={configured() && error()} fallback={
|
|
286
|
-
<Show when={configured() && usage()} fallback={
|
|
287
|
-
<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>
|
|
288
590
|
}>
|
|
289
|
-
{
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
<text>
|
|
293
|
-
|
|
294
|
-
<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())}
|
|
295
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>
|
|
296
608
|
</Show>
|
|
297
609
|
}>
|
|
298
|
-
<text fg={colors().
|
|
299
|
-
<text fg={colors().muted}>{t("reconfigureHint")}</text>
|
|
610
|
+
<text fg={colors().muted}>{t("priceLoading")}</text>
|
|
300
611
|
</Show>
|
|
301
612
|
</Show>
|
|
302
613
|
</box>
|
|
303
614
|
)
|
|
304
615
|
}
|
|
305
616
|
|
|
617
|
+
|
|
306
618
|
function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
|
|
307
619
|
return {
|
|
308
620
|
order: 60,
|
|
@@ -314,6 +626,17 @@ function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
|
|
|
314
626
|
}
|
|
315
627
|
}
|
|
316
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
|
+
|
|
317
640
|
function runConfigDialog(api: TuiPluginApi, dialog: any): void {
|
|
318
641
|
dialog?.replace(() => (
|
|
319
642
|
<api.ui.DialogPrompt
|
|
@@ -376,8 +699,124 @@ function runLangDialog(api: TuiPluginApi, dialog: any): void {
|
|
|
376
699
|
))
|
|
377
700
|
}
|
|
378
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
|
+
|
|
379
817
|
const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
380
818
|
api.slots.register(createSidebarSlot(api))
|
|
819
|
+
api.slots.register(createPricingSlot(api))
|
|
381
820
|
|
|
382
821
|
// 恢复已保存的语言;若无则按系统检测
|
|
383
822
|
const savedLang = kvTryGet(api, `${KV_PREFIX}.lang`) as LangCode | undefined
|
|
@@ -409,6 +848,13 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
409
848
|
slash: { name: "go-lang" },
|
|
410
849
|
onSelect: (dialog) => runLangDialog(api, dialog),
|
|
411
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
|
+
},
|
|
412
858
|
])
|
|
413
859
|
}
|
|
414
860
|
|