opencode-visual-cache 1.7.0-beta.2 → 1.7.0-beta.4
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/dist/_version.d.ts +1 -1
- package/dist/_version.js +1 -1
- package/dist/balance-providers.js +27 -1
- package/dist/index.js +4 -1
- package/dist/server.d.ts +1 -4
- package/dist/server.js +0 -4
- package/dist/tui.js +31 -2
- package/dist/v2/index.d.ts +3 -1
- package/dist/v2/index.js +3 -0
- package/dist/v2.js +27 -3
- package/package.json +3 -2
- package/src/_version.ts +1 -1
- package/src/balance-providers.ts +25 -1
- package/src/core/color.ts +108 -108
- package/src/core/currency.ts +46 -46
- package/src/core/estimate.ts +36 -36
- package/src/core/format.ts +81 -81
- package/src/core/index.ts +5 -5
- package/src/core/types.ts +17 -17
- package/src/index.tsx +3 -0
- package/src/panel/panel-api.ts +104 -104
- package/src/server.ts +1 -5
- package/src/v2/data.ts +112 -112
- package/src/v2/index.tsx +187 -184
- package/src/v2/sidebar.tsx +69 -69
- package/src/v2/status.tsx +96 -96
- package/src/v2/theme.ts +19 -19
- package/src/v2/types.ts +159 -159
- package/src/v2/v2-panel-api.ts +177 -177
- package/tui/index.js +12 -0
package/src/v2/data.ts
CHANGED
|
@@ -1,112 +1,112 @@
|
|
|
1
|
-
import type { Context, MessageInfo } from "./types"
|
|
2
|
-
import { num } from "../core"
|
|
3
|
-
|
|
4
|
-
/** 汇总一个会话的 token 用量与费用(V2 adapter → 同构模型)。 */
|
|
5
|
-
export interface SessionStats {
|
|
6
|
-
input: number
|
|
7
|
-
output: number
|
|
8
|
-
cacheRead: number
|
|
9
|
-
cacheWrite: number
|
|
10
|
-
reasoning: number
|
|
11
|
-
cost: number
|
|
12
|
-
hasData: boolean
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** 从 messages 汇总 token:统计全部 assistant 消息的 API 精确值。 */
|
|
16
|
-
export function collectSessionStats(context: Context, sessionID: string): SessionStats {
|
|
17
|
-
const msgs = context.data.session.message.list(sessionID) ?? []
|
|
18
|
-
let input = 0
|
|
19
|
-
let output = 0
|
|
20
|
-
let cacheRead = 0
|
|
21
|
-
let cacheWrite = 0
|
|
22
|
-
let reasoning = 0
|
|
23
|
-
let cost = 0
|
|
24
|
-
let hasData = false
|
|
25
|
-
|
|
26
|
-
for (const m of msgs) {
|
|
27
|
-
if (m.type !== "assistant") continue
|
|
28
|
-
const t = m.tokens
|
|
29
|
-
if (!t) continue
|
|
30
|
-
input += num(t.input)
|
|
31
|
-
output += num(t.output)
|
|
32
|
-
reasoning += num(t.reasoning)
|
|
33
|
-
cacheRead += num(t.cache?.read)
|
|
34
|
-
cacheWrite += num(t.cache?.write)
|
|
35
|
-
hasData = true
|
|
36
|
-
}
|
|
37
|
-
// 会话级费用优先(V2 提供 cost()),消息级 cost 兜底(结构未知,宽松读取)
|
|
38
|
-
const sessionCost = context.data.session.cost(sessionID)
|
|
39
|
-
if (Number.isFinite(sessionCost) && sessionCost > 0) cost = sessionCost
|
|
40
|
-
else {
|
|
41
|
-
for (const m of msgs) {
|
|
42
|
-
if (m.type !== "assistant" || m.cost == null) continue
|
|
43
|
-
const c = m.cost as { amount?: unknown; total?: unknown } | { amount?: unknown } | number
|
|
44
|
-
if (typeof c === "number" && Number.isFinite(c)) cost += c
|
|
45
|
-
else {
|
|
46
|
-
const amt = (c as { amount?: unknown }).amount
|
|
47
|
-
if (typeof amt === "number" && Number.isFinite(amt)) cost += amt
|
|
48
|
-
else if (typeof amt === "string") {
|
|
49
|
-
const n = parseFloat(amt)
|
|
50
|
-
if (Number.isFinite(n)) cost += n
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return { input, output, cacheRead, cacheWrite, reasoning, cost, hasData }
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** 缓存命中率(0–100):缓存读 /(新鲜输入 + 缓存读 + 缓存写),与 V1 口径一致。 */
|
|
59
|
-
export function hitRate(stats: SessionStats): number {
|
|
60
|
-
const denom = stats.input + stats.cacheRead + stats.cacheWrite
|
|
61
|
-
if (denom <= 0) return 0
|
|
62
|
-
return (stats.cacheRead / denom) * 100
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** 输入侧总量(底部栏口径,不含输出)。 */
|
|
66
|
-
export function inputTotal(stats: SessionStats): number {
|
|
67
|
-
return stats.input + stats.cacheRead + stats.cacheWrite
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** 单条命中率(V1 底部栏口径):最后一条有 token 的 assistant 消息,
|
|
71
|
-
* 分母含缓存写 read/(input+read+write);prevHitRate 为上一条(趋势用)。 */
|
|
72
|
-
export function collectLastHitRate(context: Context, sessionID: string): {
|
|
73
|
-
hitRate: number
|
|
74
|
-
prevHitRate: number
|
|
75
|
-
input: number
|
|
76
|
-
read: number
|
|
77
|
-
write: number
|
|
78
|
-
} {
|
|
79
|
-
const msgs = context.data.session.message.list(sessionID) ?? []
|
|
80
|
-
const session = context.data.session.get(sessionID) as { tokens?: { input?: number; cache?: { read?: number; write?: number } } } | undefined
|
|
81
|
-
let input = num(session?.tokens?.input)
|
|
82
|
-
let read = num(session?.tokens?.cache?.read)
|
|
83
|
-
let write = num(session?.tokens?.cache?.write)
|
|
84
|
-
// 无 session 聚合字段 → 遍历消息累加(与 V1 fallback 一致)
|
|
85
|
-
if (session?.tokens == null) {
|
|
86
|
-
for (const m of msgs) {
|
|
87
|
-
if (m.type !== "assistant") continue
|
|
88
|
-
const t = m.tokens
|
|
89
|
-
if (!t) continue
|
|
90
|
-
input += num(t.input)
|
|
91
|
-
read += num(t.cache?.read)
|
|
92
|
-
write += num(t.cache?.write)
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
let hitRate = -1, prevHitRate = -1
|
|
96
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
97
|
-
const m = msgs[i]
|
|
98
|
-
if (m.type !== "assistant") continue
|
|
99
|
-
const tk = m.tokens
|
|
100
|
-
if (!tk) continue
|
|
101
|
-
const mit = num(tk.input) + num(tk.cache?.read) + num(tk.cache?.write)
|
|
102
|
-
const mrt = num(tk.cache?.read)
|
|
103
|
-
if (mit <= 0) continue
|
|
104
|
-
const rate = (mrt / mit) * 100
|
|
105
|
-
if (hitRate < 0) { hitRate = rate; continue }
|
|
106
|
-
prevHitRate = rate
|
|
107
|
-
break
|
|
108
|
-
}
|
|
109
|
-
return { hitRate, prevHitRate, input, read, write }
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export type { MessageInfo }
|
|
1
|
+
import type { Context, MessageInfo } from "./types"
|
|
2
|
+
import { num } from "../core"
|
|
3
|
+
|
|
4
|
+
/** 汇总一个会话的 token 用量与费用(V2 adapter → 同构模型)。 */
|
|
5
|
+
export interface SessionStats {
|
|
6
|
+
input: number
|
|
7
|
+
output: number
|
|
8
|
+
cacheRead: number
|
|
9
|
+
cacheWrite: number
|
|
10
|
+
reasoning: number
|
|
11
|
+
cost: number
|
|
12
|
+
hasData: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 从 messages 汇总 token:统计全部 assistant 消息的 API 精确值。 */
|
|
16
|
+
export function collectSessionStats(context: Context, sessionID: string): SessionStats {
|
|
17
|
+
const msgs = context.data.session.message.list(sessionID) ?? []
|
|
18
|
+
let input = 0
|
|
19
|
+
let output = 0
|
|
20
|
+
let cacheRead = 0
|
|
21
|
+
let cacheWrite = 0
|
|
22
|
+
let reasoning = 0
|
|
23
|
+
let cost = 0
|
|
24
|
+
let hasData = false
|
|
25
|
+
|
|
26
|
+
for (const m of msgs) {
|
|
27
|
+
if (m.type !== "assistant") continue
|
|
28
|
+
const t = m.tokens
|
|
29
|
+
if (!t) continue
|
|
30
|
+
input += num(t.input)
|
|
31
|
+
output += num(t.output)
|
|
32
|
+
reasoning += num(t.reasoning)
|
|
33
|
+
cacheRead += num(t.cache?.read)
|
|
34
|
+
cacheWrite += num(t.cache?.write)
|
|
35
|
+
hasData = true
|
|
36
|
+
}
|
|
37
|
+
// 会话级费用优先(V2 提供 cost()),消息级 cost 兜底(结构未知,宽松读取)
|
|
38
|
+
const sessionCost = context.data.session.cost(sessionID)
|
|
39
|
+
if (Number.isFinite(sessionCost) && sessionCost > 0) cost = sessionCost
|
|
40
|
+
else {
|
|
41
|
+
for (const m of msgs) {
|
|
42
|
+
if (m.type !== "assistant" || m.cost == null) continue
|
|
43
|
+
const c = m.cost as { amount?: unknown; total?: unknown } | { amount?: unknown } | number
|
|
44
|
+
if (typeof c === "number" && Number.isFinite(c)) cost += c
|
|
45
|
+
else {
|
|
46
|
+
const amt = (c as { amount?: unknown }).amount
|
|
47
|
+
if (typeof amt === "number" && Number.isFinite(amt)) cost += amt
|
|
48
|
+
else if (typeof amt === "string") {
|
|
49
|
+
const n = parseFloat(amt)
|
|
50
|
+
if (Number.isFinite(n)) cost += n
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { input, output, cacheRead, cacheWrite, reasoning, cost, hasData }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 缓存命中率(0–100):缓存读 /(新鲜输入 + 缓存读 + 缓存写),与 V1 口径一致。 */
|
|
59
|
+
export function hitRate(stats: SessionStats): number {
|
|
60
|
+
const denom = stats.input + stats.cacheRead + stats.cacheWrite
|
|
61
|
+
if (denom <= 0) return 0
|
|
62
|
+
return (stats.cacheRead / denom) * 100
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 输入侧总量(底部栏口径,不含输出)。 */
|
|
66
|
+
export function inputTotal(stats: SessionStats): number {
|
|
67
|
+
return stats.input + stats.cacheRead + stats.cacheWrite
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 单条命中率(V1 底部栏口径):最后一条有 token 的 assistant 消息,
|
|
71
|
+
* 分母含缓存写 read/(input+read+write);prevHitRate 为上一条(趋势用)。 */
|
|
72
|
+
export function collectLastHitRate(context: Context, sessionID: string): {
|
|
73
|
+
hitRate: number
|
|
74
|
+
prevHitRate: number
|
|
75
|
+
input: number
|
|
76
|
+
read: number
|
|
77
|
+
write: number
|
|
78
|
+
} {
|
|
79
|
+
const msgs = context.data.session.message.list(sessionID) ?? []
|
|
80
|
+
const session = context.data.session.get(sessionID) as { tokens?: { input?: number; cache?: { read?: number; write?: number } } } | undefined
|
|
81
|
+
let input = num(session?.tokens?.input)
|
|
82
|
+
let read = num(session?.tokens?.cache?.read)
|
|
83
|
+
let write = num(session?.tokens?.cache?.write)
|
|
84
|
+
// 无 session 聚合字段 → 遍历消息累加(与 V1 fallback 一致)
|
|
85
|
+
if (session?.tokens == null) {
|
|
86
|
+
for (const m of msgs) {
|
|
87
|
+
if (m.type !== "assistant") continue
|
|
88
|
+
const t = m.tokens
|
|
89
|
+
if (!t) continue
|
|
90
|
+
input += num(t.input)
|
|
91
|
+
read += num(t.cache?.read)
|
|
92
|
+
write += num(t.cache?.write)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
let hitRate = -1, prevHitRate = -1
|
|
96
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
97
|
+
const m = msgs[i]
|
|
98
|
+
if (m.type !== "assistant") continue
|
|
99
|
+
const tk = m.tokens
|
|
100
|
+
if (!tk) continue
|
|
101
|
+
const mit = num(tk.input) + num(tk.cache?.read) + num(tk.cache?.write)
|
|
102
|
+
const mrt = num(tk.cache?.read)
|
|
103
|
+
if (mit <= 0) continue
|
|
104
|
+
const rate = (mrt / mit) * 100
|
|
105
|
+
if (hitRate < 0) { hitRate = rate; continue }
|
|
106
|
+
prevHitRate = rate
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
return { hitRate, prevHitRate, input, read, write }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type { MessageInfo }
|
package/src/v2/index.tsx
CHANGED
|
@@ -1,184 +1,187 @@
|
|
|
1
|
-
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
|
|
3
|
-
import { createSignal, createEffect, onMount, onCleanup, untrack } from "solid-js"
|
|
4
|
-
import type { Context, PluginModule } from "./types"
|
|
5
|
-
import { createPanelApi } from "./v2-panel-api"
|
|
6
|
-
import { TokenCachePanel } from "../panel/TokenCachePanel"
|
|
7
|
-
import type { BalanceState, PanelApi, PanelSignals } from "../panel/panel-api"
|
|
8
|
-
import { StatusView } from "./status"
|
|
9
|
-
import { mapTheme } from "./theme"
|
|
10
|
-
import { makeCommands, findOpencodeKeyV2 } from "./commands"
|
|
11
|
-
import { getBalanceProvider } from "../balance-providers"
|
|
12
|
-
import { LANG_META, detectLang, type LangCode } from "../i18n"
|
|
13
|
-
|
|
14
|
-
const KV_PREFIX = "cache_panel"
|
|
15
|
-
const BALANCE_POLL_MS = 5 * 60 * 1000 // 5 minutes(对齐 V1)
|
|
16
|
-
|
|
17
|
-
// 环境变量覆盖 + 自动检测(对齐 V1:CACHE_TUI_LANG 优先,其次系统 locale)
|
|
18
|
-
declare const process: { env: Record<string, string | undefined> } | undefined
|
|
19
|
-
const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined
|
|
20
|
-
const INIT_LANG: LangCode = DEBUG_LANG !== undefined && LANG_META.some((m) => m.code === DEBUG_LANG)
|
|
21
|
-
? (DEBUG_LANG as LangCode)
|
|
22
|
-
: detectLang()
|
|
23
|
-
|
|
24
|
-
/** V2 侧创建面板信号(实验:默认值;偏好持久化经 PanelApi.kv → storage.store)。
|
|
25
|
-
* 返回 PanelSignals + setBalanceState:余额轮询由 PluginRoot 驱动(V1 同构)。 */
|
|
26
|
-
function createPanelSignals(): PanelSignals & { setBalanceState: (v: BalanceState) => void } {
|
|
27
|
-
const [currencySymbol, setCurrencySymbol] = createSignal("$")
|
|
28
|
-
const [exchangeRate, setExchangeRate] = createSignal(1)
|
|
29
|
-
const [langCode, setLangCode] = createSignal(INIT_LANG)
|
|
30
|
-
const [sectionDetail, setSectionDetail] = createSignal(true)
|
|
31
|
-
const [sectionModel, setSectionModel] = createSignal(true)
|
|
32
|
-
const [sectionDist, setSectionDist] = createSignal(true)
|
|
33
|
-
const [sectionSkills, setSectionSkills] = createSignal(true)
|
|
34
|
-
const [sectionBalance, setSectionBalance] = createSignal(true)
|
|
35
|
-
const [sectionBottom, setSectionBottom] = createSignal(true)
|
|
36
|
-
const [balanceRefresh, setBalanceRefresh] = createSignal(0)
|
|
37
|
-
const [balanceProviderId, setBalanceProviderId] = createSignal("")
|
|
38
|
-
const [autoBalance, setAutoBalance] = createSignal(true)
|
|
39
|
-
const [balanceUnsupported, setBalanceUnsupported] = createSignal(false)
|
|
40
|
-
const [balanceState, setBalanceState] = createSignal<BalanceState>({ status: "idle", data: null, lastFetch: 0 })
|
|
41
|
-
const [balanceCurrency, setBalanceCurrency] = createSignal("")
|
|
42
|
-
const [borderVisible, setBorderVisible] = createSignal(true)
|
|
43
|
-
const [overrideSessionId, setOverrideSessionId] = createSignal<string | undefined>(undefined)
|
|
44
|
-
const [sidebarVisible, setSidebarVisible] = createSignal(true)
|
|
45
|
-
return {
|
|
46
|
-
currencySymbol, setCurrencySymbol,
|
|
47
|
-
exchangeRate, setExchangeRate,
|
|
48
|
-
langCode: langCode as PanelSignals["langCode"], setLangCode: setLangCode as PanelSignals["setLangCode"],
|
|
49
|
-
sectionDetail, setSectionDetail,
|
|
50
|
-
sectionModel, setSectionModel,
|
|
51
|
-
sectionDist, setSectionDist,
|
|
52
|
-
sectionSkills, setSectionSkills,
|
|
53
|
-
sectionBalance, setSectionBalance,
|
|
54
|
-
sectionBottom, setSectionBottom,
|
|
55
|
-
balanceRefresh, setBalanceRefresh,
|
|
56
|
-
balanceProviderId, setBalanceProviderId,
|
|
57
|
-
autoBalance, setAutoBalance,
|
|
58
|
-
balanceUnsupported, setBalanceUnsupported,
|
|
59
|
-
balanceState,
|
|
60
|
-
setBalanceState,
|
|
61
|
-
balanceCurrency, setBalanceCurrency,
|
|
62
|
-
borderVisible, setBorderVisible,
|
|
63
|
-
overrideSessionId, setOverrideSessionId,
|
|
64
|
-
sidebarVisible, setSidebarVisible,
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** 面板根组件:在组件渲染上下文注册命令 layer(keymap.layer 必须由组件调用),
|
|
69
|
-
* 再渲染共享 TokenCachePanel;同时驱动余额轮询(对齐 V1 tui() 的 pollBalance)。 */
|
|
70
|
-
function PluginRoot(props: {
|
|
71
|
-
context: Context
|
|
72
|
-
api: PanelApi
|
|
73
|
-
signals: PanelSignals & { setBalanceState: (v: BalanceState) => void }
|
|
74
|
-
sessionID: string
|
|
75
|
-
}) {
|
|
76
|
-
// 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果(对齐 V1)
|
|
77
|
-
let balanceSeq = 0
|
|
78
|
-
// 对齐官方内置插件示例(feature-plugins/system/plugins.tsx):mode 用 global
|
|
79
|
-
props.context.keymap.layer(() => ({
|
|
80
|
-
mode: "global",
|
|
81
|
-
commands: makeCommands(props.context, props.api, props.signals),
|
|
82
|
-
}))
|
|
83
|
-
// 语言偏好恢复(对齐 V1 tui() restoreLang:优先用户 /cache-lang 设置,覆盖自动检测)
|
|
84
|
-
onMount(() => {
|
|
85
|
-
try {
|
|
86
|
-
const saved = props.api.kv.get<string>(`${KV_PREFIX}.lang`)
|
|
87
|
-
if (saved && LANG_META.some((m) => m.code === saved)) {
|
|
88
|
-
props.signals.setLangCode(saved as LangCode)
|
|
89
|
-
}
|
|
90
|
-
} catch {}
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
// ── 余额轮询(对齐 V1 tui() pollBalance):手动 key 优先,缺失时自动复用 OpenCode 已认证 key ──
|
|
94
|
-
const pollBalance = async () => {
|
|
95
|
-
const provider = getBalanceProvider(props.signals.balanceProviderId())
|
|
96
|
-
const key = props.api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
|
|
97
|
-
|| findOpencodeKeyV2(props.context, provider)
|
|
98
|
-
const set = props.signals.setBalanceState
|
|
99
|
-
if (props.signals.balanceUnsupported()) { set({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
|
|
100
|
-
if (!key) { set({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
|
|
101
|
-
const now = Date.now()
|
|
102
|
-
const prev = props.signals.balanceState()
|
|
103
|
-
// key 已更换(重新输入)→ 强制重新查询,绕过缓存
|
|
104
|
-
if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS) return
|
|
105
|
-
const seq = ++balanceSeq
|
|
106
|
-
set({ ...prev, status: "loading", error: undefined, key })
|
|
107
|
-
const controller = new AbortController()
|
|
108
|
-
let timedOut = false
|
|
109
|
-
const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000)
|
|
110
|
-
try {
|
|
111
|
-
const data = await provider.fetchBalance(key, controller.signal)
|
|
112
|
-
clearTimeout(timer)
|
|
113
|
-
if (seq !== balanceSeq) return // 已被更新的请求取代,丢弃过期结果
|
|
114
|
-
set({ status: "ok", data, lastFetch: Date.now(), error: undefined, key })
|
|
115
|
-
} catch (err) {
|
|
116
|
-
clearTimeout(timer)
|
|
117
|
-
if (seq !== balanceSeq) return
|
|
118
|
-
const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "")
|
|
119
|
-
set({ status: "error", data: null, lastFetch: 0, error: code, key })
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
// Re-fetch when the API key is (re)configured via /cache-balance-key。
|
|
123
|
-
// 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹,
|
|
124
|
-
// effect 会追踪 balanceState 的变化并与 setBalanceState 形成无限循环。
|
|
125
|
-
createEffect(() => {
|
|
126
|
-
void props.signals.balanceRefresh()
|
|
127
|
-
untrack(() => { void pollBalance() })
|
|
128
|
-
})
|
|
129
|
-
// 定时轮询(5 分钟);随插件生命周期清理
|
|
130
|
-
const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS)
|
|
131
|
-
onCleanup(() => clearInterval(balanceTimer))
|
|
132
|
-
|
|
133
|
-
// auto-clear override:用户导航到不同主会话时清除子代理视图(对齐 V1 createSidebarSlot)
|
|
134
|
-
let lastSlotSid = props.sessionID
|
|
135
|
-
createEffect(() => {
|
|
136
|
-
const sid = props.sessionID
|
|
137
|
-
if (sid !== lastSlotSid) {
|
|
138
|
-
lastSlotSid = sid
|
|
139
|
-
if (props.signals.overrideSessionId()) {
|
|
140
|
-
props.signals.setOverrideSessionId(undefined)
|
|
141
|
-
void props.api.kv.set(`${KV_PREFIX}.session`, "")
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
return (
|
|
147
|
-
<TokenCachePanel
|
|
148
|
-
theme={mapTheme(props.context.theme)}
|
|
149
|
-
api={props.api}
|
|
150
|
-
sessionId={props.sessionID}
|
|
151
|
-
signals={props.signals}
|
|
152
|
-
/>
|
|
153
|
-
)
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const mod: PluginModule = {
|
|
157
|
-
id: "opencode-visual-cache",
|
|
158
|
-
setup(context: Context) {
|
|
159
|
-
const api = createPanelApi(context)
|
|
160
|
-
const signals = createPanelSignals()
|
|
161
|
-
|
|
162
|
-
// 侧边栏完整面板(与 V1 同一组件;命令 layer 在组件内注册)。
|
|
163
|
-
// prepend:排在宿主官方信息(问候/Context/用量)之前,紧跟会话标题。
|
|
164
|
-
context.ui.slot({
|
|
165
|
-
prepend: "sidebar.content",
|
|
166
|
-
render: (props) => (
|
|
167
|
-
<PluginRoot context={context} api={api} signals={signals} sessionID={String(props.sessionID ?? "")} />
|
|
168
|
-
),
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
// 底部状态栏(完整口径与 V1 一致;余额读共享 signals.balanceState)
|
|
172
|
-
context.ui.slot({
|
|
173
|
-
append: "prompt.footer.status",
|
|
174
|
-
render: (props) => (
|
|
175
|
-
<StatusView context={context} signals={signals} sessionID={String(props.sessionID ?? "")} />
|
|
176
|
-
),
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
// 偏好持久化(实验:storage.store 用法验证)
|
|
180
|
-
context.storage.store("opencode-visual-cache.panel", { initial: { collapsed: false } })
|
|
181
|
-
},
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
|
|
3
|
+
import { createSignal, createEffect, onMount, onCleanup, untrack } from "solid-js"
|
|
4
|
+
import type { Context, PluginModule } from "./types"
|
|
5
|
+
import { createPanelApi } from "./v2-panel-api"
|
|
6
|
+
import { TokenCachePanel } from "../panel/TokenCachePanel"
|
|
7
|
+
import type { BalanceState, PanelApi, PanelSignals } from "../panel/panel-api"
|
|
8
|
+
import { StatusView } from "./status"
|
|
9
|
+
import { mapTheme } from "./theme"
|
|
10
|
+
import { makeCommands, findOpencodeKeyV2 } from "./commands"
|
|
11
|
+
import { getBalanceProvider } from "../balance-providers"
|
|
12
|
+
import { LANG_META, detectLang, type LangCode } from "../i18n"
|
|
13
|
+
|
|
14
|
+
const KV_PREFIX = "cache_panel"
|
|
15
|
+
const BALANCE_POLL_MS = 5 * 60 * 1000 // 5 minutes(对齐 V1)
|
|
16
|
+
|
|
17
|
+
// 环境变量覆盖 + 自动检测(对齐 V1:CACHE_TUI_LANG 优先,其次系统 locale)
|
|
18
|
+
declare const process: { env: Record<string, string | undefined> } | undefined
|
|
19
|
+
const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined
|
|
20
|
+
const INIT_LANG: LangCode = DEBUG_LANG !== undefined && LANG_META.some((m) => m.code === DEBUG_LANG)
|
|
21
|
+
? (DEBUG_LANG as LangCode)
|
|
22
|
+
: detectLang()
|
|
23
|
+
|
|
24
|
+
/** V2 侧创建面板信号(实验:默认值;偏好持久化经 PanelApi.kv → storage.store)。
|
|
25
|
+
* 返回 PanelSignals + setBalanceState:余额轮询由 PluginRoot 驱动(V1 同构)。 */
|
|
26
|
+
function createPanelSignals(): PanelSignals & { setBalanceState: (v: BalanceState) => void } {
|
|
27
|
+
const [currencySymbol, setCurrencySymbol] = createSignal("$")
|
|
28
|
+
const [exchangeRate, setExchangeRate] = createSignal(1)
|
|
29
|
+
const [langCode, setLangCode] = createSignal(INIT_LANG)
|
|
30
|
+
const [sectionDetail, setSectionDetail] = createSignal(true)
|
|
31
|
+
const [sectionModel, setSectionModel] = createSignal(true)
|
|
32
|
+
const [sectionDist, setSectionDist] = createSignal(true)
|
|
33
|
+
const [sectionSkills, setSectionSkills] = createSignal(true)
|
|
34
|
+
const [sectionBalance, setSectionBalance] = createSignal(true)
|
|
35
|
+
const [sectionBottom, setSectionBottom] = createSignal(true)
|
|
36
|
+
const [balanceRefresh, setBalanceRefresh] = createSignal(0)
|
|
37
|
+
const [balanceProviderId, setBalanceProviderId] = createSignal("")
|
|
38
|
+
const [autoBalance, setAutoBalance] = createSignal(true)
|
|
39
|
+
const [balanceUnsupported, setBalanceUnsupported] = createSignal(false)
|
|
40
|
+
const [balanceState, setBalanceState] = createSignal<BalanceState>({ status: "idle", data: null, lastFetch: 0 })
|
|
41
|
+
const [balanceCurrency, setBalanceCurrency] = createSignal("")
|
|
42
|
+
const [borderVisible, setBorderVisible] = createSignal(true)
|
|
43
|
+
const [overrideSessionId, setOverrideSessionId] = createSignal<string | undefined>(undefined)
|
|
44
|
+
const [sidebarVisible, setSidebarVisible] = createSignal(true)
|
|
45
|
+
return {
|
|
46
|
+
currencySymbol, setCurrencySymbol,
|
|
47
|
+
exchangeRate, setExchangeRate,
|
|
48
|
+
langCode: langCode as PanelSignals["langCode"], setLangCode: setLangCode as PanelSignals["setLangCode"],
|
|
49
|
+
sectionDetail, setSectionDetail,
|
|
50
|
+
sectionModel, setSectionModel,
|
|
51
|
+
sectionDist, setSectionDist,
|
|
52
|
+
sectionSkills, setSectionSkills,
|
|
53
|
+
sectionBalance, setSectionBalance,
|
|
54
|
+
sectionBottom, setSectionBottom,
|
|
55
|
+
balanceRefresh, setBalanceRefresh,
|
|
56
|
+
balanceProviderId, setBalanceProviderId,
|
|
57
|
+
autoBalance, setAutoBalance,
|
|
58
|
+
balanceUnsupported, setBalanceUnsupported,
|
|
59
|
+
balanceState,
|
|
60
|
+
setBalanceState,
|
|
61
|
+
balanceCurrency, setBalanceCurrency,
|
|
62
|
+
borderVisible, setBorderVisible,
|
|
63
|
+
overrideSessionId, setOverrideSessionId,
|
|
64
|
+
sidebarVisible, setSidebarVisible,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 面板根组件:在组件渲染上下文注册命令 layer(keymap.layer 必须由组件调用),
|
|
69
|
+
* 再渲染共享 TokenCachePanel;同时驱动余额轮询(对齐 V1 tui() 的 pollBalance)。 */
|
|
70
|
+
function PluginRoot(props: {
|
|
71
|
+
context: Context
|
|
72
|
+
api: PanelApi
|
|
73
|
+
signals: PanelSignals & { setBalanceState: (v: BalanceState) => void }
|
|
74
|
+
sessionID: string
|
|
75
|
+
}) {
|
|
76
|
+
// 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果(对齐 V1)
|
|
77
|
+
let balanceSeq = 0
|
|
78
|
+
// 对齐官方内置插件示例(feature-plugins/system/plugins.tsx):mode 用 global
|
|
79
|
+
props.context.keymap.layer(() => ({
|
|
80
|
+
mode: "global",
|
|
81
|
+
commands: makeCommands(props.context, props.api, props.signals),
|
|
82
|
+
}))
|
|
83
|
+
// 语言偏好恢复(对齐 V1 tui() restoreLang:优先用户 /cache-lang 设置,覆盖自动检测)
|
|
84
|
+
onMount(() => {
|
|
85
|
+
try {
|
|
86
|
+
const saved = props.api.kv.get<string>(`${KV_PREFIX}.lang`)
|
|
87
|
+
if (saved && LANG_META.some((m) => m.code === saved)) {
|
|
88
|
+
props.signals.setLangCode(saved as LangCode)
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// ── 余额轮询(对齐 V1 tui() pollBalance):手动 key 优先,缺失时自动复用 OpenCode 已认证 key ──
|
|
94
|
+
const pollBalance = async () => {
|
|
95
|
+
const provider = getBalanceProvider(props.signals.balanceProviderId())
|
|
96
|
+
const key = props.api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
|
|
97
|
+
|| findOpencodeKeyV2(props.context, provider)
|
|
98
|
+
const set = props.signals.setBalanceState
|
|
99
|
+
if (props.signals.balanceUnsupported()) { set({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
|
|
100
|
+
if (!key) { set({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
|
|
101
|
+
const now = Date.now()
|
|
102
|
+
const prev = props.signals.balanceState()
|
|
103
|
+
// key 已更换(重新输入)→ 强制重新查询,绕过缓存
|
|
104
|
+
if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS) return
|
|
105
|
+
const seq = ++balanceSeq
|
|
106
|
+
set({ ...prev, status: "loading", error: undefined, key })
|
|
107
|
+
const controller = new AbortController()
|
|
108
|
+
let timedOut = false
|
|
109
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000)
|
|
110
|
+
try {
|
|
111
|
+
const data = await provider.fetchBalance(key, controller.signal)
|
|
112
|
+
clearTimeout(timer)
|
|
113
|
+
if (seq !== balanceSeq) return // 已被更新的请求取代,丢弃过期结果
|
|
114
|
+
set({ status: "ok", data, lastFetch: Date.now(), error: undefined, key })
|
|
115
|
+
} catch (err) {
|
|
116
|
+
clearTimeout(timer)
|
|
117
|
+
if (seq !== balanceSeq) return
|
|
118
|
+
const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "")
|
|
119
|
+
set({ status: "error", data: null, lastFetch: 0, error: code, key })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Re-fetch when the API key is (re)configured via /cache-balance-key。
|
|
123
|
+
// 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹,
|
|
124
|
+
// effect 会追踪 balanceState 的变化并与 setBalanceState 形成无限循环。
|
|
125
|
+
createEffect(() => {
|
|
126
|
+
void props.signals.balanceRefresh()
|
|
127
|
+
untrack(() => { void pollBalance() })
|
|
128
|
+
})
|
|
129
|
+
// 定时轮询(5 分钟);随插件生命周期清理
|
|
130
|
+
const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS)
|
|
131
|
+
onCleanup(() => clearInterval(balanceTimer))
|
|
132
|
+
|
|
133
|
+
// auto-clear override:用户导航到不同主会话时清除子代理视图(对齐 V1 createSidebarSlot)
|
|
134
|
+
let lastSlotSid = props.sessionID
|
|
135
|
+
createEffect(() => {
|
|
136
|
+
const sid = props.sessionID
|
|
137
|
+
if (sid !== lastSlotSid) {
|
|
138
|
+
lastSlotSid = sid
|
|
139
|
+
if (props.signals.overrideSessionId()) {
|
|
140
|
+
props.signals.setOverrideSessionId(undefined)
|
|
141
|
+
void props.api.kv.set(`${KV_PREFIX}.session`, "")
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<TokenCachePanel
|
|
148
|
+
theme={mapTheme(props.context.theme)}
|
|
149
|
+
api={props.api}
|
|
150
|
+
sessionId={props.sessionID}
|
|
151
|
+
signals={props.signals}
|
|
152
|
+
/>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const mod: PluginModule & { server: () => Promise<Record<string, never>> } = {
|
|
157
|
+
id: "opencode-visual-cache",
|
|
158
|
+
setup(context: Context) {
|
|
159
|
+
const api = createPanelApi(context)
|
|
160
|
+
const signals = createPanelSignals()
|
|
161
|
+
|
|
162
|
+
// 侧边栏完整面板(与 V1 同一组件;命令 layer 在组件内注册)。
|
|
163
|
+
// prepend:排在宿主官方信息(问候/Context/用量)之前,紧跟会话标题。
|
|
164
|
+
context.ui.slot({
|
|
165
|
+
prepend: "sidebar.content",
|
|
166
|
+
render: (props) => (
|
|
167
|
+
<PluginRoot context={context} api={api} signals={signals} sessionID={String(props.sessionID ?? "")} />
|
|
168
|
+
),
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// 底部状态栏(完整口径与 V1 一致;余额读共享 signals.balanceState)
|
|
172
|
+
context.ui.slot({
|
|
173
|
+
append: "prompt.footer.status",
|
|
174
|
+
render: (props) => (
|
|
175
|
+
<StatusView context={context} signals={signals} sessionID={String(props.sessionID ?? "")} />
|
|
176
|
+
),
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// 偏好持久化(实验:storage.store 用法验证)
|
|
180
|
+
context.storage.store("opencode-visual-cache.panel", { initial: { collapsed: false } })
|
|
181
|
+
},
|
|
182
|
+
// V1 server 空实现(兼容标记):参考 oh-my-opencode-slim 的 { id, server, setup }——
|
|
183
|
+
// v2 加载 setup,但 V1 检测需要 server 字段识别为插件
|
|
184
|
+
server: async () => ({}),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export default mod
|