opencode-go-usage-tui 1.3.1 → 1.3.3
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 +18 -10
- package/README_EN.md +17 -9
- package/dist/tui.js +298 -115
- package/package.json +1 -1
- package/src/i18n.ts +39 -23
- package/src/index.tsx +1028 -877
- package/src/pricing.ts +17 -3
package/src/index.tsx
CHANGED
|
@@ -1,878 +1,1029 @@
|
|
|
1
|
-
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
|
|
3
|
-
import type {
|
|
4
|
-
TuiPlugin,
|
|
5
|
-
TuiPluginApi,
|
|
6
|
-
TuiSlotContext,
|
|
7
|
-
TuiSlotPlugin,
|
|
8
|
-
TuiPluginModule,
|
|
9
|
-
TuiThemeCurrent,
|
|
10
|
-
} from "@opencode-ai/plugin/tui"
|
|
11
|
-
import { createSignal, createEffect, onMount, onCleanup, Show } from "solid-js"
|
|
12
|
-
import type { JSX } from "@opentui/solid"
|
|
13
|
-
import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from "node:fs"
|
|
14
|
-
import { join, dirname } from "node:path"
|
|
15
|
-
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
|
|
16
|
-
import { createT, detectLang, LANG_META } from "./i18n"
|
|
17
|
-
import type { LangCode } from "./i18n"
|
|
18
|
-
import { fetchPricing, comparePricing, loadPricingStore, updatePricingStore } from "./pricing"
|
|
19
|
-
import type { ModelPrice, PricingData, PricingStore } from "./pricing"
|
|
20
|
-
|
|
21
|
-
declare const process: { env: Record<string, string | undefined> } | undefined
|
|
22
|
-
|
|
23
|
-
const VERSION: string = __PLUGIN_VERSION__
|
|
24
|
-
const CHECK_INTERVAL = Number(process?.env?.OPENCODE_GO_CHECK_INTERVAL ?? 60000)
|
|
25
|
-
const WARN_THRESHOLD = Number(process?.env?.OPENCODE_GO_WARN_THRESHOLD ?? 0.8)
|
|
26
|
-
const CONFIG_DIR = process?.env?.OPENCODE_CONFIG_DIR
|
|
27
|
-
|| (process?.env?.USERPROFILE ? `${process.env.USERPROFILE}\\.config\\opencode` : "")
|
|
28
|
-
|| process?.env?.HOME + "/.config/opencode"
|
|
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`
|
|
33
|
-
const KV_PREFIX = "go-usage"
|
|
34
|
-
|
|
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 {
|
|
41
|
-
try {
|
|
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. 加密存储
|
|
90
|
-
try {
|
|
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
|
-
|
|
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
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
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) ?? {}
|
|
136
|
-
if (patch.workspace_id !== undefined) fileCfg.workspace_id = patch.workspace_id
|
|
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
|
|
156
|
-
try {
|
|
157
|
-
mkdirSync(dirname(CONFIG_FILE), { recursive: true })
|
|
158
|
-
writeFileSync(CONFIG_FILE, JSON.stringify(fileCfg, null, 2), "utf8")
|
|
159
|
-
} catch { /* 写入失败忽略 */ }
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// 模块级共享信号:/go-config 保存配置后递增,面板监听它立即刷新
|
|
163
|
-
const [configTick, setConfigTick] = createSignal(0)
|
|
164
|
-
|
|
165
|
-
// 模块级共享语言信号:/go-lang 或首次引导切换后,面板和命令实时响应
|
|
166
|
-
const [langCode, setLangCode] = createSignal<LangCode>(detectLang())
|
|
167
|
-
const t = createT(() => langCode())
|
|
168
|
-
|
|
169
|
-
// 模块级共享价格数据:PricePanel 抓取后写入,/go-settings 设置关注模型时读取
|
|
170
|
-
const [sharedPricing, setSharedPricing] = createSignal<PricingData | null>(null)
|
|
171
|
-
|
|
172
|
-
function kvTryGet(api: TuiPluginApi, key: string): string | undefined {
|
|
173
|
-
try { return api.kv.get<string>(key) } catch { return undefined }
|
|
174
|
-
}
|
|
175
|
-
function kvTrySet(api: TuiPluginApi, key: string, val: unknown): void {
|
|
176
|
-
try { api.kv.set(key, val) } catch { /* 忽略 */ }
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
interface Usage {
|
|
180
|
-
rollingUsage: { usagePercent: number; resetInSec: number }
|
|
181
|
-
weeklyUsage: { usagePercent: number; resetInSec: number }
|
|
182
|
-
monthlyUsage: { usagePercent: number; resetInSec: number }
|
|
183
|
-
error?: string
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const REQUEST_TIMEOUT = 10000
|
|
187
|
-
|
|
188
|
-
async function fetchUsage(workspaceId: string, authCookie: string, signal?: AbortSignal): Promise<Usage | null> {
|
|
189
|
-
if (!workspaceId || !authCookie) return { error: "not_configured" } as Usage
|
|
190
|
-
try {
|
|
191
|
-
const res = await fetch("https://opencode.ai/_server", {
|
|
192
|
-
method: "POST",
|
|
193
|
-
headers: {
|
|
194
|
-
"content-type": "application/json",
|
|
195
|
-
"x-server-id": "c7389bd0e731f80f49593e5ee53835475f4e28594dd6bd83eb229bab753498cd",
|
|
196
|
-
"x-server-instance": "go-usage-tui",
|
|
197
|
-
"cookie": `auth=${authCookie}`,
|
|
198
|
-
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
|
199
|
-
},
|
|
200
|
-
body: JSON.stringify({
|
|
201
|
-
t: { t: 9, i: 0, l: 1, a: [{ t: 1, s: workspaceId }], o: 0 },
|
|
202
|
-
f: 31,
|
|
203
|
-
m: [],
|
|
204
|
-
}),
|
|
205
|
-
signal,
|
|
206
|
-
})
|
|
207
|
-
if (!res.ok) {
|
|
208
|
-
if (res.status === 302) return { error: "cookie_expired" } as Usage
|
|
209
|
-
return null
|
|
210
|
-
}
|
|
211
|
-
const text = await res.text()
|
|
212
|
-
if (text.includes("/auth/authorize") || text.includes("location")) {
|
|
213
|
-
return { error: "cookie_expired" } as Usage
|
|
214
|
-
}
|
|
215
|
-
const extract = (key: string): { resetInSec: number; usagePercent: number } | null => {
|
|
216
|
-
const m = text.match(new RegExp(`${key}:[^}]*?\\{status:"[^"]*",resetInSec:(\\d+),usagePercent:(\\d+)\\}`))
|
|
217
|
-
if (!m) return null
|
|
218
|
-
return { resetInSec: Number(m[1]), usagePercent: Number(m[2]) }
|
|
219
|
-
}
|
|
220
|
-
const rollingUsage = extract("rollingUsage")
|
|
221
|
-
const weeklyUsage = extract("weeklyUsage")
|
|
222
|
-
const monthlyUsage = extract("monthlyUsage")
|
|
223
|
-
if (!rollingUsage || !weeklyUsage || !monthlyUsage) return null
|
|
224
|
-
return { rollingUsage, weeklyUsage, monthlyUsage }
|
|
225
|
-
} catch {
|
|
226
|
-
return null
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function formatReset(sec: number): string {
|
|
231
|
-
if (sec <= 0) return t("resetDone")
|
|
232
|
-
const d = Math.floor(sec / 86400)
|
|
233
|
-
const h = Math.floor((sec % 86400) / 3600)
|
|
234
|
-
const m = Math.floor((sec % 3600) / 60)
|
|
235
|
-
if (d > 0) return t("dayHour", { d, h })
|
|
236
|
-
if (h > 0) return t("hourMin", { h, m })
|
|
237
|
-
return t("minute", { m })
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function progressBar(percent: number, width: number): string {
|
|
241
|
-
const clamped = Math.max(0, Math.min(100, percent))
|
|
242
|
-
const filled = Math.round((clamped / 100) * width)
|
|
243
|
-
const empty = Math.max(0, width - filled)
|
|
244
|
-
return "\u2588".repeat(filled) + "\u2591".repeat(empty)
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function visualWidth(s: string): number {
|
|
248
|
-
let w = 0
|
|
249
|
-
for (const c of s) {
|
|
250
|
-
const code = c.codePointAt(0) ?? 0
|
|
251
|
-
w += (code >= 0x1100 && code <= 0x115F) ||
|
|
252
|
-
(code >= 0x2E80 && code <= 0xA4CF) ||
|
|
253
|
-
(code >= 0xAC00 && code <= 0xD7A3) ||
|
|
254
|
-
(code >= 0xF900 && code <= 0xFAFF) ||
|
|
255
|
-
(code >= 0xFF01 && code <= 0xFF60) ||
|
|
256
|
-
(code >= 0xFFE0 && code <= 0xFFE6) ? 2 : 1
|
|
257
|
-
}
|
|
258
|
-
return w
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const FALLBACK = {
|
|
262
|
-
primary: "#8B9DAF",
|
|
263
|
-
text: "#C5C5BB",
|
|
264
|
-
muted: "#7A7A72",
|
|
265
|
-
success: "#9CAF8B",
|
|
266
|
-
warning: "#C5B88D",
|
|
267
|
-
error: "#B08A8A",
|
|
268
|
-
border: "#6B6B63",
|
|
269
|
-
} as const
|
|
270
|
-
|
|
271
|
-
function hex(raw: any): string {
|
|
272
|
-
if (typeof raw === "string" && raw.startsWith("#") && raw.length >= 7) return raw
|
|
273
|
-
if (raw && typeof raw === "object" && typeof raw.r === "number") {
|
|
274
|
-
const r = Math.round(raw.r * 255), g = Math.round(raw.g * 255), b = Math.round(raw.b * 255)
|
|
275
|
-
return "#" + [r, g, b].map(v => v.toString(16).padStart(2, "0")).join("")
|
|
276
|
-
}
|
|
277
|
-
return ""
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX.Element {
|
|
281
|
-
const [usage, setUsage] = createSignal<Usage | null>(null)
|
|
282
|
-
const [open, setOpen] = createSignal(true)
|
|
283
|
-
const [error, setError] = createSignal("")
|
|
284
|
-
const [configured, setConfigured] = createSignal(true)
|
|
285
|
-
const [lastUpdated, setLastUpdated] = createSignal("")
|
|
286
|
-
const [panelWidth, setPanelWidth] = createSignal(24)
|
|
287
|
-
const [cfg, setCfg] = createSignal<LoadedConfig>(loadConfig())
|
|
288
|
-
createEffect(() => { void configTick(); setCfg(loadConfig()) })
|
|
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
|
-
}
|
|
299
|
-
|
|
300
|
-
async function refresh() {
|
|
301
|
-
if (isRefreshing) return
|
|
302
|
-
const cfg = loadConfig()
|
|
303
|
-
if (!cfg.workspaceId || !cfg.authCookie) {
|
|
304
|
-
setConfigured(false)
|
|
305
|
-
setUsage(null)
|
|
306
|
-
setError("")
|
|
307
|
-
return
|
|
308
|
-
}
|
|
309
|
-
setConfigured(true)
|
|
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
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
onMount(() => {
|
|
331
|
-
refresh()
|
|
332
|
-
const timer = setInterval(refresh, loadConfig().refreshIntervalMs)
|
|
333
|
-
const stopWatch = createEffect(() => {
|
|
334
|
-
void configTick()
|
|
335
|
-
refresh()
|
|
336
|
-
})
|
|
337
|
-
onCleanup(() => {
|
|
338
|
-
clearInterval(timer)
|
|
339
|
-
stopWatch()
|
|
340
|
-
cleanupPendingRequest()
|
|
341
|
-
if (resizeTimeout) { clearTimeout(resizeTimeout); resizeTimeout = null }
|
|
342
|
-
})
|
|
343
|
-
})
|
|
344
|
-
|
|
345
|
-
const [pal, setPal] = createSignal<Record<string, string>>({ ...FALLBACK })
|
|
346
|
-
createEffect(() => {
|
|
347
|
-
const th = props.theme as any
|
|
348
|
-
const p: Record<string, string> = { ...FALLBACK }
|
|
349
|
-
for (const k of Object.keys(FALLBACK)) {
|
|
350
|
-
const h = hex(th?.[k])
|
|
351
|
-
if (h) p[k] = h
|
|
352
|
-
}
|
|
353
|
-
setPal(p)
|
|
354
|
-
})
|
|
355
|
-
const colors = () => pal()
|
|
356
|
-
|
|
357
|
-
const barW = () => Math.max(4, Math.min(12, panelWidth() - 34))
|
|
358
|
-
|
|
359
|
-
const colorFor = (percent: number) => {
|
|
360
|
-
const c = colors()
|
|
361
|
-
if (percent >= WARN_THRESHOLD * 100) return c.error
|
|
362
|
-
if (percent >= 50) return c.warning
|
|
363
|
-
return c.success
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
const renderRow = (label: string, u: { usagePercent: number; resetInSec: number }) => {
|
|
367
|
-
const labelW = visualWidth(label)
|
|
368
|
-
const pad = Math.max(0, 9 - labelW)
|
|
369
|
-
return (
|
|
370
|
-
<text>
|
|
371
|
-
<span style={{ fg: colors().text }}>{label}</span>
|
|
372
|
-
<span>{" ".repeat(pad + 1)}</span>
|
|
373
|
-
<span style={{ fg: colorFor(u.usagePercent) }}>
|
|
374
|
-
{progressBar(u.usagePercent, barW())}
|
|
375
|
-
</span>
|
|
376
|
-
<span>{" "}</span>
|
|
377
|
-
<span style={{ fg: colorFor(u.usagePercent) }}>{u.usagePercent}%</span>
|
|
378
|
-
<span style={{ fg: colors().muted }}> {formatReset(u.resetInSec)}</span>
|
|
379
|
-
</text>
|
|
380
|
-
)
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const sep = () => "\u2500".repeat(Math.max(1, panelWidth() - 4))
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
if (
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
</
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
TuiPlugin,
|
|
5
|
+
TuiPluginApi,
|
|
6
|
+
TuiSlotContext,
|
|
7
|
+
TuiSlotPlugin,
|
|
8
|
+
TuiPluginModule,
|
|
9
|
+
TuiThemeCurrent,
|
|
10
|
+
} from "@opencode-ai/plugin/tui"
|
|
11
|
+
import { createSignal, createEffect, onMount, onCleanup, Show } from "solid-js"
|
|
12
|
+
import type { JSX } from "@opentui/solid"
|
|
13
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from "node:fs"
|
|
14
|
+
import { join, dirname } from "node:path"
|
|
15
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
|
|
16
|
+
import { createT, detectLang, LANG_META } from "./i18n"
|
|
17
|
+
import type { LangCode } from "./i18n"
|
|
18
|
+
import { fetchPricing, comparePricing, loadPricingStore, updatePricingStore } from "./pricing"
|
|
19
|
+
import type { ModelPrice, PricingData, PricingStore } from "./pricing"
|
|
20
|
+
|
|
21
|
+
declare const process: { env: Record<string, string | undefined> } | undefined
|
|
22
|
+
|
|
23
|
+
const VERSION: string = __PLUGIN_VERSION__
|
|
24
|
+
const CHECK_INTERVAL = Number(process?.env?.OPENCODE_GO_CHECK_INTERVAL ?? 60000)
|
|
25
|
+
const WARN_THRESHOLD = Number(process?.env?.OPENCODE_GO_WARN_THRESHOLD ?? 0.8)
|
|
26
|
+
const CONFIG_DIR = process?.env?.OPENCODE_CONFIG_DIR
|
|
27
|
+
|| (process?.env?.USERPROFILE ? `${process.env.USERPROFILE}\\.config\\opencode` : "")
|
|
28
|
+
|| process?.env?.HOME + "/.config/opencode"
|
|
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`
|
|
33
|
+
const KV_PREFIX = "go-usage"
|
|
34
|
+
|
|
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 {
|
|
41
|
+
try {
|
|
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. 加密存储
|
|
90
|
+
try {
|
|
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
|
+
|
|
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
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
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) ?? {}
|
|
136
|
+
if (patch.workspace_id !== undefined) fileCfg.workspace_id = patch.workspace_id
|
|
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
|
|
156
|
+
try {
|
|
157
|
+
mkdirSync(dirname(CONFIG_FILE), { recursive: true })
|
|
158
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(fileCfg, null, 2), "utf8")
|
|
159
|
+
} catch { /* 写入失败忽略 */ }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 模块级共享信号:/go-config 保存配置后递增,面板监听它立即刷新
|
|
163
|
+
const [configTick, setConfigTick] = createSignal(0)
|
|
164
|
+
|
|
165
|
+
// 模块级共享语言信号:/go-lang 或首次引导切换后,面板和命令实时响应
|
|
166
|
+
const [langCode, setLangCode] = createSignal<LangCode>(detectLang())
|
|
167
|
+
const t = createT(() => langCode())
|
|
168
|
+
|
|
169
|
+
// 模块级共享价格数据:PricePanel 抓取后写入,/go-settings 设置关注模型时读取
|
|
170
|
+
const [sharedPricing, setSharedPricing] = createSignal<PricingData | null>(null)
|
|
171
|
+
|
|
172
|
+
function kvTryGet(api: TuiPluginApi, key: string): string | undefined {
|
|
173
|
+
try { return api.kv.get<string>(key) } catch { return undefined }
|
|
174
|
+
}
|
|
175
|
+
function kvTrySet(api: TuiPluginApi, key: string, val: unknown): void {
|
|
176
|
+
try { api.kv.set(key, val) } catch { /* 忽略 */ }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface Usage {
|
|
180
|
+
rollingUsage: { usagePercent: number; resetInSec: number }
|
|
181
|
+
weeklyUsage: { usagePercent: number; resetInSec: number }
|
|
182
|
+
monthlyUsage: { usagePercent: number; resetInSec: number }
|
|
183
|
+
error?: string
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const REQUEST_TIMEOUT = 10000
|
|
187
|
+
|
|
188
|
+
async function fetchUsage(workspaceId: string, authCookie: string, signal?: AbortSignal): Promise<Usage | null> {
|
|
189
|
+
if (!workspaceId || !authCookie) return { error: "not_configured" } as Usage
|
|
190
|
+
try {
|
|
191
|
+
const res = await fetch("https://opencode.ai/_server", {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: {
|
|
194
|
+
"content-type": "application/json",
|
|
195
|
+
"x-server-id": "c7389bd0e731f80f49593e5ee53835475f4e28594dd6bd83eb229bab753498cd",
|
|
196
|
+
"x-server-instance": "go-usage-tui",
|
|
197
|
+
"cookie": `auth=${authCookie}`,
|
|
198
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
|
199
|
+
},
|
|
200
|
+
body: JSON.stringify({
|
|
201
|
+
t: { t: 9, i: 0, l: 1, a: [{ t: 1, s: workspaceId }], o: 0 },
|
|
202
|
+
f: 31,
|
|
203
|
+
m: [],
|
|
204
|
+
}),
|
|
205
|
+
signal,
|
|
206
|
+
})
|
|
207
|
+
if (!res.ok) {
|
|
208
|
+
if (res.status === 302) return { error: "cookie_expired" } as Usage
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
const text = await res.text()
|
|
212
|
+
if (text.includes("/auth/authorize") || text.includes("location")) {
|
|
213
|
+
return { error: "cookie_expired" } as Usage
|
|
214
|
+
}
|
|
215
|
+
const extract = (key: string): { resetInSec: number; usagePercent: number } | null => {
|
|
216
|
+
const m = text.match(new RegExp(`${key}:[^}]*?\\{status:"[^"]*",resetInSec:(\\d+),usagePercent:(\\d+)\\}`))
|
|
217
|
+
if (!m) return null
|
|
218
|
+
return { resetInSec: Number(m[1]), usagePercent: Number(m[2]) }
|
|
219
|
+
}
|
|
220
|
+
const rollingUsage = extract("rollingUsage")
|
|
221
|
+
const weeklyUsage = extract("weeklyUsage")
|
|
222
|
+
const monthlyUsage = extract("monthlyUsage")
|
|
223
|
+
if (!rollingUsage || !weeklyUsage || !monthlyUsage) return null
|
|
224
|
+
return { rollingUsage, weeklyUsage, monthlyUsage }
|
|
225
|
+
} catch {
|
|
226
|
+
return null
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function formatReset(sec: number): string {
|
|
231
|
+
if (sec <= 0) return t("resetDone")
|
|
232
|
+
const d = Math.floor(sec / 86400)
|
|
233
|
+
const h = Math.floor((sec % 86400) / 3600)
|
|
234
|
+
const m = Math.floor((sec % 3600) / 60)
|
|
235
|
+
if (d > 0) return t("dayHour", { d, h })
|
|
236
|
+
if (h > 0) return t("hourMin", { h, m })
|
|
237
|
+
return t("minute", { m })
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function progressBar(percent: number, width: number): string {
|
|
241
|
+
const clamped = Math.max(0, Math.min(100, percent))
|
|
242
|
+
const filled = Math.round((clamped / 100) * width)
|
|
243
|
+
const empty = Math.max(0, width - filled)
|
|
244
|
+
return "\u2588".repeat(filled) + "\u2591".repeat(empty)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function visualWidth(s: string): number {
|
|
248
|
+
let w = 0
|
|
249
|
+
for (const c of s) {
|
|
250
|
+
const code = c.codePointAt(0) ?? 0
|
|
251
|
+
w += (code >= 0x1100 && code <= 0x115F) ||
|
|
252
|
+
(code >= 0x2E80 && code <= 0xA4CF) ||
|
|
253
|
+
(code >= 0xAC00 && code <= 0xD7A3) ||
|
|
254
|
+
(code >= 0xF900 && code <= 0xFAFF) ||
|
|
255
|
+
(code >= 0xFF01 && code <= 0xFF60) ||
|
|
256
|
+
(code >= 0xFFE0 && code <= 0xFFE6) ? 2 : 1
|
|
257
|
+
}
|
|
258
|
+
return w
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const FALLBACK = {
|
|
262
|
+
primary: "#8B9DAF",
|
|
263
|
+
text: "#C5C5BB",
|
|
264
|
+
muted: "#7A7A72",
|
|
265
|
+
success: "#9CAF8B",
|
|
266
|
+
warning: "#C5B88D",
|
|
267
|
+
error: "#B08A8A",
|
|
268
|
+
border: "#6B6B63",
|
|
269
|
+
} as const
|
|
270
|
+
|
|
271
|
+
function hex(raw: any): string {
|
|
272
|
+
if (typeof raw === "string" && raw.startsWith("#") && raw.length >= 7) return raw
|
|
273
|
+
if (raw && typeof raw === "object" && typeof raw.r === "number") {
|
|
274
|
+
const r = Math.round(raw.r * 255), g = Math.round(raw.g * 255), b = Math.round(raw.b * 255)
|
|
275
|
+
return "#" + [r, g, b].map(v => v.toString(16).padStart(2, "0")).join("")
|
|
276
|
+
}
|
|
277
|
+
return ""
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX.Element {
|
|
281
|
+
const [usage, setUsage] = createSignal<Usage | null>(null)
|
|
282
|
+
const [open, setOpen] = createSignal(true)
|
|
283
|
+
const [error, setError] = createSignal("")
|
|
284
|
+
const [configured, setConfigured] = createSignal(true)
|
|
285
|
+
const [lastUpdated, setLastUpdated] = createSignal("")
|
|
286
|
+
const [panelWidth, setPanelWidth] = createSignal(24)
|
|
287
|
+
const [cfg, setCfg] = createSignal<LoadedConfig>(loadConfig())
|
|
288
|
+
createEffect(() => { void configTick(); setCfg(loadConfig()) })
|
|
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
|
+
}
|
|
299
|
+
|
|
300
|
+
async function refresh() {
|
|
301
|
+
if (isRefreshing) return
|
|
302
|
+
const cfg = loadConfig()
|
|
303
|
+
if (!cfg.workspaceId || !cfg.authCookie) {
|
|
304
|
+
setConfigured(false)
|
|
305
|
+
setUsage(null)
|
|
306
|
+
setError("")
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
setConfigured(true)
|
|
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
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
onMount(() => {
|
|
331
|
+
refresh()
|
|
332
|
+
const timer = setInterval(refresh, loadConfig().refreshIntervalMs)
|
|
333
|
+
const stopWatch = createEffect(() => {
|
|
334
|
+
void configTick()
|
|
335
|
+
refresh()
|
|
336
|
+
})
|
|
337
|
+
onCleanup(() => {
|
|
338
|
+
clearInterval(timer)
|
|
339
|
+
stopWatch()
|
|
340
|
+
cleanupPendingRequest()
|
|
341
|
+
if (resizeTimeout) { clearTimeout(resizeTimeout); resizeTimeout = null }
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
const [pal, setPal] = createSignal<Record<string, string>>({ ...FALLBACK })
|
|
346
|
+
createEffect(() => {
|
|
347
|
+
const th = props.theme as any
|
|
348
|
+
const p: Record<string, string> = { ...FALLBACK }
|
|
349
|
+
for (const k of Object.keys(FALLBACK)) {
|
|
350
|
+
const h = hex(th?.[k])
|
|
351
|
+
if (h) p[k] = h
|
|
352
|
+
}
|
|
353
|
+
setPal(p)
|
|
354
|
+
})
|
|
355
|
+
const colors = () => pal()
|
|
356
|
+
|
|
357
|
+
const barW = () => Math.max(4, Math.min(12, panelWidth() - 34))
|
|
358
|
+
|
|
359
|
+
const colorFor = (percent: number) => {
|
|
360
|
+
const c = colors()
|
|
361
|
+
if (percent >= WARN_THRESHOLD * 100) return c.error
|
|
362
|
+
if (percent >= 50) return c.warning
|
|
363
|
+
return c.success
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const renderRow = (label: string, u: { usagePercent: number; resetInSec: number }) => {
|
|
367
|
+
const labelW = visualWidth(label)
|
|
368
|
+
const pad = Math.max(0, 9 - labelW)
|
|
369
|
+
return (
|
|
370
|
+
<text>
|
|
371
|
+
<span style={{ fg: colors().text }}>{label}</span>
|
|
372
|
+
<span>{" ".repeat(pad + 1)}</span>
|
|
373
|
+
<span style={{ fg: colorFor(u.usagePercent) }}>
|
|
374
|
+
{progressBar(u.usagePercent, barW())}
|
|
375
|
+
</span>
|
|
376
|
+
<span>{" "}</span>
|
|
377
|
+
<span style={{ fg: colorFor(u.usagePercent) }}>{u.usagePercent}%</span>
|
|
378
|
+
<span style={{ fg: colors().muted }}> {formatReset(u.resetInSec)}</span>
|
|
379
|
+
</text>
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const sep = () => "\u2500".repeat(Math.max(1, panelWidth() - 4))
|
|
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 selectable={false} onMouseUp={(e) => { e.preventDefault(); 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
|
+
// 渲染强制刷新计数:列表首帧常在数据到达使面板变高的瞬时布局中测量,留下
|
|
449
|
+
// "模型间空行";该残留不会随盒子变宽自愈,但会在一次额外重渲染(重新测量文本
|
|
450
|
+
// 高度)后消失。故列表就绪后级联触发若干次 renderTick,强制重新测量每行高度。
|
|
451
|
+
const [renderTick, setRenderTick] = createSignal(0)
|
|
452
|
+
// 列表就绪标记:sharedPricing 数据到达那帧盒子常在重布局(数据使面板内容变高),
|
|
453
|
+
// 直接渲染列表会命中"未稳定测量"。故数据到达后延迟一帧再创建列表文本
|
|
454
|
+
const [listReady, setListReady] = createSignal(false)
|
|
455
|
+
createEffect(() => {
|
|
456
|
+
const p = sharedPricing()
|
|
457
|
+
if (p && p.models.length > 0) {
|
|
458
|
+
const id = setTimeout(() => setListReady(true), 150)
|
|
459
|
+
onCleanup(() => clearTimeout(id))
|
|
460
|
+
} else {
|
|
461
|
+
setListReady(false)
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
// 列表首帧常在"数据到达使面板变高"的瞬时布局中测量,留下"模型间空行";
|
|
465
|
+
// 该残留不会随盒子变宽自愈,但会在一次额外重渲染(重新测量文本高度)后消失
|
|
466
|
+
// —— 即用户观察到的"展开任意行后空行消失"现象。故在列表就绪后再强制若干次
|
|
467
|
+
// 重渲染,让 @opentui 在稳定布局下重新测量每行高度,自愈空行。
|
|
468
|
+
createEffect(() => {
|
|
469
|
+
if (!(listReady() && sharedPricing())) return
|
|
470
|
+
const t1 = setTimeout(() => setRenderTick((v) => v + 1), 250)
|
|
471
|
+
const t2 = setTimeout(() => setRenderTick((v) => v + 1), 600)
|
|
472
|
+
const t3 = setTimeout(() => setRenderTick((v) => v + 1), 1000)
|
|
473
|
+
onCleanup(() => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3) })
|
|
474
|
+
})
|
|
475
|
+
// 记录变化的模型:modelId -> 变化类别集合(added/pricing/limits),用于行高亮与颜色区分
|
|
476
|
+
const [changedModels, setChangedModels] = createSignal<Map<string, Set<string>>>(new Map<string, Set<string>>())
|
|
477
|
+
// 行内展开:当前展开的模型 id(单选 accordion)
|
|
478
|
+
const [expandedId, setExpandedId] = createSignal<string | null>(null)
|
|
479
|
+
const [cfg, setCfg] = createSignal<LoadedConfig>(loadConfig())
|
|
480
|
+
createEffect(() => { void configTick(); setCfg(loadConfig()) })
|
|
481
|
+
let boxEl: any
|
|
482
|
+
let priceController: AbortController | null = null
|
|
483
|
+
|
|
484
|
+
// 轮询等待布局就绪:首帧 boxEl.width 尚未测量(为 0)时文本会按临时窄宽度折行,
|
|
485
|
+
// 产生"模型间空行"。单次延时可能早于布局完成而浪费唯一机会,故循环重试,
|
|
486
|
+
// 宽度就绪那一刻强制重渲染自愈(上限 50 次 × 80ms 防泄漏)
|
|
487
|
+
let widthSyncTimer: ReturnType<typeof setTimeout> | null = null
|
|
488
|
+
onMount(() => {
|
|
489
|
+
let tries = 0
|
|
490
|
+
const syncWidth = () => {
|
|
491
|
+
tries++
|
|
492
|
+
if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
|
|
493
|
+
setPanelWidth(Math.max(20, boxEl.width))
|
|
494
|
+
setRenderTick((v) => v + 1)
|
|
495
|
+
// 补一次延迟 tick:覆盖 onSizeChange 防抖窗口结束后的稳定态
|
|
496
|
+
widthSyncTimer = setTimeout(() => setRenderTick((v) => v + 1), 150)
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
if (tries < 50) widthSyncTimer = setTimeout(syncWidth, 80)
|
|
500
|
+
}
|
|
501
|
+
widthSyncTimer = setTimeout(syncWidth, 30)
|
|
502
|
+
})
|
|
503
|
+
onCleanup(() => { if (widthSyncTimer) clearTimeout(widthSyncTimer) })
|
|
504
|
+
|
|
505
|
+
async function refreshPricing() {
|
|
506
|
+
if (priceLoading()) return
|
|
507
|
+
setPriceLoading(true)
|
|
508
|
+
setPriceError("")
|
|
509
|
+
priceController?.abort()
|
|
510
|
+
priceController = new AbortController()
|
|
511
|
+
try {
|
|
512
|
+
const data = await fetchPricing()
|
|
513
|
+
const store = updatePricingStore(data)
|
|
514
|
+
setSharedPricing(data)
|
|
515
|
+
setPricingStore(store)
|
|
516
|
+
const prev = store.previous
|
|
517
|
+
if (prev && prev.fetchTime !== data.fetchTime) {
|
|
518
|
+
const diff = comparePricing(prev, data)
|
|
519
|
+
if (diff.hasChanges) {
|
|
520
|
+
const m = new Map<string, Set<string>>()
|
|
521
|
+
for (const c of diff.changes) {
|
|
522
|
+
if (c.type === "removed") continue
|
|
523
|
+
const cat = c.type === "added" ? "added" : c.type === "pricing" ? "pricing" : "limits"
|
|
524
|
+
if (!m.has(c.modelId)) m.set(c.modelId, new Set())
|
|
525
|
+
m.get(c.modelId)!.add(cat)
|
|
526
|
+
}
|
|
527
|
+
setChangedModels(m)
|
|
528
|
+
props.api.toast({
|
|
529
|
+
variant: "info",
|
|
530
|
+
title: t("priceChanged"),
|
|
531
|
+
message: t("priceChangesSummary", {
|
|
532
|
+
t: diff.summary.total,
|
|
533
|
+
a: diff.summary.added,
|
|
534
|
+
r: diff.summary.removed,
|
|
535
|
+
l: diff.summary.limitChanges,
|
|
536
|
+
}),
|
|
537
|
+
})
|
|
538
|
+
} else {
|
|
539
|
+
setChangedModels(new Map<string, Set<string>>())
|
|
540
|
+
}
|
|
541
|
+
} else {
|
|
542
|
+
setChangedModels(new Map<string, Set<string>>())
|
|
543
|
+
}
|
|
544
|
+
} catch {
|
|
545
|
+
setPriceError(t("priceError"))
|
|
546
|
+
} finally {
|
|
547
|
+
setPriceLoading(false)
|
|
548
|
+
priceController = null
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function visualPadEnd(s: string, w: number): string {
|
|
553
|
+
const cur = visualWidth(s)
|
|
554
|
+
if (cur >= w) return s
|
|
555
|
+
return s + " ".repeat(w - cur)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function abbreviateName(name: string, maxLen: number): string {
|
|
559
|
+
if (visualWidth(name) <= maxLen) return name
|
|
560
|
+
let out = ""
|
|
561
|
+
let w = 0
|
|
562
|
+
for (const c of name) {
|
|
563
|
+
const cw = visualWidth(c)
|
|
564
|
+
if (w + cw > maxLen - 1) break
|
|
565
|
+
out += c
|
|
566
|
+
w += cw
|
|
567
|
+
}
|
|
568
|
+
return out + "…"
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// visual-cache 风格:左标签 + 自动填充 + 右值
|
|
572
|
+
// 直接读取 boxEl 实时宽度(避免 panelWidth 信号滞后)
|
|
573
|
+
function justify(label: string, value: string): string {
|
|
574
|
+
void renderTick()
|
|
575
|
+
const outer = boxEl && typeof boxEl.width === "number" && boxEl.width > 0 ? boxEl.width : panelWidth()
|
|
576
|
+
const gauge = Math.max(10, outer - 4)
|
|
577
|
+
const used = visualWidth(label) + visualWidth(value)
|
|
578
|
+
const gap = Math.max(1, gauge - used)
|
|
579
|
+
return label + " ".repeat(gap) + value
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function renderLimitRow(m: ModelPrice) {
|
|
583
|
+
const cats = changedModels().get(m.id)
|
|
584
|
+
const expanded = expandedId() === m.id
|
|
585
|
+
const name = abbreviateName(m.name, 18)
|
|
586
|
+
const req = m.usageLimit != null ? `$${m.usageLimit}` : "\u2014"
|
|
587
|
+
// 仅用颜色区分,不改缩进,名称始终与表头对齐
|
|
588
|
+
// 新增=绿色;价格变化=主色;限额/用量上限变化=橙色;普通行=常规色
|
|
589
|
+
const fg = cats?.has("added") ? colors().success
|
|
590
|
+
: cats?.has("pricing") ? colors().primary
|
|
591
|
+
: cats?.has("limits") ? colors().warning
|
|
592
|
+
: colors().text
|
|
593
|
+
// 行尾展开指示符(右对齐,不挤占名称列)
|
|
594
|
+
const ind = expanded ? " \u25be" : " \u25b8"
|
|
595
|
+
const row = (
|
|
596
|
+
<text fg={fg} onMouseUp={(e) => { e.preventDefault(); setExpandedId(expanded ? null : m.id) }}>
|
|
597
|
+
{justify(name, req + ind)}
|
|
598
|
+
</text>
|
|
599
|
+
)
|
|
600
|
+
if (!expanded) return row
|
|
601
|
+
return <>{row}{renderPriceDetail(m)}</>
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// 行内展开的价格+元数据详情(垂直清单,字段含涨跌箭头)
|
|
605
|
+
// 多档模型按档位逐块展示;单档模型维持紧凑四行
|
|
606
|
+
function renderPriceDetail(m: ModelPrice): JSX.Element[] {
|
|
607
|
+
const prev = pricingStore()?.previous?.models.find((x) => x.id === m.id)
|
|
608
|
+
const prevVariants = prev?.variants
|
|
609
|
+
const arrow = (cur: number | null, old: number | null) => {
|
|
610
|
+
if (old == null || cur == null || old === cur) return ""
|
|
611
|
+
return cur > old ? " \u2191" : " \u2193"
|
|
612
|
+
}
|
|
613
|
+
const fmt = (cur: number | null, old: number | null) =>
|
|
614
|
+
(cur == null ? "-" : `$${cur} /MTok${arrow(cur, old)}`)
|
|
615
|
+
const pad13 = (s: string) => visualPadEnd(s, 13)
|
|
616
|
+
const lines: JSX.Element[] = []
|
|
617
|
+
const tiers = m.variants
|
|
618
|
+
const hasTiers = tiers.length > 1
|
|
619
|
+
|
|
620
|
+
if (hasTiers) {
|
|
621
|
+
tiers.forEach((v, ti) => {
|
|
622
|
+
const isLastTier = ti === tiers.length - 1
|
|
623
|
+
lines.push(<text fg={colors().muted}>{` \u251c ${t("priceTier")}: ${v.label}`}</text>)
|
|
624
|
+
const inner = isLastTier ? " " : "\u2502"
|
|
625
|
+
const prevV = prevVariants?.find((p) => p.label === v.label) ?? prevVariants?.[ti]
|
|
626
|
+
const fields: [string, string][] = [
|
|
627
|
+
[t("priceInput"), fmt(v.pricing.input, prevV?.pricing.input)],
|
|
628
|
+
[t("priceOutput"), fmt(v.pricing.output, prevV?.pricing.output)],
|
|
629
|
+
[t("priceCacheRead"), fmt(v.pricing.cachedRead, prevV?.pricing.cachedRead)],
|
|
630
|
+
[t("priceCacheWrite"), fmt(v.pricing.cachedWrite, prevV?.pricing.cachedWrite)],
|
|
631
|
+
]
|
|
632
|
+
for (const [fl, fv] of fields) {
|
|
633
|
+
lines.push(<text fg={colors().muted}>{pad13(` ${inner} ${fl}:`) + fv}</text>)
|
|
634
|
+
}
|
|
635
|
+
})
|
|
636
|
+
} else {
|
|
637
|
+
const v = tiers[0]
|
|
638
|
+
const fields: [string, string][] = [
|
|
639
|
+
[t("priceInput"), fmt(v.pricing.input, prev?.pricing.input)],
|
|
640
|
+
[t("priceOutput"), fmt(v.pricing.output, prev?.pricing.output)],
|
|
641
|
+
[t("priceCacheRead"), fmt(v.pricing.cachedRead, prev?.pricing.cachedRead)],
|
|
642
|
+
[t("priceCacheWrite"), fmt(v.pricing.cachedWrite, prev?.pricing.cachedWrite)],
|
|
643
|
+
]
|
|
644
|
+
for (const [fl, fv] of fields) {
|
|
645
|
+
lines.push(<text fg={colors().muted}>{pad13(` \u251c ${fl}:`) + fv}</text>)
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const fmtFull = (n: number) => (n > 0 ? n.toLocaleString("en-US") : "\u2014")
|
|
650
|
+
const limitFields: [string, string][] = [
|
|
651
|
+
[t("priceReq5h"), fmtFull(m.limits.fiveHour)],
|
|
652
|
+
[t("priceReqWeek"), fmtFull(m.limits.weekly)],
|
|
653
|
+
[t("priceReqMonth"), fmtFull(m.limits.monthly)],
|
|
654
|
+
]
|
|
655
|
+
for (const [fl, fv] of limitFields) {
|
|
656
|
+
lines.push(<text fg={colors().muted}>{pad13(` \u251c ${fl}:`) + fv}</text>)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const meta: [string, string][] = [
|
|
660
|
+
[t("priceSdk"), m.sdk || "-"],
|
|
661
|
+
[t("priceRetention"), m.retention || "-"],
|
|
662
|
+
[t("priceTraining"), m.training || "-"],
|
|
663
|
+
]
|
|
664
|
+
meta.forEach(([ml, mv], i) => {
|
|
665
|
+
const conn = i < meta.length - 1 ? "\u251c" : "\u2514"
|
|
666
|
+
lines.push(<text fg={colors().muted}>{pad13(` ${conn} ${ml}:`) + mv}</text>)
|
|
667
|
+
})
|
|
668
|
+
return lines
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const [pal, setPal] = createSignal<Record<string, string>>({ ...FALLBACK })
|
|
672
|
+
createEffect(() => {
|
|
673
|
+
const th = props.theme as any
|
|
674
|
+
const p: Record<string, string> = { ...FALLBACK }
|
|
675
|
+
for (const k of Object.keys(FALLBACK)) {
|
|
676
|
+
const h = hex(th?.[k])
|
|
677
|
+
if (h) p[k] = h
|
|
678
|
+
}
|
|
679
|
+
setPal(p)
|
|
680
|
+
})
|
|
681
|
+
const colors = () => pal()
|
|
682
|
+
|
|
683
|
+
const sep = () => {
|
|
684
|
+
void renderTick()
|
|
685
|
+
const outer = boxEl && typeof boxEl.width === "number" && boxEl.width > 0 ? boxEl.width : panelWidth()
|
|
686
|
+
return "\u2500".repeat(Math.max(1, outer - 4))
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
setTimeout(() => {
|
|
690
|
+
const store = loadPricingStore()
|
|
691
|
+
if (store.current.models.length > 0) {
|
|
692
|
+
setSharedPricing(store.current)
|
|
693
|
+
setPricingStore(store)
|
|
694
|
+
}
|
|
695
|
+
refreshPricing()
|
|
696
|
+
setInterval(() => refreshPricing(), loadConfig().pricingIntervalMs)
|
|
697
|
+
}, 2000)
|
|
698
|
+
|
|
699
|
+
return (
|
|
700
|
+
<box
|
|
701
|
+
border
|
|
702
|
+
borderColor={colors().border}
|
|
703
|
+
paddingLeft={1}
|
|
704
|
+
paddingRight={1}
|
|
705
|
+
flexDirection="column"
|
|
706
|
+
gap={0}
|
|
707
|
+
ref={boxEl}
|
|
708
|
+
onSizeChange={() => {
|
|
709
|
+
if (resizeTimeout) clearTimeout(resizeTimeout)
|
|
710
|
+
resizeTimeout = setTimeout(() => {
|
|
711
|
+
const w = boxEl ? Math.max(20, boxEl.width ?? 24) : 24
|
|
712
|
+
setPanelWidth(w)
|
|
713
|
+
setRenderTick((v) => v + 1)
|
|
714
|
+
}, 100)
|
|
715
|
+
}}
|
|
716
|
+
>
|
|
717
|
+
<text onMouseUp={() => setOpen(o => !o)}>
|
|
718
|
+
<span style={{ fg: colors().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
|
|
719
|
+
<span style={{ fg: colors().primary }}><b>{t("priceTitle")}</b></span>
|
|
720
|
+
<Show when={sharedPricing()}>
|
|
721
|
+
<span style={{ fg: colors().muted }}> ({sharedPricing()!.models.length})</span>
|
|
722
|
+
</Show>
|
|
723
|
+
<span style={{ fg: colors().muted }}>{sep().slice(visualWidth((open() ? "\u25bc " : "\u25b6 ") + t("priceTitle") + (sharedPricing() ? ` (${sharedPricing()!.models.length})` : "")))}</span>
|
|
724
|
+
</text>
|
|
725
|
+
|
|
726
|
+
<Show when={open()}>
|
|
727
|
+
<text fg={colors().muted}>
|
|
728
|
+
{justify(t("priceModelCol"), t("priceReqs"))}
|
|
729
|
+
</text>
|
|
730
|
+
<text fg={colors().muted}>{sep()}</text>
|
|
731
|
+
|
|
732
|
+
<Show when={priceLoading() || !sharedPricing()} fallback={
|
|
733
|
+
<Show when={sharedPricing()} fallback={
|
|
734
|
+
<text fg={colors().muted}>{t("priceLoading")}</text>
|
|
735
|
+
}>
|
|
736
|
+
{listReady() ? sharedPricing()!.models.filter((m) => {
|
|
737
|
+
return cfg().focusModels.length === 0 || cfg().focusModels.includes(m.id)
|
|
738
|
+
}).map((m) => renderLimitRow(m)) : null}
|
|
739
|
+
<text fg={colors().muted}>
|
|
740
|
+
{justify(t("priceUpdated"), new Date(sharedPricing()!.fetchTime).toLocaleTimeString())}
|
|
741
|
+
</text>
|
|
742
|
+
<Show when={pricingStore() && pricingStore()!.history.length > 0}>
|
|
743
|
+
<text fg={colors().warning}>
|
|
744
|
+
{t("priceChangesSummary", {
|
|
745
|
+
t: pricingStore()!.history[0].summary.total,
|
|
746
|
+
a: pricingStore()!.history[0].summary.added,
|
|
747
|
+
r: pricingStore()!.history[0].summary.removed,
|
|
748
|
+
l: pricingStore()!.history[0].summary.limitChanges,
|
|
749
|
+
})}
|
|
750
|
+
</text>
|
|
751
|
+
</Show>
|
|
752
|
+
<Show when={changedModels().size > 0}>
|
|
753
|
+
<text fg={colors().muted}>{"绿=新增 蓝=价格 橙=限额·点击展开"}</text>
|
|
754
|
+
</Show>
|
|
755
|
+
<Show when={pricingStore() && pricingStore()!.history.length > 0 && pricingStore()!.history[0].summary.total > 0}>
|
|
756
|
+
<text fg={colors().muted}>
|
|
757
|
+
{`${t("changelogTitle")} (${new Date(pricingStore()!.history[0].fetchTime).toLocaleTimeString()})`}
|
|
758
|
+
</text>
|
|
759
|
+
{pricingStore()!.history[0].changes.slice(0, 12).map((c) => {
|
|
760
|
+
const label = c.type === "added" ? t("typeAdded")
|
|
761
|
+
: c.type === "removed" ? t("typeRemoved")
|
|
762
|
+
: c.type === "pricing" ? t("typePricing")
|
|
763
|
+
: t("typeLimits")
|
|
764
|
+
const fg = c.type === "added" ? colors().success
|
|
765
|
+
: c.type === "removed" ? colors().error
|
|
766
|
+
: c.type === "pricing" ? colors().primary
|
|
767
|
+
: colors().warning
|
|
768
|
+
return <text fg={fg}>{` ${label} ${abbreviateName(c.modelName, 20)}`}</text>
|
|
769
|
+
})}
|
|
770
|
+
</Show>
|
|
771
|
+
</Show>
|
|
772
|
+
}>
|
|
773
|
+
<text fg={colors().muted}>{t("priceLoading")}</text>
|
|
774
|
+
</Show>
|
|
775
|
+
</Show>
|
|
776
|
+
</box>
|
|
777
|
+
)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
|
|
782
|
+
return {
|
|
783
|
+
order: 60,
|
|
784
|
+
slots: {
|
|
785
|
+
sidebar_content(ctx: TuiSlotContext, _input: { session_id: string }): JSX.Element {
|
|
786
|
+
return <GoUsagePanel theme={ctx.theme.current} api={api} />
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function createPricingSlot(api: TuiPluginApi): TuiSlotPlugin {
|
|
793
|
+
return {
|
|
794
|
+
order: 61,
|
|
795
|
+
slots: {
|
|
796
|
+
sidebar_content(ctx: TuiSlotContext, _input: { session_id: string }): JSX.Element {
|
|
797
|
+
return <PricePanel theme={ctx.theme.current} api={api} />
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function runConfigDialog(api: TuiPluginApi, dialog: any): void {
|
|
804
|
+
dialog?.replace(() => (
|
|
805
|
+
<api.ui.DialogPrompt
|
|
806
|
+
title={t("wsPromptTitle")}
|
|
807
|
+
description={() => (
|
|
808
|
+
<text>{t("wsPromptDesc")}</text>
|
|
809
|
+
)}
|
|
810
|
+
placeholder={t("wsPlaceholder")}
|
|
811
|
+
onConfirm={(value) => {
|
|
812
|
+
const wsId = value.trim()
|
|
813
|
+
if (!wsId) { dialog?.clear(); return }
|
|
814
|
+
saveConfig({ workspace_id: wsId })
|
|
815
|
+
dialog?.replace(() => (
|
|
816
|
+
<api.ui.DialogPrompt
|
|
817
|
+
title={t("cookiePromptTitle")}
|
|
818
|
+
description={() => (
|
|
819
|
+
<text>{t("cookiePromptDesc")}</text>
|
|
820
|
+
)}
|
|
821
|
+
placeholder={t("cookiePlaceholder")}
|
|
822
|
+
onConfirm={(val) => {
|
|
823
|
+
const cookie = val.trim()
|
|
824
|
+
if (!cookie) { dialog?.clear(); return }
|
|
825
|
+
saveConfig({ cookie })
|
|
826
|
+
setConfigTick((v) => v + 1)
|
|
827
|
+
api.ui.toast({ variant: "success", message: t("configSaved") })
|
|
828
|
+
dialog?.clear()
|
|
829
|
+
}}
|
|
830
|
+
onCancel={() => dialog?.clear()}
|
|
831
|
+
/>
|
|
832
|
+
))
|
|
833
|
+
}}
|
|
834
|
+
onCancel={() => dialog?.clear()}
|
|
835
|
+
/>
|
|
836
|
+
))
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function runLangDialog(api: TuiPluginApi, dialog: any): void {
|
|
840
|
+
dialog?.replace(() => (
|
|
841
|
+
<api.ui.DialogSelect
|
|
842
|
+
title={t("langTitle")}
|
|
843
|
+
options={LANG_META.map((m) => ({
|
|
844
|
+
title: `${m.label}${langCode() === m.code ? " \u2713" : ""}`,
|
|
845
|
+
value: m.code,
|
|
846
|
+
}))}
|
|
847
|
+
onSelect={(opt) => {
|
|
848
|
+
const code = opt.value as LangCode
|
|
849
|
+
setLangCode(code)
|
|
850
|
+
kvTrySet(api, `${KV_PREFIX}.lang`, code)
|
|
851
|
+
api.ui.toast({ message: code === "zh" ? t("langSwitchedZh") : t("langSwitchedEn") })
|
|
852
|
+
// 语言选择后:若无配置则继续引导配置账号
|
|
853
|
+
const cfg = loadConfig()
|
|
854
|
+
if (!cfg.workspaceId || !cfg.authCookie) {
|
|
855
|
+
runConfigDialog(api, dialog)
|
|
856
|
+
} else {
|
|
857
|
+
dialog?.clear()
|
|
858
|
+
}
|
|
859
|
+
}}
|
|
860
|
+
onCancel={() => dialog?.clear()}
|
|
861
|
+
/>
|
|
862
|
+
))
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function runSettingsDialog(api: TuiPluginApi, dialog: any): void {
|
|
866
|
+
let lastSelected: string | undefined
|
|
867
|
+
const renderSettingsDialog = (dialog2: any) => {
|
|
868
|
+
const cfg = loadConfig()
|
|
869
|
+
const options = [
|
|
870
|
+
{
|
|
871
|
+
title: `${t("settingsFocus")} (${cfg.focusModels.length} 个)`,
|
|
872
|
+
value: "focus-models",
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
title: `${t("settingsRefreshUsage")} (${Math.round(cfg.refreshIntervalMs / 1000)}s)`,
|
|
876
|
+
value: "refresh-usage",
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
title: `${t("settingsRefreshPricing")} (${Math.round(cfg.pricingIntervalMs / 1000)}s)`,
|
|
880
|
+
value: "refresh-pricing",
|
|
881
|
+
},
|
|
882
|
+
]
|
|
883
|
+
dialog2?.replace(() => (
|
|
884
|
+
<api.ui.DialogSelect
|
|
885
|
+
title={t("settingsTitle")}
|
|
886
|
+
options={options}
|
|
887
|
+
onSelect={(opt) => {
|
|
888
|
+
const value = opt.value
|
|
889
|
+
if (value === "focus-models") {
|
|
890
|
+
const renderFocusDialog = (dialog3: any) => {
|
|
891
|
+
const cur = loadConfig().focusModels
|
|
892
|
+
const allModels = sharedPricing()?.models ?? []
|
|
893
|
+
const modelOptions = allModels.map((m) => ({
|
|
894
|
+
title: `${cur.includes(m.id) ? "\u2611" : "\u2610"} ${m.name} (${m.pricing.input ?? "-"}/${m.pricing.output ?? "-"})`,
|
|
895
|
+
value: m.id,
|
|
896
|
+
}))
|
|
897
|
+
const focusOptions = [
|
|
898
|
+
...modelOptions,
|
|
899
|
+
{ title: `${t("focusAll")} (${cur.length} 个已选)`, value: "select-all" },
|
|
900
|
+
{ title: t("focusNone"), value: "clear-all" },
|
|
901
|
+
{ title: `${t("focusDone")} (${t("focusSelected", { n: cur.length })})`, value: "done" },
|
|
902
|
+
]
|
|
903
|
+
dialog3?.replace(() => (
|
|
904
|
+
<api.ui.DialogSelect
|
|
905
|
+
title={t("settingsFocus")}
|
|
906
|
+
options={focusOptions}
|
|
907
|
+
current={lastSelected}
|
|
908
|
+
onSelect={(opt2) => {
|
|
909
|
+
const v2 = opt2.value
|
|
910
|
+
if (v2 === "done") {
|
|
911
|
+
dialog3?.clear()
|
|
912
|
+
renderSettingsDialog(dialog2)
|
|
913
|
+
} else if (v2 === "select-all") {
|
|
914
|
+
const all = allModels.map((m) => m.id)
|
|
915
|
+
saveConfig({ focus_models: all })
|
|
916
|
+
setConfigTick((v) => v + 1)
|
|
917
|
+
api.ui.toast({ variant: "success", message: t("settingsSaved") })
|
|
918
|
+
renderFocusDialog(dialog3)
|
|
919
|
+
} else if (v2 === "clear-all") {
|
|
920
|
+
saveConfig({ focus_models: [] })
|
|
921
|
+
setConfigTick((v) => v + 1)
|
|
922
|
+
api.ui.toast({ variant: "success", message: t("settingsSaved") })
|
|
923
|
+
renderFocusDialog(dialog3)
|
|
924
|
+
} else {
|
|
925
|
+
const next = cur.includes(v2) ? cur.filter((id) => id !== v2) : [...cur, v2]
|
|
926
|
+
saveConfig({ focus_models: next })
|
|
927
|
+
setConfigTick((v) => v + 1)
|
|
928
|
+
lastSelected = v2
|
|
929
|
+
renderFocusDialog(dialog3)
|
|
930
|
+
}
|
|
931
|
+
}}
|
|
932
|
+
onCancel={() => dialog3?.clear()}
|
|
933
|
+
/>
|
|
934
|
+
))
|
|
935
|
+
}
|
|
936
|
+
renderFocusDialog(dialog2)
|
|
937
|
+
} else if (value === "refresh-usage") {
|
|
938
|
+
dialog2?.replace(() => (
|
|
939
|
+
<api.ui.DialogPrompt
|
|
940
|
+
title={t("settingsRefreshUsage")}
|
|
941
|
+
description={() => <text>{"输入秒数(默认 60)"}</text>}
|
|
942
|
+
placeholder="60"
|
|
943
|
+
onConfirm={(val) => {
|
|
944
|
+
const sec = Number(val.trim())
|
|
945
|
+
if (!sec || sec <= 0) { dialog2?.clear(); return }
|
|
946
|
+
saveConfig({ refresh_interval_sec: sec })
|
|
947
|
+
setConfigTick((v) => v + 1)
|
|
948
|
+
api.ui.toast({ variant: "success", message: t("settingsSaved") })
|
|
949
|
+
renderSettingsDialog(dialog2)
|
|
950
|
+
}}
|
|
951
|
+
onCancel={() => dialog2?.clear()}
|
|
952
|
+
/>
|
|
953
|
+
))
|
|
954
|
+
} else if (value === "refresh-pricing") {
|
|
955
|
+
dialog2?.replace(() => (
|
|
956
|
+
<api.ui.DialogPrompt
|
|
957
|
+
title={t("settingsRefreshPricing")}
|
|
958
|
+
description={() => <text>{"输入秒数(默认 1800)"}</text>}
|
|
959
|
+
placeholder="1800"
|
|
960
|
+
onConfirm={(val) => {
|
|
961
|
+
const sec = Number(val.trim())
|
|
962
|
+
if (!sec || sec <= 0) { dialog2?.clear(); return }
|
|
963
|
+
saveConfig({ pricing_interval_sec: sec })
|
|
964
|
+
setConfigTick((v) => v + 1)
|
|
965
|
+
api.ui.toast({ variant: "success", message: t("settingsSaved") })
|
|
966
|
+
renderSettingsDialog(dialog2)
|
|
967
|
+
}}
|
|
968
|
+
onCancel={() => dialog2?.clear()}
|
|
969
|
+
/>
|
|
970
|
+
))
|
|
971
|
+
}
|
|
972
|
+
}}
|
|
973
|
+
onCancel={() => dialog2?.clear()}
|
|
974
|
+
/>
|
|
975
|
+
))
|
|
976
|
+
}
|
|
977
|
+
renderSettingsDialog(dialog)
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
981
|
+
api.slots.register(createSidebarSlot(api))
|
|
982
|
+
api.slots.register(createPricingSlot(api))
|
|
983
|
+
|
|
984
|
+
// 恢复已保存的语言;若无则按系统检测
|
|
985
|
+
const savedLang = kvTryGet(api, `${KV_PREFIX}.lang`) as LangCode | undefined
|
|
986
|
+
if (savedLang === "zh" || savedLang === "en") setLangCode(savedLang)
|
|
987
|
+
else setLangCode(detectLang())
|
|
988
|
+
|
|
989
|
+
// 首次启动引导:已引导过则跳过(用 kv 标记)
|
|
990
|
+
const onboarded = kvTryGet(api, `${KV_PREFIX}.onboarded`)
|
|
991
|
+
if (!onboarded) {
|
|
992
|
+
kvTrySet(api, `${KV_PREFIX}.onboarded`, "1")
|
|
993
|
+
setTimeout(() => {
|
|
994
|
+
// 通过 trigger 触发语言选择命令,命令 onSelect 会拿到 dialog 栈并弹窗
|
|
995
|
+
try { api.command.trigger("go-usage.lang") } catch { /* 命令未就绪时忽略 */ }
|
|
996
|
+
}, 1500)
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
api.command?.register(() => [
|
|
1000
|
+
{
|
|
1001
|
+
title: t("cmdTitle"),
|
|
1002
|
+
value: "go-usage.config",
|
|
1003
|
+
description: t("cmdDesc"),
|
|
1004
|
+
slash: { name: "go-config" },
|
|
1005
|
+
onSelect: (dialog) => runConfigDialog(api, dialog),
|
|
1006
|
+
},
|
|
1007
|
+
{
|
|
1008
|
+
title: t("langCmdTitle"),
|
|
1009
|
+
value: "go-usage.lang",
|
|
1010
|
+
description: t("langCmdDesc"),
|
|
1011
|
+
slash: { name: "go-lang" },
|
|
1012
|
+
onSelect: (dialog) => runLangDialog(api, dialog),
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
title: t("settingsTitle"),
|
|
1016
|
+
value: "go-usage.settings",
|
|
1017
|
+
description: t("settingsDesc"),
|
|
1018
|
+
slash: { name: "go-settings" },
|
|
1019
|
+
onSelect: (dialog) => runSettingsDialog(api, dialog),
|
|
1020
|
+
},
|
|
1021
|
+
])
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const mod: TuiPluginModule & { id: string } = {
|
|
1025
|
+
id: "opencode-go-usage-tui",
|
|
1026
|
+
tui,
|
|
1027
|
+
}
|
|
1028
|
+
|
|
878
1029
|
export default mod
|