diff --git a/src/index.tsx b/src/index.tsx index b517454..5804106 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -113,6 +113,14 @@ const ZH_T = { secDetail: "明细", secModel: "模型", secSkills: "已加载技能", + balTotal: "总余额:", + balNoKey: "未配置 API Key", + balLoading: "查询中...", + balError: "查询失败", + balErr401: "API Key 无效", + balErr403: "余额查询被拒绝", + balErrEmpty:"未获取到余额数据", + balErrTimeout: "查询超时", } as const const EN_T = { @@ -145,6 +153,14 @@ const EN_T = { secDetail: "Detail", secModel: "Model", secSkills: "Loaded Skills", + balTotal: "Total:", + balNoKey: "No API Key set", + balLoading: "Fetching...", + balError: "Fetch failed", + balErr401: "Invalid API Key", + balErr403: "Balance request rejected", + balErrEmpty:"No balance data", + balErrTimeout: "Request timed out", } as const // ── color helpers ──────────────────────────────────────────────── @@ -331,6 +347,64 @@ interface TokenDist { stepCost: number } +// --------------------------------------------------------------------------- +// DeepSeek Balance +// --------------------------------------------------------------------------- + +interface DeepSeekBalance { + currency: string + total: string +} + +interface BalanceState { + status: "idle" | "loading" | "ok" | "error" + data: DeepSeekBalance[] | null + lastFetch: number + error?: string + key?: string // 上次成功/尝试查询所用的 key,用于检测 key 是否更换 +} + +const BALANCE_POLL_MS = 5 * 60 * 1000 // 5 minutes + +async function fetchDeepSeekBalance(apiKey: string, signal?: AbortSignal): Promise { + const res = await fetch("https://api.deepseek.com/user/balance", { + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + signal, + }) + if (!res.ok) { + if (res.status === 401) throw new Error("401") + if (res.status === 402 || res.status === 403) throw new Error("403") + throw new Error(String(res.status)) + } + const json = await res.json() as { + is_available?: boolean + balance_infos?: { currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }[] + } + const infos = json.balance_infos ?? [] + if (infos.length === 0) throw new Error("EMPTY") + return infos.map((info) => ({ + currency: info.currency ?? "CNY", + total: info.total_balance ?? "0", + })) +} + +/** + * 将余额从来源币种换算为目标币种。 + * DEFAULT_RATES 以 USD=1 为基准:先折算为 USD,再换算到目标币种。 + */ +function convertBalance(target: string, targetRate: number, amount: number, from: string): number { + if (from === target) return amount + const fromRate = DEFAULT_RATES[from] ?? 1 + const usd = from === "USD" ? amount : amount / fromRate + return target === "USD" ? usd : usd * targetRate +} + +/** 货币符号:优先取 /cache-currency 内置映射,未知币种回退为代码。 */ +function balanceSymbol(currency: string): string { + const sym = CURRENCIES[currency] + return sym ?? currency + " " +} + // --------------------------------------------------------------------------- // Sidebar component // --------------------------------------------------------------------------- @@ -353,6 +427,14 @@ interface PanelSignals { setSectionDist: (v: boolean) => void sectionSkills: () => boolean setSectionSkills: (v: boolean) => void + sectionBalance: () => boolean + setSectionBalance: (v: boolean) => void + /** Increment to force a DeepSeek balance re-fetch. */ + balanceRefresh: () => number + setBalanceRefresh: (v: number) => void + /** Preferred currency code for balance display (CNY / USD / …). Empty = first entry. */ + balanceCurrency: () => string + setBalanceCurrency: (v: string) => void borderVisible: () => boolean setBorderVisible: (v: boolean) => void /** When set, the panel renders stats for this session instead of the main one. */ @@ -404,6 +486,9 @@ function TokenCachePanel(props: { sectionModel, setSectionModel, sectionDist, setSectionDist, sectionSkills, setSectionSkills, + sectionBalance, setSectionBalance, + balanceRefresh, + balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, } = props.signals @@ -437,6 +522,48 @@ function TokenCachePanel(props: { }) const [refreshTick, setRefreshTick] = createSignal(0) + // ── balance state + polling ────────────────────────────────── + const [balanceState, setBalanceState] = createSignal({ + status: "idle", data: null, lastFetch: 0, + }) + // 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果 + let balanceSeq = 0 + + const pollBalance = async () => { + const key = props.api.kv.get(`${KV_PREFIX}.ds_key`, "") + if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return } + const now = Date.now() + const prev = balanceState() + // key 已更换(重新输入)→ 强制重新查询,绕过缓存 + if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS) return // cache still fresh + const seq = ++balanceSeq + setBalanceState({ ...prev, status: "loading", error: undefined, key }) + const controller = new AbortController() + let timedOut = false + const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000) + try { + const data = await fetchDeepSeekBalance(key, controller.signal) + clearTimeout(timer) + if (seq !== balanceSeq) return // 已被更新的请求取代,丢弃过期结果 + setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key }) + } catch (err) { + clearTimeout(timer) + if (seq !== balanceSeq) return + const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "") + // 失败时清空旧数据,避免显示过期余额 + setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key }) + } + } + + // Re-fetch when the API key is (re)configured via /cache-balance-key. + // 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹, + // effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState + // 形成无限循环(每次重跑都发起新的 fetch 请求)。 + createEffect(() => { + void balanceRefresh() + untrack(() => { void pollBalance() }) + }) + // ── auto-clear override when the user navigates to a different main session ── let lastMainSid = props.sessionId createEffect(() => { @@ -634,10 +761,13 @@ function TokenCachePanel(props: { const rate = props.api.kv.get(`${KV_PREFIX}.rate`) if (typeof sym === "string") setCurrencySymbol(sym) if (typeof rate === "number" && rate > 0) setExchangeRate(rate) + const balCur = props.api.kv.get(`${KV_PREFIX}.balance_currency`) + if (typeof balCur === "string") setBalanceCurrency(balCur) setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true))) setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true))) setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true))) setSectionSkills(Boolean(props.api.kv.get(`${KV_PREFIX}.section.skills`, true))) + setSectionBalance(Boolean(props.api.kv.get(`${KV_PREFIX}.section.balance`, true))) const bv = props.api.kv.get(`${KV_PREFIX}.border`, true) setBorderVisible(bv !== false) // Restore language preference @@ -692,7 +822,8 @@ function TokenCachePanel(props: { const unsubMsg = props.api.event.on("message.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) }) const unsubSession = props.api.event.on("session.updated", () => { setRefreshTick(v => v + 1) }) setRefreshTick(v => v + 1) - onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); unsubSession() }) + const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS) + onCleanup(() => { clearTimeout(partTimer); clearInterval(balanceTimer); unsubPart(); unsubMsg(); unsubSession() }) }) // ── colours ── @@ -977,6 +1108,65 @@ function TokenCachePanel(props: { + + {/* ── DeepSeek balance (single line) ── */} + + {sep()} + + + {"> "} + {t().balNoKey} + + + + + {"> "} + {t().balLoading} + + + + + {"> "} + {(() => { + const code = balanceState().error + if (code === "401") return t().balErr401 + if (code === "403") return t().balErr403 + if (code === "EMPTY") return t().balErrEmpty + if (code === "TIMEOUT") return t().balErrTimeout + return t().balError + (code ? ` (${code})` : "") + })()} + + + + {(() => { + const list = balanceState().data! + const pref = balanceCurrency() + // 偏好币种是 DeepSeek 原生返回的(CNY/USD)→ 直接显示 + const native = pref ? list.find(x => x.currency === pref) : undefined + if (native) { + return ( + + {justify(t().balTotal, balanceSymbol(native.currency) + native.total)} + + ) + } + // 非原生币种(EUR/JPY/GBP/KRW…)→ 取第一条余额按汇率换算 + const base = list[0] + const baseAmt = parseFloat(base.total) + const converted = Number.isFinite(baseAmt) + ? convertBalance(pref || base.currency, exchangeRate(), baseAmt, base.currency) + : baseAmt + const shown = pref && base.currency !== pref + ? converted.toLocaleString("en-US", { maximumFractionDigits: 2 }) + : base.total + return ( + + {justify(t().balTotal, balanceSymbol(pref || base.currency) + shown)} + + ) + })()} + + @@ -1022,6 +1212,9 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const [sectionModel, setSectionModel] = createSignal(true) const [sectionDist, setSectionDist] = createSignal(true) const [sectionSkills, setSectionSkills] = createSignal(true) + const [sectionBalance, setSectionBalance] = createSignal(true) + const [balanceRefresh, setBalanceRefresh] = createSignal(0) + const [balanceCurrency, setBalanceCurrency] = createSignal("") const [borderVisible, setBorderVisible] = createSignal(true) const [langZH, setLangZH] = createSignal(LANG_ZH) const [overrideSessionId, setOverrideSessionId] = createSignal(undefined) @@ -1034,6 +1227,9 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { sectionModel, setSectionModel, sectionDist, setSectionDist, sectionSkills, setSectionSkills, + sectionBalance, setSectionBalance, + balanceRefresh, setBalanceRefresh, + balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, overrideSessionId, setOverrideSessionId, } @@ -1061,6 +1257,9 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const defRate = DEFAULT_RATES[opt.value] ?? 1 api.kv.set(`${KV_PREFIX}.currency`, sym) api.kv.set(`${KV_PREFIX}.rate`, defRate) + // 同步余额显示币种偏好:CNY/USD 原生直显,其余币种按汇率换算 + api.kv.set(`${KV_PREFIX}.balance_currency`, opt.value) + signals.setBalanceCurrency(opt.value) signals.setCurrencySymbol(sym) signals.setExchangeRate(defRate) api.ui.toast({ message: `Currency: ${opt.value} (${sym}), rate: ${defRate}` }) @@ -1105,6 +1304,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true)) const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true)) const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true)) + const balanceOn = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true)) const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true)) dialog?.replace(() => ( { { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" }, { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" }, { title: `Loaded Skills [${skillsOn ? "ON" : "OFF"}]`, value: "skills" }, + { title: `DS Balance [${balanceOn ? "ON" : "OFF"}]`, value: "balance" }, { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" }, ]} onSelect={(opt) => { @@ -1130,6 +1331,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { if (opt.value === "model") signals.setSectionModel(!cur) if (opt.value === "dist") signals.setSectionDist(!cur) if (opt.value === "skills") signals.setSectionSkills(!cur) + if (opt.value === "balance") signals.setSectionBalance(!cur) api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` }) } dialog?.clear() @@ -1150,9 +1352,10 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const model = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true)) const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true)) const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true)) + const balance = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true)) api.ui.toast({ title: "Cache Panel Config", - message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"} | Skills: ${skills ? "ON" : "OFF"}`, + message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"} | Skills: ${skills ? "ON" : "OFF"} | Balance: ${balance ? "ON" : "OFF"}`, duration: 8000, }) dialog?.clear() @@ -1183,6 +1386,54 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { )) }, }, + { + title: "Cache: Set DeepSeek API Key", + value: "cache.balance.key", + description: "Set or update the DeepSeek API key for balance display", + slash: { name: "cache-balance-key" }, + onSelect: (dialog) => { + const zh = langZH() + const current = api.kv.get(`${KV_PREFIX}.ds_key`, "") + // 已保存的 key 以脱敏形式预填:保留 "sk-" 前缀 + 头 5 尾 5 字符,中间用 * 填充 + const maskKey = (k: string): string => { + if (!k) return "" + const prefix = k.startsWith("sk-") ? "sk-" : "" + const body = prefix ? k.slice(3) : k + if (body.length <= 10) return prefix + body.slice(0, 5) + "*".repeat(Math.max(3, body.length - 5)) + return prefix + body.slice(0, 5) + "*".repeat(Math.max(3, body.length - 10)) + body.slice(-5) + } + const masked = maskKey(current) + dialog?.replace(() => ( + {zh ? "输入 DeepSeek API Key 以显示账户余额(留空清除)" : "Enter your DeepSeek API key to show account balance (leave empty to clear)"}} + placeholder="sk-..." + value={masked} + onConfirm={(val) => { + const input = val.trim() + // 空 → 清除;含 * (脱敏占位符残留)→ 视为未修改,保留原 key;否则为新 key + let key: string + if (input === "") { + key = "" + } else if (input.includes("*")) { + key = current + } else { + key = input + } + api.kv.set(`${KV_PREFIX}.ds_key`, key) + setBalanceRefresh(v => v + 1) + if (key) { + api.ui.toast({ message: zh ? "API Key 已保存,正在查询余额..." : "API Key saved, fetching balance..." }) + } else { + api.ui.toast({ message: zh ? "API Key 已清除" : "API Key cleared" }) + } + dialog?.clear() + }} + onCancel={() => dialog?.clear()} + /> + )) + }, + }, { title: "Cache: Debug Skills Detection", value: "cache.debug-skills",