diff --git a/index.json b/index.json
index 8bf70ce..6f03961 100644
--- a/index.json
+++ b/index.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "updated_at": "2026-08-31T13:52:10Z",
+ "updated_at": "2026-09-23T02:43:09Z",
"scripts": [
{
"id": "codex-relay-balance",
@@ -283,7 +283,43 @@
],
"homepage": "https://github.com/cocoxia123/CodexPluginTranslator",
"script_url": "https://raw.githubusercontent.com/BigPizzaV3/CodexPlusPlusScriptMarket/main/scripts/codex-plugin-translator.js",
- "sha256": "7baf09e43e1e0d7a2db7790b9a9bee9c186a3796869baa73eee04aaa9fbfb4b4"
- }
- ]
-}
+ "sha256": "7baf09e43e1e0d7a2db7790b9a9bee9c186a3796869baa73eee04aaa9fbfb4b4"
+ },
+ {
+ "id": "deepseek-token-usage",
+ "name": "DeepSeek Token Usage",
+ "description": "按 DeepSeek 官方费率逐条请求统计 token 用量与费用;按天/按月图表、峰谷时段计价、缓存命中拆分;面板可拖动缩放、可收成 mini 条;可选显示账户余额与每日余额消耗(按天逐笔累加、跨零点按小时分摊;助手会回读本机 balance.log 把缺口补成真实数据),余额只认查到的数字,全部由可选的本机助手提供(面板不收集 Key,也没有手动填余额的入口)。只观察不改写,响应正文按 provider/model 归属读取:DeepSeek 域名的请求,或 OpenAI 兼容 completions 路径且本次请求点名了 deepseek 模型的请求。",
+ "version": "1.19.11",
+ "author": "Saydness",
+ "tags": [
+ "codex",
+ "deepseek",
+ "tokens",
+ "cost",
+ "usage",
+ "chart",
+ "ui"
+ ],
+ "homepage": "https://github.com/Saydness/codexpp-deepseek-token-usage",
+ "script_url": "https://raw.githubusercontent.com/BigPizzaV3/CodexPlusPlusScriptMarket/main/scripts/deepseek-token-usage.js",
+ "sha256": "734df009b58d3d5ba8d44ede2c1314f11cce99684adf8badb7cd2e1a2c7d2ecd",
+ "requirements": [
+ "Codex++ 用户脚本环境",
+ "Codex 会话中使用 DeepSeek 模型",
+ "(可选)账户余额:完全交给本机助手(面板上没有手填入口,也不收集 Key)——装一次助手,它在本机用 Codex 已有的那把 Key 每 5 分钟自动更新一次,点「刷新余额」立刻补一次",
+ "(可选)余额自动更新:面板「设置 → 本机助手」里点「一键安装(交给 Codex)」,安装请求会直接写进 Codex 对话框、由它在这台电脑上执行,不用复制粘贴;也可以点「复制安装命令」自己粘。支持 Codex++ 现有的三种安装包(Windows x64、macOS Intel、macOS Apple 芯片),命令按当前系统给、也能手动切换。Windows 上还会顺带注册一个计划任务(登录时 + 每 5 分钟巡检),看门狗万一被应用重启或杀软带走会自动拉回来;安装动作本身也会切到“真实环境”里执行,不受 Codex 沙箱的文件覆盖层影响。缺 Node.js 时脚本先检测、再自动补上(Windows 优先 winget、macOS 优先 Homebrew,都不需要管理员权限)"
+ ],
+ "limitations": [
+ "只在 Codex 页面内运行时采集,其他客户端的 DeepSeek 调用不计入",
+ "费用按内置官方费率表估算,与账号实际账单可能有小额差异",
+ "历史数据保存在本机 localStorage,清理站点数据后会丢失",
+ "账户余额只认查到的数字(接口返回 / 历史导入 / 手动记录),不会用本机 token 用量推算,因为同一账户可能多台机器共用;表格里的「余额消耗」是账户级的(共用账号里其他人花的也算),旁边那列「本机估算」才是本机 Codex 按官方费率算的钱",
+ "Codex 页面不能直接联网,所以面板页内那条直连查询通道暂停中(Codex++ 的网络桥只放行 POST,而 DeepSeek 余额接口只认 GET,代码保留、桥放开 GET 就会恢复)。余额全部由可选的本机助手提供:面板「设置 → 本机助手」点「一键安装(交给 Codex)」装一次,Codex 运行时每 5 分钟自动更新一次、点「刷新余额」立刻补一次;Key 由助手在本机自己找(本机代理 / 环境变量 / config.toml 的 env_key / ~/.codex/auth.json / 本机加密保存),面板不接触 Key,也不提供手动填余额",
+ "响应正文按 provider/model 归属读取:主机名含 deepseek 的地址,或路径为 /chat/completions、/completions、/beta/chat/completions 且本次请求体里出现 deepseek 模型的地址才读;它一律不读:回环地址上的其它接口、/responses、页面自身接口、以及请求里没点名 deepseek 的流量",
+ "WebSocket 只解析「消息里出现过 deepseek 模型名」的连接(首帧认出归属后,该连接的后续帧继续解析,避免漏掉流式末尾的 usage),其余连接一帧都不读",
+ "每日余额消耗 = 当天自己的快照逐笔累加,跨零点那一小段按小时比例分给相邻两天;相邻快照隔得久(> 3 小时,例如助手没在跑)时这一段只是按时间比例估算、表里标 ≈,助手读到余额日志后会用真实快照替换",
+ "本机助手会把每次读到的余额写进助手目录的 balance.log,并回读它 + 桌面 balance\\balance.log 回填面板;如果助手是被 Codex 沙箱拉起来的(沙箱里写不了文件),面板状态行会明确提示,在普通终端重跑一次安装命令即可恢复"
+ ]
+ }
+ ]
+}
diff --git a/scripts/deepseek-token-usage.js b/scripts/deepseek-token-usage.js
new file mode 100644
index 0000000..5eb72f5
--- /dev/null
+++ b/scripts/deepseek-token-usage.js
@@ -0,0 +1,4606 @@
+// ==UserScript==
+// @name DeepSeek Token Usage
+// @namespace codex-plus-plus
+// @version 1.19.11
+// @description DeepSeek API Token 用量与费用统计面板,按官方费率计算,只在 Codex 运行时工作。
+// @match app://-/*
+// @run-at document-start
+// ==/UserScript==
+
+(() => {
+ "use strict";
+
+ const VERSION = "1.19.11";
+ const PANEL_API = "__deepseekUsagePanel";
+ const STORAGE_KEY = "__deepseekUsagePanelV1";
+ const SIDEBAR_BUTTON_ID = "deepseek-usage-sidebar-button";
+ const PANEL_ID = "deepseek-usage-panel";
+ const STYLE_ID = "deepseek-usage-panel-style";
+ const SIDEBAR_NAV_ID = "codex-plus-sidebar-nav";
+ const HEADER_TOOLBAR_SELECTOR = ".ms-auto.flex.shrink-0.items-center";
+ const DEFAULT_MODEL = "deepseek-flash";
+ const RETENTION_DAYS = 400;
+ const MAX_RECORDS = 50000;
+ const DEDUPE_WINDOW_MS = 10 * 60 * 1000;
+ const SAVE_DELAY_MS = 300;
+
+ /*
+ * 余额:Codex 页面被 CSP 挡在门外(default-src 'none'),面板自己发不出
+ * 普通请求,唯一能出去的路是 Codex++ 的宿主桥:
+ * window.__codexSessionDeleteBridge("/llm-proxy", { url, method, headers })
+ * 所以余额由面板自己查,Key 由用户提供,来源二选一:
+ * 1. Codex 配置里的 Key(Codex++ /settings/get 的 relayApiKey,也就是 Codex 正在用的那把)
+ * 2. 用户直接粘进面板的 Key
+ * 面板不再依赖任何随 Codex 启动的本机助手:助手在就顺带用,不在也完全不提示。
+ * 桥目前只允许 POST,DeepSeek 余额接口只认 GET,所以查询会先试 GET 再试 POST,
+ * 两条都不通时给出的是桥的限制说明,而不是"助手没在跑"。
+ */
+ const BALANCE_HELPER_TIMEOUT_MS = 3 * 60 * 1000;
+ const BALANCE_BRIDGE_FN = "__codexSessionDeleteBridge";
+ const BALANCE_BRIDGE_PATH = "/llm-proxy";
+ const BALANCE_ENDPOINT = "https://api.deepseek.com/user/balance";
+ const BALANCE_QUERY_TIMEOUT_MS = 20000;
+ /*
+ * Codex++ 的网络桥目前只放行 POST,而 DeepSeek 余额接口只认 GET,面板直连
+ * 这条路暂时查不通,所以在 UI 上先撤掉(实现代码全部保留)。等 Codex++ 放开
+ * GET(或出现 POST 版余额接口),把下面这一行改回 true,面板直连查询与状态提示
+ * 会一起恢复,其它地方都不用动(面板上没有 Key 输入区,直连只认 Codex 配置里的 Key)。
+ */
+ const BRIDGE_BALANCE_QUERY_ENABLED = false;
+ const BALANCE_QUERY_MIN_GAP_MS = 30 * 1000;
+ const BALANCE_QUERY_AUTO_MS = 15 * 60 * 1000;
+ /* 网络桥明确不支持 GET 时别死磕:静默查询退避到 6 小时后,手动点按钮不受限。 */
+ const BALANCE_QUERY_BLOCKED_BACKOFF_MS = 6 * 60 * 60 * 1000;
+ /* 用户勾选"记住"后,Key 存这里(本机 localStorage,不会随脚本上传)。 */
+ const BALANCE_KEY_STORE = "__deepseekUsageBalanceKeyV1";
+ /* 状态行只当短提示:助手失联那类消息在恢复后不该一直挂在面板上。 */
+ const BALANCE_STATUS_TTL_MS = 45 * 1000;
+ const BALANCE_BAR_DAYS = 14;
+ const MAX_BALANCE_SNAPSHOTS = 5000;
+ /*
+ * 可选的本机助手:面板自己装不了本机程序(页面在沙箱里,宿主桥也没有执行、
+ * 写文件的口子),但应用里的 Codex 能在这台电脑上跑命令:点「一键安装」时
+ * 把安装请求写进对话框、直接发送,由 Codex 执行安装脚本;找不到对话框
+ * (或 Codex 正忙)时才退回「复制命令」让用户自己粘。
+ * 命令按系统给:Windows 是 PowerShell,macOS 是 curl | bash。安装脚本自己会先
+ * 检查机器上有没有能用的 Node.js:有就直接用,没有才替用户装。
+ */
+ const HELPER_RAW_BASE =
+ "https://raw.githubusercontent.com/Saydness/codexpp-deepseek-token-usage/main/helper";
+ /* 直连 GitHub 不通时的备用源(国内可访问的 CDN)。 */
+ const HELPER_MIRROR_BASE =
+ "https://cdn.jsdelivr.net/gh/Saydness/codexpp-deepseek-token-usage@main/helper";
+ /* 用户刚填的 Key:默认只存在页面内存;勾了"记住"才写 localStorage。 */
+ let pendingBalanceKey = "";
+ /* 这是一份脚本 = 一次 Codex 启动;第一次打开面板要占这个标记。 */
+ let openedThisRun = false;
+ let panelBalanceKey = "";
+ let balanceConfigKey = "";
+ let balanceQueryPromise = null;
+ let balanceQueryTimer = 0;
+
+ const MODEL_OPTIONS = [
+ ["deepseek-flash", "DeepSeek Flash (V4.1)"],
+ ["deepseek-v4-pro", "DeepSeek V4 Pro"],
+ ];
+
+ /*
+ * DeepSeek 官方价格页(元 / 百万 tokens)
+ * 空闲时段价格为高峰时段的一半;高峰时段为北京时间周一至周五
+ * 09:00-12:00、14:00-18:00。
+ */
+ const RATE_CHANGE_TS = Date.UTC(2026, 8, 10, 4, 0, 0);
+ const RATE_TABLE = {
+ "deepseek-flash": {
+ hit: [0.02, 0.04],
+ miss: [1.0, 2.0],
+ output: [4.0, 8.0],
+ },
+ "deepseek-v4-pro": {
+ hit: [0.15, 0.3],
+ miss: [4.5, 9.0],
+ output: [13.5, 27.0],
+ },
+ };
+ const LEGACY_FLASH_RATE = {
+ hit: [0.05, 0.1],
+ miss: [1.5, 3.0],
+ output: [4.5, 9.0],
+ };
+
+ const state = {
+ records: [],
+ balances: [],
+ settings: {
+ model: DEFAULT_MODEL,
+ mode: "day",
+ day: todayKey(),
+ month: todayKey().slice(0, 7),
+ panelLeft: null,
+ panelTop: null,
+ panelWidth: null,
+ panelHeight: null,
+ panelMinimized: false,
+ hasOpened: false,
+ balanceCurrency: "CNY",
+ balanceSettingsOpen: false,
+ balanceRequestAt: 0,
+ balanceHandledAt: 0,
+ balanceSyncAt: 0,
+ balancePushedAt: 0,
+ balanceProxyAuth: null,
+ balanceSyncNote: "",
+ /* 命令按哪个系统给:auto 看浏览器线索,用户也可以自己指定。 */
+ helperPlatformPick: "auto",
+ balanceEnabled: true,
+ balanceSource: "auto",
+ balanceKeyRequestAt: 0,
+ balanceKeyClearAt: 0,
+ balanceKeySaved: false,
+ balanceKeyPresent: null,
+ balanceSourceUsed: "",
+ balanceKeyMode: "auto",
+ balanceKeyRemember: false,
+ balanceKeyFromConfig: false,
+ balanceQueryAt: 0,
+ balanceQueryOk: null,
+ balanceQueryBusy: false,
+ balanceQueryNote: "",
+ balanceQuerySource: "",
+ balanceQueryCooldownUntil: 0,
+ },
+ activeModel: DEFAULT_MODEL,
+ turnTotals: Object.create(null),
+ seen: new Map(),
+ keys: new Set(),
+ fingerprints: new Set(),
+ ui: null,
+ saveTimer: 0,
+ ensureTimer: 0,
+ renderTimer: 0,
+ dragState: null,
+ resizeState: null,
+ resizeObserver: null,
+ observer: null,
+ chartHitboxes: [],
+ balanceStatus: "",
+ balanceStatusTone: "",
+ balanceStatusAt: 0,
+ };
+
+ /* 版本号当三位数字比大小,用来判断页面里那份是不是更新。 */
+ function versionRank(text) {
+ return String(text || "0")
+ .split(".")
+ .map((part) => Number(part) || 0);
+ }
+
+ function isOlderThan(other) {
+ const mine = versionRank(VERSION);
+ const theirs = versionRank(other);
+ for (let index = 0; index < 3; index += 1) {
+ const left = mine[index] || 0;
+ const right = theirs[index] || 0;
+ if (left !== right) return left < right;
+ }
+ return false;
+ }
+
+ const existingPanelApi = window[PANEL_API];
+ if (existingPanelApi?.version === VERSION) return;
+ if (existingPanelApi?.version && isOlderThan(existingPanelApi.version)) {
+ return;
+ }
+
+ /*
+ * 同一页面里常常同时存在两份脚本(Codex++ 热重载会注入新版本,旧版本的回调
+ * 仍在跑)。旧版本如果继续按自己的版本重写面板内容,两份就会互相覆盖,面板
+ * 看起来像「按钮全失效」。所以只有接管了 window.__deepseekUsagePanel 的那一份
+ * 才准动面板,另一份发现名字被抢走后就安静退休。
+ */
+ const instance = { api: null, retired: false, bindTag: {} };
+
+ function ownsPanel() {
+ if (instance.retired) return false;
+ /* start() 是同步跑在最后那行登记之前的,那时先当自己是主人。 */
+ if (!instance.api) return true;
+ return window[PANEL_API] === instance.api;
+ }
+
+ function retirePanel() {
+ if (instance.retired) return;
+ instance.retired = true;
+ try {
+ state.observer?.disconnect?.();
+ } catch (_) {
+ /* 退不掉也不影响,下面的守卫已经不会再动面板。 */
+ }
+ state.observer = null;
+ /* 退休以后不许再往 localStorage 写:否则会把接班那份的状态盖回旧的。 */
+ if (state.saveTimer) {
+ window.clearTimeout(state.saveTimer);
+ state.saveTimer = 0;
+ }
+ for (const key of ["ensureTimer", "renderTimer"]) {
+ if (!state[key]) continue;
+ cancelFrame(state[key]);
+ state[key] = 0;
+ }
+ }
+
+ function count(value) {
+ const number = Number(value);
+ return Number.isFinite(number) && number > 0 ? Math.round(number) : 0;
+ }
+
+ /*
+ * 窗口被最小化或者切到后台时,requestAnimationFrame 根本不会回调,面板就会
+ * 一直停在旧数字上、按钮看着像「全失效」。这种时候退回 setTimeout,保证状态
+ * 改了页面就跟着画。
+ */
+ function scheduleFrame(callback) {
+ if (typeof document !== "undefined" && document.hidden) {
+ return window.setTimeout(callback, 16);
+ }
+ return window.requestAnimationFrame(callback);
+ }
+
+ function cancelFrame(handle) {
+ if (!handle) return;
+ try {
+ window.clearTimeout(handle);
+ window.cancelAnimationFrame(handle);
+ } catch (_) {
+ /* 句柄已经失效,忽略 */
+ }
+ }
+
+ function firstDefined(...values) {
+ for (const value of values) {
+ if (value !== undefined && value !== null) return value;
+ }
+ return undefined;
+ }
+
+ function todayKey(timestamp = Date.now()) {
+ const date = new Date(timestamp + 8 * 60 * 60 * 1000);
+ return [
+ date.getUTCFullYear(),
+ String(date.getUTCMonth() + 1).padStart(2, "0"),
+ String(date.getUTCDate()).padStart(2, "0"),
+ ].join("-");
+ }
+
+ function hourKey(timestamp) {
+ const date = new Date(timestamp + 8 * 60 * 60 * 1000);
+ return String(date.getUTCHours()).padStart(2, "0");
+ }
+
+ function isPeak(timestamp) {
+ const date = new Date(timestamp + 8 * 60 * 60 * 1000);
+ const weekday = date.getUTCDay();
+ if (weekday < 1 || weekday > 5) return false;
+ const minutes = date.getUTCHours() * 60 + date.getUTCMinutes();
+ return (
+ (minutes >= 9 * 60 && minutes < 12 * 60) ||
+ (minutes >= 14 * 60 && minutes < 18 * 60)
+ );
+ }
+
+ function normalizeModel(model) {
+ const name = String(model || "").toLowerCase().replace(/_/g, "-");
+ if (!name) return state.settings.model || DEFAULT_MODEL;
+ if (
+ name.includes("pro") ||
+ name.includes("reasoner")
+ ) {
+ return "deepseek-v4-pro";
+ }
+ if (
+ name.includes("flash") ||
+ name.includes("vision") ||
+ name.includes("chat")
+ ) {
+ return "deepseek-flash";
+ }
+ return name;
+ }
+
+ function rateForModel(model, timestamp = Date.now()) {
+ const normalized = normalizeModel(model);
+ if (normalized === "deepseek-v4-pro") {
+ return RATE_TABLE["deepseek-v4-pro"];
+ }
+ return Number(timestamp) >= RATE_CHANGE_TS
+ ? RATE_TABLE["deepseek-flash"]
+ : LEGACY_FLASH_RATE;
+ }
+
+ function modelLabel(model) {
+ const entry = MODEL_OPTIONS.find(([key]) => key === model);
+ return entry ? entry[1] : model;
+ }
+
+ function normalizeUsage(raw) {
+ if (!raw || typeof raw !== "object") return null;
+ const input = count(
+ firstDefined(
+ raw.inputTokens,
+ raw.input_tokens,
+ raw.promptTokens,
+ raw.prompt_tokens,
+ raw.inputTotalTokens,
+ raw.input_total_tokens
+ )
+ );
+ const cached = count(
+ firstDefined(
+ raw.cachedInputTokens,
+ raw.cached_input_tokens,
+ raw.cachedTokens,
+ raw.cached_tokens,
+ raw.prompt_cache_hit_tokens,
+ raw.promptTokensDetails?.cachedTokens,
+ raw.prompt_tokens_details?.cached_tokens,
+ raw.inputTokensDetails?.cachedTokens,
+ raw.input_tokens_details?.cached_tokens
+ )
+ );
+ const cacheWrite = count(
+ firstDefined(
+ raw.cacheWriteInputTokens,
+ raw.cache_write_input_tokens,
+ raw.cacheCreationInputTokens,
+ raw.cache_creation_input_tokens
+ )
+ );
+ const output = count(
+ firstDefined(
+ raw.outputTokens,
+ raw.output_tokens,
+ raw.completionTokens,
+ raw.completion_tokens
+ )
+ );
+ const reasoning = count(
+ firstDefined(
+ raw.reasoningOutputTokens,
+ raw.reasoning_output_tokens,
+ raw.reasoningTokens,
+ raw.reasoning_tokens,
+ raw.outputTokensDetails?.reasoningTokens,
+ raw.output_tokens_details?.reasoning_tokens
+ )
+ );
+ let total = count(
+ firstDefined(raw.totalTokens, raw.total_tokens, raw.total)
+ );
+ const effectiveInput = Math.max(input, cached + cacheWrite);
+ if (!effectiveInput && !output) return null;
+ if (!total) total = effectiveInput + output;
+ return {
+ input: effectiveInput,
+ cached: Math.min(cached, effectiveInput),
+ cacheWrite,
+ output,
+ reasoning: Math.min(reasoning, output),
+ total: Math.max(total, effectiveInput + output),
+ };
+ }
+
+ function coerceUsage(raw) {
+ if (
+ raw &&
+ typeof raw === "object" &&
+ (Object.prototype.hasOwnProperty.call(raw, "input") ||
+ Object.prototype.hasOwnProperty.call(raw, "output"))
+ ) {
+ const input = count(raw.input);
+ const cached = Math.min(count(raw.cached), input);
+ const output = count(raw.output);
+ if (!input && !output) return null;
+ return {
+ input,
+ cached,
+ cacheWrite: count(raw.cacheWrite),
+ output,
+ reasoning: Math.min(count(raw.reasoning), output),
+ total: Math.max(count(raw.total), input + output),
+ };
+ }
+ return normalizeUsage(raw);
+ }
+
+ function subtractUsage(current, previous) {
+ return {
+ input: Math.max(0, current.input - previous.input),
+ cached: Math.max(0, current.cached - previous.cached),
+ cacheWrite: Math.max(0, current.cacheWrite - previous.cacheWrite),
+ output: Math.max(0, current.output - previous.output),
+ reasoning: Math.max(0, current.reasoning - previous.reasoning),
+ total: Math.max(0, current.total - previous.total),
+ };
+ }
+
+ function usageSignature(usage) {
+ return [
+ usage.input,
+ usage.cached,
+ usage.output,
+ usage.reasoning,
+ usage.total,
+ ].join(":");
+ }
+
+ function usageCost(model, usage, timestamp) {
+ const rates = rateForModel(model, timestamp);
+ const peakIndex = isPeak(timestamp) ? 1 : 0;
+ const cacheMiss = Math.max(
+ 0,
+ usage.input - usage.cached - usage.cacheWrite
+ );
+ return (
+ (usage.cached / 1_000_000) * rates.hit[peakIndex] +
+ (cacheMiss / 1_000_000) * rates.miss[peakIndex] +
+ (usage.output / 1_000_000) * rates.output[peakIndex]
+ );
+ }
+
+ function rateSummary(model) {
+ const rates = rateForModel(model);
+ return (
+ `缓存命中 ${rates.hit[0]}/${rates.hit[1]} 元 · ` +
+ `未命中 ${rates.miss[0]}/${rates.miss[1]} 元 · ` +
+ `输出 ${rates.output[0]}/${rates.output[1]} 元`
+ );
+ }
+
+ function loadState() {
+ try {
+ const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || "null");
+ if (stored && Array.isArray(stored.records)) {
+ state.records = stored.records.filter(isValidRecord);
+ state.keys = new Set(
+ state.records.map((record) => record.k).filter(Boolean)
+ );
+ state.fingerprints = new Set(
+ state.records.map((record) => recordFingerprint(record)).filter(Boolean)
+ );
+ }
+ if (Array.isArray(stored?.balances)) {
+ const usable = stored.balances.filter(isValidBalance);
+ state.balances = usable;
+ /* 旧版本可能存下 0 元脏快照,清理后立刻写回,避免每次刷新都重算。 */
+ if (usable.length !== stored.balances.length) scheduleSave();
+ }
+ if (stored?.settings && typeof stored.settings === "object") {
+ state.settings = {
+ ...state.settings,
+ ...stored.settings,
+ };
+ }
+ /*
+ * Codex 每次启动后先看当天的用量:不沿用上次选的日期/月份,
+ * 免得一打开看到的是别的日子或上个月。
+ */
+ state.settings.mode = "day";
+ state.settings.day = todayKey();
+ state.settings.month = state.settings.day.slice(0, 7);
+ scheduleSave();
+ state.settings.model = normalizeModel(
+ state.settings.model || DEFAULT_MODEL
+ );
+ /* First-time users always get the full panel, never the mini bar. */
+ if (state.settings.hasOpened !== true) {
+ state.settings.panelMinimized = false;
+ }
+ } catch (_) {
+ state.records = [];
+ }
+ pruneRecords();
+ pruneBalances();
+ }
+
+ function isValidRecord(record) {
+ return (
+ record &&
+ typeof record === "object" &&
+ Number.isFinite(Number(record.t)) &&
+ typeof record.d === "string" &&
+ count(record.i) + count(record.o) > 0
+ );
+ }
+
+ function pruneRecords() {
+ const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000;
+ const records = state.records
+ .filter((record) => Number(record.t) >= cutoff)
+ .sort((a, b) => Number(a.t) - Number(b.t));
+ if (records.length > MAX_RECORDS) {
+ records.splice(0, records.length - MAX_RECORDS);
+ }
+ state.records = records;
+ state.keys = new Set(
+ records.map((record) => record.k).filter(Boolean)
+ );
+ state.fingerprints = new Set(
+ records.map((record) => recordFingerprint(record)).filter(Boolean)
+ );
+ }
+
+ function recordFingerprint(record) {
+ if (!record || !record.m) return "";
+ const signature = [
+ count(record.i),
+ count(record.c),
+ count(record.o),
+ count(record.r),
+ count(record.n),
+ ].join(":");
+ return `${record.m}|${signature}|${Math.floor(Number(record.t) / 300000)}`;
+ }
+
+ function isValidBalance(snapshot) {
+ return (
+ snapshot &&
+ typeof snapshot === "object" &&
+ Number.isFinite(Number(snapshot.t)) &&
+ typeof snapshot.d === "string" &&
+ Number.isFinite(Number(snapshot.v)) &&
+ /* 手动入口的空输入会变成 0,那不是真实余额,直接丢弃。 */
+ !(Number(snapshot.v) <= 0 && snapshot.s !== "api")
+ );
+ }
+
+ function pruneBalances() {
+ const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000;
+ const snapshots = state.balances
+ .filter(isValidBalance)
+ .filter((snapshot) => Number(snapshot.t) >= cutoff)
+ .sort((a, b) => Number(a.t) - Number(b.t));
+ if (snapshots.length > MAX_BALANCE_SNAPSHOTS) {
+ snapshots.splice(0, snapshots.length - MAX_BALANCE_SNAPSHOTS);
+ }
+ state.balances = snapshots;
+ balanceCache = null;
+ }
+
+ function serializedState() {
+ return {
+ version: VERSION,
+ settings: state.settings,
+ records: state.records,
+ balances: state.balances,
+ };
+ }
+
+ function scheduleSave() {
+ if (instance.retired) return;
+ if (state.saveTimer) return;
+ state.saveTimer = window.setTimeout(() => {
+ state.saveTimer = 0;
+ pruneRecords();
+ pruneBalances();
+ try {
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify(serializedState())
+ );
+ } catch (_) {
+ state.records = state.records.slice(
+ -Math.max(500, Math.floor(state.records.length * 0.8))
+ );
+ try {
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify(serializedState())
+ );
+ } catch (_) {
+ // Keep running even if localStorage is unavailable.
+ }
+ }
+ }, SAVE_DELAY_MS);
+ }
+
+ function recentSeen(key) {
+ const now = Date.now();
+ if (state.seen.size > 5000) {
+ for (const [item, timestamp] of state.seen) {
+ if (now - timestamp > DEDUPE_WINDOW_MS) state.seen.delete(item);
+ }
+ }
+ const previous = state.seen.get(key);
+ if (previous && now - previous < DEDUPE_WINDOW_MS) return true;
+ state.seen.set(key, now);
+ return false;
+ }
+
+ function addRecord({
+ timestamp = Date.now(),
+ model,
+ usage,
+ source = "codex",
+ id = "",
+ }) {
+ if (!usage) return false;
+ const normalized = coerceUsage(usage);
+ if (!normalized) return false;
+ const resolvedModel = normalizeModel(model);
+ const signature = usageSignature(normalized);
+ const key = `${id || "anonymous"}|${resolvedModel}|${signature}`;
+ if (state.keys.has(key)) return false;
+ state.keys.add(key);
+ if (recentSeen(key)) return false;
+ const fingerprint = recordFingerprint({
+ t: timestamp,
+ m: resolvedModel,
+ i: normalized.input,
+ c: normalized.cached,
+ o: normalized.output,
+ r: normalized.reasoning,
+ n: normalized.total,
+ });
+ if (fingerprint && state.fingerprints.has(fingerprint)) return false;
+ if (fingerprint) state.fingerprints.add(fingerprint);
+ const peak = isPeak(timestamp);
+ state.records.push({
+ k: key,
+ t: timestamp,
+ d: todayKey(timestamp),
+ m: resolvedModel,
+ i: normalized.input,
+ c: normalized.cached,
+ o: normalized.output,
+ r: normalized.reasoning,
+ n: normalized.total,
+ cost: Number(usageCost(resolvedModel, normalized, timestamp).toFixed(8)),
+ peak: peak ? 1 : 0,
+ src: source,
+ });
+ scheduleSave();
+ updateLauncherBadge();
+ scheduleRender();
+ return true;
+ }
+
+ /* ---------------------------------------------------------------------- */
+ /* Account balance */
+ /* ---------------------------------------------------------------------- */
+
+ function balanceSymbol(currency) {
+ return String(currency || "CNY").toUpperCase() === "CNY" ? "¥" : "$";
+ }
+
+ function formatBalance(value, currency) {
+ const number = Number(value);
+ if (!Number.isFinite(number)) return "—";
+ return `${balanceSymbol(currency)}${number.toFixed(2)}`;
+ }
+
+ /* 余额下降记为正数消耗,余额上升(充值)显示为带 + 的负数。 */
+ function formatBalanceSpend(value, currency) {
+ const number = Number(value);
+ if (!Number.isFinite(number)) return "—";
+ const prefix = number < 0 ? "+" : "";
+ return `${prefix}${balanceSymbol(currency)}${Math.abs(number).toFixed(2)}`;
+ }
+
+ /* 快照来源:api=助手自动读取,log=历史文件导入,其它=手动记录。 */
+ function balanceSourceLabel(source) {
+ if (source === "api") return "自动获取";
+ if (source === "log") return "历史导入";
+ return "手动记录";
+ }
+
+ /* 助手最后一次成功读到余额用的是哪条路。 */
+ function balanceReadSourceLabel(source) {
+ if (source === "proxy") return "本机代理";
+ if (source === "env") return "环境变量 Key";
+ if (source === "auth") return "Codex auth.json";
+ if (source === "store") return "本机保存的 Key";
+ return "";
+ }
+
+ /* ------------------ 面板自己查余额:Key 来源和宿主桥 ------------------ */
+
+ function looksLikeKey(value) {
+ return /^sk-[A-Za-z0-9_-]{8,}$/.test(String(value || "").trim());
+ }
+
+ function maskKeyTail(key) {
+ const text = String(key || "");
+ return text.length >= 12 ? `${text.slice(0, 5)}…${text.slice(-4)}` : "sk-…";
+ }
+
+ function balanceStoredKey() {
+ try {
+ return String(window.localStorage?.getItem(BALANCE_KEY_STORE) || "");
+ } catch (_) {
+ return "";
+ }
+ }
+
+ function writeBalanceStoredKey(key) {
+ try {
+ if (looksLikeKey(key)) window.localStorage?.setItem(BALANCE_KEY_STORE, String(key).trim());
+ else window.localStorage?.removeItem(BALANCE_KEY_STORE);
+ } catch (_) {
+ /* 本机不让写就只保留内存里的那份 */
+ }
+ }
+
+ /*
+ * Key 优先级:面板刚填的 > 本机记住的 > Codex 配置里的(Codex++ 设置里的
+ * relayApiKey)。两条都不需要额外装助手,也不需要用户找文件。
+ */
+ function balanceKeyInfo() {
+ if (looksLikeKey(panelBalanceKey)) {
+ return { key: panelBalanceKey.trim(), label: "面板里填的 Key", source: "panel" };
+ }
+ const stored = balanceStoredKey();
+ if (looksLikeKey(stored)) {
+ return { key: stored.trim(), label: "本机记住的 Key", source: "store" };
+ }
+ /* 手动模式只用用户自己给的那把,不去翻 Codex 配置。 */
+ if (state.settings.balanceKeyMode === "manual") {
+ return { key: "", label: "", source: "" };
+ }
+ if (looksLikeKey(balanceConfigKey)) {
+ return { key: balanceConfigKey.trim(), label: "Codex 配置里的 Key", source: "config" };
+ }
+ return { key: "", label: "", source: "" };
+ }
+
+
+ function balanceBridgeReady() {
+ return typeof window[BALANCE_BRIDGE_FN] === "function";
+ }
+
+ /* 宿主桥:Codex++ 的本地代理,能把一次请求转到任意 HTTPS 地址。 */
+ function callHostBridge(path, payload, timeoutMs = BALANCE_QUERY_TIMEOUT_MS) {
+ if (!balanceBridgeReady()) {
+ return Promise.resolve({ __failed: "当前 Codex++ 没有开放网络桥" });
+ }
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = (value) => {
+ if (settled) return;
+ settled = true;
+ window.clearTimeout(timer);
+ resolve(value);
+ };
+ const timer = window.setTimeout(
+ () => finish({ __failed: "网络桥超时" }),
+ timeoutMs
+ );
+ try {
+ Promise.resolve(window[BALANCE_BRIDGE_FN](path, payload || {})).then(finish, (error) =>
+ finish({ __failed: String((error && error.message) || error) })
+ );
+ } catch (error) {
+ finish({ __failed: String((error && error.message) || error) });
+ }
+ });
+ }
+
+ function parseBalanceValue(payload) {
+ const infos = Array.isArray(payload?.balance_infos) ? payload.balance_infos : [];
+ if (!infos.length) return null;
+ const chosen =
+ infos.find(
+ (item) => String(item?.currency || "").toUpperCase() === "CNY"
+ ) || infos[0];
+ const value = Number(chosen?.total_balance);
+ if (!Number.isFinite(value)) return null;
+ return {
+ value,
+ currency: String(
+ chosen?.currency || state.settings.balanceCurrency || "CNY"
+ ).toUpperCase(),
+ available: payload?.is_available !== false,
+ };
+ }
+
+ /* 余额接口只认 GET;宿主桥只放 POST,两条都试一遍再决定怎么报错。 */
+ async function requestBalanceOnce(key) {
+ const headers = { Authorization: `Bearer ${key}`, Accept: "application/json" };
+ const reasons = [];
+ let bridgeBlocksGet = false;
+ for (const method of ["GET", "POST"]) {
+ const payload = {
+ url: BALANCE_ENDPOINT,
+ method,
+ headers,
+ timeout_ms: 15000,
+ };
+ if (method === "POST") payload.body = "";
+ const result = await callHostBridge(BALANCE_BRIDGE_PATH, payload);
+ if (result?.__failed) {
+ const message = String(result.__failed);
+ if (method === "GET" && /仅支持\s*POST|POST 请求/.test(message)) {
+ bridgeBlocksGet = true;
+ }
+ reasons.push(`${method}:${message}`);
+ continue;
+ }
+ if (result?.status !== "ok") {
+ const message = String(result?.message || "未知失败");
+ if (method === "GET" && /仅支持\s*POST|POST 请求/.test(message)) {
+ bridgeBlocksGet = true;
+ }
+ reasons.push(`${method}:${message}`);
+ continue;
+ }
+ const status = Number(result.http_status || 0);
+ const body =
+ result.body_json ?? parseJson(String(result.body_text || "")) ?? null;
+ if (status >= 200 && status < 300) {
+ const parsed = parseBalanceValue(body);
+ if (parsed) return { ok: true, ...parsed, method };
+ reasons.push(`${method}:返回里没有余额字段`);
+ continue;
+ }
+ if (status === 401 || status === 403) {
+ return {
+ ok: false,
+ fatal: true,
+ message: `Key 被接口拒绝(HTTP ${status}),检查 Key 是否正确`,
+ };
+ }
+ reasons.push(
+ `${method}:HTTP ${status || "?"}${
+ String(result.body_text || "").trim()
+ ? ` ${String(result.body_text).trim().slice(0, 80)}`
+ : ""
+ }`
+ );
+ }
+ return { ok: false, bridgeBlocksGet, reasons };
+ }
+
+ function balanceQueryFailureText(result) {
+ if (result?.fatal && result.message) return result.message;
+ if (result?.bridgeBlocksGet) {
+ return "面板直连查不了:当前 Codex++ 的网络桥只允许 POST,余额接口只认 GET(装本机助手可以自动更新,或等 Codex++ 开放 GET 后自动生效)";
+ }
+ const reasons = Array.isArray(result?.reasons) ? result.reasons : [];
+ if (!reasons.length) return "余额查询失败";
+ return `余额查询失败:${reasons.join(";").slice(0, 160)}`;
+ }
+
+ async function queryBalanceNow({ silent = false } = {}) {
+ if (!balanceEnabled()) {
+ if (!silent) setBalanceStatus("余额功能已关闭,可在「设置」里打开", "warn");
+ return null;
+ }
+ if (!BRIDGE_BALANCE_QUERY_ENABLED) {
+ /*
+ * 面板直连这条路先撤了:有助手就让助手顺手刷一次,没有就安静返回,
+ * 不写失败状态,免得开面板时总挂一条"查不到余额"的提示。
+ */
+ if (balanceHelperAlive()) {
+ state.settings.balanceRequestAt = Date.now();
+ scheduleSave();
+ }
+ return null;
+ }
+ if (balanceQueryPromise) return balanceQueryPromise;
+ if (silent && Date.now() < (Number(state.settings.balanceQueryCooldownUntil) || 0)) {
+ return null;
+ }
+ if (!silent) state.settings.balanceQueryCooldownUntil = 0;
+ /*
+ * 面板永远自己先试一次直连;本机如果还跑着可选的本机助手,顺手也请它刷一次
+ * (它用 GET,读得到),两条路谁先回来用谁,互不依赖。两者都不依赖本机代理。
+ */
+ if (balanceHelperAlive()) {
+ state.settings.balanceRequestAt = Date.now();
+ scheduleSave();
+ }
+ const info = balanceKeyInfo();
+ if (!info.key) {
+ state.settings.balanceQueryOk = null;
+ state.settings.balanceQueryNote =
+ "面板直连要 Key:装本机助手后由它自动更新余额";
+ if (!silent) {
+ setBalanceStatus("面板直连要 Key;装本机助手可以让余额自动更新", "warn");
+ }
+ render();
+ return null;
+ }
+ if (!balanceBridgeReady()) {
+ state.settings.balanceQueryOk = false;
+ state.settings.balanceQueryNote = "当前 Codex++ 没开放网络桥,面板查不到余额(装本机助手可以自动更新)";
+ if (!silent) setBalanceStatus(state.settings.balanceQueryNote, "warn");
+ render();
+ return null;
+ }
+ state.settings.balanceQueryBusy = true;
+ state.settings.balanceQueryNote = "";
+ state.settings.balanceQueryAt = Date.now();
+ render();
+ const promise = requestBalanceOnce(info.key)
+ .then((result) => {
+ state.settings.balanceQueryBusy = false;
+ if (result?.ok) {
+ state.settings.balanceQueryCooldownUntil = 0;
+ state.settings.balanceQueryOk = true;
+ state.settings.balanceQuerySource = info.source;
+ state.settings.balanceQueryNote = `刚刚更新(${result.method})`;
+ state.settings.balanceSourceUsed =
+ info.source === "config" ? "auth" : "store";
+ setBalanceStatus(
+ `余额已更新:${formatBalance(result.value, result.currency)}`,
+ "ok"
+ );
+ recordBalance(result.value, {
+ source: "api",
+ currency: result.currency,
+ });
+ } else {
+ state.settings.balanceQueryOk = false;
+ state.settings.balanceQueryNote = balanceQueryFailureText(result);
+ if (result?.bridgeBlocksGet) {
+ state.settings.balanceQueryCooldownUntil =
+ Date.now() + BALANCE_QUERY_BLOCKED_BACKOFF_MS;
+ }
+ if (!silent) {
+ setBalanceStatus(state.settings.balanceQueryNote, "warn");
+ }
+ }
+ scheduleSave();
+ render();
+ return result;
+ })
+ .catch((error) => {
+ state.settings.balanceQueryBusy = false;
+ state.settings.balanceQueryOk = false;
+ state.settings.balanceQueryNote = `余额查询失败:${String(
+ (error && error.message) || error
+ ).slice(0, 120)}`;
+ scheduleSave();
+ render();
+ return null;
+ })
+ .finally(() => {
+ if (balanceQueryPromise === promise) balanceQueryPromise = null;
+ });
+ balanceQueryPromise = promise;
+ return promise;
+ }
+
+ /* 从 Codex 自己的配置里取 Key,省得用户再抄一遍。 */
+ function pickConfigKey(settings) {
+ const candidates = [];
+ if (settings && typeof settings === "object") {
+ candidates.push(settings.relayApiKey);
+ const profiles = Array.isArray(settings.relayProfiles)
+ ? settings.relayProfiles
+ : [];
+ const active =
+ profiles.find((item) => item && item.id === settings.activeRelayId) ||
+ profiles[0];
+ if (active) candidates.push(active.apiKey, active.relayApiKey);
+ }
+ for (const candidate of candidates) {
+ if (looksLikeKey(candidate)) return String(candidate).trim();
+ }
+ return "";
+ }
+
+ async function useCodexConfigKey({ silent = false } = {}) {
+ if (!balanceBridgeReady()) {
+ if (!silent) {
+ setBalanceStatus("当前 Codex++ 没开放网络桥,读不到配置里的 Key", "warn");
+ }
+ return false;
+ }
+ const settings = await callHostBridge("/settings/get", {}, 10000);
+ const key = settings?.__failed ? "" : pickConfigKey(settings);
+ if (!key) {
+ if (!silent) {
+ setBalanceStatus("Codex 配置里没有可用的 Key,直接粘一个到下面也行", "warn");
+ }
+ return false;
+ }
+ balanceConfigKey = key;
+ state.settings.balanceKeyFromConfig = true;
+ state.settings.balanceKeyMode = "auto";
+ scheduleSave();
+ render();
+ if (!silent) {
+ setBalanceStatus(
+ BRIDGE_BALANCE_QUERY_ENABLED
+ ? `已读到配置里的 Key(${maskKeyTail(key)})`
+ : balanceHelperAlive()
+ ? `已读到配置里的 Key(${maskKeyTail(key)})· 余额交给本机助手更新`
+ : `已读到配置里的 Key(${maskKeyTail(key)})· 装上本机助手(点「一键安装」)就能自动更新`,
+ "ok"
+ );
+ }
+ queryBalanceNow({ silent: true });
+ return true;
+ }
+
+ function balanceEnabled() {
+ return state.settings.balanceEnabled !== false;
+ }
+
+ function balanceSourceChoice() {
+ const value = String(state.settings.balanceSource || "auto");
+ return value === "proxy" || value === "key" ? value : "auto";
+ }
+
+ function latestBalance() {
+ let latest = null;
+ for (const snapshot of state.balances) {
+ if (!latest || Number(snapshot.t) >= Number(latest.t)) latest = snapshot;
+ }
+ return latest;
+ }
+
+ function recordBalance(
+ value,
+ { source = "manual", currency = "CNY", timestamp = Date.now() } = {}
+ ) {
+ const number = Number(value);
+ if (!Number.isFinite(number) || number < 0) return null;
+ /* 0 元快照不能记账:会被当日消耗算成一次充值。官方接口回传的 0 元仍然保留。 */
+ if (number === 0 && source !== "api") return null;
+ const rounded = Number(number.toFixed(6));
+ const day = todayKey(timestamp);
+ const latest = latestBalance();
+ if (latest && latest.v === rounded && latest.d === day) {
+ latest.t = Number(timestamp);
+ latest.s = source;
+ } else {
+ state.balances.push({
+ t: Number(timestamp),
+ d: day,
+ v: rounded,
+ c: String(currency || "CNY").toUpperCase(),
+ s: source,
+ });
+ }
+ pruneBalances();
+ scheduleSave();
+ render();
+ updateLauncherBadge();
+ return latestBalance();
+ }
+
+ /*
+ * 批量导入历史余额快照(本机助手读 balance.log 这类文件后回填)。
+ * 只补缺口:同一时刻、或同一分钟内同一金额都算已存在,重复导入不会翻倍;
+ * 0 元是查询失败的占位,一律丢掉。
+ */
+ function importBalanceSnapshots(entries) {
+ if (!Array.isArray(entries)) return 0;
+ let added = 0;
+ for (const entry of entries) {
+ const value = Number(entry?.v);
+ const at = Number(entry?.t);
+ if (!Number.isFinite(value) || value <= 0) continue;
+ if (!Number.isFinite(at) || at <= 0) continue;
+ const day = todayKey(at);
+ const rounded = Number(value.toFixed(6));
+ const known = state.balances.some(
+ (item) =>
+ Number(item.t) === at ||
+ (item.d === day &&
+ Number(item.v) === rounded &&
+ Math.abs(Number(item.t) - at) < 60000)
+ );
+ if (known) continue;
+ state.balances.push({
+ t: at,
+ d: day,
+ v: rounded,
+ c: String(
+ entry?.c || state.settings.balanceCurrency || "CNY"
+ ).toUpperCase(),
+ s: String(entry?.s || "log"),
+ });
+ added += 1;
+ }
+ if (!added) return 0;
+ pruneBalances();
+ scheduleSave();
+ render();
+ updateLauncherBadge();
+ return added;
+ }
+
+ const DAY_MS = 86400000;
+ const DAY_SHIFT_MS = 8 * 60 * 60 * 1000;
+ /* 相邻两次快照隔得比这还久(助手/面板都没在跑),那一段只能算估算。 */
+ const BALANCE_GAP_MS = 3 * 60 * 60 * 1000;
+
+ function dayStartMs(timestamp) {
+ return (
+ Math.floor((Number(timestamp) + DAY_SHIFT_MS) / DAY_MS) * DAY_MS -
+ DAY_SHIFT_MS
+ );
+ }
+
+ /* 把 [from, to) 按自然日(+08:00)切开,跨零点的那一段按小时比例分给相邻两天。 */
+ function splitByDay(fromMs, toMs) {
+ const parts = [];
+ let cursor = Number(fromMs);
+ const end = Number(toMs);
+ let guard = 0;
+ while (cursor < end && guard < 400) {
+ const stop = Math.min(end, dayStartMs(cursor) + DAY_MS);
+ parts.push({
+ day: todayKey(cursor),
+ end: stop,
+ ms: stop - cursor,
+ });
+ cursor = stop;
+ guard += 1;
+ }
+ return parts;
+ }
+
+ /*
+ * 当日消耗怎么算:
+ * 1) 快照按时间排序,相邻两次之间的余额差(下降=消耗、上升=充值)按这段时间落在哪几天分摊,
+ * 跨零点的那一小段按小时比例分给相邻两天,不会整段算到第二天头上。
+ * 2) 每一笔只把属于这一天的部分记进这一天,所以别的时间段(比如面板没记录的那几个小时)
+ * 的消耗不会被挪过来。
+ * 3) 相邻两次快照隔得久(超过 BALANCE_GAP_MS)时,这段分摊只是估算,表里带 ≈;
+ * 本机助手会去读 balance.log 把这段换成真实快照。
+ */
+ let balanceCache = null;
+
+ function balanceDayList() {
+ if (balanceCache) return balanceCache;
+ const days = new Map();
+ const ensureDay = (day) => {
+ let item = days.get(day);
+ if (!item) {
+ item = {
+ day,
+ count: 0,
+ first: null,
+ last: null,
+ spend: 0,
+ spendKnown: false,
+ gapMs: 0,
+ currency: "CNY",
+ close: null,
+ closeEstimated: false,
+ };
+ days.set(day, item);
+ }
+ return item;
+ };
+ /* 0 元快照是查询失败的占位,只用于显示当前余额,不参与记账。 */
+ const snapshots = state.balances
+ .filter((snapshot) => Number(snapshot.v) > 0)
+ .slice()
+ .sort((a, b) => Number(a.t) - Number(b.t));
+ for (const snapshot of snapshots) {
+ const item = ensureDay(snapshot.d || todayKey(snapshot.t));
+ item.count += 1;
+ if (!item.first || Number(snapshot.t) < Number(item.first.t)) {
+ item.first = snapshot;
+ }
+ if (!item.last || Number(snapshot.t) >= Number(item.last.t)) {
+ item.last = snapshot;
+ }
+ item.currency = String(snapshot.c || item.currency || "CNY").toUpperCase();
+ }
+ for (let index = 1; index < snapshots.length; index += 1) {
+ const previous = snapshots[index - 1];
+ const current = snapshots[index];
+ const delta = Number(previous.v) - Number(current.v);
+ const span = Number(current.t) - Number(previous.t);
+ if (!Number.isFinite(delta) || !delta || !(span > 0)) continue;
+ const gap = span > BALANCE_GAP_MS;
+ for (const part of splitByDay(previous.t, current.t)) {
+ const item = ensureDay(part.day);
+ item.spend += delta * (part.ms / span);
+ item.spendKnown = true;
+ if (gap) item.gapMs += part.ms;
+ /* 整天被断档跨过时,收盘余额按两端余额线性估一个。 */
+ if (!item.last) {
+ const left = part.end - Number(previous.t);
+ item.close = Number(
+ (Number(previous.v) - delta * (left / span)).toFixed(6)
+ );
+ item.closeEstimated = true;
+ }
+ }
+ }
+ const list = Array.from(days.values()).sort((a, b) =>
+ a.day < b.day ? -1 : a.day > b.day ? 1 : 0
+ );
+ for (const item of list) {
+ item.spend = item.spendKnown ? Number(item.spend.toFixed(6)) : null;
+ if (item.last) {
+ item.close = Number(item.last.v);
+ item.currency = String(item.last.c || item.currency || "CNY").toUpperCase();
+ } else if (item.close === null) {
+ item.close = 0;
+ item.closeEstimated = true;
+ }
+ }
+ balanceCache = list;
+ return list;
+ }
+
+ function balanceDayItem(day) {
+ return balanceDayList().find((item) => item.day === day) || null;
+ }
+
+ function balanceMonthSpend(month, dayList = balanceDayList()) {
+ const inside = dayList.filter((item) => item.day.startsWith(month));
+ if (!inside.length) return null;
+ const first = inside[0];
+ const last = inside[inside.length - 1];
+ const covered = inside.filter((item) => item.spend !== null);
+ return {
+ open: first.first ? Number(first.first.v) : Number(first.close),
+ close: Number(last.close),
+ spend: Number(
+ covered.reduce((sum, item) => sum + Number(item.spend), 0).toFixed(6)
+ ),
+ from: first.day,
+ to: last.day,
+ days: covered.length,
+ currency: last.currency || "CNY",
+ };
+ }
+
+ function balanceStatusElement() {
+ return (
+ state.ui?.panel?.querySelector('[data-field="balanceStatus"]') || null
+ );
+ }
+
+ function setBalanceStatus(message, tone = "") {
+ state.balanceStatus = message;
+ state.balanceStatusTone = tone;
+ state.balanceStatusAt = Date.now();
+ paintBalanceStatus();
+ }
+
+ function paintBalanceStatus() {
+ const element = balanceStatusElement();
+ if (!element) return;
+ const fresh =
+ Date.now() - Number(state.balanceStatusAt || 0) < BALANCE_STATUS_TTL_MS;
+ element.textContent = fresh ? state.balanceStatus : "";
+ element.dataset.tone = fresh ? state.balanceStatusTone || "" : "";
+ }
+
+ function balanceSyncAge() {
+ const at = Number(state.settings.balanceSyncAt) || 0;
+ return at ? Date.now() - at : Infinity;
+ }
+
+ function balanceHelperAlive() {
+ return balanceSyncAge() < BALANCE_HELPER_TIMEOUT_MS;
+ }
+
+ /*
+ * Codex++ 目前只发布了三种安装包:Windows x64、macOS Intel、macOS Apple 芯片,
+ * 所以命令也只按这两套系统给:Windows 用 PowerShell,macOS 用 curl | bash。
+ * 先按浏览器的线索猜一个,猜错可以在面板里手动挑。
+ */
+ function helperPlatformPick() {
+ const picked = String(state.settings.helperPlatformPick || "auto");
+ return picked === "win" || picked === "mac" ? picked : "auto";
+ }
+
+ function detectHelperPlatform() {
+ const nav = typeof navigator !== "undefined" ? navigator : {};
+ const ua = String(nav.userAgent || "");
+ if (/Windows|Win32|Win64/i.test(ua)) return "win";
+ /* iPad 的桌面模式会把自己报成 Mac,跟着走就行。 */
+ if (/Macintosh|Mac OS X|iPhone|iPad|iPod/i.test(ua)) return "mac";
+ /* 认不出来时按 Windows 给:桌面版用户里它最多。 */
+ return "win";
+ }
+
+ function helperPlatform() {
+ const picked = helperPlatformPick();
+ return picked === "auto" ? detectHelperPlatform() : picked;
+ }
+
+ function helperPlatformLabel(platform = helperPlatform()) {
+ if (platform === "mac") return "macOS";
+ return "Windows";
+ }
+
+ function helperShellCommand(extra = "") {
+ /* curl 是 macOS 自带的;GitHub 直连不通时换国内镜像,括号里先挑好再管给 bash。 */
+ return `(curl -fsSL ${HELPER_RAW_BASE}/install-helper.sh || curl -fsSL ${HELPER_MIRROR_BASE}/install-helper.sh) | bash${extra}`;
+ }
+
+ /*
+ * Windows 这条命令要能同时粘进 cmd 和 PowerShell:
+ * 所以整条命令里不出现 $ 变量(在 PowerShell 里会被提前展开),
+ * 只用单引号和括号表达式,脚本直接在内存里跑,落地文件都不需要。
+ */
+ function helperWindowsCommand(uninstall) {
+ const tail = uninstall ? " -Uninstall" : "";
+ const run = (base) =>
+ `& ([scriptblock]::Create((New-Object Net.WebClient).DownloadString('${base}/install-helper.ps1')))${tail}`;
+ return (
+ 'powershell -NoProfile -ExecutionPolicy Bypass -Command "' +
+ `try { ${run(HELPER_RAW_BASE)} } catch { ${run(HELPER_MIRROR_BASE)} }` +
+ '"'
+ );
+ }
+
+ function helperInstallCommand() {
+ if (helperPlatform() === "win") {
+ return helperWindowsCommand(false);
+ }
+ return helperShellCommand();
+ }
+
+ function helperUninstallCommand() {
+ if (helperPlatform() === "win") {
+ return helperWindowsCommand(true);
+ }
+ return helperShellCommand(" -s -- -Uninstall");
+ }
+
+ /*
+ * 把安装 / 卸载请求交给应用里的 Codex 执行:写进它的输入框,能发就直接发。
+ * 返回值说明:sent 已发送;waiting 已写进输入框但发送键不可用(Codex 正忙,
+ * 这颗键会变成「停止」,绝不能点);composer-occupied 输入框里有用户自己的
+ * 内容,不能覆盖;no-composer / insert-failed 退回复制命令。
+ */
+ function codexComposerInput() {
+ return document.querySelector("[data-codex-composer]");
+ }
+
+ function codexSendButton() {
+ const composer = codexComposerInput();
+ if (!composer) return null;
+ let node = composer;
+ /* 只在输入框所在的这块结构里找,绝不扫到整页去点别的按钮。 */
+ for (
+ let depth = 0;
+ depth < 8 && node && node !== document.body && node !== document.documentElement;
+ depth += 1, node = node.parentElement
+ ) {
+ if (!node.querySelectorAll) continue;
+ for (const button of node.querySelectorAll("button")) {
+ const label = String(button.getAttribute("aria-label") || "").trim();
+ /* 只认「发送」,codexHelperRequest 的注释里说明了为什么不能点别的键。 */
+ if (/^(发送|发送消息|提交|Send|Send message|Submit)$/i.test(label)) {
+ return button;
+ }
+ }
+ }
+ return null;
+ }
+
+ function codexHelperRequest(command, uninstall) {
+ const composer = codexComposerInput();
+ if (!composer) return "no-composer";
+ const existing = String(composer.textContent || "");
+ if (!existing.includes("DeepSeek 用量面板")) {
+ if (existing.trim()) return "composer-occupied";
+ const lines = uninstall
+ ? [
+ "【DeepSeek 用量面板】请在这台电脑上卸载 DeepSeek 余额小助手(面板其它功能不受影响)。",
+ "",
+ "直接执行下面这条命令即可,不需要管理员权限:",
+ command,
+ "",
+ "执行完说一声结果就行。",
+ ]
+ : [
+ "【DeepSeek 用量面板】请在这台电脑上装一下「本机助手」,它负责把 DeepSeek 账户余额推给用量面板。",
+ "",
+ "直接执行下面这条命令即可:脚本会先检查这台机器,Node.js 已经有就直接用、没有才自动补上,全程不需要管理员权限:",
+ command,
+ "",
+ "装好后面板上的助手状态会变成「运行中」,不用重启 Codex。",
+ ];
+ composer.focus();
+ const range = document.createRange();
+ range.selectNodeContents(composer);
+ range.collapse(false);
+ const selection = window.getSelection();
+ if (selection) {
+ selection.removeAllRanges();
+ selection.addRange(range);
+ }
+ document.execCommand("insertText", false, lines.join("\n"));
+ if (!String(composer.textContent || "").includes("DeepSeek 用量面板")) {
+ return "insert-failed";
+ }
+ }
+ const send = codexSendButton();
+ if (!send) return "waiting";
+ send.click();
+ return "sent";
+ }
+
+ function setHelperPlatformPick(value) {
+ state.settings.helperPlatformPick =
+ value === "win" || value === "mac" ? value : "auto";
+ scheduleSave();
+ render();
+ }
+
+ /* 页面里没有剪贴板权限时退回到老办法;两条都不行就把命令留在状态行里。 */
+ function fallbackCopyText(value) {
+ try {
+ const area = document.createElement("textarea");
+ area.value = value;
+ area.setAttribute("readonly", "");
+ area.style.position = "fixed";
+ area.style.top = "-1000px";
+ area.style.opacity = "0";
+ document.body.appendChild(area);
+ area.select();
+ const ok = document.execCommand("copy");
+ area.remove();
+ return ok;
+ } catch (_) {
+ return false;
+ }
+ }
+
+ function copyHelperCommand(kind) {
+ const uninstall = kind === "uninstall";
+ const command = uninstall ? helperUninstallCommand() : helperInstallCommand();
+ const where = helperPlatform() === "win" ? "PowerShell 窗口" : "终端";
+ const done = (ok) => {
+ const tail = uninstall
+ ? ""
+ : ";脚本自己检查依赖,缺什么会替你补上";
+ setBalanceStatus(
+ ok
+ ? `已复制${uninstall ? "卸载" : "安装"}命令(${helperPlatformLabel()}),粘到${where}回车即可${tail}`
+ : `复制失败,请手动复制这条命令:${command}`,
+ ok ? "ok" : "warn"
+ );
+ };
+ try {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard
+ .writeText(command)
+ .then(() => done(true))
+ .catch(() => done(fallbackCopyText(command)));
+ return;
+ }
+ } catch (_) {
+ /* 落到下面的兜底 */
+ }
+ done(fallbackCopyText(command));
+ }
+
+ /*
+ * 面板上的「一键安装 / 卸载」:优先让 Codex 直接执行;它没在对话页(找不到
+ * 输入框)、正忙或输入框被占用时,退回复制命令,用户照样能一条命令装好。
+ */
+ function runHelperFromPanel(uninstall) {
+ const command = uninstall ? helperUninstallCommand() : helperInstallCommand();
+ const result = codexHelperRequest(command, uninstall);
+ if (result === "sent") {
+ setBalanceStatus(
+ uninstall
+ ? "已让 Codex 在这台电脑上卸载助手,进度看对话窗口"
+ : "已让 Codex 在这台电脑上安装助手(先检测依赖、缺了才补),装好后这里会显示「运行中」",
+ "ok"
+ );
+ return;
+ }
+ if (result === "waiting") {
+ setBalanceStatus(
+ "安装请求已经写进 Codex 输入框;它正忙,等忙完按回车发送即可",
+ "warn"
+ );
+ return;
+ }
+ if (result === "composer-occupied") {
+ setBalanceStatus(
+ "Codex 输入框里已经有内容,没有动它:可以点「复制安装命令」自己粘一次",
+ "warn"
+ );
+ return;
+ }
+ /* 找不到输入框或写入失败:退回复制,不让用户空手而归。 */
+ copyHelperCommand(uninstall ? "uninstall" : "install");
+ }
+
+ function formatAgo(milliseconds) {
+ if (!Number.isFinite(milliseconds)) return "从未";
+ const seconds = Math.max(0, Math.round(milliseconds / 1000));
+ if (seconds < 90) return `${seconds} 秒前`;
+ const minutes = Math.round(seconds / 60);
+ if (minutes < 90) return `${minutes} 分钟前`;
+ const hours = Math.round(minutes / 60);
+ if (hours < 36) return `${hours} 小时前`;
+ return `${Math.round(hours / 24)} 天前`;
+ }
+
+ function balanceSyncText() {
+ if (!balanceEnabled()) return "余额功能已关闭";
+ if (BRIDGE_BALANCE_QUERY_ENABLED && state.settings.balanceQueryBusy) {
+ return "正在查询余额…";
+ }
+ const note = String(state.settings.balanceQueryNote || "").trim();
+ const at = Number(state.settings.balanceQueryAt) || 0;
+ if (BRIDGE_BALANCE_QUERY_ENABLED && state.settings.balanceQueryOk === true) {
+ return at ? `面板直连 · ${formatAgo(Date.now() - at)}更新` : "面板直连 · 已更新";
+ }
+ /* 本机如果有助手在跑,它的数据照样算数;没有就完全不提它。 */
+ if (Number(state.settings.balanceSyncAt) && balanceHelperAlive()) {
+ const used = balanceReadSourceLabel(state.settings.balanceSourceUsed);
+ if (used) return `余额来源:${used} · ${formatAgo(balanceSyncAge())}同步`;
+ }
+ if (
+ BRIDGE_BALANCE_QUERY_ENABLED &&
+ state.settings.balanceQueryOk === false &&
+ note
+ ) {
+ return note;
+ }
+ /* 面板自己连不了网:自动更新的活交给本机助手,没装就直接引导去装。 */
+ if (!BRIDGE_BALANCE_QUERY_ENABLED) {
+ if (balanceHelperAlive()) {
+ return "余额由本机助手自动更新 · 点「刷新余额」立刻补一次";
+ }
+ return "自动更新要装一次本机助手:点「一键安装」,余额就会自动更新";
+ }
+ if (!balanceKeyInfo().key) {
+ return "面板直连要 Key(读 Codex 配置里那把);装本机助手可以让余额自动更新";
+ }
+ return "已就绪,点「刷新余额」立刻查询";
+ }
+
+ /*
+ * 面板自己连不了网,「刷新余额」就请本机助手去查:它在本机用 GET,读得到。
+ * 没检测到助手就直接说去装:面板没有手填渠道,余额只由助手提供。
+ */
+ function requestHelperBalanceRefresh({ silent = false } = {}) {
+ if (!balanceHelperAlive()) {
+ if (!silent) {
+ setBalanceStatus(
+ "本机助手没在跑:点「一键安装」装上它,余额就会自动更新",
+ "warn"
+ );
+ }
+ return false;
+ }
+ state.settings.balanceRequestAt = Date.now();
+ scheduleSave();
+ if (!silent) setBalanceStatus("已请本机助手刷新余额,几秒后回填", "ok");
+ render();
+ return true;
+ }
+
+ /*
+ * 点「刷新余额」:有 Key 就直接用宿主桥查一次(面板自己完成),没有 Key
+ * 就提示去填;同一个请求标记也留着,本机如果装了助手,它会顺手补一次。
+ */
+ function requestBalanceRefresh({ silent = false } = {}) {
+ if (!balanceEnabled()) {
+ if (!silent) {
+ setBalanceStatus("余额功能已关闭,勾选卡片上的「启用」即可打开", "warn");
+ }
+ return false;
+ }
+ if (!BRIDGE_BALANCE_QUERY_ENABLED) {
+ return requestHelperBalanceRefresh({ silent });
+ }
+ state.settings.balanceRequestAt = Date.now();
+ scheduleSave();
+ if (balanceKeyInfo().key) {
+ queryBalanceNow({ silent });
+ render();
+ return true;
+ }
+ if (!silent) {
+ setBalanceStatus("面板直连要 Key;装本机助手可以让余额自动更新", "warn");
+ }
+ render();
+ return false;
+ }
+
+ /*
+ * 自动查询:面板开着的时候每 BALANCE_QUERY_AUTO_MS 查一次,两次之间至少
+ * 隔 BALANCE_QUERY_MIN_GAP_MS,页面在后台时不查,避免无意义请求。
+ */
+ function scheduleBalanceAutoQuery() {
+ /* 桥能查之前不需要这个定时器:本机助手自己会定时推余额。 */
+ if (!BRIDGE_BALANCE_QUERY_ENABLED) return;
+ if (balanceQueryTimer) return;
+ balanceQueryTimer = window.setInterval(() => {
+ if (!balanceEnabled() || document.hidden) return;
+ if (!balanceKeyInfo().key) return;
+ if (balanceSyncAge() < BALANCE_HELPER_TIMEOUT_MS) return;
+ const last = Number(state.settings.balanceQueryAt) || 0;
+ if (Date.now() - last < BALANCE_QUERY_MIN_GAP_MS) return;
+ queryBalanceNow({ silent: true });
+ }, BALANCE_QUERY_AUTO_MS);
+ }
+
+ /*
+ * 打开面板时顺手补一次查询:没有 Key 就先试着从 Codex 配置里读一个,
+ * 读到了再查;全程静默,不会弹任何"没装助手"的提示。
+ */
+ function refreshBalanceOnOpen() {
+ if (!balanceEnabled()) return;
+ if (!BRIDGE_BALANCE_QUERY_ENABLED) return;
+ if (!balanceBridgeReady()) return;
+ const last = Number(state.settings.balanceQueryAt) || 0;
+ if (balanceKeyInfo().key) {
+ if (Date.now() - last < BALANCE_QUERY_MIN_GAP_MS) return;
+ queryBalanceNow({ silent: true });
+ return;
+ }
+ useCodexConfigKey({ silent: true });
+ }
+
+ function balanceSyncReport() {
+ const latest = latestBalance();
+ return {
+ requestAt: Number(state.settings.balanceRequestAt) || 0,
+ handledAt: Number(state.settings.balanceHandledAt) || 0,
+ syncAt: Number(state.settings.balanceSyncAt) || 0,
+ pushedAt: Number(state.settings.balancePushedAt) || 0,
+ proxyAuth: state.settings.balanceProxyAuth,
+ note: state.settings.balanceSyncNote || "",
+ currency: state.settings.balanceCurrency || "CNY",
+ lastValue: latest ? Number(latest.v) : null,
+ lastDay: latest ? latest.d : "",
+ visible: Boolean(state.ui?.panel && !state.ui.panel.hidden),
+ enabled: balanceEnabled(),
+ source: balanceSourceChoice(),
+ sourceUsed: state.settings.balanceSourceUsed || "",
+ hasKey: state.settings.balanceKeyPresent,
+ /* 面板自己查余额的状态,供页面/自动化读取。 */
+ queryAt: Number(state.settings.balanceQueryAt) || 0,
+ queryOk: state.settings.balanceQueryOk,
+ queryBusy: state.settings.balanceQueryBusy === true,
+ queryNote: state.settings.balanceQueryNote || "",
+ querySource: state.settings.balanceQuerySource || "",
+ keyLabel: balanceKeyInfo().label,
+ keyTail: balanceKeyInfo().key ? maskKeyTail(balanceKeyInfo().key) : "",
+ bridge: balanceBridgeReady(),
+ keyRequestAt: Number(state.settings.balanceKeyRequestAt) || 0,
+ keyClearAt: Number(state.settings.balanceKeyClearAt) || 0,
+ keySaved: state.settings.balanceKeySaved === true,
+ /* 助手取走这个值去加密保存;取完会通过 keySaved 让面板清掉。 */
+ pendingKey: pendingBalanceKey,
+ };
+ }
+
+ /* 本机助手调用:登记一次同步结果(心跳 / 推送成功 / 失败原因)。 */
+ function applyBalanceSync(report = {}) {
+ const info = report && typeof report === "object" ? report : {};
+ state.settings.balanceSyncAt = Date.now();
+ if (typeof info.hasAuth === "boolean") {
+ state.settings.balanceProxyAuth = info.hasAuth;
+ }
+ const handled = Number(info.handledRequestAt);
+ if (Number.isFinite(handled)) {
+ state.settings.balanceHandledAt = Math.max(
+ Number(state.settings.balanceHandledAt) || 0,
+ handled
+ );
+ }
+ if (info.pushed) {
+ state.settings.balancePushedAt = state.settings.balanceSyncAt;
+ }
+ if (typeof info.hasKey === "boolean") {
+ state.settings.balanceKeyPresent = info.hasKey;
+ }
+ if (typeof info.sourceUsed === "string" && info.sourceUsed) {
+ state.settings.balanceSourceUsed = info.sourceUsed;
+ }
+ /* 助手把面板里的 Key 存好了:内存里那份立刻丢掉。 */
+ if (info.keySaved === true) {
+ pendingBalanceKey = "";
+ state.settings.balanceKeySaved = true;
+ state.settings.balanceKeyPresent = true;
+ }
+ if (info.keyCleared === true) {
+ pendingBalanceKey = "";
+ state.settings.balanceKeySaved = false;
+ state.settings.balanceKeyPresent = false;
+ }
+ state.settings.balanceSyncNote = String(info.note || info.error || "");
+ scheduleSave();
+ render();
+ return balanceSyncReport();
+ }
+
+ function parseJson(value) {
+ if (typeof value !== "string") return value;
+ const text = value.trim();
+ if (!text || (text[0] !== "{" && text[0] !== "[")) return null;
+ try {
+ return JSON.parse(text);
+ } catch (_) {
+ return null;
+ }
+ }
+
+ function findKeyValue(value, keys, depth = 0) {
+ if (!value || depth > 7) return null;
+ if (Array.isArray(value)) {
+ for (const item of value.slice(0, 30)) {
+ const found = findKeyValue(item, keys, depth + 1);
+ if (found !== null) return found;
+ }
+ return null;
+ }
+ if (typeof value === "string") {
+ const parsed = parseJson(value);
+ return parsed === null
+ ? null
+ : findKeyValue(parsed, keys, depth + 1);
+ }
+ if (typeof value !== "object") return null;
+ for (const key of keys) {
+ if (value[key] !== undefined && value[key] !== null) {
+ return value[key];
+ }
+ }
+ for (const key of [
+ "info",
+ "payload",
+ "data",
+ "message",
+ "event",
+ "params",
+ "result",
+ "body",
+ "tokenUsage",
+ "token_usage",
+ "thread_settings",
+ "threadSettings",
+ "settings",
+ ]) {
+ if (value[key] === undefined) continue;
+ const found = findKeyValue(value[key], keys, depth + 1);
+ if (found !== null) return found;
+ }
+ return null;
+ }
+
+ function findModel(value, depth = 0) {
+ const model = findKeyValue(
+ value,
+ ["model", "modelSlug", "model_slug", "modelId", "model_id"],
+ depth
+ );
+ if (typeof model === "string" && model.trim()) return model.trim();
+ return "";
+ }
+
+ function captureModelFromText(value) {
+ if (value === undefined || value === null) return "";
+ if (typeof value === "object") {
+ const model = findModel(value);
+ if (model) {
+ state.activeModel = normalizeModel(model);
+ return state.activeModel;
+ }
+ }
+ const text = String(value);
+ const quoted = text.match(/"model"\s*:\s*"([^"]+)"/i);
+ const bare = text.match(/deepseek-[a-z0-9.-]+/i);
+ const detected = quoted?.[1] || bare?.[0] || "";
+ if (detected) {
+ state.activeModel = normalizeModel(detected);
+ }
+ return detected;
+ }
+
+ function findTurnId(value) {
+ const turnId = findKeyValue(
+ value,
+ ["turnId", "turn_id", "rootTurnId", "root_turn_id"]
+ );
+ return typeof turnId === "string" ? turnId : "";
+ }
+
+ function findResponseId(value) {
+ const id = findKeyValue(
+ value,
+ ["response_id", "responseId", "request_id", "requestId", "id"]
+ );
+ return typeof id === "string" ? id : "";
+ }
+
+ function pickUsage(value, keys) {
+ if (!value || typeof value !== "object") return null;
+ for (const key of keys) {
+ const usage = normalizeUsage(value[key]);
+ if (usage) return usage;
+ }
+ return null;
+ }
+
+ function findUsageInfo(payload) {
+ const tokenUsage = findKeyValue(
+ payload,
+ ["tokenUsage", "token_usage", "tokenUsageInfo", "token_usage_info"]
+ );
+ if (tokenUsage && typeof tokenUsage === "object") {
+ const last = pickUsage(tokenUsage, [
+ "lastTokenUsage",
+ "last_token_usage",
+ "lastUsage",
+ "last_usage",
+ "last",
+ ]);
+ if (last) return { usage: last, kind: "last" };
+ const total = pickUsage(tokenUsage, [
+ "totalTokenUsage",
+ "total_token_usage",
+ "totalUsage",
+ "total_usage",
+ "total",
+ ]);
+ if (total) return { usage: total, kind: "total" };
+ const direct = normalizeUsage(tokenUsage);
+ if (direct) return { usage: direct, kind: "last" };
+ }
+
+ const direct = pickUsage(payload, [
+ "lastTokenUsage",
+ "last_token_usage",
+ "lastUsage",
+ "last_usage",
+ "usage",
+ ]);
+ if (direct) return { usage: direct, kind: "last" };
+ return null;
+ }
+
+ function handlePayload(payload, source) {
+ if (payload === undefined || payload === null) return false;
+ const parsed = parseJson(payload);
+ if (parsed !== null && parsed !== payload) {
+ return handlePayload(parsed, source);
+ }
+ if (typeof payload !== "object") return false;
+
+ const detectedModel = findModel(payload);
+ if (detectedModel) {
+ state.activeModel = normalizeModel(detectedModel);
+ }
+ const info = findUsageInfo(payload);
+ if (!info) return false;
+ const model =
+ detectedModel ||
+ state.activeModel ||
+ state.settings.model ||
+ DEFAULT_MODEL;
+ const id = findResponseId(payload);
+ const turnId = findTurnId(payload);
+
+ if (info.kind === "last") {
+ return addRecord({
+ model,
+ usage: info.usage,
+ source,
+ id: id || turnId,
+ });
+ }
+
+ if (!turnId) {
+ return addRecord({
+ model,
+ usage: info.usage,
+ source,
+ id,
+ });
+ }
+
+ const previous = state.turnTotals[turnId];
+ state.turnTotals[turnId] = info.usage;
+ if (!previous) return false;
+ const delta = subtractUsage(info.usage, previous);
+ if (delta.input + delta.output + delta.cached <= 0) return false;
+ return addRecord({
+ model,
+ usage: delta,
+ source,
+ id: id || turnId,
+ });
+ }
+
+ function parseResponseText(text, source, url = "") {
+ const payloads = [];
+ const parsed = parseJson(text);
+ if (parsed !== null) payloads.push(parsed);
+ if (!payloads.length) {
+ for (const line of String(text).split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed === "data: [DONE]") continue;
+ const data = trimmed.startsWith("data:")
+ ? trimmed.slice(5).trim()
+ : trimmed;
+ const item = parseJson(data);
+ if (item !== null) payloads.push(item);
+ }
+ }
+ let captured = false;
+ for (const payload of payloads) {
+ captured = handlePayload(
+ {
+ ...payload,
+ __deepseek_url: url,
+ },
+ source
+ ) || captured;
+ }
+ return captured;
+ }
+
+ /*
+ * 响应正文的读取范围(只读这些,别的都不读):
+ * 1. 主机名里带 deepseek 的地址(api.deepseek.com 等);
+ * 2. 路径是 OpenAI 兼容 completions 端点、并且**请求里点名了 deepseek 模型**
+ * 的地址——本机中转、自建代理走的就是这个路径,靠请求体里的模型名认归属。
+ * 早先还匹配「任何回环地址」和「/responses」,那会把本机其它服务、别的 provider
+ * 的响应也读一遍,范围过宽,已经去掉。
+ */
+ const API_PATH_SUFFIXES = [
+ "/chat/completions",
+ "/completions",
+ "/beta/chat/completions",
+ ];
+ /* 请求体可能很大(含提示词),只做一次子串判断,不保存任何内容。 */
+ const REQUEST_BODY_SCAN_LIMIT = 200_000;
+
+ function splitUrl(value) {
+ const text = String(value || "").trim();
+ if (!text) return { host: "", path: "" };
+ try {
+ const parsed = new URL(text, window.location?.href || undefined);
+ return {
+ host: String(parsed.hostname || "").toLowerCase(),
+ path: String(parsed.pathname || "").toLowerCase(),
+ };
+ } catch (_) {
+ /* 相对路径解析不了时按整体字符串判断,够用。 */
+ return { host: "", path: text.toLowerCase().split("?")[0] };
+ }
+ }
+
+ function mentionsDeepSeek(value) {
+ if (typeof value !== "string" || !value) return false;
+ const text =
+ value.length > REQUEST_BODY_SCAN_LIMIT
+ ? value.slice(0, REQUEST_BODY_SCAN_LIMIT)
+ : value;
+ return text.toLowerCase().includes("deepseek");
+ }
+
+ function isDeepSeekHost(host) {
+ return String(host || "").includes("deepseek");
+ }
+
+ /*
+ * requestMentionsDeepSeek:这次请求的请求体里有没有点名 deepseek 模型。
+ * 非 deepseek 域名(本机中转、自建代理)只有拿到这个 true 才读响应。
+ */
+ function isLikelyApiUrl(url, requestMentionsDeepSeek = false) {
+ const { host, path } = splitUrl(url);
+ if (!host && !path) return false;
+ if (isDeepSeekHost(host)) return true;
+ if (!API_PATH_SUFFIXES.some((suffix) => path.endsWith(suffix))) return false;
+ return requestMentionsDeepSeek === true;
+ }
+
+ /*
+ * WebSocket 侧的归属判断:流式分片通常只有首帧带模型名,所以「首帧认出归属、
+ * 这条连接的后续帧继续解析」,末尾那条带 usage 的帧才不会漏掉;
+ * 而一条从头到尾没提过 deepseek 的连接,一帧都不碰。
+ */
+ const deepSeekSockets = new WeakSet();
+
+ function isDeepSeekSocketFrame(socket, text) {
+ if (deepSeekSockets.has(socket)) return true;
+ if (!mentionsDeepSeek(text)) return false;
+ deepSeekSockets.add(socket);
+ return true;
+ }
+
+ function installFetchObserver() {
+ if (
+ typeof window.fetch !== "function" ||
+ window.fetch.__deepseekUsageWrapped === VERSION
+ ) {
+ return;
+ }
+ const originalFetch = window.fetch;
+ async function wrappedFetch(input, init) {
+ const url =
+ typeof input === "string"
+ ? input
+ : input?.url || "";
+ captureModelFromText(init?.body);
+ const deepSeekRequest = mentionsDeepSeek(init?.body);
+ const response = await originalFetch.call(this, input, init);
+ if (
+ isLikelyApiUrl(url, deepSeekRequest) &&
+ response?.clone &&
+ typeof response.clone === "function"
+ ) {
+ response
+ .clone()
+ .text()
+ .then((text) => parseResponseText(text, "fetch", url))
+ .catch(() => {});
+ }
+ return response;
+ }
+ wrappedFetch.__deepseekUsageWrapped = VERSION;
+ wrappedFetch.__deepseekUsageOriginal = originalFetch;
+ window.fetch = wrappedFetch;
+ }
+
+ function installXhrObserver() {
+ const Xhr = window.XMLHttpRequest;
+ if (
+ !Xhr?.prototype ||
+ Xhr.prototype.__deepseekUsageWrapped === VERSION
+ ) {
+ return;
+ }
+ const originalOpen = Xhr.prototype.open;
+ const originalSend = Xhr.prototype.send;
+ Xhr.prototype.open = function open(method, url, ...rest) {
+ this.__deepseekUsageUrl = url;
+ return originalOpen.call(this, method, url, ...rest);
+ };
+ Xhr.prototype.send = function send(...args) {
+ captureModelFromText(args[0]);
+ /* 先算成布尔值,避免把请求体(含提示词)留到回调里。 */
+ const deepSeekRequest = mentionsDeepSeek(args[0]);
+ this.addEventListener?.("loadend", () => {
+ const url = this.__deepseekUsageUrl || "";
+ if (!isLikelyApiUrl(url, deepSeekRequest)) return;
+ try {
+ parseResponseText(this.responseText || "", "xhr", url);
+ } catch (_) {
+ // Ignore unreadable XHR bodies.
+ }
+ });
+ return originalSend.apply(this, args);
+ };
+ Xhr.prototype.__deepseekUsageWrapped = VERSION;
+ Xhr.prototype.__deepseekUsageOriginalOpen = originalOpen;
+ Xhr.prototype.__deepseekUsageOriginalSend = originalSend;
+ }
+
+ function installWebSocketObserver() {
+ if (
+ typeof window.WebSocket !== "function" ||
+ window.WebSocket.__deepseekUsageWrapped === VERSION
+ ) {
+ return;
+ }
+ const NativeWebSocket = window.WebSocket;
+ function DeepSeekUsageWebSocket(...args) {
+ const socket = new NativeWebSocket(...args);
+ socket.addEventListener?.("message", (event) => {
+ try {
+ if (typeof event.data === "string") {
+ if (!isDeepSeekSocketFrame(socket, event.data)) return;
+ captureModelFromText(event.data);
+ handlePayload(event.data, "websocket");
+ } else if (
+ event.data instanceof Blob &&
+ event.data.size <= 1_000_000
+ ) {
+ event.data
+ .text()
+ .then((text) => {
+ if (!isDeepSeekSocketFrame(socket, text)) return;
+ handlePayload(text, "websocket");
+ })
+ .catch(() => {});
+ }
+ } catch (_) {
+ // Keep the original socket behavior untouched.
+ }
+ });
+ return socket;
+ }
+ try {
+ DeepSeekUsageWebSocket.prototype = NativeWebSocket.prototype;
+ Object.setPrototypeOf(DeepSeekUsageWebSocket, NativeWebSocket);
+ } catch (_) {
+ // Best-effort compatibility.
+ }
+ DeepSeekUsageWebSocket.__deepseekUsageWrapped = VERSION;
+ DeepSeekUsageWebSocket.__deepseekUsageOriginal = NativeWebSocket;
+ window.WebSocket = DeepSeekUsageWebSocket;
+ }
+
+ function installCapture() {
+ window.addEventListener?.(
+ "message",
+ (event) => {
+ try {
+ handlePayload(event.data, "post-message");
+ } catch (_) {
+ // Ignore unrelated messages.
+ }
+ },
+ true
+ );
+ window.addEventListener?.(
+ "codex-message-from-view",
+ (event) => {
+ try {
+ handlePayload(event.detail, "codex-message");
+ } catch (_) {
+ // Ignore unrelated messages.
+ }
+ },
+ true
+ );
+ installFetchObserver();
+ installXhrObserver();
+ installWebSocketObserver();
+ }
+
+ function escapeHtml(value) {
+ return String(value ?? "").replace(/[&<>"']/g, (character) => ({
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+ }[character]));
+ }
+
+ function formatTokens(value) {
+ const number = Number(value || 0);
+ if (number >= 1_000_000_000) return `${(number / 1_000_000_000).toFixed(2)}B`;
+ if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(2)}M`;
+ if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`;
+ return String(Math.round(number));
+ }
+
+ function formatCost(value) {
+ const number = Number(value || 0);
+ if (!number) return "¥0.000000";
+ if (number < 0.01) return `¥${number.toFixed(6)}`;
+ return `¥${number.toFixed(4)}`;
+ }
+
+ function formatDateTime(timestamp) {
+ const date = new Date(Number(timestamp));
+ if (Number.isNaN(date.getTime())) return "";
+ return date.toLocaleString("zh-CN", {
+ hour12: false,
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ }
+
+ function panelShell() {
+ const modelOptions = MODEL_OPTIONS.map(
+ ([value, label]) =>
+ ``
+ ).join("");
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
请求次数0
+
缓存命中0
+
缓存未命中0
+
输出 tokens0
+
估算费用¥0.000000
+
+
+
+ 账户余额
+
+
+
+
+
+
+ 当前余额
+ —
+ 等助手回填
+
+
今日消耗—
+
昨日消耗—
+
本月消耗—
+
+
+
+
+
+
+
+ 余额同步
+ 检测中…
+
+
+ 本机助手(可选)
+
+ 助手状态
+ 检测中…
+
+
+
+
+
+
+
+
+ 点「一键安装」=让 Codex 在这台电脑上装好助手:缺 Node.js 会自动补,不需要管理员权限。命令按下面的系统选(现在按 Windows)。Key 不用你管:助手会用 Codex 里已有的那把,面板不保存任何 Key。
+
+
+ 余额是怎么自动更新的?
+ Codex 页面自己连不了网,余额由「本机助手」在本机查:装一次之后每 5 分钟更新一次,点「刷新余额」立刻补一次。Key 由助手自己找(环境变量 / config.toml / ~/.codex/auth.json),面板不接触 Key。
+
+
+
+
+
+
+
+ 按小时用量
+
+ 命中
+ 未命中
+ 输出
+ 费用
+
+
+
+
+
当前时间段暂无数据
+
+
+
+
+
+
+ `;
+ }
+
+ function installStyles() {
+ const existing = document.getElementById(STYLE_ID);
+ /* 样式表跟着版本走:热重载换了脚本,旧样式也要一起换掉。 */
+ if (existing && existing.dataset.dsuVersion === VERSION) return;
+ const style = existing || document.createElement("style");
+ style.id = STYLE_ID;
+ style.dataset.dsuVersion = VERSION;
+ style.textContent = `
+ #${PANEL_ID},
+ #${PANEL_ID} *,
+ #${SIDEBAR_BUTTON_ID} {
+ -webkit-app-region: no-drag !important;
+ }
+ #${PANEL_ID} {
+ pointer-events: auto !important;
+ }
+ #${SIDEBAR_BUTTON_ID} {
+ color: inherit;
+ }
+ #${SIDEBAR_BUTTON_ID}:hover,
+ #${SIDEBAR_BUTTON_ID}[data-active="true"] {
+ background: color-mix(in srgb, currentColor 10%, transparent);
+ }
+ #${SIDEBAR_BUTTON_ID} .dsu-sidebar-badge {
+ margin-inline-start: auto;
+ color: var(--text-secondary, #94a3b8);
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ }
+ .dsu-panel {
+ position: fixed;
+ top: 90px;
+ right: 18px;
+ z-index: 2147483000;
+ width: min(760px, calc(100vw - 36px));
+ max-height: min(620px, calc(100vh - 120px));
+ min-width: 420px;
+ min-height: 180px;
+ resize: none;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ color: #e8eaed;
+ background: color-mix(in srgb, #151920 96%, transparent);
+ border: 1px solid color-mix(in srgb, #ffffff 14%, transparent);
+ border-radius: 16px;
+ box-shadow: 0 18px 60px rgba(0, 0, 0, 0.46);
+ backdrop-filter: blur(18px);
+ font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
+ }
+ .dsu-panel[hidden] { display: none !important; }
+ .dsu-header {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ cursor: move;
+ user-select: none;
+ touch-action: none;
+ padding: 16px 18px 12px;
+ border-bottom: 1px solid color-mix(in srgb, #ffffff 10%, transparent);
+ position: sticky;
+ top: 0;
+ background: color-mix(in srgb, #151920 96%, transparent);
+ backdrop-filter: blur(18px);
+ z-index: 2;
+ }
+ .dsu-drag-grip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 30px;
+ border-radius: 7px;
+ color: #64748b;
+ font-size: 14px;
+ letter-spacing: -2px;
+ line-height: 1;
+ cursor: grab;
+ flex: 0 0 auto;
+ touch-action: none;
+ }
+ .dsu-drag-grip:hover {
+ color: #cbd5e1;
+ background: color-mix(in srgb, currentColor 12%, transparent);
+ }
+ .dsu-panel.dsu-dragging .dsu-drag-grip {
+ cursor: grabbing;
+ }
+ .dsu-header button,
+ .dsu-header input,
+ .dsu-header select,
+ .dsu-header a {
+ cursor: pointer;
+ user-select: auto;
+ }
+ .dsu-body {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: auto;
+ }
+ .dsu-panel.dsu-dragging {
+ transition: none;
+ opacity: 0.98;
+ box-shadow: 0 24px 72px rgba(0, 0, 0, 0.58);
+ }
+ .dsu-panel.dsu-minimized {
+ width: auto !important;
+ height: auto !important;
+ min-width: 280px;
+ max-width: calc(100vw - 16px);
+ min-height: 0;
+ max-height: none;
+ resize: none;
+ border-radius: 10px;
+ }
+ .dsu-panel.dsu-minimized .dsu-body {
+ display: none !important;
+ }
+ .dsu-panel.dsu-minimized .dsu-title-block,
+ .dsu-panel.dsu-minimized .dsu-seg,
+ .dsu-panel.dsu-minimized [data-action="refresh"] {
+ display: none !important;
+ }
+ .dsu-panel.dsu-minimized .dsu-mini-stats {
+ display: flex;
+ }
+ .dsu-panel.dsu-minimized .dsu-header {
+ min-height: 36px;
+ padding: 4px 8px;
+ gap: 6px;
+ }
+ .dsu-panel.dsu-minimized .dsu-actions {
+ gap: 5px;
+ }
+ .dsu-panel.dsu-minimized .dsu-icon-button {
+ width: 26px;
+ height: 24px;
+ }
+ .dsu-resize-handle {
+ position: absolute;
+ right: 2px;
+ bottom: 2px;
+ width: 18px;
+ height: 18px;
+ z-index: 5;
+ cursor: nwse-resize;
+ touch-action: none;
+ -webkit-app-region: no-drag !important;
+ }
+ .dsu-resize-handle::before,
+ .dsu-resize-handle::after {
+ content: "";
+ position: absolute;
+ right: 3px;
+ bottom: 3px;
+ width: 8px;
+ height: 2px;
+ border-radius: 2px;
+ background: #64748b;
+ transform: rotate(-45deg);
+ transform-origin: right center;
+ }
+ .dsu-resize-handle::after {
+ right: 3px;
+ bottom: 7px;
+ width: 5px;
+ }
+ .dsu-resize-handle:hover::before,
+ .dsu-resize-handle:hover::after {
+ background: #cbd5e1;
+ }
+ .dsu-panel.dsu-minimized .dsu-resize-handle {
+ display: none;
+ }
+ .dsu-resize-zone {
+ position: absolute;
+ z-index: 6;
+ touch-action: none;
+ -webkit-app-region: no-drag !important;
+ }
+ .dsu-resize-zone[data-resize-direction="n"],
+ .dsu-resize-zone[data-resize-direction="s"] {
+ left: 14px;
+ right: 14px;
+ height: 8px;
+ cursor: ns-resize;
+ }
+ .dsu-resize-zone[data-resize-direction="n"] { top: 0; }
+ .dsu-resize-zone[data-resize-direction="s"] { bottom: 0; }
+ .dsu-resize-zone[data-resize-direction="e"],
+ .dsu-resize-zone[data-resize-direction="w"] {
+ top: 14px;
+ bottom: 14px;
+ width: 8px;
+ cursor: ew-resize;
+ }
+ .dsu-resize-zone[data-resize-direction="e"] { right: 0; }
+ .dsu-resize-zone[data-resize-direction="w"] { left: 0; }
+ .dsu-resize-zone[data-resize-direction="ne"],
+ .dsu-resize-zone[data-resize-direction="nw"],
+ .dsu-resize-zone[data-resize-direction="se"],
+ .dsu-resize-zone[data-resize-direction="sw"] {
+ width: 16px;
+ height: 16px;
+ }
+ .dsu-resize-zone[data-resize-direction="ne"] {
+ top: 0;
+ right: 0;
+ cursor: nesw-resize;
+ }
+ .dsu-resize-zone[data-resize-direction="nw"] {
+ top: 0;
+ left: 0;
+ cursor: nwse-resize;
+ }
+ .dsu-resize-zone[data-resize-direction="se"] {
+ right: 0;
+ bottom: 0;
+ cursor: nwse-resize;
+ }
+ .dsu-resize-zone[data-resize-direction="sw"] {
+ left: 0;
+ bottom: 0;
+ cursor: nesw-resize;
+ }
+ .dsu-panel.dsu-minimized .dsu-resize-zone {
+ display: none;
+ }
+ .dsu-title-block { display: flex; align-items: center; gap: 10px; min-width: 0; }
+ .dsu-logo {
+ width: 34px; height: 34px; border-radius: 10px;
+ display: grid; place-items: center;
+ background: linear-gradient(135deg, #2563eb, #06b6d4);
+ color: #fff; font-weight: 750; font-size: 13px;
+ }
+ .dsu-title { font-size: 15px; font-weight: 700; }
+ .dsu-subtitle { color: #94a3b8; font-size: 11px; margin-top: 2px; }
+ .dsu-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
+ .dsu-mini-stats {
+ display: none;
+ align-items: center;
+ gap: 8px;
+ white-space: nowrap;
+ font-size: 11px;
+ }
+ .dsu-mini-scope { color: #64748b; }
+ .dsu-mini-tokens {
+ color: #cbd5e1;
+ font-variant-numeric: tabular-nums;
+ }
+ .dsu-mini-cost {
+ color: #f87171;
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+ }
+ .dsu-seg { display: inline-flex; padding: 2px; border-radius: 9px; background: #0f141c; border: 1px solid #2b3544; }
+ .dsu-seg button {
+ border: 0; background: transparent; color: #94a3b8; cursor: pointer;
+ border-radius: 7px; padding: 5px 11px; font-size: 12px;
+ }
+ .dsu-seg button[data-active="true"] { background: #2563eb; color: #fff; font-weight: 650; }
+ .dsu-icon-button, .dsu-text-button {
+ border: 1px solid #2b3544; background: #0f141c; color: #cbd5e1;
+ border-radius: 8px; cursor: pointer; font-size: 12px;
+ }
+ .dsu-icon-button { width: 30px; height: 28px; padding: 0; font-size: 17px; line-height: 1; }
+ .dsu-text-button { padding: 6px 10px; }
+ .dsu-danger { color: #fca5a5; }
+ .dsu-toolbar {
+ display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
+ padding: 12px 18px; border-bottom: 1px solid #232c38;
+ }
+ .dsu-scope-label { min-width: 116px; text-align: center; font-size: 13px; font-weight: 650; }
+ .dsu-date-input, .dsu-month-input, .dsu-model-label select {
+ background: #0f141c; color: #e2e8f0; border: 1px solid #2b3544;
+ border-radius: 8px; padding: 6px 8px; font-size: 12px;
+ }
+ .dsu-model-label { display: flex; align-items: center; gap: 7px; color: #94a3b8; font-size: 12px; }
+ .dsu-cards {
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(138px, 1fr));
+ gap: 10px; padding: 16px 18px 4px;
+ }
+ .dsu-card {
+ min-width: 0; padding: 12px 13px; border-radius: 12px;
+ background: #10161f; border: 1px solid #263140;
+ }
+ .dsu-card span { display: block; color: #94a3b8; font-size: 11px; }
+ .dsu-card strong { display: block; margin-top: 7px; font-size: 19px; font-weight: 700; font-variant-numeric: tabular-nums; }
+ .dsu-card small { display: block; margin-top: 4px; color: #64748b; font-size: 10px; min-height: 12px; }
+ .dsu-blue strong { color: #38bdf8; }
+ .dsu-orange strong { color: #fb923c; }
+ .dsu-green strong { color: #4ade80; }
+ .dsu-red strong { color: #f87171; }
+ .dsu-balance-card {
+ margin: 14px 18px 0; padding: 14px; border-radius: 12px;
+ background: #10161f; border: 1px solid #263140;
+ }
+ .dsu-balance-status { margin-left: auto; color: #64748b; font-size: 10px; }
+ .dsu-balance-status[data-tone="warn"] { color: #fbbf24; }
+ .dsu-balance-status[data-tone="ok"] { color: #4ade80; }
+ .dsu-balance-toggle { margin-left: 8px; padding: 4px 9px; font-size: 11px; }
+ .dsu-balance-grid {
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(124px, 1fr));
+ gap: 10px; margin-top: 12px;
+ }
+ .dsu-balance-grid > div {
+ min-width: 0; padding: 10px 11px; border-radius: 10px;
+ background: #0d1320; border: 1px solid #1f2937;
+ }
+ .dsu-balance-grid span { display: block; color: #94a3b8; font-size: 11px; }
+ .dsu-balance-grid strong {
+ display: block; margin-top: 6px; font-size: 17px; font-weight: 700;
+ font-variant-numeric: tabular-nums; color: #e2e8f0;
+ }
+ .dsu-balance-grid small { display: block; margin-top: 4px; color: #64748b; font-size: 10px; min-height: 12px; }
+ .dsu-balance-main strong { color: #facc15; }
+ .dsu-balance-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
+ .dsu-balance-actions input {
+ width: 168px; background: #0f141c; color: #e2e8f0; border: 1px solid #2b3544;
+ border-radius: 8px; padding: 6px 8px; font-size: 12px;
+ }
+ .dsu-balance-settings {
+ display: flex; flex-direction: column;
+ gap: 14px; margin-top: 14px; padding-top: 14px; border-top: 1px solid #232c38;
+ }
+ .dsu-balance-settings[hidden] { display: none; }
+ /* Codex++ 网络桥只放行 POST 期间:面板直连相关的 UI 先藏起来,实现代码保留。 */
+ #${PANEL_ID}[data-dsu-bridge-query="off"] [data-bridge-only] {
+ display: none !important;
+ }
+ .dsu-balance-sync {
+ display: flex; align-items: baseline; gap: 8px; margin: 0;
+ padding: 7px 10px; border-radius: 8px;
+ background: #0d1320; border: 1px solid #1f2937;
+ color: #94a3b8; font-size: 11px; line-height: 1.45;
+ }
+ .dsu-balance-sync-label { flex: none; color: #475569; }
+ .dsu-balance-sync strong { font-weight: 500; color: #cbd5e1; }
+ .dsu-balance-sync strong[data-tone="ok"] { color: #4ade80; }
+ .dsu-balance-sync strong[data-tone="warn"] { color: #fbbf24; }
+ .dsu-balance-group { display: flex; flex-direction: column; gap: 10px; }
+ .dsu-balance-group-title {
+ margin: 0; color: #64748b; font-size: 10px; font-weight: 600;
+ letter-spacing: 0.08em;
+ }
+ .dsu-balance-settings input[type="text"],
+ .dsu-balance-settings input[type="password"] {
+ width: 100%; min-width: 0; background: #0f141c; color: #e2e8f0;
+ border: 1px solid #2b3544; border-radius: 8px; padding: 6px 8px; font-size: 12px;
+ }
+ .dsu-balance-settings select {
+ width: 100%; background: #0f141c; color: #e2e8f0; border: 1px solid #2b3544;
+ border-radius: 8px; padding: 6px 8px; font-size: 12px;
+ }
+ .dsu-balance-note { grid-column: 1 / -1; margin: 0; color: #64748b; font-size: 10px; line-height: 1.6; }
+ .dsu-balance-key-actions { display: flex; flex-wrap: wrap; gap: 8px; }
+ .dsu-balance-key-actions .dsu-inline-pick {
+ display: flex; align-items: center; gap: 6px;
+ color: #64748b; font-size: 11px;
+ }
+ .dsu-balance-key-actions .dsu-inline-pick select {
+ width: auto; padding: 4px 6px; font-size: 11px;
+ }
+ .dsu-balance-enable {
+ display: flex; align-items: center; gap: 5px;
+ color: #94a3b8; font-size: 11px; cursor: pointer;
+ }
+ /*
+ * 勾选框自己画:Codex 应用 26.915 起给原生 checkbox 设了
+ * appearance:none / width:0 / height:0,跟着应用走的话这颗框会整个消失,
+ * 只剩一个点不动的「启用」文字。这里连尺寸、边框、对勾一起自带。
+ */
+ .dsu-balance-enable input {
+ appearance: none;
+ -webkit-appearance: none;
+ flex: 0 0 auto;
+ width: 13px;
+ height: 13px;
+ margin: 0;
+ border: 1px solid #3f4c5e;
+ border-radius: 3px;
+ background: #131a24;
+ position: relative;
+ cursor: pointer;
+ transition: background 0.12s ease, border-color 0.12s ease;
+ }
+ .dsu-balance-enable:hover input { border-color: #52627a; }
+ .dsu-balance-enable input:checked {
+ background: #38bdf8;
+ border-color: #38bdf8;
+ }
+ .dsu-balance-enable input:checked::after {
+ content: "";
+ position: absolute;
+ left: 3.5px;
+ top: 0.5px;
+ width: 3px;
+ height: 7px;
+ border: solid #0b1220;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+ }
+ .dsu-balance-enable input:focus-visible {
+ outline: 2px solid rgba(56, 189, 248, 0.5);
+ outline-offset: 1px;
+ }
+ .dsu-balance-help { border-top: 1px dashed #232c38; padding-top: 10px; }
+ .dsu-balance-help summary { color: #64748b; font-size: 11px; cursor: pointer; }
+ .dsu-balance-help summary:hover { color: #94a3b8; }
+ .dsu-balance-help .dsu-balance-note { margin-top: 8px; }
+ .dsu-balance-footer { display: flex; justify-content: flex-end; }
+ .dsu-balance-card[data-enabled="false"] .dsu-balance-grid { opacity: 0.5; }
+ .dsu-balance-table { margin-top: 12px; padding-top: 10px; border-top: 1px solid #232c38; max-height: 236px; overflow: auto; }
+ .dsu-balance-table table { width: 100%; border-collapse: collapse; font-size: 11px; }
+ .dsu-balance-table th, .dsu-balance-table td { padding: 6px 5px; border-bottom: 1px solid #202a36; text-align: left; }
+ .dsu-balance-table th { color: #64748b; font-weight: 500; position: sticky; top: 0; background: #10161f; }
+ .dsu-balance-table td { color: #cbd5e1; }
+ .dsu-balance-span { color: #fbbf24; }
+ .dsu-chart-card {
+ position: relative;
+ margin: 14px 18px 0; padding: 14px; border-radius: 12px;
+ background: #10161f; border: 1px solid #263140;
+ }
+ .dsu-card-title { display: flex; align-items: center; gap: 12px; color: #cbd5e1; font-size: 12px; }
+ .dsu-legend { margin-left: auto; color: #64748b; font-size: 10px; }
+ .dsu-dot { display: inline-block; width: 8px; height: 8px; border-radius: 3px; margin: 0 4px 0 8px; }
+ .dsu-dot-blue { background: #38bdf8; }
+ .dsu-dot-orange { background: #fb923c; }
+ .dsu-dot-green { background: #4ade80; }
+ .dsu-dot-yellow { background: #facc15; }
+ .dsu-chart-card canvas { display: block; width: 100%; height: 250px; margin-top: 8px; }
+ .dsu-chart-tooltip {
+ position: absolute;
+ z-index: 20;
+ min-width: 154px;
+ padding: 8px 10px;
+ border-radius: 9px;
+ border: 1px solid #3b4a5d;
+ background: color-mix(in srgb, #0b111a 96%, transparent);
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.46);
+ pointer-events: none;
+ font-size: 11px;
+ line-height: 1.55;
+ }
+ .dsu-chart-tooltip[hidden] { display: none !important; }
+ .dsu-tip-title {
+ color: #e2e8f0;
+ font-size: 12px;
+ font-weight: 700;
+ margin-bottom: 4px;
+ }
+ .dsu-tip-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ color: #94a3b8;
+ }
+ .dsu-tip-row b {
+ color: #e2e8f0;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ }
+ .dsu-tip-cost { color: #f87171; }
+ .dsu-tip-balance { color: #facc15; }
+ .dsu-empty { color: #64748b; font-size: 12px; text-align: center; padding: 70px 0; }
+ .dsu-table-grid { display: grid; grid-template-columns: 1fr 1.35fr; gap: 12px; padding: 14px 18px 0; }
+ .dsu-table-card { min-width: 0; padding: 13px; border-radius: 12px; background: #10161f; border: 1px solid #263140; }
+ .dsu-table-card h3 { margin: 0 0 10px; font-size: 12px; color: #cbd5e1; }
+ .dsu-table-card table { width: 100%; border-collapse: collapse; font-size: 11px; }
+ .dsu-table-card th, .dsu-table-card td { padding: 6px 5px; border-bottom: 1px solid #202a36; text-align: left; }
+ .dsu-table-card th { color: #64748b; font-weight: 500; }
+ .dsu-table-card td { color: #cbd5e1; }
+ .dsu-num { text-align: right !important; font-variant-numeric: tabular-nums; }
+ .dsu-empty-row { color: #64748b !important; text-align: center !important; padding: 16px !important; }
+ .dsu-footer {
+ display: flex; flex-wrap: wrap; gap: 8px 18px; justify-content: space-between;
+ padding: 14px 18px 18px; color: #64748b; font-size: 10px;
+ }
+ @media (max-width: 760px) {
+ .dsu-panel { top: 84px; right: 8px; width: calc(100vw - 16px); }
+ .dsu-table-grid { grid-template-columns: 1fr; }
+ .dsu-toolbar { align-items: stretch; }
+ }
+ `;
+ (document.head || document.documentElement).appendChild(style);
+ }
+
+ function launcherMarkup(compact) {
+ if (compact) {
+ return `
+
+
+ `;
+ }
+ return `
+
+ DeepSeek 用量
+
+ `;
+ }
+
+ function ensureLauncher() {
+ const headerToolbar = document.querySelector(HEADER_TOOLBAR_SELECTOR);
+ const nav = document.getElementById(SIDEBAR_NAV_ID);
+ const desiredParent = headerToolbar || nav;
+ if (!desiredParent) return false;
+ let button = document.getElementById(SIDEBAR_BUTTON_ID);
+ if (!button) {
+ button = document.createElement("button");
+ button.id = SIDEBAR_BUTTON_ID;
+ button.type = "button";
+ button.setAttribute("aria-label", "DeepSeek 用量统计");
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ togglePanel();
+ });
+ }
+
+ const compact = Boolean(headerToolbar);
+ const parentChanged = button.parentElement !== desiredParent;
+ const shapeChanged = button.dataset.dsuCompact !== String(compact);
+ if (parentChanged || shapeChanged) {
+ button.dataset.dsuCompact = String(compact);
+ if (compact) {
+ button.className =
+ "no-drag cursor-interaction flex h-7 items-center " +
+ "justify-center gap-1 rounded-md px-1.5 text-xs";
+ button.style.cssText =
+ "border:0;background:transparent;color:inherit;cursor:pointer;";
+ button.innerHTML = launcherMarkup(true);
+ headerToolbar.insertBefore(button, headerToolbar.firstChild);
+ } else {
+ button.removeAttribute("style");
+ button.className =
+ "sidebar-item relative h-[var(--height-token-row)] " +
+ "cursor-interaction shrink-0 items-center overflow-hidden text-start " +
+ "text-sm disabled:cursor-not-allowed disabled:opacity-50 flex w-full " +
+ "gap-2 px-[var(--padding-row-cell-x,var(--padding-row-x))] py-row-y " +
+ "hover:bg-primary-ghost-hover data-[state=open]:bg-primary-ghost-hover " +
+ "focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring " +
+ "focus-visible:outline-offset-[-2px]";
+ button.innerHTML = launcherMarkup(false);
+ const codexPlusButton = Array.from(nav.querySelectorAll("button")).find(
+ (item) => (item.textContent || "").trim().startsWith("Codex++")
+ );
+ if (codexPlusButton?.nextSibling) {
+ nav.insertBefore(button, codexPlusButton.nextSibling);
+ } else {
+ nav.appendChild(button);
+ }
+ }
+ }
+ updateLauncherBadge();
+ return true;
+ }
+
+ function updateLauncherBadge() {
+ const badge = document.querySelector(
+ `#${SIDEBAR_BUTTON_ID} [data-field="sidebarBadge"]`
+ );
+ if (!badge) return;
+ const cost = state.records
+ .filter((record) => record.d === todayKey())
+ .reduce((sum, record) => sum + Number(record.cost || 0), 0);
+ badge.textContent = cost ? formatCost(cost) : "—";
+ }
+
+ function handlePanelAction(action) {
+ if (action === "mode-day") setMode("day");
+ else if (action === "mode-month") setMode("month");
+ else if (action === "previous") moveScope(-1);
+ else if (action === "next") moveScope(1);
+ else if (action === "today") setToday();
+ else if (action === "refresh") render({ animate: true });
+ else if (action === "minimize") toggleMinimized();
+ else if (action === "close") closePanel();
+ else if (action === "clear") clearRecords();
+ else if (action === "balance-fetch") requestBalanceRefresh();
+ else if (action === "balance-settings") toggleBalanceSettings();
+ else if (action === "balance-reset") resetBalances();
+ else if (action === "helper-install") copyHelperCommand("install");
+ else if (action === "helper-uninstall") copyHelperCommand("uninstall");
+ else if (action === "helper-run") runHelperFromPanel(false);
+ else if (action === "helper-uninstall-run") runHelperFromPanel(true);
+ }
+
+ function bindPanelControls(panel) {
+ panel.querySelectorAll("[data-action]").forEach((button) => {
+ button.onclick = (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ handlePanelAction(button.dataset.action);
+ };
+ button.onpointerdown = (event) => event.stopPropagation();
+ button.onmousedown = (event) => event.stopPropagation();
+ });
+ const dateInput = panel.querySelector('[data-field="dateInput"]');
+ const monthInput = panel.querySelector('[data-field="monthInput"]');
+ const modelSelect = panel.querySelector('[data-field="modelSelect"]');
+ const chart = panel.querySelector('[data-field="chart"]');
+ const helperPlatformPickBox = panel.querySelector(
+ '[data-field="helperPlatformPick"]'
+ );
+ if (helperPlatformPickBox) {
+ helperPlatformPickBox.onchange = () =>
+ setHelperPlatformPick(helperPlatformPickBox.value);
+ }
+ const balanceEnabledBox = panel.querySelector('[data-field="balanceEnabled"]');
+ if (balanceEnabledBox) {
+ balanceEnabledBox.onchange = () => toggleBalanceEnabled(balanceEnabledBox.checked);
+ }
+ if (chart) {
+ chart.onmousemove = handleChartHover;
+ chart.onmouseleave = hideChartTooltip;
+ }
+ if (dateInput) {
+ dateInput.onchange = () => {
+ state.settings.day = dateInput.value || todayKey();
+ state.settings.month = state.settings.day.slice(0, 7);
+ scheduleSave();
+ render({ animate: true });
+ };
+ }
+ if (monthInput) {
+ monthInput.onchange = () => {
+ state.settings.month =
+ monthInput.value || todayKey().slice(0, 7);
+ state.settings.day = `${state.settings.month}-01`;
+ scheduleSave();
+ render({ animate: true });
+ };
+ }
+ if (modelSelect) {
+ modelSelect.onchange = () => {
+ state.settings.model = modelSelect.value || DEFAULT_MODEL;
+ scheduleSave();
+ render();
+ };
+ }
+ }
+
+ function ensurePanel() {
+ let panel = document.getElementById(PANEL_ID);
+ let panelContentReplaced = false;
+ if (panel && panel.dataset.dsuVersion !== VERSION) {
+ /*
+ * Codex++ 热重载会把新脚本注入同一个页面,此时旧脚本的回调仍在跑。
+ * 删掉节点再新建会和旧副本来回抢同一个面板,所以这里原地换内容:
+ * 节点身份不变,位置和尺寸也就不会丢。
+ */
+ const probe = document.createElement("div");
+ probe.innerHTML = panelShell().trim();
+ const fresh = probe.firstElementChild;
+ if (fresh) {
+ /* 只换内容,可见性沿用节点当前状态,避免换版时把面板藏起来。 */
+ panel.className = fresh.className;
+ panel.innerHTML = fresh.innerHTML;
+ }
+ panel.dataset.dsuVersion = VERSION;
+ panel.dataset.dsuBridgeQuery = BRIDGE_BALANCE_QUERY_ENABLED ? "on" : "off";
+ panelContentReplaced = true;
+ }
+ if (!panel) {
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = panelShell().trim();
+ panel = wrapper.firstElementChild;
+ panel.dataset.dsuVersion = VERSION;
+ panel.dataset.dsuBridgeQuery = BRIDGE_BALANCE_QUERY_ENABLED ? "on" : "off";
+ document.body.appendChild(panel);
+ }
+ /*
+ * 桥查询开关决定哪些余额控件露出来。用行内样式而不是只靠 CSS:
+ * 热重载时旧的样式表可能还挂着,行内样式不受影响。
+ */
+ for (const element of panel.querySelectorAll("[data-bridge-only]")) {
+ element.style.display = BRIDGE_BALANCE_QUERY_ENABLED ? "" : "none";
+ }
+ state.ui = {
+ panel,
+ body: panel.querySelector('[data-field="panelBody"]'),
+ dateInput: panel.querySelector('[data-field="dateInput"]'),
+ monthInput: panel.querySelector('[data-field="monthInput"]'),
+ modelSelect: panel.querySelector('[data-field="modelSelect"]'),
+ scopeLabel: panel.querySelector('[data-field="scopeLabel"]'),
+ chart: panel.querySelector('[data-field="chart"]'),
+ modelRows: panel.querySelector('[data-field="modelRows"]'),
+ recentRows: panel.querySelector('[data-field="recentRows"]'),
+ balanceRows: panel.querySelector('[data-field="balanceRows"]'),
+ balanceSettings: panel.querySelector('[data-field="balanceSettings"]'),
+ balanceSyncHint: panel.querySelector('[data-field="balanceSyncHint"]'),
+ balanceCard: panel.querySelector(".dsu-balance-card"),
+ balanceEnabledBox: panel.querySelector('[data-field="balanceEnabled"]'),
+ balanceHelperState: panel.querySelector(
+ '[data-field="balanceHelperState"]'
+ ),
+ helperPlatformLabel: panel.querySelector(
+ '[data-field="helperPlatformLabel"]'
+ ),
+ helperPlatformPick: panel.querySelector(
+ '[data-field="helperPlatformPick"]'
+ ),
+ balanceFetchButton: panel.querySelector('[data-action="balance-fetch"]'),
+ };
+ restorePanelPosition(panel);
+ applyPanelMinimized(panel);
+ applyPanelSize(panel);
+ setupPanelDrag(panel);
+ setupPanelResize(panel);
+ if (!state.resizeObserver && typeof ResizeObserver === "function") {
+ state.resizeObserver = new ResizeObserver(() => {
+ if (state.dragState || state.resizeState) return;
+ clampPanelPosition(panel);
+ scheduleFrame(() => clampPanelPosition(panel));
+ });
+ state.resizeObserver.observe(panel);
+ }
+ /*
+ * 用「本份脚本自己的标记对象」判断有没有绑过,而不是只用版本号:热重载时
+ * 新旧两份版本号可能相同(或旧那份先绑了),只比版本号会让接管面板的那份
+ * 跳过绑定,按钮就留在已经退休的那份身上 —— 看起来就是「按钮全失效」。
+ */
+ if (panel.__deepseekUsageBindTag === instance.bindTag) return;
+ panel.__deepseekUsageBindTag = instance.bindTag;
+ bindPanelControls(panel);
+ /*
+ * 接管别人留下的面板时,旧内容已经换成新内容但还没渲染过,面板又是开着的:
+ * 立刻画一次,否则要等用户点一下才显示数字。
+ */
+ if (panelContentReplaced && !panel.hidden) render();
+ window.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") closePanel();
+ });
+ window.addEventListener("resize", () => {
+ if (!state.ui?.panel?.hidden) {
+ clampPanelPosition();
+ if (!state.settings.panelMinimized) {
+ drawChart(chartBuckets(visibleRecords()));
+ }
+ }
+ });
+ }
+
+ /*
+ * 页面上真正活着的那个面板节点,才是唯一该画的。热重载期间别的副本可能已经
+ * 换过一次内容甚至换过节点,这里的 state.ui 如果还指着老的,按钮和数字就全
+ * 停在原地 —— 表现就是「按键全失效」。每次渲染前对一下,对不上就重挂引用。
+ */
+ function livePanelNode() {
+ const live = document.getElementById(PANEL_ID);
+ if (!live) return null;
+ if (
+ !state.ui ||
+ state.ui.panel !== live ||
+ live.__deepseekUsageBindTag !== instance.bindTag
+ ) {
+ ensurePanel();
+ }
+ return document.getElementById(PANEL_ID);
+ }
+
+ const PANEL_MARGIN = 8;
+
+ function clampPanelPosition(panel = state.ui?.panel) {
+ if (!panel || panel.hidden) return;
+ const viewportWidth = Math.max(240, window.innerWidth);
+ const viewportHeight = Math.max(160, window.innerHeight);
+ const minimized = panel.classList.contains("dsu-minimized");
+ const maxWidth = Math.max(200, viewportWidth - PANEL_MARGIN * 2);
+ const maxHeight = Math.max(120, viewportHeight - PANEL_MARGIN * 2);
+ let rect = panel.getBoundingClientRect();
+
+ /*
+ * When expanding a minimized bar near the right edge, the width changes
+ * from ~280px to ~760px. Force the enlarged width to fit first, then
+ * calculate the left edge again so no part stays off screen.
+ */
+ if (!minimized && rect.width > maxWidth + 1) {
+ panel.style.width = `${Math.round(maxWidth)}px`;
+ rect = panel.getBoundingClientRect();
+ }
+ if (!minimized && rect.height > maxHeight + 1) {
+ panel.style.maxHeight = `${Math.round(maxHeight)}px`;
+ rect = panel.getBoundingClientRect();
+ }
+
+ const width = rect.width || panel.offsetWidth || 760;
+ const height = rect.height || panel.offsetHeight || 520;
+ const maxLeft = Math.max(
+ PANEL_MARGIN,
+ viewportWidth - width - PANEL_MARGIN
+ );
+ const maxTop = Math.max(
+ PANEL_MARGIN,
+ viewportHeight - height - PANEL_MARGIN
+ );
+ const requestedLeft = Number.parseFloat(panel.style.left);
+ const requestedTop = Number.parseFloat(panel.style.top);
+ const left = Math.min(
+ Math.max(
+ PANEL_MARGIN,
+ Number.isFinite(requestedLeft) ? requestedLeft : rect.left || PANEL_MARGIN
+ ),
+ maxLeft
+ );
+ const top = Math.min(
+ Math.max(
+ PANEL_MARGIN,
+ Number.isFinite(requestedTop) ? requestedTop : rect.top || PANEL_MARGIN
+ ),
+ maxTop
+ );
+ panel.style.left = `${Math.round(left)}px`;
+ panel.style.top = `${Math.round(top)}px`;
+ panel.style.right = "auto";
+ }
+
+ function schedulePanelClamp() {
+ const run = () => {
+ const panel = state.ui?.panel;
+ if (!panel || panel.hidden) return;
+ clampPanelPosition(panel);
+ if (!state.settings.panelMinimized) {
+ drawChart(chartBuckets(visibleRecords()));
+ }
+ };
+ run();
+ window.setTimeout(run, 0);
+ window.setTimeout(run, 120);
+ scheduleFrame(() => scheduleFrame(run));
+ }
+
+ function restorePanelPosition(panel) {
+ const left = Number(state.settings.panelLeft);
+ const top = Number(state.settings.panelTop);
+ if (Number.isFinite(left) && Number.isFinite(top) && left > 0 && top > 0) {
+ panel.style.left = `${Math.round(left)}px`;
+ panel.style.top = `${Math.round(top)}px`;
+ panel.style.right = "auto";
+ }
+ }
+
+ function setupPanelDrag(panel) {
+ if (panel.__deepseekDragTag === instance.bindTag) return;
+ panel.__deepseekDragTag = instance.bindTag;
+
+ /*
+ * Interactive controls stay clickable, but their pointer events must not
+ * leak into Codex's own window-drag handling.
+ */
+ panel.addEventListener(
+ "pointerdown",
+ (event) => event.stopPropagation(),
+ true
+ );
+ panel.addEventListener(
+ "mousedown",
+ (event) => event.stopPropagation(),
+ true
+ );
+
+ const isInteractive = (target) =>
+ Boolean(
+ target.closest?.(
+ "button,input,select,textarea,a[href],[data-action]"
+ )
+ );
+
+ const startDrag = (event) => {
+ if (state.dragState) return;
+ if (event.button !== undefined && event.button !== 0) return;
+ if (isInteractive(event.target)) return;
+ if (!event.target.closest?.("[data-drag-handle]")) return;
+ event.preventDefault();
+ event.stopPropagation();
+
+ const rect = panel.getBoundingClientRect();
+ const drag = {
+ startX: event.clientX,
+ startY: event.clientY,
+ baseLeft: rect.left,
+ baseTop: rect.top,
+ width: rect.width,
+ height: rect.height,
+ left: rect.left,
+ top: rect.top,
+ frame: 0,
+ pending: null,
+ };
+ state.dragState = drag;
+ panel.style.left = `${Math.round(rect.left)}px`;
+ panel.style.top = `${Math.round(rect.top)}px`;
+ panel.style.right = "auto";
+ panel.style.willChange = "transform";
+ panel.classList.add("dsu-dragging");
+
+ const applyMove = () => {
+ const current = state.dragState;
+ if (!current || !current.pending) return;
+ const point = current.pending;
+ const maxLeft = Math.max(
+ PANEL_MARGIN,
+ window.innerWidth - current.width - PANEL_MARGIN
+ );
+ const maxTop = Math.max(
+ PANEL_MARGIN,
+ window.innerHeight - current.height - PANEL_MARGIN
+ );
+ current.left = Math.min(
+ Math.max(PANEL_MARGIN, current.baseLeft + point.x - current.startX),
+ maxLeft
+ );
+ current.top = Math.min(
+ Math.max(PANEL_MARGIN, current.baseTop + point.y - current.startY),
+ maxTop
+ );
+ panel.style.transform = `translate3d(${Math.round(
+ current.left - current.baseLeft
+ )}px, ${Math.round(current.top - current.baseTop)}px, 0)`;
+ };
+
+ const move = (moveEvent) => {
+ const current = state.dragState;
+ if (!current) return;
+ current.pending = { x: moveEvent.clientX, y: moveEvent.clientY };
+ if (current.frame) return;
+ current.frame = scheduleFrame(() => {
+ if (state.dragState) state.dragState.frame = 0;
+ applyMove();
+ });
+ };
+
+ const finish = () => {
+ const current = state.dragState;
+ if (!current) return;
+ if (current.frame) cancelFrame(current.frame);
+ applyMove();
+ state.dragState = null;
+ panel.style.left = `${Math.round(current.left)}px`;
+ panel.style.top = `${Math.round(current.top)}px`;
+ panel.style.transform = "";
+ panel.style.willChange = "";
+ panel.classList.remove("dsu-dragging");
+ state.settings.panelLeft = Math.round(current.left);
+ state.settings.panelTop = Math.round(current.top);
+ scheduleSave();
+ window.removeEventListener("pointermove", move, true);
+ window.removeEventListener("pointerup", finish, true);
+ window.removeEventListener("pointercancel", finish, true);
+ window.removeEventListener("mousemove", move, true);
+ window.removeEventListener("mouseup", finish, true);
+ window.removeEventListener("blur", finish, true);
+ };
+
+ window.addEventListener("pointermove", move, true);
+ window.addEventListener("pointerup", finish, true);
+ window.addEventListener("pointercancel", finish, true);
+ window.addEventListener("mousemove", move, true);
+ window.addEventListener("mouseup", finish, true);
+ window.addEventListener("blur", finish, true);
+ };
+
+ panel.addEventListener("pointerdown", startDrag, true);
+ panel.addEventListener("mousedown", startDrag, true);
+ }
+
+ function applyPanelSize(panel = state.ui?.panel) {
+ if (!panel || panel.classList.contains("dsu-minimized")) return;
+ const width = Number(state.settings.panelWidth);
+ const height = Number(state.settings.panelHeight);
+ if (Number.isFinite(width) && width > 0) {
+ panel.style.width = `${Math.round(width)}px`;
+ }
+ if (Number.isFinite(height) && height > 0) {
+ panel.style.height = `${Math.round(height)}px`;
+ panel.style.maxHeight = "none";
+ }
+ }
+
+ function setupPanelResize(panel) {
+ if (panel.__deepseekResizeTag === instance.bindTag) return;
+ panel.__deepseekResizeTag = instance.bindTag;
+ for (const direction of ["n", "s", "e", "w", "ne", "nw", "sw"]) {
+ if (panel.querySelector(`[data-resize-direction="${direction}"]`)) {
+ continue;
+ }
+ const zone = document.createElement("div");
+ zone.className = "dsu-resize-zone";
+ zone.dataset.resizeDirection = direction;
+ zone.title = "拖动调整面板大小";
+ panel.appendChild(zone);
+ }
+ const handle = panel.querySelector('[data-resize-direction="se"]');
+ if (!handle) return;
+
+ const startResize = (event) => {
+ if (state.resizeState) return;
+ const direction = event.target.closest?.("[data-resize-direction]")
+ ?.dataset?.resizeDirection;
+ if (!direction) return;
+ if (event.button !== undefined && event.button !== 0) return;
+ if (panel.classList.contains("dsu-minimized")) return;
+ event.preventDefault();
+ event.stopPropagation();
+ const rect = panel.getBoundingClientRect();
+ const resize = {
+ direction,
+ startX: event.clientX,
+ startY: event.clientY,
+ baseLeft: rect.left,
+ baseTop: rect.top,
+ baseWidth: rect.width,
+ baseHeight: rect.height,
+ left: rect.left,
+ top: rect.top,
+ width: rect.width,
+ height: rect.height,
+ frame: 0,
+ pending: null,
+ };
+ state.resizeState = resize;
+ panel.classList.add("dsu-resizing");
+
+ const applyResize = () => {
+ const current = state.resizeState;
+ if (!current || !current.pending) return;
+ const direction = current.direction;
+ const margin = PANEL_MARGIN;
+ const deltaX = current.pending.x - current.startX;
+ const deltaY = current.pending.y - current.startY;
+ const maxWidth = Math.max(380, window.innerWidth - margin * 2);
+ const maxHeight = Math.max(180, window.innerHeight - margin * 2);
+ let width = current.baseWidth;
+ let height = current.baseHeight;
+ let left = current.baseLeft;
+ let top = current.baseTop;
+
+ if (direction.includes("e")) width = current.baseWidth + deltaX;
+ if (direction.includes("w")) width = current.baseWidth - deltaX;
+ if (direction.includes("s")) height = current.baseHeight + deltaY;
+ if (direction.includes("n")) height = current.baseHeight - deltaY;
+ width = Math.min(Math.max(380, width), maxWidth);
+ height = Math.min(Math.max(180, height), maxHeight);
+
+ if (direction.includes("w")) {
+ left = current.baseLeft + (current.baseWidth - width);
+ }
+ if (direction.includes("n")) {
+ top = current.baseTop + (current.baseHeight - height);
+ }
+ if (left < margin) {
+ if (direction.includes("w")) {
+ width = Math.min(width, current.baseLeft + current.baseWidth - margin);
+ }
+ left = margin;
+ }
+ if (top < margin) {
+ if (direction.includes("n")) {
+ height = Math.min(height, current.baseTop + current.baseHeight - margin);
+ }
+ top = margin;
+ }
+ if (left + width > window.innerWidth - margin) {
+ if (direction.includes("e")) {
+ width = window.innerWidth - margin - left;
+ } else {
+ left = window.innerWidth - margin - width;
+ }
+ }
+ if (top + height > window.innerHeight - margin) {
+ if (direction.includes("s")) {
+ height = window.innerHeight - margin - top;
+ } else {
+ top = window.innerHeight - margin - height;
+ }
+ }
+ width = Math.max(380, Math.min(width, maxWidth));
+ height = Math.max(180, Math.min(height, maxHeight));
+ current.width = width;
+ current.height = height;
+ current.left = left;
+ current.top = top;
+ panel.style.left = `${Math.round(left)}px`;
+ panel.style.top = `${Math.round(top)}px`;
+ panel.style.right = "auto";
+ panel.style.width = `${Math.round(current.width)}px`;
+ panel.style.height = `${Math.round(current.height)}px`;
+ panel.style.maxHeight = "none";
+ };
+
+ const move = (moveEvent) => {
+ const current = state.resizeState;
+ if (!current) return;
+ current.pending = { x: moveEvent.clientX, y: moveEvent.clientY };
+ if (current.frame) return;
+ current.frame = scheduleFrame(() => {
+ if (state.resizeState) state.resizeState.frame = 0;
+ applyResize();
+ });
+ };
+
+ const finish = () => {
+ const current = state.resizeState;
+ if (!current) return;
+ if (current.frame) cancelFrame(current.frame);
+ applyResize();
+ state.resizeState = null;
+ panel.classList.remove("dsu-resizing");
+ state.settings.panelWidth = Math.round(current.width);
+ state.settings.panelHeight = Math.round(current.height);
+ state.settings.panelLeft = Math.round(current.left);
+ state.settings.panelTop = Math.round(current.top);
+ scheduleSave();
+ schedulePanelClamp();
+ scheduleRender();
+ window.removeEventListener("pointermove", move, true);
+ window.removeEventListener("pointerup", finish, true);
+ window.removeEventListener("pointercancel", finish, true);
+ window.removeEventListener("mousemove", move, true);
+ window.removeEventListener("mouseup", finish, true);
+ window.removeEventListener("blur", finish, true);
+ };
+
+ window.addEventListener("pointermove", move, true);
+ window.addEventListener("pointerup", finish, true);
+ window.addEventListener("pointercancel", finish, true);
+ window.addEventListener("mousemove", move, true);
+ window.addEventListener("mouseup", finish, true);
+ window.addEventListener("blur", finish, true);
+ };
+
+ panel.addEventListener("pointerdown", startResize, true);
+ panel.addEventListener("mousedown", startResize, true);
+ handle.addEventListener("dblclick", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ state.settings.panelWidth = null;
+ state.settings.panelHeight = null;
+ panel.style.width = "";
+ panel.style.height = "";
+ panel.style.maxHeight = "";
+ scheduleSave();
+ schedulePanelClamp();
+ scheduleRender();
+ });
+ }
+
+ function applyPanelMinimized(panel = state.ui?.panel) {
+ if (!panel) return;
+ panel.classList.toggle(
+ "dsu-minimized",
+ Boolean(state.settings.panelMinimized)
+ );
+ const body = panel.querySelector('[data-field="panelBody"]');
+ if (body) body.hidden = Boolean(state.settings.panelMinimized);
+ const button = panel.querySelector('[data-action="minimize"]');
+ if (button) {
+ button.textContent = state.settings.panelMinimized ? "+" : "−";
+ button.title = state.settings.panelMinimized ? "展开" : "收起";
+ }
+ /* Force layout before fitting, so expanding from a narrow bar repositions. */
+ panel.getBoundingClientRect();
+ clampPanelPosition(panel);
+ }
+
+ function toggleMinimized() {
+ state.settings.panelMinimized = !state.settings.panelMinimized;
+ applyPanelMinimized();
+ applyPanelSize();
+ scheduleSave();
+ clampPanelPosition();
+ schedulePanelClamp();
+ }
+
+ function openPanel() {
+ ensurePanel();
+ if (!state.ui?.panel) return;
+ /*
+ * 每次 Codex 启动(= 这份脚本被注入一次)后的第一次打开,先给完整窗口,
+ * 不要沿用上次收起来的 mini 条;之后用户自己收起来的话,再打开就听他的。
+ */
+ if (!openedThisRun) {
+ openedThisRun = true;
+ state.settings.panelMinimized = false;
+ applyPanelMinimized();
+ applyPanelSize();
+ if (state.settings.hasOpened !== true) state.settings.hasOpened = true;
+ scheduleSave();
+ }
+ state.ui.panel.hidden = false;
+ const button = document.getElementById(SIDEBAR_BUTTON_ID);
+ button?.setAttribute("data-active", "true");
+ render({ animate: true });
+ clampPanelPosition();
+ schedulePanelClamp();
+ refreshBalanceOnOpen();
+ }
+
+ function closePanel() {
+ if (state.ui?.panel) state.ui.panel.hidden = true;
+ const button = document.getElementById(SIDEBAR_BUTTON_ID);
+ button?.setAttribute("data-active", "false");
+ }
+
+ function togglePanel() {
+ if (state.ui?.panel?.hidden === false) closePanel();
+ else openPanel();
+ }
+
+ function setMode(mode) {
+ state.settings.mode = mode === "month" ? "month" : "day";
+ scheduleSave();
+ render({ animate: true });
+ }
+
+ function moveScope(direction) {
+ if (state.settings.mode === "month") {
+ const [year, month] = state.settings.month.split("-").map(Number);
+ const date = new Date(Date.UTC(year, month - 1 + direction, 1));
+ state.settings.month = `${date.getUTCFullYear()}-${String(
+ date.getUTCMonth() + 1
+ ).padStart(2, "0")}`;
+ state.settings.day = `${state.settings.month}-01`;
+ } else {
+ const date = new Date(`${state.settings.day}T12:00:00+08:00`);
+ date.setUTCDate(date.getUTCDate() + direction);
+ state.settings.day = [
+ date.getUTCFullYear(),
+ String(date.getUTCMonth() + 1).padStart(2, "0"),
+ String(date.getUTCDate()).padStart(2, "0"),
+ ].join("-");
+ }
+ scheduleSave();
+ render({ animate: true });
+ }
+
+ function setToday() {
+ state.settings.day = todayKey();
+ state.settings.month = state.settings.day.slice(0, 7);
+ scheduleSave();
+ render({ animate: true });
+ }
+
+ function clearRecords() {
+ if (!window.confirm("确定清空 Codex++ 面板中的 DeepSeek 用量统计吗?")) {
+ return;
+ }
+ state.records = [];
+ state.turnTotals = Object.create(null);
+ state.seen.clear();
+ state.keys.clear();
+ state.fingerprints.clear();
+ scheduleSave();
+ updateLauncherBadge();
+ render({ animate: true });
+ }
+
+ function toggleBalanceSettings() {
+ state.settings.balanceSettingsOpen = !state.settings.balanceSettingsOpen;
+ scheduleSave();
+ render();
+ }
+
+ /*
+ * 用户填的 Key:默认只留在页面内存,面板拿着它自己查余额;勾了「记住」
+ * 才写本机 localStorage(脚本本身永远不含任何 Key)。同时登记一个请求,
+ * 本机如果装了助手,它会顺手把 Key 用 DPAPI 再存一份。
+ */
+ function rememberBalanceKey(value, { announce = true } = {}) {
+ const key = String(value ?? "").trim();
+ if (!looksLikeKey(key)) {
+ if (announce) setBalanceStatus("Key 看着不对(应以 sk- 开头)", "warn");
+ return false;
+ }
+ panelBalanceKey = key;
+ pendingBalanceKey = key;
+ state.settings.balanceKeyRequestAt = Date.now();
+ state.settings.balanceRequestAt = state.settings.balanceKeyRequestAt;
+ state.settings.balanceSource = "key";
+ state.settings.balanceKeyMode = "manual";
+ state.settings.balanceKeyPresent = true;
+ writeBalanceStoredKey(state.settings.balanceKeyRemember ? key : "");
+ scheduleSave();
+ render();
+ if (announce) {
+ const scope = state.settings.balanceKeyRemember
+ ? `已记在本机(${maskKeyTail(key)})`
+ : `只在本次运行有效(${maskKeyTail(key)})`;
+ setBalanceStatus(
+ BRIDGE_BALANCE_QUERY_ENABLED
+ ? `Key ${scope},正在查询余额…`
+ : `Key ${scope};自动查询等 Codex++ 放开 GET 后就会用它`,
+ "ok"
+ );
+ }
+ queryBalanceNow({ silent: true });
+ return true;
+ }
+
+
+ function clearSavedBalanceKey() {
+ panelBalanceKey = "";
+ balanceConfigKey = "";
+ pendingBalanceKey = "";
+ writeBalanceStoredKey("");
+ state.settings.balanceKeyClearAt = Date.now();
+ state.settings.balanceKeySaved = false;
+ state.settings.balanceKeyPresent = null;
+ state.settings.balanceKeyFromConfig = false;
+ state.settings.balanceQueryOk = null;
+ state.settings.balanceQueryAt = 0;
+ state.settings.balanceQueryNote = "";
+ scheduleSave();
+ render();
+ setBalanceStatus("Key 已清除(已有的余额记录不受影响)", "ok");
+ }
+
+ function setBalanceKeyMode(value) {
+ const mode = value === "manual" ? "manual" : "auto";
+ state.settings.balanceKeyMode = mode;
+ scheduleSave();
+ render();
+ if (mode === "auto") {
+ setBalanceStatus("已切到自动:先读 Codex 配置里的 Key", "ok");
+ useCodexConfigKey();
+ } else {
+ setBalanceStatus("已切到手动:用下面填的 Key", "ok");
+ }
+ }
+
+ function setBalanceKeyRemember(remember) {
+ state.settings.balanceKeyRemember = remember === true;
+ const key = panelBalanceKey || balanceKeyInfo().key;
+ writeBalanceStoredKey(state.settings.balanceKeyRemember ? key : "");
+ scheduleSave();
+ render();
+ setBalanceStatus(
+ state.settings.balanceKeyRemember
+ ? "Key 会记在这台机器上(存在面板自己的本机存储里)"
+ : "Key 不会写入本机存储,重开 Codex 需要重新填",
+ "ok"
+ );
+ }
+
+ function setBalanceSource(value) {
+ state.settings.balanceSource =
+ value === "proxy" || value === "key" ? value : "auto";
+ state.settings.balanceRequestAt = Date.now();
+ scheduleSave();
+ render();
+ setBalanceStatus(`余额来源已切到「${balanceReadSourceLabelForChoice()}」`, "ok");
+ }
+
+ function balanceReadSourceLabelForChoice() {
+ const choice = balanceSourceChoice();
+ if (choice === "proxy") return "本机代理";
+ if (choice === "key") return "我填的 API Key";
+ return "自动";
+ }
+
+ function toggleBalanceEnabled(enabled) {
+ state.settings.balanceEnabled = enabled !== false;
+ scheduleSave();
+ render();
+ setBalanceStatus(
+ state.settings.balanceEnabled ? "余额统计已启用" : "余额统计已关闭",
+ "ok"
+ );
+ }
+
+ function resetBalances() {
+ if (!window.confirm("确定清除全部余额记录吗?用量统计不受影响。")) return;
+ state.balances = [];
+ balanceCache = null;
+ scheduleSave();
+ render();
+ setBalanceStatus("余额记录已清除", "ok");
+ }
+
+ function visibleRecords() {
+ if (state.settings.mode === "month") {
+ const month = state.settings.month;
+ return state.records.filter(
+ (record) => String(record.d || "").startsWith(month)
+ );
+ }
+ const day = state.settings.day;
+ return state.records.filter((record) => record.d === day);
+ }
+
+ function aggregateRecords(records) {
+ const totals = {
+ calls: records.length,
+ input: 0,
+ cached: 0,
+ miss: 0,
+ output: 0,
+ reasoning: 0,
+ total: 0,
+ cost: 0,
+ };
+ const models = new Map();
+ for (const record of records) {
+ const input = count(record.i);
+ const cached = count(record.c);
+ const miss = Math.max(0, input - cached);
+ const output = count(record.o);
+ totals.input += input;
+ totals.cached += cached;
+ totals.miss += miss;
+ totals.output += output;
+ totals.reasoning += count(record.r);
+ totals.total += count(record.n) || input + output;
+ totals.cost += Number(record.cost || 0);
+ const model = record.m || state.settings.model;
+ const item = models.get(model) || {
+ model,
+ calls: 0,
+ input: 0,
+ output: 0,
+ total: 0,
+ cost: 0,
+ };
+ item.calls += 1;
+ item.input += input;
+ item.output += output;
+ item.total += count(record.n) || input + output;
+ item.cost += Number(record.cost || 0);
+ models.set(model, item);
+ }
+ return {
+ totals,
+ models: Array.from(models.values()).sort(
+ (a, b) => b.cost - a.cost || b.calls - a.calls
+ ),
+ };
+ }
+
+ function chartBuckets(records) {
+ const buckets = new Map();
+ if (state.settings.mode === "month") {
+ const [year, month] = state.settings.month.split("-").map(Number);
+ const days = new Date(Date.UTC(year, month, 0)).getUTCDate();
+ for (let index = 1; index <= days; index += 1) {
+ const key = `${year}-${String(month).padStart(2, "0")}-${String(index).padStart(2, "0")}`;
+ buckets.set(key, {
+ label: String(index),
+ calls: 0,
+ hit: 0,
+ miss: 0,
+ output: 0,
+ cost: 0,
+ });
+ }
+ for (const record of records) {
+ const item = buckets.get(record.d);
+ if (!item) continue;
+ item.hit += count(record.c);
+ item.calls += 1;
+ item.miss += Math.max(0, count(record.i) - count(record.c));
+ item.output += count(record.o);
+ item.cost += Number(record.cost || 0);
+ }
+ } else {
+ for (let hour = 0; hour < 24; hour += 1) {
+ const key = String(hour).padStart(2, "0");
+ buckets.set(key, {
+ label: key,
+ calls: 0,
+ hit: 0,
+ miss: 0,
+ output: 0,
+ cost: 0,
+ });
+ }
+ for (const record of records) {
+ const key = hourKey(record.t);
+ const item = buckets.get(key);
+ if (!item) continue;
+ item.hit += count(record.c);
+ item.calls += 1;
+ item.miss += Math.max(0, count(record.i) - count(record.c));
+ item.output += count(record.o);
+ item.cost += Number(record.cost || 0);
+ }
+ }
+ return Array.from(buckets.values());
+ }
+
+ function setText(field, value) {
+ const element = state.ui?.panel?.querySelector(`[data-field="${field}"]`);
+ if (element) element.textContent = value;
+ }
+
+ function scheduleRender() {
+ if (state.ui?.panel && !state.ui.panel.hidden) {
+ render();
+ }
+ }
+
+ function hideChartTooltip() {
+ const tooltip = state.ui?.panel?.querySelector(
+ '[data-field="chartTooltip"]'
+ );
+ if (tooltip) tooltip.hidden = true;
+ }
+
+ function chartNodeLabel(item) {
+ return state.settings.mode === "month"
+ ? `${state.settings.month}-${String(item.label).padStart(2, "0")}`
+ : `${item.label}:00`;
+ }
+
+ function handleChartHover(event) {
+ const canvas = state.ui?.chart;
+ const tooltip = state.ui?.panel?.querySelector(
+ '[data-field="chartTooltip"]'
+ );
+ if (!canvas || !tooltip) return;
+ const canvasRect = canvas.getBoundingClientRect();
+ if (!canvasRect.width) return;
+ const x = event.clientX - canvasRect.left;
+ const hit = state.chartHitboxes.find(
+ (item) => x >= item.x && x <= item.x + item.width
+ );
+ if (!hit) {
+ hideChartTooltip();
+ return;
+ }
+ const data = hit.item;
+ const total = count(data.hit) + count(data.miss) + count(data.output);
+ const month = state.settings.month;
+ const balanceItem =
+ state.settings.mode === "month"
+ ? balanceDayItem(`${month}-${String(data.label).padStart(2, "0")}`)
+ : null;
+ if (!count(data.calls) && !total && !balanceItem) {
+ hideChartTooltip();
+ return;
+ }
+ tooltip.innerHTML =
+ `
${escapeHtml(chartNodeLabel(data))}
` +
+ (count(data.calls) || total
+ ? `请求${count(data.calls)} 次
` +
+ `缓存命中${formatTokens(data.hit)}
` +
+ `缓存未命中${formatTokens(data.miss)}
` +
+ `输出${formatTokens(data.output)}
` +
+ `总 tokens${formatTokens(total)}
` +
+ `费用${formatCost(data.cost)}
`
+ : '调用无
') +
+ (balanceItem
+ ? `收盘余额${balanceItem.closeEstimated ? "≈" : ""}${formatBalance(balanceItem.close, balanceItem.currency)}
` +
+ (balanceItem.spend === null
+ ? ""
+ : `余额消耗${formatBalanceSpend(balanceItem.spend, balanceItem.currency)}
`)
+ : "");
+ tooltip.hidden = false;
+
+ const card = canvas.closest(".dsu-chart-card");
+ const cardRect = card.getBoundingClientRect();
+ let left =
+ canvasRect.left - cardRect.left + (event.clientX - canvasRect.left) + 14;
+ let top =
+ canvasRect.top - cardRect.top + (event.clientY - canvasRect.top) - 12;
+ const tipRect = tooltip.getBoundingClientRect();
+ left = Math.min(
+ Math.max(8, left),
+ Math.max(8, cardRect.width - tipRect.width - 8)
+ );
+ top = Math.min(
+ Math.max(8, top),
+ Math.max(8, cardRect.height - tipRect.height - 8)
+ );
+ tooltip.style.left = `${Math.round(left)}px`;
+ tooltip.style.top = `${Math.round(top)}px`;
+ }
+
+ function render({ animate = false } = {}) {
+ if (!ownsPanel()) {
+ retirePanel();
+ return;
+ }
+ if (state.renderTimer) return;
+ state.renderTimer = scheduleFrame(() => {
+ state.renderTimer = 0;
+ if (!ownsPanel()) {
+ retirePanel();
+ return;
+ }
+ const live = livePanelNode();
+ if (!live || !state.ui?.panel || state.ui.panel.hidden || live.hidden) {
+ return;
+ }
+ const records = visibleRecords();
+ const { totals, models } = aggregateRecords(records);
+ const input = totals.input || 0;
+ const hitRate = input ? (totals.cached / input) * 100 : 0;
+ const selectedModel =
+ state.activeModel ||
+ state.settings.model ||
+ DEFAULT_MODEL;
+
+ state.ui.dateInput.value =
+ state.settings.mode === "day" ? state.settings.day : state.settings.day;
+ state.ui.monthInput.value = state.settings.month;
+ state.ui.dateInput.hidden = state.settings.mode !== "day";
+ state.ui.monthInput.hidden = state.settings.mode !== "month";
+ state.ui.modelSelect.value = state.settings.model || DEFAULT_MODEL;
+
+ const scopeLabel =
+ state.settings.mode === "month"
+ ? state.settings.month
+ : state.settings.day;
+ setText("scopeLabel", scopeLabel);
+ setText("calls", String(totals.calls));
+ setText("scopeHint", state.settings.mode === "month" ? "按月汇总" : "按天汇总");
+ setText("hit", formatTokens(totals.cached));
+ setText("hitRate", input ? `命中率 ${hitRate.toFixed(1)}%` : "无输入");
+ setText("miss", formatTokens(totals.miss));
+ setText("output", formatTokens(totals.output));
+ setText(
+ "reasoning",
+ totals.reasoning ? `其中思考 ${formatTokens(totals.reasoning)}` : ""
+ );
+ setText("cost", formatCost(totals.cost));
+ setText("rate", modelLabel(selectedModel));
+ setText(
+ "chartTitle",
+ state.settings.mode === "month" ? "按天用量" : "按小时用量"
+ );
+ setText("footerRate", rateSummary(selectedModel));
+ setText(
+ "miniScope",
+ state.settings.mode === "day" && scopeLabel === todayKey()
+ ? "今日"
+ : scopeLabel
+ );
+ setText(
+ "miniTokens",
+ `${formatTokens(totals.total || totals.input + totals.output)} tokens`
+ );
+ setText("miniCost", formatCost(totals.cost));
+
+ const currency = state.settings.balanceCurrency || "CNY";
+ const latest = latestBalance();
+ const balanceDayItems = balanceDayList();
+ const todayItem =
+ balanceDayItems.find((item) => item.day === todayKey()) || null;
+ const yesterdayItem =
+ balanceDayItems.find(
+ (item) => item.day === todayKey(Date.now() - 86400000)
+ ) || null;
+ const monthInfo = balanceMonthSpend(
+ state.settings.month,
+ balanceDayItems
+ );
+
+ setText("balanceNow", latest ? formatBalance(latest.v, latest.c) : "—");
+ setText(
+ "balanceNowHint",
+ latest
+ ? `${formatDateTime(latest.t)} · ${balanceSourceLabel(latest.s)}`
+ : !balanceEnabled()
+ ? "余额功能已关闭(勾选上面的「启用」即可打开)"
+ : balanceSyncText()
+ );
+ setText(
+ "balanceToday",
+ todayItem && todayItem.spend !== null
+ ? formatBalanceSpend(todayItem.spend, todayItem.currency)
+ : "—"
+ );
+ setText(
+ "balanceTodayHint",
+ todayItem
+ ? todayItem.spend !== null
+ ? `快照 ${todayItem.count} 次 · 当天累加`
+ : todayItem.count > 0
+ ? "等下一次快照就能算"
+ : "今日暂无快照"
+ : "今日暂无快照"
+ );
+ setText(
+ "balanceYesterday",
+ yesterdayItem && yesterdayItem.spend !== null
+ ? formatBalanceSpend(yesterdayItem.spend, yesterdayItem.currency)
+ : "—"
+ );
+ setText(
+ "balanceYesterdayHint",
+ yesterdayItem
+ ? `收盘 ${formatBalance(yesterdayItem.close, yesterdayItem.currency)}`
+ : "昨日无快照"
+ );
+ setText(
+ "balanceMonth",
+ monthInfo ? formatBalanceSpend(monthInfo.spend, monthInfo.currency) : "—"
+ );
+ setText(
+ "balanceMonthHint",
+ monthInfo
+ ? `自 ${monthInfo.from} 起 · ${monthInfo.days} 天`
+ : "本月暂无余额"
+ );
+
+ if (state.ui.balanceSyncHint) {
+ const hint = balanceSyncText();
+ state.ui.balanceSyncHint.textContent = hint;
+ const healthy =
+ state.settings.balanceQueryOk === true ||
+ (state.settings.balanceQueryOk === null && balanceHelperAlive());
+ state.ui.balanceSyncHint.dataset.tone = healthy ? "ok" : "warn";
+ }
+ if (state.ui.balanceSettings) {
+ state.ui.balanceSettings.hidden = !state.settings.balanceSettingsOpen;
+ }
+ if (state.ui.balanceEnabledBox) {
+ state.ui.balanceEnabledBox.checked = balanceEnabled();
+ }
+ if (state.ui.balanceHelperState) {
+ const alive = balanceHelperAlive();
+ state.ui.balanceHelperState.textContent = alive
+ ? `运行中 · ${formatAgo(balanceSyncAge())}同步`
+ : "未检测到(自动更新余额要靠它)";
+ state.ui.balanceHelperState.dataset.tone = alive ? "ok" : "warn";
+ }
+ if (state.ui.helperPlatformLabel) {
+ state.ui.helperPlatformLabel.textContent = helperPlatformLabel();
+ }
+ if (state.ui.helperPlatformPick) {
+ const picked = helperPlatformPick();
+ if (state.ui.helperPlatformPick.value !== picked) {
+ state.ui.helperPlatformPick.value = picked;
+ }
+ }
+ /*
+ * 「刷新余额」在桥不可用时本来是藏着的;装了本机助手之后它有意义了
+ * (请助手立刻重读一次),所以只要助手在跑就重新露出来。
+ */
+ if (state.ui.balanceFetchButton) {
+ state.ui.balanceFetchButton.style.display =
+ BRIDGE_BALANCE_QUERY_ENABLED || balanceHelperAlive() ? "" : "none";
+ }
+ if (state.ui.balanceCard) {
+ state.ui.balanceCard.dataset.enabled = balanceEnabled()
+ ? "true"
+ : "false";
+ }
+ paintBalanceStatus();
+
+ const dayCost = new Map();
+ for (const record of state.records) {
+ dayCost.set(
+ record.d,
+ (dayCost.get(record.d) || 0) + Number(record.cost || 0)
+ );
+ }
+ const balanceDays = balanceDayItems.slice(-BALANCE_BAR_DAYS).reverse();
+ if (state.ui.balanceRows) {
+ state.ui.balanceRows.innerHTML = balanceDays.length
+ ? balanceDays
+ .map((item) => {
+ const estimated = dayCost.get(item.day) || 0;
+ const spend =
+ item.spend === null
+ ? '单次快照'
+ : formatBalanceSpend(item.spend, item.currency) +
+ (item.gapMs > 0
+ ? ' ≈'
+ : "");
+ const close = item.closeEstimated
+ ? `≈${formatBalance(item.close, item.currency)}`
+ : formatBalance(item.close, item.currency);
+ return (
+ `| ${escapeHtml(item.day.slice(5))} | ` +
+ `${close} | ` +
+ `${spend} | ` +
+ `${estimated ? formatCost(estimated) : "—"} |
`
+ );
+ })
+ .join("")
+ : '| 还没有余额记录 |
';
+ }
+
+ state.ui.panel
+ .querySelectorAll('[data-action^="mode-"]')
+ .forEach((button) => {
+ button.dataset.active = String(
+ button.dataset.action === `mode-${state.settings.mode}`
+ );
+ });
+
+ if (!models.length) {
+ state.ui.modelRows.innerHTML =
+ '| 暂无数据 |
';
+ } else {
+ state.ui.modelRows.innerHTML = models
+ .map(
+ (item) =>
+ `| ${escapeHtml(modelLabel(item.model))} | ` +
+ `${item.calls} | ` +
+ `${formatTokens(item.total)} | ` +
+ `${formatCost(item.cost)} |
`
+ )
+ .join("");
+ }
+
+ const recent = records.slice(-20).reverse();
+ if (!recent.length) {
+ state.ui.recentRows.innerHTML =
+ '| 暂无数据 |
';
+ } else {
+ state.ui.recentRows.innerHTML = recent
+ .map(
+ (record) =>
+ `| ${escapeHtml(formatDateTime(record.t))} | ` +
+ `${escapeHtml(modelLabel(record.m))} | ` +
+ `${formatTokens(record.i)} | ` +
+ `${formatTokens(record.o)} | ` +
+ `${formatCost(record.cost)} |
`
+ )
+ .join("");
+ }
+
+ drawChart(chartBuckets(records), animate);
+ });
+ }
+
+ /*
+ * 图表细节辅助:圆角柱、平滑曲线、圆角数值小标——只负责画,不碰数据。
+ */
+ function chartRoundRect(context, x, y, width, height, radius, bottomRadius) {
+ const top = Math.max(0, Math.min(radius, width / 2, height));
+ const bottom = Math.max(
+ 0,
+ Math.min(
+ bottomRadius === undefined ? radius : bottomRadius,
+ width / 2,
+ height - top
+ )
+ );
+ context.beginPath();
+ context.moveTo(x, y + height - bottom);
+ context.arcTo(x, y + height, x + bottom, y + height, bottom);
+ context.lineTo(x + width - bottom, y + height);
+ context.arcTo(x + width, y + height, x + width, y + height - bottom, bottom);
+ context.lineTo(x + width, y + top);
+ context.arcTo(x + width, y, x + width - top, y, top);
+ context.lineTo(x + top, y);
+ context.arcTo(x, y, x, y + top, top);
+ context.closePath();
+ }
+
+ function chartSmoothPath(context, points) {
+ context.moveTo(points[0].x, points[0].y);
+ for (let index = 0; index < points.length - 1; index += 1) {
+ const current = points[index];
+ const next = points[index + 1];
+ const middle = (current.x + next.x) / 2;
+ context.bezierCurveTo(middle, current.y, middle, next.y, next.x, next.y);
+ }
+ }
+
+ function chartPill(context, x, y, text, background, color, align, limit) {
+ context.font = "bold 10px Segoe UI, sans-serif";
+ const pillWidth = context.measureText(text).width + 14;
+ let left =
+ align === "right"
+ ? x - pillWidth
+ : align === "center"
+ ? x - pillWidth / 2
+ : x;
+ if (Number.isFinite(limit)) left = Math.min(left, limit - pillWidth);
+ left = Math.max(4, left);
+ chartRoundRect(context, left, y, pillWidth, 15, 7);
+ context.fillStyle = background;
+ context.fill();
+ context.fillStyle = color;
+ context.textAlign = "center";
+ context.textBaseline = "middle";
+ context.fillText(text, left + pillWidth / 2, y + 7.6);
+ }
+
+ function drawChart(buckets, animate = false) {
+ const canvas = state.ui?.chart;
+ if (!canvas) return;
+ hideChartTooltip();
+ state.chartHitboxes = [];
+ const empty = state.ui.panel.querySelector('[data-field="chartEmpty"]');
+ const hasData = buckets.some(
+ (item) => item.hit + item.miss + item.output > 0
+ );
+ if (empty) empty.hidden = hasData;
+ canvas.hidden = !hasData;
+ if (!hasData) return;
+
+ const rect = canvas.getBoundingClientRect();
+ const width = Math.max(320, rect.width || canvas.clientWidth || 720);
+ const height = 250;
+ const ratio = window.devicePixelRatio || 1;
+ canvas.width = Math.round(width * ratio);
+ canvas.height = Math.round(height * ratio);
+ const context = canvas.getContext("2d");
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
+ context.clearRect(0, 0, width, height);
+
+ const padding = { top: 18, right: 58, bottom: 32, left: 54 };
+ const chartWidth = width - padding.left - padding.right;
+ const chartHeight = height - padding.top - padding.bottom;
+ const maxTokens = Math.max(
+ 1,
+ ...buckets.map((item) => item.hit + item.miss + item.output)
+ );
+ const maxCost = Math.max(0.000001, ...buckets.map((item) => item.cost));
+ const step = chartWidth / buckets.length;
+ const barWidth = Math.max(5, Math.min(26, step * 0.68));
+ const colors = {
+ hit: "#38bdf8",
+ miss: "#fb923c",
+ output: "#4ade80",
+ cost: "#facc15",
+ grid: "#263140",
+ text: "#64748b",
+ };
+
+ context.font = "10px Segoe UI, sans-serif";
+ context.textAlign = "right";
+ context.textBaseline = "middle";
+ for (let index = 0; index <= 4; index += 1) {
+ const y = padding.top + chartHeight - (chartHeight * index) / 4;
+ context.strokeStyle = colors.grid;
+ context.lineWidth = 1;
+ context.beginPath();
+ context.moveTo(padding.left, y);
+ context.lineTo(padding.left + chartWidth, y);
+ context.stroke();
+ context.fillStyle = colors.text;
+ context.fillText(
+ formatTokens((maxTokens * index) / 4),
+ padding.left - 8,
+ y
+ );
+ }
+
+ const labelEvery = buckets.length > 20 ? 3 : 1;
+ buckets.forEach((item, index) => {
+ const x = padding.left + index * step + step / 2;
+ state.chartHitboxes.push({
+ x: padding.left + index * step,
+ width: step,
+ item,
+ });
+ const values = [
+ [item.hit, colors.hit],
+ [item.miss, colors.miss],
+ [item.output, colors.output],
+ ];
+ const barTotal = item.hit + item.miss + item.output;
+ if (barTotal > 0) {
+ /*
+ * 圆角画在整根柱子上:先用圆角矩形当裁剪区,再在里面堆命中/未命中/输出。
+ * 只磨圆顶端两角、柱脚保持直角,这样顶部圆角不会被细小分段盖没。
+ */
+ const barHeight = (chartHeight * barTotal) / maxTokens;
+ const barTop = padding.top + chartHeight - barHeight;
+ const barRadius = Math.min(5, barWidth * 0.3);
+ context.save();
+ chartRoundRect(
+ context,
+ x - barWidth / 2,
+ barTop,
+ barWidth,
+ barHeight,
+ barRadius,
+ 0
+ );
+ context.clip();
+ let y = padding.top + chartHeight;
+ values.forEach(([value, color], segmentIndex) => {
+ const segmentHeight = (chartHeight * value) / maxTokens;
+ if (segmentHeight <= 0) return;
+ y -= segmentHeight;
+ if (segmentIndex === 0) {
+ const barGradient = context.createLinearGradient(0, y, 0, y + segmentHeight);
+ barGradient.addColorStop(0, "#4cc3fb");
+ barGradient.addColorStop(1, "#2b8fc9");
+ context.fillStyle = barGradient;
+ } else {
+ context.fillStyle = color;
+ }
+ context.fillRect(x - barWidth / 2, y, barWidth, segmentHeight);
+ });
+ context.restore();
+ }
+ if (index % labelEvery === 0 || index === buckets.length - 1) {
+ context.fillStyle = colors.text;
+ context.textAlign = "center";
+ context.textBaseline = "top";
+ context.fillText(item.label, x, padding.top + chartHeight + 8);
+ }
+ });
+
+ const costPoints = buckets.map((item, index) => ({
+ x: padding.left + index * step + step / 2,
+ y: padding.top + chartHeight - (chartHeight * item.cost) / maxCost,
+ }));
+ const costGradient = context.createLinearGradient(
+ 0,
+ padding.top,
+ 0,
+ padding.top + chartHeight
+ );
+ costGradient.addColorStop(0, "rgba(250, 204, 21, 0.14)");
+ costGradient.addColorStop(1, "rgba(250, 204, 21, 0)");
+ context.beginPath();
+ chartSmoothPath(context, costPoints);
+ context.lineTo(costPoints[costPoints.length - 1].x, padding.top + chartHeight);
+ context.lineTo(costPoints[0].x, padding.top + chartHeight);
+ context.closePath();
+ context.fillStyle = costGradient;
+ context.fill();
+ context.beginPath();
+ chartSmoothPath(context, costPoints);
+ context.strokeStyle = colors.cost;
+ context.lineWidth = 1.8;
+ context.stroke();
+ buckets.forEach((item, index) => {
+ if (item.cost <= 0) return;
+ const point = costPoints[index];
+ context.beginPath();
+ context.arc(point.x, point.y, 2.6, 0, Math.PI * 2);
+ context.fillStyle = colors.cost;
+ context.fill();
+ context.beginPath();
+ context.arc(point.x, point.y, 2.6, 0, Math.PI * 2);
+ context.strokeStyle = "#10161f";
+ context.lineWidth = 1;
+ context.stroke();
+ });
+
+ context.textAlign = "left";
+ context.textBaseline = "middle";
+ for (let index = 0; index <= 2; index += 1) {
+ const y = padding.top + chartHeight - (chartHeight * index) / 2;
+ context.fillStyle = "#a16207";
+ context.fillText(
+ formatCost((maxCost * index) / 2),
+ padding.left + chartWidth + 8,
+ y
+ );
+ }
+ /* 峰值自动标注:最高的一柱写总量,费用最高的那点写金额,省得每次去悬停。 */
+ let peakIndex = 0;
+ let costPeakIndex = 0;
+ buckets.forEach((item, index) => {
+ const total = item.hit + item.miss + item.output;
+ const peakItem = buckets[peakIndex];
+ if (total > peakItem.hit + peakItem.miss + peakItem.output) peakIndex = index;
+ if (item.cost > buckets[costPeakIndex].cost) costPeakIndex = index;
+ });
+ const peakItem = buckets[peakIndex];
+ const peakTotal = peakItem.hit + peakItem.miss + peakItem.output;
+ if (peakTotal > 0) {
+ const peakX = padding.left + peakIndex * step + step / 2;
+ const peakY = padding.top + chartHeight - (chartHeight * peakTotal) / maxTokens;
+ chartPill(
+ context,
+ Math.max(padding.left + 10, Math.min(peakX, width - 10)),
+ Math.max(4, peakY - 21),
+ formatTokens(peakTotal),
+ "#1c2735",
+ "#e8eaed",
+ "center",
+ width - 4
+ );
+ }
+ const costPeak = buckets[costPeakIndex];
+ if (costPeak.cost > 0) {
+ const point = costPoints[costPeakIndex];
+ chartPill(
+ context,
+ point.x + 14,
+ Math.max(4, point.y - 5),
+ formatCost(costPeak.cost),
+ "#2a2410",
+ colors.cost,
+ "left",
+ width - 4
+ );
+ }
+ }
+
+ function scheduleEnsure() {
+ if (!ownsPanel()) {
+ retirePanel();
+ return;
+ }
+ if (state.ensureTimer) return;
+ state.ensureTimer = scheduleFrame(() => {
+ state.ensureTimer = 0;
+ if (!ownsPanel()) {
+ retirePanel();
+ return;
+ }
+ ensureLauncher();
+ ensurePanel();
+ });
+ }
+
+ function start() {
+ installStyles();
+ scheduleEnsure();
+ state.observer = new MutationObserver(scheduleEnsure);
+ state.observer.observe(document.documentElement, {
+ childList: true,
+ subtree: true,
+ });
+ updateLauncherBadge();
+ /*
+ * 余额由面板自己查:启动时如果已经有 Key(本机记住的 / Codex 配置里的),
+ * 静默补一次,之后每 15 分钟一次。没有 Key 就先从 Codex 配置里读一个,
+ * 全程不弹提示,也不要求装任何助手。
+ */
+ scheduleBalanceAutoQuery();
+ window.setTimeout(() => refreshBalanceOnOpen(), 1500);
+ }
+
+ loadState();
+ installCapture();
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", start, { once: true });
+ } else {
+ start();
+ }
+
+ instance.api = {
+ version: VERSION,
+ /* 诊断用:被更新的版本接管后,旧实例会把这个变成 true 并停止动面板。 */
+ retired: () => instance.retired,
+ open: openPanel,
+ close: closePanel,
+ toggle: togglePanel,
+ reposition: () => {
+ clampPanelPosition();
+ schedulePanelClamp();
+ },
+ getState: () => JSON.parse(JSON.stringify(state.records)),
+ importProxyRecords: (records) => {
+ if (!Array.isArray(records)) return 0;
+ let imported = 0;
+ for (const record of records) {
+ if (!record || typeof record !== "object") continue;
+ const timestamp =
+ Date.parse(record.created_utc || "") ||
+ Date.parse(String(record.created_at || "") + "+08:00") ||
+ Date.now();
+ const usage = normalizeUsage({
+ input_tokens: record.prompt_tokens,
+ cached_tokens: record.cache_hit_tokens,
+ output_tokens: record.completion_tokens,
+ reasoning_tokens: record.reasoning_tokens,
+ total_tokens: record.total_tokens,
+ });
+ if (!usage) continue;
+ const model = normalizeModel(record.model);
+ const id = record.request_id || record.created_utc || "";
+ const added = addRecord({
+ timestamp,
+ model,
+ usage,
+ source: "proxy-import",
+ id,
+ });
+ if (added) imported += 1;
+ }
+ scheduleSave();
+ updateLauncherBadge();
+ scheduleRender();
+ return imported;
+ },
+ replaceProxyRecords: (records) => {
+ state.records = [];
+ state.keys.clear();
+ state.seen.clear();
+ state.fingerprints.clear();
+ const imported = window[PANEL_API].importProxyRecords(records);
+ scheduleSave();
+ return imported;
+ },
+ mergeProxyRecords: (records) =>
+ window[PANEL_API].importProxyRecords(records),
+ clear: clearRecords,
+ getBalances: () => JSON.parse(JSON.stringify(state.balances)),
+ importBalanceSnapshots: (entries) => importBalanceSnapshots(entries),
+ recordBalance: (value, source = "external", options = {}) =>
+ recordBalance(value, {
+ source,
+ currency:
+ options?.currency ||
+ state.settings.balanceCurrency ||
+ "CNY",
+ timestamp: Number(options?.timestamp) || Date.now(),
+ }),
+ /* 本机助手用这两个接口做心跳和推送确认。 */
+ getBalanceSync: () => balanceSyncReport(),
+ ackBalanceSync: (report) => applyBalanceSync(report),
+ requestBalanceRefresh: (options) => requestBalanceRefresh(options),
+ /* 兼容旧名字:面板不联网,只登记刷新请求。 */
+ fetchBalance: (options) => requestBalanceRefresh(options),
+ /*
+ * 给外面(助手 / 自动化)用:setBalanceKey 把 Key 放进面板内存等助手取走,
+ * takeBalanceKey 取走后就地清空,面板内存里也不再留。
+ */
+ setBalanceKey: (key) => rememberBalanceKey(key),
+ takeBalanceKey: () => {
+ const key = pendingBalanceKey;
+ pendingBalanceKey = "";
+ return key;
+ },
+ clearBalanceKey: () => clearSavedBalanceKey(),
+ /* 面板自己查余额用的入口,页面上/自动化都能调。 */
+ queryBalance: (options) => queryBalanceNow(options || {}),
+ useCodexConfigKey: (options) => useCodexConfigKey(options || {}),
+ getBalanceKeyState: () => {
+ const info = balanceKeyInfo();
+ return {
+ hasKey: Boolean(info.key),
+ label: info.label,
+ tail: info.key ? maskKeyTail(info.key) : "",
+ mode: state.settings.balanceKeyMode === "manual" ? "manual" : "auto",
+ remember: state.settings.balanceKeyRemember === true,
+ bridge: balanceBridgeReady(),
+ };
+ },
+ setBalanceKeyMode: (value) => setBalanceKeyMode(value),
+ setBalanceEnabled: (enabled) => toggleBalanceEnabled(enabled),
+ setBalanceSource: (value) => setBalanceSource(value),
+ };
+ /* 登记之后,旧版本脚本下次动手时就会发现自己已经不是主人了。 */
+ window[PANEL_API] = instance.api;
+})();