From e8309ab4d1ead05f2963a3a5ebf762fedace68d6 Mon Sep 17 00:00:00 2001 From: jackson Date: Sat, 14 Nov 2026 00:14:05 +0800 Subject: [PATCH] feat(balance-dashboard): add sidebar balance widget with consumption analytics --- .../.zcode-plugin/plugin.json | 66 + external_plugins/balance-dashboard/LICENSE | 21 + external_plugins/balance-dashboard/README.md | 155 ++ .../balance-dashboard/README_CN.md | 177 ++ .../commands/remove-sidebar-widget.md | 19 + .../balance-dashboard/hooks/finalize.mjs | 152 ++ .../balance-dashboard/hooks/hooks.json | 19 + .../balance-dashboard/hooks/post-patch.mjs | 197 ++ .../balance-dashboard/hooks/session-start.mjs | 384 ++++ .../balance-dashboard/scripts/asar-patch.mjs | 447 ++++ .../scripts/collect-once.mjs | 19 + .../scripts/query-analytics.mjs | 370 ++++ .../scripts/restore-backup.mjs | 80 + .../balance-dashboard/scripts/unpatch.mjs | 88 + .../balance-dashboard/server/data.mjs | 1267 ++++++++++++ .../balance-dashboard/sidebar/inject-main.cjs | 442 ++++ .../sidebar/sidebar-widget.js | 1792 +++++++++++++++++ marketplace.json | 84 +- 18 files changed, 5766 insertions(+), 13 deletions(-) create mode 100644 external_plugins/balance-dashboard/.zcode-plugin/plugin.json create mode 100644 external_plugins/balance-dashboard/LICENSE create mode 100644 external_plugins/balance-dashboard/README.md create mode 100644 external_plugins/balance-dashboard/README_CN.md create mode 100644 external_plugins/balance-dashboard/commands/remove-sidebar-widget.md create mode 100644 external_plugins/balance-dashboard/hooks/finalize.mjs create mode 100644 external_plugins/balance-dashboard/hooks/hooks.json create mode 100644 external_plugins/balance-dashboard/hooks/post-patch.mjs create mode 100644 external_plugins/balance-dashboard/hooks/session-start.mjs create mode 100644 external_plugins/balance-dashboard/scripts/asar-patch.mjs create mode 100644 external_plugins/balance-dashboard/scripts/collect-once.mjs create mode 100644 external_plugins/balance-dashboard/scripts/query-analytics.mjs create mode 100644 external_plugins/balance-dashboard/scripts/restore-backup.mjs create mode 100644 external_plugins/balance-dashboard/scripts/unpatch.mjs create mode 100644 external_plugins/balance-dashboard/server/data.mjs create mode 100644 external_plugins/balance-dashboard/sidebar/inject-main.cjs create mode 100644 external_plugins/balance-dashboard/sidebar/sidebar-widget.js diff --git a/external_plugins/balance-dashboard/.zcode-plugin/plugin.json b/external_plugins/balance-dashboard/.zcode-plugin/plugin.json new file mode 100644 index 00000000..41b65a71 --- /dev/null +++ b/external_plugins/balance-dashboard/.zcode-plugin/plugin.json @@ -0,0 +1,66 @@ +{ + "name": "balance-dashboard", + "version": "0.3.0", + "description": "Left-sidebar balance widget for DeepSeek, Volcengine (account + Coding Plan) and ZCode. Click a row for consumption analytics with time/project/task filters and charts; the gear configures API keys. The widget is injected into ZCode via app.asar and requires a restart.", + "description_i18n": { + "en": "Left-sidebar balance widget for DeepSeek, Volcengine (account + Coding Plan) and ZCode. Click a row for consumption analytics with time/project/task filters and charts; the gear configures API keys. The widget is injected into ZCode via app.asar and requires a restart.", + "zh-CN": "左侧边栏余额组件:DeepSeek、火山引擎账户与 Coding Plan、ZCode 套餐与今日消耗。点击任意余额行打开多维度消耗分析(今天/昨天/本周/本月/自定义 + 项目/任务过滤 + 图表),齿轮打开设置弹框。组件通过 app.asar 注入,安装/更新后需重启 ZCode。" + }, + "author": { + "name": "AwenJackson", + "url": "https://github.com/AwenJackson" + }, + "homepage": "https://github.com/AwenJackson/balance-dashboard", + "repository": "https://github.com/AwenJackson/balance-dashboard", + "license": "MIT", + "keywords": [ + "balance", + "usage", + "deepseek", + "volcengine", + "coding-plan", + "analytics", + "sidebar" + ], + "commands": "commands", + "userConfig": { + "deepseek_api_key": { + "type": "string", + "default": "", + "sensitive": true, + "description": "DeepSeek 平台 API Key(余额查询)。留空时自动从 ZCode 的 DeepSeek 供应商配置读取。" + }, + "volc_access_key_id": { + "type": "string", + "default": "", + "description": "火山引擎 Access Key ID(查询账户余额与 Coding Plan 额度)。也可用环境变量 VOLCENGINE_ACCESS_KEY_ID。" + }, + "volc_secret_access_key": { + "type": "string", + "default": "", + "sensitive": true, + "description": "火山引擎 Secret Access Key。也可用环境变量 VOLCENGINE_SECRET_ACCESS_KEY。" + }, + "volc_region": { + "type": "string", + "default": "", + "description": "火山引擎区域。留空时 Coding Plan 使用 cn-beijing、账单使用 cn-north-1。" + }, + "zcode_usage_db": { + "type": "string", + "default": "", + "description": "ZCode 本地用量数据库路径。留空时自动使用 ~/.zcode/cli/db/db.sqlite。" + }, + "zcode_jwt_token": { + "type": "string", + "default": "", + "sensitive": true, + "description": "可选:覆盖 ZCode OAuth 令牌(读取 Start Plan 余额)。留空时自动从 ~/.zcode/v2/credentials.json 解密。" + }, + "zcode_device_mid": { + "type": "string", + "default": "", + "description": "可选:ZCode 设备号(Start Plan 余额接口需要)。留空时自动读取 telemetry-state.json。" + } + } +} diff --git a/external_plugins/balance-dashboard/LICENSE b/external_plugins/balance-dashboard/LICENSE new file mode 100644 index 00000000..cfb1b51e --- /dev/null +++ b/external_plugins/balance-dashboard/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 jackson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/external_plugins/balance-dashboard/README.md b/external_plugins/balance-dashboard/README.md new file mode 100644 index 00000000..48075472 --- /dev/null +++ b/external_plugins/balance-dashboard/README.md @@ -0,0 +1,155 @@ +# Balance Dashboard + +[![Version](https://img.shields.io/badge/version-0.3.0-blue)](../../releases) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Platform](https://img.shields.io/badge/platform-ZCode%20Desktop%203.18%2B-orange)](https://zcode.z.ai) + +[中文说明](./README_CN.md) + +A compact balance widget that lives in ZCode Desktop's left sidebar: + +- Each row shows a provider's **balance/plan** and **today's usage (cost + tokens)**; +- Clicking any balance row opens the **consumption analytics** modal: today / yesterday / this week / this month / custom date range, with project, task, and provider filters, stacked bar charts, provider shares, top models, and project/task rankings; +- The gear opens the same modal on its **settings** tab where API keys can be configured directly; saving triggers an immediate refresh. + +Data sources: + +- **DeepSeek**: account balance (total / granted / topped-up) and today's spend. +- **Volcengine**: account balance, today's billing spend, and Coding Plan quota windows (daily / 5-hour / weekly / monthly). +- **ZCode**: Start Plan or coding-plan quota usage, plus today's local request count and token usage. + +> 中文:ZCode Desktop 左侧边栏余额组件,支持 DeepSeek / 火山引擎(账户与 Coding Plan)/ ZCode 套餐余额与今日消耗;点击余额行打开多维度消耗分析,齿轮打开设置弹框。 + +## Side effects (please read) + +All side effects of this plugin, disclosed for transparency: + +- **Hook (SessionStart)**: runs after install/update, syncs runtime files to `~/.zcode/balance-dashboard/runtime/`, and checks `resources/app.asar`; +- **Modifies `app.asar`**: ZCode's plugin system has no left-sidebar mount point, so the plugin appends one plugin-specific dynamic `import()` line at the end of `out/main/index.js` (with full per-file SHA-256 verification, an automatic `app.asar.balance-dashboard.bak` backup, and rollback on failure; `/remove-sidebar-widget` restores the original); +- **Local file writes**: `~/.zcode/balance-dashboard/runtime/` (status, runtime copies, plaintext `options.json` settings); +- **Network requests** (official endpoints only): `api.deepseek.com` (balance), signed `billing.volces.com` APIs (Volcengine balance/billing/quota), `zcode.z.ai` and `api.z.ai` / `bigmodel.cn` (plan quota); no telemetry is sent; +- **Credential handling**: API keys are stored in ZCode's plugin config or the local `options.json` (plaintext, same level as ZCode's own plugin-secret storage); read locally only, never logged or uploaded; +- **Command execution**: the hook and the main-process injector spawn `node` child processes running this plugin's own scripts (usage collection, analytics queries, asar verify/patch); +- **MCP server**: none. + +## Prerequisites + +- A local Node.js runtime (`node` on PATH). +- The sidebar widget needs to write to ZCode's `app.asar`; after install/update you must **fully quit and restart ZCode**. +- If ZCode is installed under `Program Files` and the current user lacks write permission, the injector reports "admin required"; run ZCode once as administrator or run the injection script manually. +- If ZCode is installed in a non-default location, set the `ZCODE_ASAR` environment variable to the actual `resources/app.asar`. + +## Install + +- Inside the official `zai-org/zcode-plugins` marketplace: install `balance-dashboard` from the plugin manager, then fully quit and restart ZCode. +- Standalone repo: add this repository as a marketplace in ZCode (**Plugin Marketplace → Add → Add marketplace**), or download a release zip and add the extracted folder. + +After installation the SessionStart hook runs: it copies runtime files, checks `app.asar`, and if the file is locked by a running ZCode it schedules a detached finalizer that completes the swap once ZCode has fully quit. Then **fully quit and reopen ZCode** (including tray/background processes); the widget appears above the profile row in the left sidebar. + +## Configuration + +Settings can be changed in three places (lowest to highest precedence): + +1. ZCode **Settings → Plugins → balance-dashboard** (`userConfig`); +2. Sensitive values in `~/.zcode/cli/plugin-secrets.json`; +3. `~/.zcode/balance-dashboard/runtime/options.json` saved by the sidebar modal (highest). + +Environment variables also work. Secrets already configured are never echoed back; leaving a secret field empty keeps the current value. + +| Option | Env var | Description | +| --- | --- | --- | +| `deepseek_api_key` | `DEEPSEEK_API_KEY` | DeepSeek platform API key. Empty: auto-read from ZCode's DeepSeek provider config. | +| `volc_access_key_id` | `VOLCENGINE_ACCESS_KEY_ID` | Volcengine Access Key ID. | +| `volc_secret_access_key` | `VOLCENGINE_SECRET_ACCESS_KEY` | Volcengine Secret Access Key. | +| `volc_region` | `VOLC_REGION` | Optional. Coding Plan defaults to `cn-beijing`, billing to `cn-north-1`. | +| `zcode_usage_db` | `ZCODE_USAGE_DB` | Optional. Defaults to `~/.zcode/cli/db/db.sqlite`. | +| `zcode_jwt_token` | `ZCODE_JWT_TOKEN` | Optional. Overrides the ZCode OAuth token; auto-decrypted from `~/.zcode/v2/credentials.json`. | +| `zcode_device_mid` | `ZCODE_DEVICE_MID` | Optional. Auto-read from `telemetry-state.json`. | + +Volcengine account balance and Coding Plan quota require **AK/SK signing**. With only an Ark API key, today's local usage estimate is still shown, but official balance/quota cannot be read. + +### Display settings + +The settings modal has a "display" section controlling which rows appear, saved in `runtime/options.json`: + +| Option | Default | Description | +| --- | --- | --- | +| `show_deepseek` | `1` | Show the DeepSeek row | +| `show_volcano` | `1` | Show the Volcengine row | +| `show_zcode` | `1` | Show the ZCode row | +| `hide_unconfigured` | `1` | Hide providers without configured keys | + +A row is shown when its switch is on **and** (auto-hide is off **or** the provider is configured). When every row is hidden the card keeps only its header and gear so settings remain reachable. + +## Consumption analytics + +Clicking a balance row runs `scripts/query-analytics.mjs` on demand against the local usage database: + +- Time: today/yesterday bucketed by hour, this week/this month/custom by day, weekly beyond 62 days; +- Projects: grouped by `session.project_id + session.directory`; +- Tasks: main sessions (`session.id + title`); `session_task_link` is currently empty; +- Providers: DeepSeek / Volcengine Coding Plan / Volcengine account / ZCode / other; +- Tokens: `computed_total_tokens` first, `input + output` as fallback; cache/reasoning tokens are not double-counted; +- Cost is estimated from public list prices and labeled "estimated". + +All charts are inline SVG with no external chart libraries; colors follow ZCode's `--color-usage-chart-*` theme variables. + +## Sidebar injection details + +ZCode's plugin system currently has no left-sidebar mount point, so the widget is implemented the same way as the token-usage statusbar plugin: + +- One plugin-specific dynamic `import()` line appended to `out/main/index.js` in `app.asar`; +- The main-process loader only injects a renderer script into ZCode's own local pages; +- The widget is mounted before the profile footer inside `aside[data-testid="sidebar"]` and re-asserts its position after React re-renders; +- A one-shot Node collector runs every 60 seconds and pushes data to the renderer; modal requests flow through a `window.__balanceDashboardRequests` queue handled by the main process; +- The widget hides/re-mounts safely when the sidebar collapses or the DOM changes and never breaks ZCode on failure. + +Injection markers belong to this plugin only; updates strip only this plugin's line and preserve other plugins' injections (e.g. the token-usage statusbar). Every replacement path validates the staged asar with full SHA-256 checks before swapping, creates a backup first, and rolls back automatically if the post-swap verification fails. + +> `app.asar` cannot be replaced while ZCode is running, so the first install usually needs one full quit/reopen. If a status shows "pending restart", quit and reopen once more. + +Status is written to `~/.zcode/balance-dashboard/runtime/status.json`. + +## Data semantics + +- **DeepSeek balance**: `GET https://api.deepseek.com/user/balance`. +- **DeepSeek today's spend**: DeepSeek's API has no usage endpoint, so it is estimated from ZCode's local usage database (rows belonging to DeepSeek providers) and labeled "estimated". +- **Volcengine account balance**: `QueryBalanceAcct` (billing, 2022-01-01). +- **Volcengine today's spend**: `ListBillDetail` (billing, `ExpenseDate` = today); falls back to a local estimate when today's bill is not yet generated. +- **Volcengine Coding Plan**: `GetAFPUsage` (Agent Plan) first, otherwise `GetCodingPlanUsage` (Coding Plan), both via signed Ark APIs. +- **ZCode plan balance**: Start Plan via `zcode-plan/billing/balance`; coding plans via BigModel / Z.ai `monitor/usage/quota/limit`. +- **ZCode today's usage**: local `model_usage` table aggregated by local timezone. The sidebar ZCode row counts only the ZCode provider bucket, matching the analytics view; the all-provider total appears in the expanded details. + +Credentials are only stored in ZCode's plugin config, the local `options.json`, or process environment variables; they are never written to logs or caches. Network requests go to the corresponding official domains only. + +## Removing the sidebar widget + +Run: + +```bash +node "${ZCODE_PLUGIN_ROOT}/scripts/unpatch.mjs" +``` + +or execute `/remove-sidebar-widget` in a session. It removes only this plugin's injection line and preserves other plugins'. Reopen ZCode afterwards. + +## Incident & fix notes (0.2.1) + +0.2.0's asar repack script had a serious bug: it rewrote each file's `offset` before reading source data with the already-rewritten offset, misaligning 4000+ files after the patch point and failing ZCode's integrity check on startup. 0.2.1 fixed this: + +- Original offsets are captured before mutation; copies only ever use original offsets; +- After repacking, every one of the 30k+ entries is re-hashed (SHA-256 plus 4 MB block hashes) and compared against the header integrity; +- `app.asar` is only replaced when full verification passes; +- A backup (`app.asar.balance-dashboard.bak`, NTFS hardlink when possible) is created before replacement, re-verified after, and rolled back automatically on failure; +- `finalize` / `post-patch` / `session-start` validate every staged asar before promotion. + +If a broken `app.asar` from 0.2.0 is present, quit ZCode completely and run: + +```bash +node "/scripts/restore-backup.mjs" +``` + +Without a backup, restore `resources/app.asar` from the official ZCode installer. Do not use `app.asar.broken-*` files. + +## Known limitations + +- This is an unofficial injection capability. Official ZCode updates rewrite `app.asar`; the SessionStart hook attempts automatic repair. If the sidebar DOM changes in a future version, the widget fails closed (hidden, without affecting the app) until selectors are updated. +- Install/injection requires a restart; if `app.asar` is locked, wait for the finalizer or rerun the injection after fully quitting ZCode. +- DeepSeek's and Volcengine Coding Plan's "today's spend" are local estimates, not provider billing. diff --git a/external_plugins/balance-dashboard/README_CN.md b/external_plugins/balance-dashboard/README_CN.md new file mode 100644 index 00000000..cedd529e --- /dev/null +++ b/external_plugins/balance-dashboard/README_CN.md @@ -0,0 +1,177 @@ +# Balance Dashboard · 余额面板 + +[![Version](https://img.shields.io/badge/version-0.3.0-blue)](../../releases) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Platform](https://img.shields.io/badge/platform-ZCode%20Desktop%203.18%2B-orange)](https://zcode.z.ai) + +> **关键词 / Keywords**: ZCode 插件, ZCode plugin, ZCode Desktop, 余额查询, 用量统计, token 消耗, DeepSeek 余额, 火山引擎余额, 火山方舟 Coding Plan, BigModel, GLM, consumption analytics, usage dashboard, sidebar widget, Electron + +在 ZCode 左侧边栏常驻一个紧凑的余额组件: + +- 每行显示 **余额/套餐** 与 **今日消耗(费用 + token)**; +- 点击任意余额行打开 **消耗分析** 弹框:今天 / 昨天 / 本周 / 本月 / 自定义日期,支持项目、任务、供应商过滤,展示堆叠柱状图、供应商占比、Top 模型和项目/任务排行; +- 点击齿轮打开同一个弹框的 **设置** 标签,可直接填写密钥,保存后立即刷新。 + +支持的数据: + +- **DeepSeek**:账户余额(总额 / 赠送 / 充值)与今日消耗。 +- **火山引擎**:账户可用余额、当日账单消耗,以及 Coding Plan 的额度窗口(日 / 5 小时 / 周 / 月)。 +- **ZCode**:Start Plan 或编码套餐的额度占用,以及本机今日请求数与 token 消耗。 + +> English: a ZCode Desktop sidebar widget that shows DeepSeek / Volcengine (Ark & Coding Plan) / ZCode plan balances and today's token usage. Clicking any row opens a consumption analytics modal (time ranges, project/task/provider filters, stacked charts); the gear opens a settings modal for API keys. + +## 副作用披露(必读) + +为透明起见,本插件的所有副作用如下: + +- **Hook(SessionStart)**:安装/更新后自动运行,同步运行时文件到 `~/.zcode/balance-dashboard/runtime/`,并检查 `resources/app.asar`; +- **修改 `app.asar`**:ZCode 插件系统没有左侧边栏挂载点,本插件会在 `out/main/index.js` 末尾追加一行本插件专属的动态 import(带完整 SHA-256 全量校验、自动备份 `app.asar.balance-dashboard.bak` 与失败回滚;`/remove-sidebar-widget` 可还原); +- **本地文件写入**:`~/.zcode/balance-dashboard/runtime/`(状态、运行时副本、`options.json` 明文配置); +- **网络请求**(仅官方域名):`api.deepseek.com`(余额)、`billing.volces.com` 签名接口(火山余额/账单/额度)、`zcode.z.ai` 与 `api.z.ai` / `bigmodel.cn`(套餐额度);不发送任何遥测; +- **凭据处理**:API Key 存储在 ZCode 插件配置或本机 `options.json`(明文,与 ZCode 自身密钥存储同级),仅本机读取,不写日志、不上传; +- **命令执行**:Hook 与主进程注入器会以子进程方式调用 `node` 执行本插件自带脚本(采集用量、查询分析、asar 校验/重打包); +- **MCP server**:无。 + +## 前置条件 + +- 本机可用的 Node.js 运行时(`node` 命令)。 +- 左侧组件需要写入 ZCode 的 `app.asar`;安装或更新后必须**完全退出并重开 ZCode**。 +- 如果 ZCode 安装在 `Program Files` 且当前用户没有写权限,注入会提示“需要管理员权限”;以管理员身份运行一次 ZCode,或手动执行注入脚本。 +- 如果 ZCode 安装在非默认目录,可设置 `ZCODE_ASAR` 环境变量指向实际的 `resources/app.asar`。 + +## 安装 + +仓库自带 `marketplace.json`,可以把本目录作为 ZCode 插件市场直接添加。 + +**方式一:添加 GitHub 市场(推荐)** + +1. 把本仓库推送到 GitHub 后,复制仓库地址(如 `https://github.com//balance-dashboard`)。 +2. 在 ZCode 中打开 **插件市场 → 添加 → 添加插件市场**,粘贴仓库地址。 +3. 在对应市场里安装 `balance-dashboard`。 + +也可以直接在 [Releases](../../releases) 下载 `balance-dashboard-x.y.z.zip`,解压后把解压目录作为插件市场路径添加(zip 内自带 `marketplace.json`)。 + +**方式二:本地目录** + +1. `git clone` 本仓库到任意目录。 +2. 在 ZCode 中打开 **插件市场 → 添加 → 添加插件市场**,粘贴克隆出的目录路径(即包含 `marketplace.json` 的目录)。 +3. 在 **个人市场** 中安装 `balance-dashboard`。 + +安装完成后会触发 SessionStart 钩子: + +1. 复制运行时文件到 `~/.zcode/balance-dashboard/runtime/`; +2. 检查并修改 `app.asar`; +3. 如果 ZCode 正在运行导致文件被锁定,会排定一个收尾进程,等你完全退出 ZCode 后自动替换; +4. **完全退出并重开 ZCode**(含托盘/后台进程),左侧边栏底部、头像行上方会出现余额组件。 + +## 配置 + +三种方式任选其一,优先级从低到高: + +1. ZCode **设置 → 插件 → balance-dashboard** 的 `userConfig`; +2. `~/.zcode/cli/plugin-secrets.json` 中保存的敏感值; +3. 组件齿轮弹框保存的 `~/.zcode/balance-dashboard/runtime/options.json`(最高优先级)。 + +也可以在进程环境变量中提供。弹框中已配置的密钥不会回显,留空表示保持原值。 + +| 选项 | 环境变量 | 说明 | +| --- | --- | --- | +| `deepseek_api_key` | `DEEPSEEK_API_KEY` | DeepSeek 平台 API Key。留空时自动读取 ZCode 的 DeepSeek 供应商配置。 | +| `volc_access_key_id` | `VOLCENGINE_ACCESS_KEY_ID` | 火山引擎 Access Key ID。 | +| `volc_secret_access_key` | `VOLCENGINE_SECRET_ACCESS_KEY` | 火山引擎 Secret Access Key。 | +| `volc_region` | `VOLC_REGION` | 可选。Coding Plan 默认 `cn-beijing`,账单默认 `cn-north-1`。 | +| `zcode_usage_db` | `ZCODE_USAGE_DB` | 可选。默认 `~/.zcode/cli/db/db.sqlite`。 | +| `zcode_jwt_token` | `ZCODE_JWT_TOKEN` | 可选。默认从 `~/.zcode/v2/credentials.json` 解密。 | +| `zcode_device_mid` | `ZCODE_DEVICE_MID` | 可选。默认读取 `telemetry-state.json`。 | + +火山引擎的账户余额与 Coding Plan 额度接口需要 **AK/SK 签名**。仅有 Ark API Key 时,可以显示本地统计的今日消耗,但无法读取官方余额与额度。 + +`runtime/options.json` 为明文存储(与当前 ZCode `plugin-secrets.json` 同级别),仅保存在本机用户目录。 + +### 显示设置 + +设置弹框顶部的“显示设置”可以控制侧边栏显示哪些行,保存在 `runtime/options.json`: + +| 选项 | 默认 | 说明 | +| --- | --- | --- | +| `show_deepseek` | `1` | 是否显示 DeepSeek 行 | +| `show_volcano` | `1` | 是否显示火山行 | +| `show_zcode` | `1` | 是否显示 ZCode 行 | +| `hide_unconfigured` | `1` | 未配置 Key/AK 的供应商自动隐藏 | + +某行最终显示 = 对应开关开启 **且**(`hide_unconfigured` 关闭 **或** 该供应商已接入)。所有行都隐藏时,卡片只保留头部和齿轮,仍可打开设置恢复。 + +## 消耗分析 + +点击余额行后,主进程按需调用 `scripts/query-analytics.mjs` 查询本地用量库: + +- 时间:今天/昨天按小时分桶,本周/本月/自定义按天分桶,超过 62 天按周聚合; +- 项目:按 `session.project_id + session.directory` 聚合; +- 任务:`session_task_link` 当前为空,因此以主会话(`session.id + title`)作为任务; +- 供应商:DeepSeek / 火山 Coding Plan / 火山账户 / ZCode / 其他; +- Token 口径:`computed_total_tokens` 优先,否则 `input + output`,不重复累加 cache/reasoning; +- 费用按公开牌价估算,界面标注「估算」。 + +所有图表都是内联 SVG,不依赖外部图表库;颜色使用 ZCode 主题的 `--color-usage-chart-*` 变量。 + +## 左侧组件注入说明 + +ZCode 的插件系统目前没有左侧边栏挂载点,因此左侧组件通过和 token 用量悬浮条相同的方式实现: + +- 在 `app.asar` 主入口 `out/main/index.js` 尾部追加一行动态 import; +- 主进程 loader 只向 ZCode 本地页面注入 renderer 脚本; +- 组件挂在 `aside[data-testid="sidebar"]` 的 `footer`(头像行)之前,并在 React 重渲染后自动纠正顺序; +- 每 60 秒调用一次一次性 Node 采集器,把数据推送到 renderer;弹框请求通过 `window.__balanceDashboardRequests` 队列交给主进程处理; +- 侧边栏折叠、DOM 结构变化时自动隐藏/重挂,失败时不会破坏 ZCode。 + +注入标记只属于本插件;升级或重新安装时会只剥离本插件自己的行,**保留 token 用量悬浮条等其他插件的注入**。如果检测到 token 用量悬浮条的 `app.asar.zusage.tmp` 待替换包,本插件会先让行,并启动一个后台跟进进程:等悬浮条完成替换后自动把余额组件写入新的 `app.asar`,通常仍然只需要一次“完全退出并重开”。 + +> 注入在 ZCode 运行时无法直接覆盖 `app.asar`,所以第一次安装后一般需要**完全退出并重开一次**;如果状态显示“待重启生效”,再退出重开一次即可。 + +状态写入 `~/.zcode/balance-dashboard/runtime/status.json`。 + +## 数据口径 + +- **DeepSeek 余额**:`GET https://api.deepseek.com/user/balance`。 +- **DeepSeek 今日消耗**:DeepSeek 官方 API 不提供用量查询,因此按 ZCode 本机用量库中归属于 DeepSeek 供应商的请求估算金额,界面中标注「估算」。 +- **火山引擎账户余额**:`QueryBalanceAcct`(billing,2022-01-01)。 +- **火山引擎账户今日消耗**:`ListBillDetail`(billing,`ExpenseDate`=今天)。当日账单可能尚未生成,此时回退为本地估算。 +- **火山引擎 Coding Plan**:优先 `GetAFPUsage`(Agent Plan),否则 `GetCodingPlanUsage`(Coding Plan),均通过 Ark 签名接口查询。 +- **ZCode 套餐余额**:Start Plan 读取 `zcode-plan/billing/balance`;编码套餐读取 BigModel / Z.ai 的 `monitor/usage/quota/limit`。 +- **ZCode 今日消耗**:读取本机 `model_usage` 表,按本地时区当天聚合请求数、输入 / 输出 / 思考 token。侧边栏 ZCode 行只统计 ZCode 供应商桶,与「消耗分析」中的 ZCode 一致;全部供应商的今日总量在展开详情的「全部供应商今日」中显示。 + +凭据只保存在 ZCode 的插件配置、本机 `options.json` 或进程环境变量里,不会被写入日志或缓存文件。网络请求只发往对应的官方域名。 + +## 移除左侧组件 + +运行: + +```bash +node "${ZCODE_PLUGIN_ROOT}/scripts/unpatch.mjs" +``` + +或在会话中执行 `/remove-sidebar-widget`。它只移除本插件的注入行,保留其他插件的注入。替换成功后重开 ZCode 生效。 + +## 事故与修复(0.2.1) + +0.2.0 的 asar 重打包脚本有一个严重 bug:它先把每个文件的 `offset` 改成了新位置,然后又用这个新 offset 去原 asar 里读取数据,导致替换点之后的 4000+ 个文件整体错位,ZCode 启动时完整性校验失败。 + +0.2.1 已修复: + +- 重打包前先保存不可变的原始 offset,拷贝数据只使用原始 offset; +- 重打包后对**全部 3 万多个条目**逐文件重算 SHA-256 和 block hashes,并与 header 中的 integrity 比对; +- 只有全量校验通过才会替换 `app.asar`; +- 替换前创建 `app.asar.balance-dashboard.bak`(优先 NTFS hardlink),替换后再校验一次,失败自动用备份回滚; +- `finalize` / `post-patch` / `session-start` 的每条替换路径都会先校验 stage,禁止直接提升未校验的补丁。 + +如果 0.2.0 的事故留下损坏的 `app.asar`,请先完全退出 ZCode,再运行: + +```bash +node "<插件目录>/scripts/restore-backup.mjs" +``` + +如果没有备份,使用 ZCode 官方安装包恢复 `resources/app.asar`。不要使用 `app.asar.broken-*` 文件。 + +## 已知限制 + +- 这是非官方注入能力。ZCode 官方更新会重写 `app.asar`,SessionStart 钩子会尝试自动修复;如果左侧边栏 DOM 结构变化,组件会 fail-closed(不显示,不影响应用),需要按新结构更新选择器。 +- 安装/注入后需要重启;如果 `app.asar` 被占用,需要等收尾进程完成或手动在 ZCode 完全退出后重跑一次注入。 +- DeepSeek 今日消耗和火山 Coding Plan 的今日消耗都是本机统计,不是服务商账单。 diff --git a/external_plugins/balance-dashboard/commands/remove-sidebar-widget.md b/external_plugins/balance-dashboard/commands/remove-sidebar-widget.md new file mode 100644 index 00000000..3206cfb4 --- /dev/null +++ b/external_plugins/balance-dashboard/commands/remove-sidebar-widget.md @@ -0,0 +1,19 @@ +--- +description: 移除余额面板注入到 ZCode 左侧边栏的组件(保留其他插件注入) +--- + +从 ZCode 的 `app.asar` 中移除余额面板的左侧边栏注入行,并保留 token 用量悬浮条等其他插件的注入。 + +执行: + +```bash +node "${ZCODE_PLUGIN_ROOT}/scripts/unpatch.mjs" +``` + +如果提示 `app.asar` 被占用,请让用户完全退出 ZCode 后重新执行。替换成功后需要重开 ZCode 才会消失。 + +完成后告诉用户: + +- 已移除左侧余额组件; +- 其他插件(例如 token 用量悬浮条)的注入未被修改; +- 如需重新启用,重启 ZCode 时 SessionStart 钩子会重新注入。 diff --git a/external_plugins/balance-dashboard/hooks/finalize.mjs b/external_plugins/balance-dashboard/hooks/finalize.mjs new file mode 100644 index 00000000..21c364db --- /dev/null +++ b/external_plugins/balance-dashboard/hooks/finalize.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +/** + * Detached finalizer for the balance-dashboard app.asar injection. + * + * ZCode keeps app.asar open while it runs. If the atomic replace fails, this + * process waits for the lock to clear, verifies that app.asar is still the same + * source the stage was built from, then replaces it. It never overwrites an + * asar that changed in the meantime (for example after an official update). + */ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +function arg(name) { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +const stage = arg("--stage"); +const asar = arg("--asar"); +const runtime = arg("--runtime") || (stage ? path.dirname(stage) : ""); +const metaPath = stage ? `${stage}.json` : ""; +const statusPath = runtime ? path.join(runtime, "status.json") : ""; + +function log(...args) { + if (!runtime) return; + try { + fs.appendFileSync( + path.join(runtime, "inject.log"), + `[${new Date().toISOString()}] [finalize] ${args.join(" ")}\n`, + ); + } catch { + // logging must never throw + } +} + +function writeStatus(state, message, extra = {}) { + if (!statusPath) return; + try { + fs.mkdirSync(runtime, { recursive: true }); + const tmp = `${statusPath}.tmp`; + fs.writeFileSync( + tmp, + JSON.stringify( + { + state, + message, + asarPath: asar || "", + injected: state === "injected", + pending: state === "pending-restart", + updatedAt: new Date().toISOString(), + ...extra, + }, + null, + 2, + ), + ); + fs.renameSync(tmp, statusPath); + } catch { + // status is diagnostic only + } +} + +function readMeta() { + try { + return JSON.parse(fs.readFileSync(metaPath, "utf8")); + } catch { + return null; + } +} + +function sameSource(meta) { + try { + const stat = fs.statSync(asar); + return stat.size === meta.source.size && stat.mtimeMs === meta.source.mtimeMs; + } catch { + return false; + } +} + +function removeStage() { + try { + fs.unlinkSync(stage); + } catch { + // ignore + } + try { + fs.unlinkSync(metaPath); + } catch { + // ignore + } +} + +async function main() { + if (!stage || !asar) throw new Error("--stage and --asar are required"); + const started = Date.now(); + const deadline = started + 2 * 60 * 60 * 1000; + const patchMod = await import( + `${pathToFileURL(path.join(runtime, "scripts", "asar-patch.mjs")).href}?v=${Date.now()}` + ); + + while (Date.now() < deadline) { + if (!fs.existsSync(stage)) return; + const meta = readMeta(); + if (!meta || !meta.source) { + log("stage metadata missing; discarding", stage); + removeStage(); + return; + } + if (!sameSource(meta)) { + log("app.asar changed since the stage was built; discarding", stage); + removeStage(); + writeStatus("error", "ZCode 已更新或 app.asar 已变化,请重启 ZCode 触发重新注入。"); + return; + } + try { + // Never promote an unvalidated stage. Full hash check of every entry. + patchMod.validateAsar(stage, { expectBalance: true }); + } catch (error) { + log("stage validation failed; discarding:", error.message); + removeStage(); + writeStatus("error", `补丁校验失败,未替换 app.asar:${error.message}`); + return; + } + try { + const result = patchMod.replaceAsarWithBackup(stage, asar, { + stageOptions: { expectBalance: true }, + postOptions: { expectBalance: true }, + }); + try { + fs.unlinkSync(metaPath); + } catch { + // ignore + } + log("replacement completed; backup:", result.backup); + writeStatus("injected", "左侧余额组件已注入;重启 ZCode 后生效。", { + backup: result.backup, + }); + return; + } catch (error) { + log("replacement blocked, retrying:", error.message); + } + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + writeStatus("pending-restart", "等待 ZCode 退出超时;请完全退出 ZCode 后重启。"); +} + +main().catch((error) => { + log("fatal:", error.stack || error.message); + writeStatus("error", `注入收尾失败:${error.message || error}`); + process.exitCode = 1; +}); diff --git a/external_plugins/balance-dashboard/hooks/hooks.json b/external_plugins/balance-dashboard/hooks/hooks.json new file mode 100644 index 00000000..5e0b91db --- /dev/null +++ b/external_plugins/balance-dashboard/hooks/hooks.json @@ -0,0 +1,19 @@ +{ + "description": "balance-dashboard: 会话启动时同步运行时文件,并检查/修复左侧边栏余额组件的 app.asar 注入(会修改 ZCode 的 app.asar,安装或更新后需完全退出并重开)。", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "shell": true, + "command": "node \"${ZCODE_PLUGIN_ROOT}/hooks/session-start.mjs\"", + "timeoutMs": 90000, + "statusMessage": "balance-dashboard: 检查左侧余额组件…" + } + ] + } + ] + } +} diff --git a/external_plugins/balance-dashboard/hooks/post-patch.mjs b/external_plugins/balance-dashboard/hooks/post-patch.mjs new file mode 100644 index 00000000..7bad5902 --- /dev/null +++ b/external_plugins/balance-dashboard/hooks/post-patch.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node +/** + * Detached follow-up patch after another asar injector finishes. + * + * If the token-usage statusbar has a pending app.asar repack, the hook lets it + * finish first. This process waits for `app.asar.zusage.tmp` to disappear, then + * appends the balance-dashboard line to the new asar so the user only needs one + * restart instead of two. + */ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +function arg(name) { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +const asar = arg("--asar"); +const runtime = arg("--runtime"); +const waitFor = arg("--wait-for") || (asar ? `${asar}.zusage.tmp` : ""); +const statusPath = runtime ? path.join(runtime, "status.json") : ""; +const lockPath = asar ? `${asar}.balance-dashboard.postpatch.lock` : ""; + +if (lockPath) { + process.on("exit", () => { + try { + fs.unlinkSync(lockPath); + } catch { + // ignore + } + }); +} + +function log(...args) { + if (!runtime) return; + try { + fs.appendFileSync( + path.join(runtime, "inject.log"), + `[${new Date().toISOString()}] [post-patch] ${args.join(" ")}\n`, + ); + } catch { + // ignore + } +} + +function writeStatus(state, message, extra = {}) { + if (!statusPath) return; + try { + const tmp = `${statusPath}.tmp`; + fs.writeFileSync( + tmp, + JSON.stringify( + { + state, + message, + asarPath: asar || "", + injected: state === "injected", + pending: state === "pending-restart", + updatedAt: new Date().toISOString(), + ...extra, + }, + null, + 2, + ), + ); + fs.renameSync(tmp, statusPath); + } catch { + // ignore + } +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function main() { + if (!asar || !runtime) throw new Error("--asar and --runtime are required"); + const patchMod = await import( + `${pathToFileURL(path.join(runtime, "scripts", "asar-patch.mjs")).href}?v=${Date.now()}` + ); + const stage = `${asar}.balance-dashboard.tmp`; + const metaPath = `${stage}.json`; + const deadline = Date.now() + 2 * 60 * 60 * 1000; + + while (Date.now() < deadline) { + let inspection; + try { + inspection = patchMod.inspectAsar(asar); + } catch (error) { + log("cannot read app.asar:", error.message); + await sleep(5000); + continue; + } + if (inspection.hasBalanceLine) { + writeStatus("injected", "左侧余额组件已注入;重启 ZCode 后生效。", { + asarPath: asar, + injected: true, + pending: false, + }); + return; + } + if (waitFor && fs.existsSync(waitFor)) { + await sleep(3000); + continue; + } + + let before; + try { + before = fs.statSync(asar); + } catch { + await sleep(5000); + continue; + } + try { + const result = patchMod.buildPatchedAsar({ + asarPath: asar, + loaderPath: path.join(runtime, "sidebar", "inject-main.cjs"), + stagePath: stage, + }); + if (result.status === "already") { + writeStatus("injected", "左侧余额组件已注入。", { asarPath: asar, injected: true }); + return; + } + const after = fs.statSync(asar); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + try { + fs.unlinkSync(stage); + fs.unlinkSync(metaPath); + } catch { + // ignore + } + await sleep(5000); + continue; + } + fs.writeFileSync( + metaPath, + JSON.stringify( + { source: { size: before.size, mtimeMs: before.mtimeMs }, createdAt: new Date().toISOString() }, + null, + 2, + ), + ); + try { + patchMod.validateAsar(stage, { expectBalance: true }); + } catch (error) { + try { + fs.unlinkSync(stage); + fs.unlinkSync(metaPath); + } catch { + // ignore + } + log("stage validation failed; discarding:", error.message); + writeStatus("error", `补丁校验失败,未替换 app.asar:${error.message}`, { asarPath: asar }); + return; + } + try { + const replaced = patchMod.replaceAsarWithBackup(stage, asar, { + stageOptions: { expectBalance: true }, + postOptions: { expectBalance: true }, + }); + try { + fs.unlinkSync(metaPath); + } catch { + // ignore + } + log("follow-up patch completed; backup:", replaced.backup); + writeStatus("injected", "左侧余额组件已注入;重启 ZCode 后生效。", { + asarPath: asar, + injected: true, + pending: false, + backup: replaced.backup, + }); + return; + } catch { + // ZCode is running again; retry until it quits or the next session repairs it. + await sleep(5000); + } + } catch (error) { + const code = error?.code || ""; + if (["EACCES", "EPERM", "EROFS"].includes(code)) { + writeStatus("needs-admin", "写入 app.asar 需要管理员权限。", { asarPath: asar }); + return; + } + log("patch attempt failed:", error.message); + await sleep(5000); + } + } + writeStatus("waiting-zusage", "等待其他插件完成 app.asar 更新超时;请重启 ZCode 后重试。", { + asarPath: asar, + pending: true, + }); +} + +main().catch((error) => { + log("fatal:", error.stack || error.message); + writeStatus("error", `后续注入失败:${error.message || error}`); + process.exitCode = 1; +}); diff --git a/external_plugins/balance-dashboard/hooks/session-start.mjs b/external_plugins/balance-dashboard/hooks/session-start.mjs new file mode 100644 index 00000000..79a6f709 --- /dev/null +++ b/external_plugins/balance-dashboard/hooks/session-start.mjs @@ -0,0 +1,384 @@ +#!/usr/bin/env node +/** + * SessionStart hook. + * + * 1. Sync the injector runtime into a stable user directory. + * 2. Make sure app.asar contains the loader line (repair after ZCode updates). + * 3. If app.asar is locked, stage the patch and start a detached finalizer. + * + * The hook always prints one JSON object and never fails the session. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const RUNTIME = + (process.env.BALANCE_RUNTIME_DIR && process.env.BALANCE_RUNTIME_DIR.trim()) || + path.join(os.homedir(), ".zcode", "balance-dashboard", "runtime"); +const STATUS_PATH = path.join(RUNTIME, "status.json"); +const VERSION = "0.3.0"; + +const FILES = [ + ["server/data.mjs", "server/data.mjs"], + ["scripts/collect-once.mjs", "scripts/collect-once.mjs"], + ["scripts/query-analytics.mjs", "scripts/query-analytics.mjs"], + ["scripts/asar-patch.mjs", "scripts/asar-patch.mjs"], + ["scripts/restore-backup.mjs", "scripts/restore-backup.mjs"], + ["sidebar/inject-main.cjs", "sidebar/inject-main.cjs"], + ["sidebar/sidebar-widget.js", "sidebar/sidebar-widget.js"], + ["hooks/finalize.mjs", "hooks/finalize.mjs"], + ["hooks/post-patch.mjs", "hooks/post-patch.mjs"], +]; + +function readPreviousStatus() { + try { + return JSON.parse(fs.readFileSync(STATUS_PATH, "utf8")); + } catch { + return null; + } +} + +function writeStatus(state, message, extra = {}) { + try { + fs.mkdirSync(RUNTIME, { recursive: true }); + const tmp = `${STATUS_PATH}.tmp`; + fs.writeFileSync( + tmp, + JSON.stringify( + { + state, + message, + updatedAt: new Date().toISOString(), + pluginRoot: PLUGIN_ROOT, + runtime: RUNTIME, + version: VERSION, + ...extra, + }, + null, + 2, + ), + ); + fs.renameSync(tmp, STATUS_PATH); + } catch { + // diagnostic only + } +} + +function copyRuntime() { + const errors = []; + for (const [srcRel, dstRel] of FILES) { + const src = path.join(PLUGIN_ROOT, srcRel); + const dst = path.join(RUNTIME, dstRel); + try { + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.copyFileSync(src, dst); + } catch (error) { + errors.push(`${srcRel}: ${error.message}`); + } + } + try { + fs.mkdirSync(RUNTIME, { recursive: true }); + fs.writeFileSync( + path.join(RUNTIME, "config.json"), + JSON.stringify( + { + pluginRoot: PLUGIN_ROOT, + version: VERSION, + loader: path.join(RUNTIME, "sidebar", "inject-main.cjs"), + updatedAt: new Date().toISOString(), + }, + null, + 2, + ), + ); + } catch (error) { + errors.push(`config.json: ${error.message}`); + } + return errors; +} + +function removeStage(stage) { + for (const file of [stage, `${stage}.json`]) { + try { + fs.unlinkSync(file); + } catch { + // ignore + } + } +} + +function spawnPostPatch(asar) { + const lock = `${asar}.balance-dashboard.postpatch.lock`; + try { + const stat = fs.statSync(lock); + if (Date.now() - stat.mtimeMs < 30 * 60 * 1000) return false; + } catch { + // no live lock + } + try { + fs.writeFileSync(lock, String(Date.now())); + } catch { + // ignore; the process still de-duplicates by checking the marker + } + try { + const child = spawn( + process.execPath, + [ + path.join(RUNTIME, "hooks", "post-patch.mjs"), + "--asar", + asar, + "--runtime", + RUNTIME, + "--wait-for", + `${asar}.zusage.tmp`, + ], + { detached: true, stdio: "ignore", windowsHide: true, cwd: RUNTIME }, + ); + child.unref(); + return true; + } catch { + return false; + } +} + +function spawnFinalizer(stage, asar) { + try { + const child = spawn( + process.execPath, + [ + path.join(RUNTIME, "hooks", "finalize.mjs"), + "--stage", + stage, + "--asar", + asar, + "--runtime", + RUNTIME, + ], + { detached: true, stdio: "ignore", windowsHide: true, cwd: RUNTIME }, + ); + child.unref(); + return true; + } catch { + return false; + } +} + +async function main() { + const copyErrors = copyRuntime(); + const patchMod = await import( + `${pathToFileURL(path.join(RUNTIME, "scripts", "asar-patch.mjs")).href}?v=${Date.now()}` + ); + + const asar = patchMod.findAppAsar(process.env.ZCODE_ASAR || process.env.ZCODE_APP_ASAR); + if (!asar) { + writeStatus("unknown", "未找到 ZCode 的 app.asar;左侧组件未注入。", { pluginRoot: PLUGIN_ROOT }); + return { suppressOutput: true }; + } + + const stage = `${asar}.balance-dashboard.tmp`; + const metaPath = `${stage}.json`; + let inspection; + try { + inspection = patchMod.inspectAsar(asar); + } catch (error) { + writeStatus("error", `无法读取 app.asar:${error.message}`, { asarPath: asar }); + return { suppressOutput: true }; + } + + if (inspection.hasBalanceLine) { + removeStage(stage); + writeStatus("injected", "左侧余额组件已注入;如刚更新 ZCode,请重启后生效。", { + asarPath: asar, + injected: true, + pending: false, + hasZusageLine: inspection.hasZusageLine, + }); + return { suppressOutput: true }; + } + + // Do not race with the token-usage statusbar's own pending repack. Let it + // finish, then patch the new asar with a detached follow-up process. + if (fs.existsSync(`${asar}.zusage.tmp`)) { + spawnPostPatch(asar); + writeStatus( + "waiting-zusage", + "检测到 token 用量悬浮条正在更新 app.asar;已排定后续注入,等它完成后自动写入。", + { asarPath: asar, injected: false, pending: true }, + ); + return { suppressOutput: true }; + } + + // A stage from an earlier session may still be waiting for ZCode to quit. + if (fs.existsSync(stage) && fs.existsSync(metaPath)) { + try { + const meta = JSON.parse(fs.readFileSync(metaPath, "utf8")); + const stat = fs.statSync(asar); + if (meta?.source?.size === stat.size && meta?.source?.mtimeMs === stat.mtimeMs) { + try { + patchMod.validateAsar(stage, { expectBalance: true }); + } catch (error) { + removeStage(stage); + writeStatus("error", `旧补丁校验失败,已丢弃:${error.message}`, { asarPath: asar }); + return { suppressOutput: true }; + } + try { + const replaced = patchMod.replaceAsarWithBackup(stage, asar, { + stageOptions: { expectBalance: true }, + postOptions: { expectBalance: true }, + }); + removeStage(stage); + writeStatus("injected", "左侧余额组件已注入;重启 ZCode 后生效。", { + asarPath: asar, + injected: true, + backup: replaced.backup, + }); + return { suppressOutput: true }; + } catch { + spawnFinalizer(stage, asar); + writeStatus("pending-restart", "左侧余额组件已排定,完全退出 ZCode 后自动完成替换;重开后生效。", { + asarPath: asar, + injected: false, + pending: true, + }); + return { suppressOutput: true }; + } + } + removeStage(stage); + } catch { + removeStage(stage); + } + } + + let before; + try { + before = fs.statSync(asar); + } catch (error) { + writeStatus("error", `无法读取 app.asar 状态:${error.message}`, { asarPath: asar }); + return { suppressOutput: true }; + } + + let result; + try { + result = patchMod.buildPatchedAsar({ + asarPath: asar, + loaderPath: path.join(RUNTIME, "sidebar", "inject-main.cjs"), + stagePath: stage, + }); + } catch (error) { + const code = error?.code || ""; + const needsAdmin = ["EACCES", "EPERM", "EROFS"].includes(code); + writeStatus( + needsAdmin ? "needs-admin" : "error", + needsAdmin + ? "写入 app.asar 需要管理员权限。请以管理员身份运行 ZCode,或手动执行注入脚本。" + : `生成左侧组件补丁失败:${error.message}`, + { asarPath: asar, injected: false, pending: false }, + ); + return { suppressOutput: true }; + } + + if (result.status === "already") { + writeStatus("injected", "左侧余额组件已经注入。", { asarPath: asar, injected: true }); + return { suppressOutput: true }; + } + + // If an official update rewrote the asar during the repack, discard the stage. + try { + const after = fs.statSync(asar); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + removeStage(stage); + writeStatus("error", "重打包期间 app.asar 发生变化,已丢弃本次补丁;请重启 ZCode 重试。", { + asarPath: asar, + injected: false, + }); + return { suppressOutput: true }; + } + } catch { + removeStage(stage); + writeStatus("error", "重打包后无法确认 app.asar 状态,已丢弃本次补丁。", { asarPath: asar }); + return { suppressOutput: true }; + } + + try { + fs.writeFileSync( + metaPath, + JSON.stringify( + { source: { size: before.size, mtimeMs: before.mtimeMs }, createdAt: new Date().toISOString() }, + null, + 2, + ), + ); + } catch (error) { + removeStage(stage); + writeStatus("error", `无法写入补丁元数据:${error.message}`, { asarPath: asar }); + return { suppressOutput: true }; + } + + try { + patchMod.validateAsar(stage, { expectBalance: true }); + } catch (error) { + removeStage(stage); + writeStatus("error", `补丁校验失败,未替换 app.asar:${error.message}`, { asarPath: asar }); + return { suppressOutput: true }; + } + + try { + const replaced = patchMod.replaceAsarWithBackup(stage, asar, { + stageOptions: { expectBalance: true }, + postOptions: { expectBalance: true }, + }); + removeStage(stage); + writeStatus("injected", "左侧余额组件已注入;请完全退出并重开 ZCode 生效。", { + asarPath: asar, + injected: true, + pending: false, + backup: replaced.backup, + }); + return { + suppressOutput: true, + systemMessage: "余额面板的左侧组件已安装,请完全退出并重开 ZCode 生效。", + }; + } catch { + if (!spawnFinalizer(stage, asar)) { + removeStage(stage); + writeStatus("error", "app.asar 被占用,且无法启动收尾进程;请完全退出 ZCode 后重启。", { + asarPath: asar, + }); + return { suppressOutput: true }; + } + writeStatus("pending-restart", "左侧余额组件已排定,完全退出 ZCode 后自动完成替换;重开后生效。", { + asarPath: asar, + injected: false, + pending: true, + }); + return { + suppressOutput: true, + systemMessage: "余额面板的左侧组件已排定,完全退出 ZCode 后会自动完成替换;重开后生效。", + }; + } +} + +function output(payload) { + process.stdout.write(`${JSON.stringify(payload)}\n`); +} + +try { + const previous = readPreviousStatus(); + const result = await main(); + const current = readPreviousStatus(); + if ( + current && + (current.state === "pending-restart" || current.state === "needs-admin") && + (!previous || previous.state !== current.state) && + result.systemMessage === undefined + ) { + result.systemMessage = current.message; + } + output(result); +} catch (error) { + writeStatus("error", `左侧组件自检失败:${error.message || error}`); + output({ suppressOutput: true, systemMessage: `余额面板左侧组件自检失败:${error.message || error}` }); +} diff --git a/external_plugins/balance-dashboard/scripts/asar-patch.mjs b/external_plugins/balance-dashboard/scripts/asar-patch.mjs new file mode 100644 index 00000000..584d0d7c --- /dev/null +++ b/external_plugins/balance-dashboard/scripts/asar-patch.mjs @@ -0,0 +1,447 @@ +#!/usr/bin/env node +/** + * Minimal, dependency-free app.asar entry-line patcher. + * + * It only appends/removes the balance-dashboard loader line in + * `out/main/index.js`; every other file is stream-copied byte-for-byte. + * The asar layout follows Electron's pickle format (same approach as the + * token-usage statusbar plugin), including per-file offsets and integrity. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const ENTRY = "/out/main/index.js"; +const ALIGN = 4; +const BLOCK = 4 * 1024 * 1024; +const RUNTIME_DIR = + (process.env.BALANCE_RUNTIME_DIR && process.env.BALANCE_RUNTIME_DIR.trim()) || + path.join(os.homedir(), ".zcode", "balance-dashboard", "runtime"); +const LOADER_PATH = path.join(RUNTIME_DIR, "sidebar", "inject-main.cjs"); +const STAGE_SUFFIX = ".balance-dashboard.tmp"; + +const INJECT_LINE = `\n;import(${JSON.stringify(pathToFileURL(LOADER_PATH).href)}).then(() => null, (e) => console.error("[balance-dashboard] load failed", e));`; +const INJECT_RE = + /\n;import\("file:[^"]*inject-main\.cjs"\)\.then\(\(\) => null, \(e\) => console\.error\("\[balance-dashboard\] load failed", e\)\);?/g; + +export function injectionLine(loaderPath = LOADER_PATH) { + return `\n;import(${JSON.stringify(pathToFileURL(loaderPath).href)}).then(() => null, (e) => console.error("[balance-dashboard] load failed", e));`; +} + +function readHeader(fd) { + const head = Buffer.alloc(16); + fs.readSync(fd, head, 0, 16, 0); + const a = head.readUInt32LE(0); + const b = head.readUInt32LE(4); + const c = head.readUInt32LE(8); + const d = head.readUInt32LE(12); + if (a !== 4) throw new Error(`not an asar archive (prefix ${a})`); + if (d <= 0 || d > 64 * 1024 * 1024) throw new Error(`invalid asar header length ${d}`); + const json = Buffer.alloc(d); + fs.readSync(fd, json, 0, d, 16); + const header = JSON.parse(json.toString("utf8")); + return { header, base: 8 + b, headerLength: d, headerBlock: c }; +} + +function iterFiles(node, prefix = "") { + const out = []; + for (const [name, child] of Object.entries(node.files || {})) { + const p = `${prefix}/${name}`; + if (child && typeof child === "object" && child.files) out.push(...iterFiles(child, p)); + else out.push({ path: p, node: child }); + } + return out; +} + +function integrityOf(data) { + const hash = crypto.createHash("sha256").update(data).digest("hex"); + const blocks = []; + if (data.length === 0) { + // Electron's asar writer records one empty block for zero-length files. + blocks.push(hash); + } else { + for (let i = 0; i < data.length; i += BLOCK) { + blocks.push(crypto.createHash("sha256").update(data.subarray(i, i + BLOCK)).digest("hex")); + } + } + return { algorithm: "SHA256", hash, blockSize: BLOCK, blocks }; +} + +function readEntry(asarPath) { + const fd = fs.openSync(asarPath, "r"); + try { + const { header, base } = readHeader(fd); + const entry = iterFiles(header).find((item) => item.path === ENTRY); + if (!entry) throw new Error(`asar is missing ${ENTRY}`); + const data = Buffer.alloc(entry.node.size); + fs.readSync(fd, data, 0, data.length, base + Number(entry.node.offset)); + return { header, base, entry: { ...entry, data } }; + } finally { + fs.closeSync(fd); + } +} + +export function inspectAsar(asarPath) { + const fd = fs.openSync(asarPath, "r"); + try { + const { header, base } = readHeader(fd); + const files = iterFiles(header); + const entry = files.find((item) => item.path === ENTRY); + if (!entry) throw new Error(`asar is missing ${ENTRY}`); + const data = Buffer.alloc(entry.node.size); + fs.readSync(fd, data, 0, data.length, base + Number(entry.node.offset)); + const text = data.toString("utf8"); + INJECT_RE.lastIndex = 0; + return { + asarPath, + fileCount: files.length, + entrySize: data.length, + hasBalanceLine: INJECT_RE.test(text), + hasZusageLine: /\[zusage\] load failed/.test(text), + }; + } finally { + INJECT_RE.lastIndex = 0; + fs.closeSync(fd); + } +} + +/** + * Rebuild an asar with one replaced file. + * + * Critical invariant: `node.offset` is the SOURCE offset until the copy loop + * finishes. The new offsets are kept separately, otherwise every file after a + * size-changing replacement is read from the wrong place and the archive looks + * structurally valid while its contents are shifted. + */ +function repackAsar(asarPath, dstPath, replacements) { + const srcFd = fs.openSync(asarPath, "r"); + try { + const { header, base } = readHeader(srcFd); + const copy = JSON.parse(JSON.stringify(header)); + const entries = iterFiles(copy); + const missing = [...replacements.keys()].filter((p) => !entries.some((item) => item.path === p)); + if (missing.length) throw new Error(`asar paths not found: ${missing.join(", ")}`); + for (const { path: p, node } of entries) { + if (node.unpacked && replacements.has(p)) { + throw new Error(`cannot replace unpacked asar entry: ${p}`); + } + } + + // Immutable snapshot of the source layout. This is the fix for the + // 2026-11-13 corruption: never read from a rewritten offset. + const originalOffsets = new Map( + entries.map(({ path: p, node }) => [p, Number(node.offset)]), + ); + const newOffsets = new Map(); + let offset = 0; + for (const { path: p, node } of entries) { + if (node.unpacked) continue; + if (replacements.has(p)) { + const data = replacements.get(p); + node.size = data.length; + node.integrity = integrityOf(data); + } + newOffsets.set(p, offset); + offset += Number(node.size); + } + for (const { path: p, node } of entries) { + if (!node.unpacked) node.offset = String(newOffsets.get(p)); + } + + const headerBytes = Buffer.from(JSON.stringify(copy), "utf8"); + const d = headerBytes.length; + const c = Math.ceil((d + 4) / ALIGN) * ALIGN; + const b = c + 4; + const prefix = Buffer.alloc(16); + prefix.writeUInt32LE(4, 0); + prefix.writeUInt32LE(b, 4); + prefix.writeUInt32LE(c, 8); + prefix.writeUInt32LE(d, 12); + + const dstFd = fs.openSync(dstPath, "w"); + try { + fs.writeSync(dstFd, prefix, 0, 16); + fs.writeSync(dstFd, headerBytes, 0, headerBytes.length); + const pad = Buffer.alloc(c - d - 4); + if (pad.length) fs.writeSync(dstFd, pad, 0, pad.length); + + const chunk = Buffer.alloc(1024 * 1024); + for (const { path: p, node } of entries) { + if (node.unpacked) continue; + if (replacements.has(p)) { + const data = replacements.get(p); + fs.writeSync(dstFd, data, 0, data.length); + continue; + } + let remaining = Number(node.size); + let srcOffset = base + originalOffsets.get(p); + while (remaining > 0) { + const size = Math.min(chunk.length, remaining); + fs.readSync(srcFd, chunk, 0, size, srcOffset); + fs.writeSync(dstFd, chunk, 0, size); + srcOffset += size; + remaining -= size; + } + } + fs.fsyncSync(dstFd); + } finally { + fs.closeSync(dstFd); + } + return { fileCount: entries.length, headerLength: d }; + } finally { + fs.closeSync(srcFd); + } +} + +/** + * Fully validate an asar: header layout, contiguous offsets, bounds, and the + * SHA-256 (plus block hashes) of every entry against the header's integrity. + * This is intentionally expensive; it is the gate before any file replacement. + */ +export function validateAsar(asarPath, options = {}) { + const fd = fs.openSync(asarPath, "r"); + let entryText = null; + try { + const stat = fs.fstatSync(fd); + const { header, base, headerLength, headerBlock } = readHeader(fd); + if (headerBlock !== Math.ceil((headerLength + 4) / ALIGN) * ALIGN) { + throw new Error(`invalid asar header alignment (d=${headerLength}, c=${headerBlock})`); + } + if (base !== 12 + headerBlock) { + throw new Error(`invalid asar data base (base=${base}, c=${headerBlock})`); + } + const entries = iterFiles(header).filter(({ node }) => !node.unpacked); + let expectedOffset = 0; + let entryCount = 0; + for (const { path: p, node } of entries) { + const off = Number(node.offset); + const size = Number(node.size); + if (!Number.isInteger(off) || !Number.isInteger(size) || off < 0 || size < 0) { + throw new Error(`invalid offset/size for ${p}`); + } + if (off !== expectedOffset) { + throw new Error(`non-contiguous offset for ${p}: ${off} != ${expectedOffset}`); + } + if (size > 64 * 1024 * 1024) throw new Error(`unreasonable entry size for ${p}: ${size}`); + if (base + off + size > stat.size) throw new Error(`entry out of bounds: ${p}`); + const data = Buffer.alloc(size); + if (size > 0) fs.readSync(fd, data, 0, size, base + off); + if (node.integrity && typeof node.integrity === "object") { + const actual = integrityOf(data); + if (node.integrity.hash && actual.hash !== node.integrity.hash) { + throw new Error(`integrity hash mismatch: ${p}`); + } + if (Array.isArray(node.integrity.blocks)) { + if (actual.blocks.length !== node.integrity.blocks.length) { + throw new Error(`integrity block count mismatch: ${p}`); + } + for (let i = 0; i < actual.blocks.length; i += 1) { + if (actual.blocks[i] !== node.integrity.blocks[i]) { + throw new Error(`integrity block mismatch: ${p} [${i}]`); + } + } + } + } + if (p === ENTRY) entryText = data.toString("utf8"); + expectedOffset += size; + entryCount += 1; + } + if (base + expectedOffset !== stat.size) { + throw new Error( + `asar length mismatch: header end ${base + expectedOffset}, file size ${stat.size}`, + ); + } + const hasBalanceLine = entryText ? /\[balance-dashboard\] load failed/.test(entryText) : false; + const hasZusageLine = entryText ? /\[zusage\] load failed/.test(entryText) : false; + if (options.expectBalance !== undefined && hasBalanceLine !== options.expectBalance) { + throw new Error( + `injection marker mismatch: expected balance=${options.expectBalance}, got ${hasBalanceLine}`, + ); + } + if (options.expectZusage !== undefined && hasZusageLine !== options.expectZusage) { + throw new Error( + `zusage marker mismatch: expected ${options.expectZusage}, got ${hasZusageLine}`, + ); + } + return { + ok: true, + asarPath, + fileCount: entryCount, + entrySize: entryText ? Buffer.byteLength(entryText) : 0, + hasBalanceLine, + hasZusageLine, + size: stat.size, + }; + } finally { + fs.closeSync(fd); + } +} + +export const BACKUP_SUFFIX = ".balance-dashboard.bak"; + +export function backupPathOf(asarPath) { + return `${asarPath}${BACKUP_SUFFIX}`; +} + +export function restoreBackup(asarPath) { + const backup = backupPathOf(asarPath); + if (!fs.existsSync(backup)) throw new Error(`backup not found: ${backup}`); + const check = validateAsar(backup); + fs.copyFileSync(backup, asarPath); + return { status: "restored", asarPath, backup, ...check }; +} + +export function replaceAsarWithBackup(stagePath, asarPath, options = {}) { + const stageCheck = validateAsar(stagePath, options.stageOptions || {}); + const backup = backupPathOf(asarPath); + try { + fs.unlinkSync(backup); + } catch { + // no previous backup + } + try { + fs.linkSync(asarPath, backup); + } catch { + fs.copyFileSync(asarPath, backup); + } + try { + fs.renameSync(stagePath, asarPath); + const postCheck = validateAsar(asarPath, options.postOptions || {}); + return { backup, stageCheck, postCheck }; + } catch (error) { + try { + fs.copyFileSync(backup, asarPath); + } catch { + // leave the backup for manual restoration + } + throw error; + } +} + +export function buildPatchedAsar({ asarPath, loaderPath = LOADER_PATH, stagePath }) { + const sourceCheck = validateAsar(asarPath); + const { entry } = readEntry(asarPath); + const text = entry.data.toString("utf8"); + const stripped = text.replace(INJECT_RE, ""); + const line = injectionLine(loaderPath); + const hadZusage = /\[zusage\] load failed/.test(text); + if (text.includes(line) || stripped + line === text) { + return { status: "already", asarPath, entrySize: entry.data.length, hasZusageLine: hadZusage }; + } + const newEntry = Buffer.from(stripped + line, "utf8"); + const stage = stagePath || `${asarPath}${STAGE_SUFFIX}`; + const tmp = `${stage}.stage-${process.pid}`; + try { + repackAsar(asarPath, tmp, new Map([[ENTRY, newEntry]])); + const check = validateAsar(tmp, { expectBalance: true, expectZusage: hadZusage }); + fs.renameSync(tmp, stage); + return { status: "patched", stagePath: stage, sourceCheck, ...check, asarPath }; + } catch (error) { + try { + fs.unlinkSync(tmp); + } catch { + // ignore + } + throw error; + } +} + +export function buildUnpatchedAsar({ asarPath, stagePath }) { + const sourceCheck = validateAsar(asarPath); + const { entry } = readEntry(asarPath); + const text = entry.data.toString("utf8"); + INJECT_RE.lastIndex = 0; + if (!INJECT_RE.test(text)) { + INJECT_RE.lastIndex = 0; + return { status: "already-clean", asarPath, sourceCheck }; + } + INJECT_RE.lastIndex = 0; + const stripped = text.replace(INJECT_RE, ""); + const hadZusage = /\[zusage\] load failed/.test(text); + const stage = stagePath || `${asarPath}${STAGE_SUFFIX}`; + const tmp = `${stage}.stage-${process.pid}`; + try { + repackAsar(asarPath, tmp, new Map([[ENTRY, Buffer.from(stripped, "utf8")]])); + const check = validateAsar(tmp, { expectBalance: false, expectZusage: hadZusage }); + fs.renameSync(tmp, stage); + return { status: "unpatched", stagePath: stage, sourceCheck, ...check, asarPath }; + } catch (error) { + try { + fs.unlinkSync(tmp); + } catch { + // ignore + } + throw error; + } +} + +export function replaceAsar(stagePath, asarPath) { + return replaceAsarWithBackup(stagePath, asarPath); +} + +export function findAppAsar(explicit) { + if (explicit) return explicit; + if (process.env.ZCODE_ASAR) return process.env.ZCODE_ASAR; + const candidates = []; + const add = (p) => p && candidates.push(p); + if (process.platform === "win32") { + add(path.join(process.env.ProgramFiles || "C:\\Program Files", "ZCode", "resources", "app.asar")); + add(path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "ZCode", "resources", "app.asar")); + add(path.join(process.env.LOCALAPPDATA || "", "Programs", "ZCode", "resources", "app.asar")); + add("D:\\Program Files\\ZCode\\resources\\app.asar"); + add("D:\\ZCode\\resources\\app.asar"); + } else if (process.platform === "darwin") { + add("/Applications/ZCode.app/Contents/Resources/app.asar"); + } else { + add("/opt/ZCode/resources/app.asar"); + add(path.join(process.cwd(), "resources", "app.asar")); + } + return candidates.find((p) => { + try { + return fs.statSync(p).isFile(); + } catch { + return false; + } + }) || null; +} + +function argValue(name) { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) { + const command = process.argv[2] || "check"; + const asar = findAppAsar(argValue("--asar")); + if (!asar) { + console.error("app.asar not found; pass --asar "); + process.exit(2); + } + try { + if (command === "check") { + console.log(JSON.stringify(inspectAsar(asar), null, 2)); + } else if (command === "validate") { + console.log(JSON.stringify(validateAsar(asar), null, 2)); + } else if (command === "patch") { + const result = buildPatchedAsar({ asarPath: asar, loaderPath: argValue("--loader") || LOADER_PATH }); + console.log(JSON.stringify(result, null, 2)); + } else if (command === "unpatch") { + const result = buildUnpatchedAsar({ asarPath: asar }); + console.log(JSON.stringify(result, null, 2)); + } else if (command === "finalize") { + const stage = argValue("--stage"); + if (!stage) throw new Error("--stage is required"); + const result = replaceAsarWithBackup(stage, asar); + console.log(JSON.stringify({ status: "replaced", asar, stage, ...result }, null, 2)); + } else if (command === "restore") { + console.log(JSON.stringify(restoreBackup(asar), null, 2)); + } else { + throw new Error(`unknown command: ${command}`); + } + } catch (error) { + console.error(error.stack || error.message || String(error)); + process.exit(1); + } +} diff --git a/external_plugins/balance-dashboard/scripts/collect-once.mjs b/external_plugins/balance-dashboard/scripts/collect-once.mjs new file mode 100644 index 00000000..45c8c420 --- /dev/null +++ b/external_plugins/balance-dashboard/scripts/collect-once.mjs @@ -0,0 +1,19 @@ +/** + * One-shot data collector used by the injected sidebar widget. + * Prints a single JSON document to stdout and exits. + */ +import { getBalances } from "../server/data.mjs"; + +try { + const data = await getBalances(process.argv.includes("--force")); + process.stdout.write(JSON.stringify(data)); +} catch (error) { + process.stdout.write( + JSON.stringify({ + schemaVersion: 1, + generatedAt: new Date().toISOString(), + error: { code: "collect_failed", message: error?.message || String(error) }, + }), + ); + process.exitCode = 1; +} diff --git a/external_plugins/balance-dashboard/scripts/query-analytics.mjs b/external_plugins/balance-dashboard/scripts/query-analytics.mjs new file mode 100644 index 00000000..e8e4bae5 --- /dev/null +++ b/external_plugins/balance-dashboard/scripts/query-analytics.mjs @@ -0,0 +1,370 @@ +#!/usr/bin/env node +/** + * On-demand usage analytics query for the sidebar modal. + * + * Prints one JSON document to stdout: + * { ok, range, summary, series, providers, models, projects, tasks } + * + * Only reads the local ZCode usage database (read-only connection). Filtering + * and bucketing happen in local time so the numbers match what the user sees + * in the app's own usage views. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + bucketOfProvider, + priceOfModel, + DEFAULT_PRICE_USD, + USD_TO_CNY, +} from "../server/data.mjs"; + +function arg(name) { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +function num(value, fallback = 0) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +function round2(n) { + return Math.round((Number(n) + Number.EPSILON) * 100) / 100; +} + +function dbPath() { + return ( + (process.env.ZCODE_USAGE_DB && process.env.ZCODE_USAGE_DB.trim()) || + (process.env.BALANCE_USAGE_DB && process.env.BALANCE_USAGE_DB.trim()) || + path.join(os.homedir(), ".zcode", "cli", "db", "db.sqlite") + ); +} + +function bucketExpr(bucket) { + if (bucket === "hour") { + return "strftime('%Y-%m-%d %H:00', u.started_at / 1000, 'unixepoch', 'localtime')"; + } + if (bucket === "week") { + // Monday date of the week, so the renderer can zero-fill by stepping 7 days. + return "strftime('%Y-%m-%d', (u.started_at / 1000) - (((strftime('%w', u.started_at / 1000, 'unixepoch', 'localtime') + 6) % 7) * 86400), 'unixepoch', 'localtime')"; + } + return "strftime('%Y-%m-%d', u.started_at / 1000, 'unixepoch', 'localtime')"; +} + +const PROVIDER_LABELS = { + deepseek: "DeepSeek", + volcanoCodingPlan: "火山 Coding Plan", + volcanoAccount: "火山账户", + zcode: "ZCode", + other: "其他", +}; + +function costOfRow(row) { + const rates = priceOfModel(row.modelId) || DEFAULT_PRICE_USD; + const cachedWrite = num(row.cacheWriteTokens); + const cachedRead = num(row.cacheReadTokens); + const bareInput = Math.max(0, num(row.inputTokens) - cachedRead - cachedWrite); + const usd = + (bareInput * rates.input + + cachedRead * rates.cacheRead + + cachedWrite * rates.cacheWrite + + num(row.outputTokens) * rates.output) / + 1_000_000; + return usd * USD_TO_CNY; +} + +function totalTokensOf(row) { + return num(row.computedTotalTokens) || num(row.inputTokens) + num(row.outputTokens); +} + +async function main() { + const started = Date.now(); + const now = Date.now(); const start = num(arg("--start"), now - 24 * 3600 * 1000); + const end = num(arg("--end"), now); + const bucket = ["hour", "day", "week"].includes(arg("--bucket")) ? arg("--bucket") : "day"; + const projectId = arg("--project-id") || ""; + const directory = arg("--directory") || ""; + const sessionId = arg("--session-id") || ""; + const providerFilter = arg("--provider") || ""; + + const file = dbPath(); + if (!fs.existsSync(file)) { + return { + ok: false, + error: { code: "db_missing", message: `用量数据库不存在:${file}` }, + }; + } + + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(file, { readOnly: true }); + try { + // Shared WHERE fragments. started_at is epoch ms, half-open [start, end). + const rangeWhere = ["u.status = 'completed'", "u.started_at >= ?", "u.started_at < ?"]; + const rangeArgs = [start, end]; + if (projectId) { + rangeWhere.push("s.project_id = ?"); + rangeArgs.push(projectId); + } + if (directory) { + rangeWhere.push("s.directory = ?"); + rangeArgs.push(directory); + } + if (sessionId) { + rangeWhere.push("u.session_id = ?"); + rangeArgs.push(sessionId); + } + + const grouped = db + .prepare( + `select ${bucketExpr(bucket)} as bucket_key, + s.project_id, s.directory, s.id as session_id, s.title as session_title, + u.provider_id, u.model_id, + count(*) as attempts, + count(distinct u.logical_request_id) as requests, + coalesce(sum(u.input_tokens),0) as input_tokens, + coalesce(sum(u.output_tokens),0) as output_tokens, + coalesce(sum(u.reasoning_tokens),0) as reasoning_tokens, + coalesce(sum(u.cache_creation_input_tokens),0) as cache_write_tokens, + coalesce(sum(u.cache_read_input_tokens),0) as cache_read_tokens, + coalesce(sum(u.computed_total_tokens),0) as computed_total_tokens + from model_usage u + join session s on s.id = u.session_id + where ${rangeWhere.join(" and ")} + group by bucket_key, u.provider_id, u.model_id, s.id`, + ) + .all(...rangeArgs) + .map((row) => ({ + key: String(row.bucket_key || ""), + projectId: String(row.project_id || ""), + directory: String(row.directory || ""), + sessionId: String(row.session_id || ""), + sessionTitle: String(row.session_title || ""), + providerId: String(row.provider_id || ""), + modelId: String(row.model_id || ""), + attempts: num(row.attempts), + requests: num(row.requests), + inputTokens: num(row.input_tokens), + outputTokens: num(row.output_tokens), + reasoningTokens: num(row.reasoning_tokens), + cacheWriteTokens: num(row.cache_write_tokens), + cacheReadTokens: num(row.cache_read_tokens), + computedTotalTokens: num(row.computed_total_tokens), + })) + .map((row) => ({ + ...row, + bucket: bucketOfProvider(row.providerId, row.modelId), + totalTokens: totalTokensOf(row), + })) + .filter((row) => { + if (!providerFilter) return true; + if (row.bucket === providerFilter) return true; + return providerFilter === "volcano" && row.bucket.startsWith("volcano"); + }); + + const summary = { + requests: 0, + attempts: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + cost: 0, + sessions: 0, + }; + const seriesMap = new Map(); + const providerMap = new Map(); + const modelMap = new Map(); + const taskMap = new Map(); + const uniqueRequests = new Set(); + // Requests are only unique per (session, logical_request_id); group rows do + // not carry the id, so the exact distinct count comes from a separate query + // and per-bucket numbers use the grouped counts (attempt-level distinct). + let costUSD = 0; + + for (const row of grouped) { + const cost = costOfRow(row); + costUSD += cost; + summary.attempts += row.attempts; + summary.requests += row.requests; + summary.inputTokens += row.inputTokens; + summary.outputTokens += row.outputTokens; + summary.reasoningTokens += row.reasoningTokens; + summary.cacheReadTokens += row.cacheReadTokens; + summary.cacheWriteTokens += row.cacheWriteTokens; + summary.totalTokens += row.totalTokens; + + const bucketEntry = seriesMap.get(row.key) || { + key: row.key, + totalTokens: 0, + cost: 0, + requests: 0, + providers: {}, + }; + const bucketProvider = bucketEntry.providers[row.bucket] || { tokens: 0, cost: 0, requests: 0 }; + bucketProvider.tokens += row.totalTokens; + bucketProvider.cost += cost; + bucketProvider.requests += row.requests; + bucketEntry.providers[row.bucket] = bucketProvider; + bucketEntry.totalTokens += row.totalTokens; + bucketEntry.cost += cost; + bucketEntry.requests += row.requests; + seriesMap.set(row.key, bucketEntry); + + const provider = providerMap.get(row.bucket) || { + id: row.bucket, + label: PROVIDER_LABELS[row.bucket] || row.bucket, + tokens: 0, + cost: 0, + requests: 0, + models: 0, + }; + provider.tokens += row.totalTokens; + provider.cost += cost; + provider.requests += row.requests; + providerMap.set(row.bucket, provider); + + const modelKey = `${row.providerId}\u0000${row.modelId}`; + const model = modelMap.get(modelKey) || { + providerId: row.providerId, + modelId: row.modelId, + bucket: row.bucket, + tokens: 0, + cost: 0, + requests: 0, + }; + model.tokens += row.totalTokens; + model.cost += cost; + model.requests += row.requests; + modelMap.set(modelKey, model); + + if (row.sessionId) { + uniqueRequests.add(row.sessionId); + const task = taskMap.get(row.sessionId) || { + id: row.sessionId, + title: row.sessionTitle || row.sessionId, + projectId: row.projectId, + directory: row.directory, + tokens: 0, + cost: 0, + requests: 0, + }; + task.tokens += row.totalTokens; + task.cost += cost; + task.requests += row.requests; + taskMap.set(row.sessionId, task); + } + } + + summary.sessions = uniqueRequests.size; + summary.cost = round2(costUSD); + + const projectRows = db + .prepare( + `select s.project_id, s.directory, + count(distinct s.id) as sessions, + coalesce(sum(u.computed_total_tokens),0) as computed_total_tokens, + coalesce(sum(u.input_tokens + u.output_tokens),0) as plain_tokens + from model_usage u + join session s on s.id = u.session_id + where u.status = 'completed' and u.started_at >= ? and u.started_at < ? + group by s.project_id, s.directory + order by plain_tokens desc`, + ) + .all(start, end); + + const projects = projectRows.map((row) => ({ + id: String(row.project_id || ""), + directory: String(row.directory || ""), + label: String(row.directory || row.project_id || "").split(/[\\/]/).filter(Boolean).pop() || String(row.project_id || ""), + sessions: num(row.sessions), + tokens: num(row.computed_total_tokens) || num(row.plain_tokens), + })); + + const taskRows = db + .prepare( + `select s.id, s.title, s.project_id, s.directory, + count(*) as attempts, + coalesce(sum(u.computed_total_tokens),0) as computed_total_tokens, + coalesce(sum(u.input_tokens + u.output_tokens),0) as plain_tokens + from model_usage u + join session s on s.id = u.session_id + where u.status = 'completed' and u.started_at >= ? and u.started_at < ? + and s.parent_id is null + and s.id not like 'sess_subagent%' + and s.id not like 'sess_dwf-%' + ${projectId ? "and s.project_id = ?" : ""} + ${directory ? "and s.directory = ?" : ""} + group by s.id + order by plain_tokens desc + limit 50`, + ) + .all( + start, + end, + ...(projectId ? [projectId] : []), + ...(directory ? [directory] : []), + ); + + const tasks = taskRows.map((row) => ({ + id: String(row.id || ""), + title: String(row.title || row.id || ""), + projectId: String(row.project_id || ""), + directory: String(row.directory || ""), + tokens: num(row.computed_total_tokens) || num(row.plain_tokens), + attempts: num(row.attempts), + })); + + const series = Array.from(seriesMap.values()) + .map((entry) => ({ ...entry, cost: round2(entry.cost) })) + .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); + + const providers = Array.from(providerMap.values()) + .map((entry) => ({ + ...entry, + cost: round2(entry.cost), + share: summary.totalTokens > 0 ? entry.tokens / summary.totalTokens : 0, + })) + .sort((a, b) => b.tokens - a.tokens); + + const models = Array.from(modelMap.values()) + .map((entry) => ({ ...entry, cost: round2(entry.cost) })) + .sort((a, b) => b.tokens - a.tokens) + .slice(0, 12); + + return { + ok: true, + range: { start, end, bucket, provider: providerFilter || "all" }, + filters: { + projectId, + directory, + sessionId, + provider: providerFilter || "all", + }, + summary, + series, + providers, + models, + projects, + tasks, + elapsedMs: Date.now() - started, + }; + } finally { + db.close(); + } +} + +try { + const result = await main(); + process.stdout.write(JSON.stringify(result)); +} catch (error) { + process.stdout.write( + JSON.stringify({ + ok: false, + error: { code: "analytics_failed", message: error?.message || String(error) }, + }), + ); + process.exitCode = 1; +} diff --git a/external_plugins/balance-dashboard/scripts/restore-backup.mjs b/external_plugins/balance-dashboard/scripts/restore-backup.mjs new file mode 100644 index 00000000..5abe95e1 --- /dev/null +++ b/external_plugins/balance-dashboard/scripts/restore-backup.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +/** + * Restore app.asar from the pre-patch backup created by replaceAsarWithBackup. + * Use this if ZCode cannot start after an injection attempt. + * + * Quit ZCode completely before running. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const RUNTIME = + (process.env.BALANCE_RUNTIME_DIR && process.env.BALANCE_RUNTIME_DIR.trim()) || + path.join(process.env.USERPROFILE || process.env.HOME || "", ".zcode", "balance-dashboard", "runtime"); + +function arg(name) { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +async function main() { + const patchMod = await import(pathToFileURL(path.join(HERE, "asar-patch.mjs")).href); + const asar = patchMod.findAppAsar(arg("--asar") || process.env.ZCODE_ASAR || process.env.ZCODE_APP_ASAR); + if (!asar) { + console.error("未找到 app.asar;可用 --asar 指定。"); + process.exitCode = 2; + return; + } + const backup = patchMod.backupPathOf(asar); + if (!fs.existsSync(backup)) { + console.error(`备份不存在:${backup}`); + process.exitCode = 2; + return; + } + let check; + try { + check = patchMod.validateAsar(backup); + } catch (error) { + console.error(`备份本身校验失败,未恢复:${error.message}`); + process.exitCode = 1; + return; + } + try { + fs.copyFileSync(backup, asar); + const post = patchMod.validateAsar(asar); + try { + fs.mkdirSync(RUNTIME, { recursive: true }); + fs.writeFileSync( + path.join(RUNTIME, "status.json"), + JSON.stringify( + { + state: "restored", + message: "已从备份恢复 app.asar;重启 ZCode 后生效。", + updatedAt: new Date().toISOString(), + asarPath: asar, + backup, + injected: false, + pending: false, + }, + null, + 2, + ), + ); + } catch { + // diagnostic only + } + console.log(JSON.stringify({ status: "restored", asar, backup, check, post }, null, 2)); + } catch (error) { + console.error( + `恢复失败:${error.message}\n请完全退出 ZCode 后重试:\n node "${path.join(HERE, "restore-backup.mjs")}"`, + ); + process.exitCode = 1; + } +} + +main().catch((error) => { + console.error(error.stack || error.message || String(error)); + process.exitCode = 1; +}); diff --git a/external_plugins/balance-dashboard/scripts/unpatch.mjs b/external_plugins/balance-dashboard/scripts/unpatch.mjs new file mode 100644 index 00000000..30c9e85e --- /dev/null +++ b/external_plugins/balance-dashboard/scripts/unpatch.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Remove only the balance-dashboard loader line from app.asar. + * Other injection lines (including the token-usage statusbar) are preserved. + * + * Run with ZCode fully quit when possible. If app.asar is locked, the command + * leaves a stage file and explains how to finish. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const RUNTIME = + (process.env.BALANCE_RUNTIME_DIR && process.env.BALANCE_RUNTIME_DIR.trim()) || + path.join(process.env.USERPROFILE || process.env.HOME || "", ".zcode", "balance-dashboard", "runtime"); + +function arg(name) { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +async function main() { + const { findAppAsar, inspectAsar, buildUnpatchedAsar, replaceAsarWithBackup } = await import( + pathToFileURL(path.join(HERE, "asar-patch.mjs")).href + ); + const asar = findAppAsar(arg("--asar") || process.env.ZCODE_ASAR || process.env.ZCODE_APP_ASAR); + if (!asar) { + console.error("未找到 app.asar;可用 --asar 指定。"); + process.exitCode = 2; + return; + } + const before = inspectAsar(asar); + if (!before.hasBalanceLine) { + console.log(JSON.stringify({ status: "already-clean", asar }, null, 2)); + return; + } + const result = buildUnpatchedAsar({ asarPath: asar }); + if (result.status === "already-clean") { + console.log(JSON.stringify({ status: "already-clean", asar }, null, 2)); + return; + } + try { + const replaced = replaceAsarWithBackup(result.stagePath, asar, { + stageOptions: { expectBalance: false }, + postOptions: { expectBalance: false }, + }); + try { + fs.unlinkSync(`${result.stagePath}.json`); + } catch { + // ignore + } + try { + fs.mkdirSync(RUNTIME, { recursive: true }); + fs.writeFileSync( + path.join(RUNTIME, "status.json"), + JSON.stringify( + { + state: "disabled", + message: "左侧余额组件已移除;重启 ZCode 后生效。", + updatedAt: new Date().toISOString(), + asarPath: asar, + injected: false, + pending: false, + backup: replaced.backup, + }, + null, + 2, + ), + ); + } catch { + // ignore + } + console.log(JSON.stringify({ status: "removed", asar, restartRequired: true }, null, 2)); + } catch (error) { + console.error( + `app.asar 当前被 ZCode 占用,未能替换(${error.message})。\n` + + `请完全退出 ZCode 后重新运行:\n node "${path.join(HERE, "unpatch.mjs")}"\n` + + `临时补丁文件保留在:${result.stagePath}`, + ); + process.exitCode = 1; + } +} + +main().catch((error) => { + console.error(error.stack || error.message || String(error)); + process.exitCode = 1; +}); diff --git a/external_plugins/balance-dashboard/server/data.mjs b/external_plugins/balance-dashboard/server/data.mjs new file mode 100644 index 00000000..d1031a6f --- /dev/null +++ b/external_plugins/balance-dashboard/server/data.mjs @@ -0,0 +1,1267 @@ +/** + * Read-only balance data layer shared by the MCP tool, the one-shot collector, + * and the sidebar injector. + * + * Fetches official balances where the provider offers an API and reads the + * local ZCode usage database for today's token usage. No credentials are logged + * or written to disk. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; + +const CACHE_TTL_MS = 60 * 1000; +const FETCH_TIMEOUT_MS = 12 * 1000; +const USER_AGENT = "balance-dashboard/0.3.0"; + +const env = process.env; +const HOME = os.homedir(); +const ZCODE_ROOT = path.join(HOME, ".zcode"); +const ZCODE_V2_DIR = path.join(ZCODE_ROOT, "v2"); +const RUNTIME_DIR = + (env.BALANCE_RUNTIME_DIR && env.BALANCE_RUNTIME_DIR.trim()) || + path.join(ZCODE_ROOT, "balance-dashboard", "runtime"); +const SIDEBAR_STATUS_PATH = path.join(RUNTIME_DIR, "status.json"); +const CREDENTIALS_PATH = env.ZCODE_CREDENTIALS_FILE || path.join(ZCODE_V2_DIR, "credentials.json"); +const PROVIDER_CONFIG_PATH = + env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE || path.join(ZCODE_V2_DIR, "provider_config.json"); +const SETTING_PATH = env.ZCODE_SETTING_FILE || path.join(ZCODE_V2_DIR, "setting.json"); +const CLI_CONFIG_CANDIDATES = [ + env.ZCODE_CLI_CONFIG, + path.join(ZCODE_ROOT, "cli", "config.json"), + path.join(ZCODE_ROOT, "config.json"), +].filter(Boolean); +const SECRETS_PATH = path.join(ZCODE_ROOT, "cli", "plugin-secrets.json"); +const RUNTIME_OPTIONS_PATH = path.join(RUNTIME_DIR, "options.json"); +let pluginOptionsCache = null; +let pluginOptionsSources = []; + +function pluginOptionPools(doc) { + if (!doc || typeof doc !== "object" || Array.isArray(doc)) return []; + const pools = []; + if (doc.plugins && typeof doc.plugins === "object" && doc.plugins.options) pools.push(doc.plugins.options); + if (doc.options && typeof doc.options === "object") pools.push(doc.options); + pools.push(doc); + return pools; +} + +function pickPluginOptions(doc) { + for (const pool of pluginOptionPools(doc)) { + const key = Object.keys(pool).find((name) => name.startsWith("balance-dashboard@")); + if (key && pool[key] && typeof pool[key] === "object" && !Array.isArray(pool[key])) return pool[key]; + } + return null; +} + +/** + * Plugin userConfig values are stored in ZCode's CLI config. The MCP server + * receives them as environment variables, but the injected main-process + * collector does not; reading the same options here keeps both views in sync. + * + * Merge order (low to high precedence): + * 1. ~/.zcode/cli/config.json -> plugins.options + * 2. ~/.zcode/cli/plugin-secrets.json + * 3. ~/.zcode/balance-dashboard/runtime/options.json (saved by the sidebar modal) + */ +function loadPluginOptions() { + if (pluginOptionsCache) return pluginOptionsCache; + const out = {}; + pluginOptionsSources = []; + + const merge = (values, source, decrypt) => { + if (!values || typeof values !== "object" || Array.isArray(values)) return; + let changed = false; + for (const [name, raw] of Object.entries(values)) { + if (raw == null) continue; + const resolved = decrypt ? decryptCredential(raw) : raw; + if (resolved == null || typeof resolved === "object") continue; + out[name] = String(resolved); + changed = true; + } + if (changed && !pluginOptionsSources.includes(source)) pluginOptionsSources.push(source); + }; + + for (const file of CLI_CONFIG_CANDIDATES) { + const doc = readJson(file); + merge(pickPluginOptions(doc), file, true); + } + + const secretsDoc = readJson(SECRETS_PATH); + merge(pickPluginOptions(secretsDoc), SECRETS_PATH, true); + + const runtimeDoc = readJson(RUNTIME_OPTIONS_PATH); + merge(runtimeDoc && typeof runtimeDoc === "object" ? runtimeDoc : null, RUNTIME_OPTIONS_PATH, false); + + pluginOptionsCache = out; + return pluginOptionsCache; +} + +const SECRET_OPTION_FIELDS = new Set(["deepseek_api_key", "volc_secret_access_key", "zcode_jwt_token"]); +const TEXT_OPTION_FIELDS = ["volc_access_key_id", "volc_region", "zcode_usage_db", "zcode_device_mid"]; + +export function settingsSnapshot() { + const options = loadPluginOptions(); + const values = {}; + const secrets = {}; + for (const name of SECRET_OPTION_FIELDS) { + secrets[name] = !!(options[name] && String(options[name]).trim()); + } + for (const name of TEXT_OPTION_FIELDS) { + values[name] = options[name] ? String(options[name]) : ""; + } + return { + values, + secrets, + ui: { + showDeepseek: options.show_deepseek !== "0", + showVolcano: options.show_volcano !== "0", + showZcode: options.show_zcode !== "0", + hideUnconfigured: options.hide_unconfigured !== "0", + }, + autoDetected: { + deepseek: !!discoverDeepSeekKey(), + volcano: !!discoverVolcengineCredentials(), + zcodeToken: !!loadCredentials()["zcodejwttoken"], + }, + runtimeOptionsPath: RUNTIME_OPTIONS_PATH, + sources: pluginOptionsSources.slice(), + }; +} + +function usageDbPath() { + const options = loadPluginOptions(); + return ( + (env.ZCODE_USAGE_DB && env.ZCODE_USAGE_DB.trim()) || + (options.zcode_usage_db && String(options.zcode_usage_db).trim()) || + path.join(ZCODE_ROOT, "cli", "db", "db.sqlite") + ); +} + +export const USD_TO_CNY = 7.2; +export const MODEL_PRICES_USD_PER_M = { + "deepseek-flash": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, + "deepseek-v4-flash": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, + "deepseek-v4.1-flash": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, + "deepseek-v4-1-flash-261017": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, + "deepseek-v4-flash-vision-exp": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, + "deepseek-v4-pro": { input: 0.66, output: 1.98, cacheRead: 0.022, cacheWrite: 0 }, + "deepseek-reasoner": { input: 0.55, output: 1.66, cacheRead: 0.14, cacheWrite: 0 }, + "deepseek-chat": { input: 0.27, output: 1.1, cacheRead: 0.07, cacheWrite: 0 }, + "doubao-seed-2-0-code-preview-260215": { input: 0.475, output: 2.375, cacheRead: 0.095, cacheWrite: 0 }, + "doubao-seed-2-0-pro-260215": { input: 0.475, output: 2.375, cacheRead: 0.095, cacheWrite: 0 }, + "doubao-seed-2-0-lite-260428": { input: 0.089, output: 0.534, cacheRead: 0.018, cacheWrite: 0 }, + "doubao-seed-2-0-mini-260428": { input: 0.03, output: 0.297, cacheRead: 0.006, cacheWrite: 0 }, + "doubao-seed-2-1-pro-260628": { input: 0.891, output: 4.453, cacheRead: 0.178, cacheWrite: 0 }, + "doubao-seed-2-1-turbo-260628": { input: 0.445, output: 2.227, cacheRead: 0.089, cacheWrite: 0 }, + "glm-5.3": { input: 0.4, output: 1.6, cacheRead: 0.08, cacheWrite: 0.5 }, + "glm-5.3-flash": { input: 0.05, output: 0.2, cacheRead: 0.01, cacheWrite: 0.05 }, +}; +export const DEFAULT_PRICE_USD = { input: 1.0, output: 5.0, cacheRead: 0.1, cacheWrite: 1.25 }; + +const log = (...args) => { + if (env.BALANCE_DEBUG === "1" || env.BALANCE_DEBUG === "true") { + console.error("[balance-dashboard]", ...args); + } +}; + +/* ------------------------------------------------------------------ */ +/* Generic helpers */ +/* ------------------------------------------------------------------ */ + +function readJson(file, fallback = null) { + try { + let text = fs.readFileSync(file, "utf8"); + if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); + return JSON.parse(text); + } catch { + return fallback; + } +} + +function finiteNumber(value) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const n = Number(value); + if (Number.isFinite(n)) return n; + } + return null; +} + +function round2(n) { + return Math.round((Number(n) + Number.EPSILON) * 100) / 100; +} + +function redact(value, visible = 4) { + const s = String(value || ""); + if (s.length <= visible * 2) return s ? `${s.slice(0, 2)}***` : ""; + return `${s.slice(0, visible)}…${s.slice(-visible)}`; +} + +function errorInfo(code, message, hint) { + return { code, message: String(message || code), ...(hint ? { hint } : {}) }; +} + +async function fetchWithTimeout(url, options = {}, timeoutMs = FETCH_TIMEOUT_MS) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + headers: { "user-agent": USER_AGENT, ...(options.headers || {}) }, + }); + const text = await response.text(); + let json = null; + if (text) { + try { + json = JSON.parse(text); + } catch { + json = null; + } + } + return { status: response.status, ok: response.ok, text, json }; + } finally { + clearTimeout(timer); + } +} + +function apiFailure(response, allowCodes = []) { + if (response.ok && !allowCodes.includes(response.status)) return null; + const body = response.json || {}; + const raw = body.msg ?? body.message ?? body.error ?? body.ResponseMetadata?.Error?.Message; + const message = + (typeof raw === "string" && raw) || + (raw && typeof raw === "object" && (raw.message || raw.msg)) || + response.text.slice(0, 160); + return errorInfo( + response.status === 401 || response.status === 403 ? "auth_failed" : "http_error", + typeof message === "string" ? message : JSON.stringify(message), + ); +} + +/* ------------------------------------------------------------------ */ +/* Local ZCode configuration and usage */ +/* ------------------------------------------------------------------ */ + +let providerCache = null; + +function loadProviderRules() { + if (providerCache) return providerCache; + const doc = readJson(PROVIDER_CONFIG_PATH); + const rules = doc?.config?.providerConfigRules?.providerRules; + const map = new Map(); + if (Array.isArray(rules)) { + for (const rule of rules) { + if (!rule || typeof rule !== "object" || !rule.providerId) continue; + map.set(String(rule.providerId), { + id: String(rule.providerId), + name: typeof rule.providerName === "string" ? rule.providerName : String(rule.providerId), + baseUrl: typeof rule?.config?.api?.baseUrl === "string" ? rule.config.api.baseUrl : "", + apiKey: typeof rule?.config?.access?.apiKey === "string" ? rule.config.access.apiKey : "", + accessType: typeof rule?.config?.access?.type === "string" ? rule.config.access.type : "", + }); + } + } + providerCache = map; + return map; +} + +export function bucketOfProvider(providerId, modelId = "") { + const info = loadProviderRules().get(providerId); + const baseUrl = info?.baseUrl || ""; + let host = ""; + let pathname = ""; + try { + const parsed = new URL(baseUrl); + host = parsed.hostname.toLowerCase(); + pathname = parsed.pathname.toLowerCase(); + } catch { + // ignore malformed provider configuration + } + const name = String(info?.name || providerId || "").toLowerCase(); + const model = String(modelId || "").toLowerCase(); + + if (host === "api.deepseek.com" || providerId.includes("deepseek")) return "deepseek"; + if (host === "ark.cn-beijing.volces.com" || host.endsWith(".volces.com")) { + if (pathname.includes("/coding") || name.includes("coding plan")) return "volcanoCodingPlan"; + return "volcanoAccount"; + } + if ( + providerId.startsWith("account:") || + host === "bigmodel.cn" || + host === "z.ai" || + host === "api.z.ai" || + host === "open.bigmodel.cn" + ) { + return "zcode"; + } + if (model.startsWith("deepseek") && providerId === "deepseek") return "deepseek"; + return "other"; +} + +function discoverDeepSeekKey() { + const options = loadPluginOptions(); + const explicit = ( + env.DEEPSEEK_API_KEY || + env.DEEPSEEK_BALANCE_API_KEY || + options.deepseek_api_key || + "" + ) + .toString() + .trim(); + if (explicit) return { key: explicit, source: "user_config" }; + for (const info of loadProviderRules().values()) { + if (!info.apiKey) continue; + let host = ""; + try { + host = new URL(info.baseUrl).hostname.toLowerCase(); + } catch { + host = ""; + } + if (host === "api.deepseek.com" || (!info.baseUrl && info.id === "deepseek")) { + return { key: info.apiKey, source: "provider_config" }; + } + } + return null; +} + +function discoverVolcengineCredentials() { + const options = loadPluginOptions(); + const ak = ( + env.VOLC_ACCESS_KEY_ID || + env.VOLCENGINE_ACCESS_KEY_ID || + env.VOLCENGINE_AK || + options.volc_access_key_id || + "" + ) + .toString() + .trim(); + const sk = ( + env.VOLC_SECRET_ACCESS_KEY || + env.VOLCENGINE_SECRET_ACCESS_KEY || + env.VOLCENGINE_SK || + options.volc_secret_access_key || + "" + ) + .toString() + .trim(); + if (!ak || !sk) return null; + const region = String(env.VOLC_REGION || env.VOLCENGINE_REGION || options.volc_region || "").trim(); + return { ak, sk, region: region || "" }; +} + +function deriveCredentialSecret() { + const direct = (env.ZCODE_CREDENTIAL_SECRET || "").trim(); + if (direct) return direct; + const file = (env.ZCODE_CREDENTIAL_SECRET_FILE || "").trim(); + if (file) { + try { + const value = fs.readFileSync(file, "utf8").trim(); + if (value) return value; + } catch { + // fall through to the host-compatible fallback secret + } + } + let username = "unknown"; + try { + username = os.userInfo().username; + } catch { + // keep placeholder + } + return `zcode-credential-fallback:${os.platform()}:${os.homedir()}:${username}`; +} + +function decryptCredential(value) { + if (typeof value !== "string" || !value.startsWith("enc:")) return value; + if (!value.startsWith("enc:v1:")) { + log("unsupported credential envelope", value.slice(0, 9)); + return null; + } + const parts = value.slice("enc:v1:".length).split("."); + if (parts.length !== 3) return null; + try { + const key = crypto.createHash("sha256").update(deriveCredentialSecret()).digest(); + const iv = Buffer.from(parts[0], "base64url"); + const tag = Buffer.from(parts[1], "base64url"); + const ciphertext = Buffer.from(parts[2], "base64url"); + if (iv.length !== 12 || tag.length !== 16) return null; + const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); + } catch { + return null; + } +} + +function loadCredentials() { + const doc = readJson(CREDENTIALS_PATH, {}); + const out = {}; + if (doc && typeof doc === "object" && !Array.isArray(doc)) { + for (const [key, value] of Object.entries(doc)) { + const decrypted = decryptCredential(value); + if (decrypted != null) out[key] = decrypted; + } + } + return out; +} + +function readDeviceMid() { + const options = loadPluginOptions(); + const explicit = String(env.ZCODE_DEVICE_MID || options.zcode_device_mid || "").trim(); + if (explicit) return explicit; + for (const name of ["telemetry-state.json", "onboarding-record.json"]) { + const doc = readJson(path.join(ZCODE_V2_DIR, name)); + if (doc && typeof doc.deviceMid === "string" && doc.deviceMid.trim()) return doc.deviceMid.trim(); + } + return ""; +} + +function loadPlanSelection() { + const doc = readJson(SETTING_PATH, {}); + return { + family: typeof doc?.providerFamilyDomain === "string" ? doc.providerFamilyDomain : "", + selection: doc?.providerFamilyConnectionSelections || {}, + legacyKeys: doc?.modelProviderFamilySelectedKeys || {}, + }; +} + +async function loadUsageRows() { + try { + const { DatabaseSync } = await import("node:sqlite"); + const usageDb = usageDbPath(); + if (!fs.existsSync(usageDb)) { + return { available: false, error: errorInfo("db_missing", `用量数据库不存在:${usageDb}`) }; + } + const db = new DatabaseSync(usageDb, { readOnly: true }); + try { + const start = new Date(); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 1); + const rows = db + .prepare( + `select provider_id, model_id, count(*) as requests, + coalesce(sum(input_tokens),0) as input_tokens, + coalesce(sum(output_tokens),0) as output_tokens, + coalesce(sum(reasoning_tokens),0) as reasoning_tokens, + coalesce(sum(cache_read_input_tokens),0) as cache_read_tokens, + coalesce(sum(cache_creation_input_tokens),0) as cache_write_tokens, + coalesce(sum(computed_total_tokens),0) as computed_total_tokens + from model_usage + where started_at >= ? and started_at < ? + group by provider_id, model_id`, + ) + .all(start.getTime(), end.getTime()); + return { + available: true, + start: start.toISOString(), + end: end.toISOString(), + rows: rows.map((row) => ({ + providerId: String(row.provider_id || ""), + modelId: String(row.model_id || ""), + requests: Number(row.requests) || 0, + inputTokens: Number(row.input_tokens) || 0, + outputTokens: Number(row.output_tokens) || 0, + reasoningTokens: Number(row.reasoning_tokens) || 0, + cacheReadTokens: Number(row.cache_read_tokens) || 0, + cacheWriteTokens: Number(row.cache_write_tokens) || 0, + computedTotalTokens: Number(row.computed_total_tokens) || 0, + })), + }; + } finally { + db.close(); + } + } catch (error) { + return { available: false, error: errorInfo("db_read_failed", error.message || String(error)) }; + } +} + +export function priceOfModel(modelId) { + const key = String(modelId || "").toLowerCase(); + return MODEL_PRICES_USD_PER_M[key] || null; +} + +function summarizeRows(rows, { estimateCost = false } = {}) { + const summary = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + models: [], + cost: null, + }; + let costUSD = 0; + let pricedRequests = 0; + let unknownModels = 0; + + for (const row of rows) { + summary.requests += row.requests; + summary.inputTokens += row.inputTokens; + summary.outputTokens += row.outputTokens; + summary.reasoningTokens += row.reasoningTokens; + summary.cacheReadTokens += row.cacheReadTokens; + summary.cacheWriteTokens += row.cacheWriteTokens; + // Same token semantics as the analytics view: computed_total_tokens + // first, input+output as the fallback. + summary.totalTokens += row.computedTotalTokens || row.inputTokens + row.outputTokens; + summary.models.push({ + modelId: row.modelId, + providerId: row.providerId, + requests: row.requests, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + }); + + if (estimateCost) { + const rates = priceOfModel(row.modelId) || DEFAULT_PRICE_USD; + if (!priceOfModel(row.modelId)) unknownModels += 1; + else pricedRequests += row.requests; + const bareInput = Math.max(0, row.inputTokens - row.cacheReadTokens - row.cacheWriteTokens); + costUSD += + (bareInput * rates.input + + row.cacheReadTokens * rates.cacheRead + + row.cacheWriteTokens * rates.cacheWrite + + row.outputTokens * rates.output) / + 1_000_000; + } + } + + summary.models.sort((a, b) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens)); + if (estimateCost) { + summary.cost = { + amount: round2(costUSD * USD_TO_CNY), + currency: "CNY", + estimated: true, + note: unknownModels ? "部分模型无公开牌价,按通用均价估算" : "按公开牌价估算", + unknownModels, + pricedRequests, + }; + } + return summary; +} + +function usageForBucket(usage, bucket, options) { + if (!usage?.available) return null; + const rows = usage.rows.filter((row) => bucketOfProvider(row.providerId, row.modelId) === bucket); + const summary = summarizeRows(rows, options); + return summary.requests > 0 ? summary : null; +} + +/* ------------------------------------------------------------------ */ +/* DeepSeek */ +/* ------------------------------------------------------------------ */ + +async function getDeepSeek(usage, force) { + const keyInfo = discoverDeepSeekKey(); + const localToday = usageForBucket(usage, "deepseek", { estimateCost: true }); + if (!keyInfo) { + return { + status: "unconfigured", + configured: false, + balance: null, + today: localToday, + hint: "未找到 DeepSeek API Key。请在 ZCode 的 DeepSeek 供应商中配置 Key,或在插件的 deepseek_api_key 选项中填写。", + }; + } + try { + const response = await cached("deepseek", force, () => + fetchWithTimeout("https://api.deepseek.com/user/balance", { + headers: { authorization: `Bearer ${keyInfo.key}`, accept: "application/json" }, + }), + ); + const failure = apiFailure(response); + if (failure) return { status: "error", configured: true, error: failure, balance: null, today: localToday }; + const body = response.json || {}; + const infos = Array.isArray(body.balance_infos) ? body.balance_infos : []; + if (!infos.length) { + if (body.is_available === false) { + return { + status: "ok", + configured: true, + balance: { amount: 0, currency: "CNY", parts: [] }, + today: localToday, + keySource: keyInfo.source, + }; + } + return { + status: "error", + configured: true, + error: errorInfo("parse_error", "DeepSeek 余额响应中没有 balance_infos"), + balance: null, + today: localToday, + }; + } + const parsed = infos + .map((item) => { + const total = finiteNumber(item.total_balance); + const granted = finiteNumber(item.granted_balance); + const topped = finiteNumber(item.topped_up_balance); + if (total == null) return null; + return { + currency: typeof item.currency === "string" ? item.currency : "CNY", + total, + granted: granted == null ? 0 : granted, + topped: topped == null ? 0 : topped, + }; + }) + .filter(Boolean); + if (!parsed.length) { + return { + status: "error", + configured: true, + error: errorInfo("parse_error", "DeepSeek 余额字段无法解析"), + balance: null, + today: localToday, + }; + } + const best = parsed.sort((a, b) => b.total - a.total)[0]; + return { + status: "ok", + configured: true, + keySource: keyInfo.source, + balance: { + amount: round2(best.total), + currency: best.currency, + parts: [ + { label: "赠送", amount: round2(best.granted) }, + { label: "充值", amount: round2(best.topped) }, + ], + available: body.is_available !== false, + }, + today: localToday, + }; + } catch (error) { + return { + status: "error", + configured: true, + error: errorInfo( + error.name === "AbortError" ? "timeout" : "network_error", + error.name === "AbortError" ? "DeepSeek 余额查询超时" : error.message || String(error), + ), + balance: null, + today: localToday, + }; + } +} + +/* ------------------------------------------------------------------ */ +/* Volcengine signing */ +/* ------------------------------------------------------------------ */ + +function hmac(key, data) { + return crypto.createHmac("sha256", key).update(data).digest(); +} + +async function volcRequest({ action, version, service, region, body = "", timeoutMs = FETCH_TIMEOUT_MS }) { + const credentials = discoverVolcengineCredentials(); + if (!credentials) throw Object.assign(new Error("missing Volcano AK/SK"), { code: "unconfigured" }); + const host = "open.volcengineapi.com"; + // Ark's plan APIs take Region in the query; the billing APIs document it as a header. + const queryPairs = [ + ["Action", action], + ...(service === "ark" ? [["Region", region]] : []), + ["Version", version], + ].sort((a, b) => a[0].localeCompare(b[0])); + const query = queryPairs.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&"); + const now = new Date(); + const xDate = now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, ""); + const date = xDate.slice(0, 8); + const payloadHash = crypto.createHash("sha256").update(body).digest("hex"); + const canonicalHeaders = + `host:${host}\nx-date:${xDate}\nx-content-sha256:${payloadHash}\ncontent-type:application/json; charset=utf-8\n`; + const signedHeaders = "host;x-date;x-content-sha256;content-type"; + const canonicalRequest = `POST\n/\n${query}\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`; + const scope = `${date}/${region}/${service}/request`; + const stringToSign = `HMAC-SHA256\n${xDate}\n${scope}\n${crypto + .createHash("sha256") + .update(canonicalRequest) + .digest("hex")}`; + let signingKey = hmac(credentials.sk, date); + signingKey = hmac(signingKey, region); + signingKey = hmac(signingKey, service); + signingKey = hmac(signingKey, "request"); + const signature = crypto.createHmac("sha256", signingKey).update(stringToSign).digest("hex"); + const authorization = `HMAC-SHA256 Credential=${credentials.ak}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + + const response = await fetchWithTimeout( + `https://${host}/?${query}`, + { + method: "POST", + headers: { + "content-type": "application/json; charset=utf-8", + "x-date": xDate, + "x-content-sha256": payloadHash, + authorization, + region, + servicename: service, + }, + body, + }, + timeoutMs, + ); + if (!response.ok) { + const bodyJson = response.json || {}; + const gatewayError = bodyJson?.ResponseMetadata?.Error; + throw Object.assign( + new Error( + gatewayError?.Message || bodyJson?.message || bodyJson?.msg || response.text.slice(0, 180) || `HTTP ${response.status}`, + ), + { code: response.status === 401 || response.status === 403 ? "auth_failed" : "http_error", status: response.status }, + ); + } + const bodyJson = response.json || {}; + if (bodyJson?.ResponseMetadata?.Error) { + const e = bodyJson.ResponseMetadata.Error; + throw Object.assign(new Error(e.Message || e.Code || "Volcengine API error"), { code: "business_error", status: 200 }); + } + return bodyJson; +} + +function periodParts(date = new Date()) { + const pad = (n) => String(n).padStart(2, "0"); + return { + period: `${date.getFullYear()}-${pad(date.getMonth() + 1)}`, + date: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + }; +} + +async function getVolcanoAccount(usage, force) { + const credentials = discoverVolcengineCredentials(); + const localToday = usageForBucket(usage, "volcanoAccount", { estimateCost: true }); + if (!credentials) { + return { + status: "unconfigured", + configured: false, + balance: null, + today: localToday, + hint: "账户余额需要火山引擎 AK/SK。请在插件配置中填写 volc_access_key_id / volc_secret_access_key。", + }; + } + const billingRegion = credentials.region || "cn-north-1"; + try { + const balanceBody = await cached("volcano-account-balance", force, () => + volcRequest({ + action: "QueryBalanceAcct", + version: "2022-01-01", + service: "billing", + region: billingRegion, + }), + ); + const result = balanceBody?.Result || {}; + const available = finiteNumber(result.AvailableBalance); + const cash = finiteNumber(result.CashBalance); + const arrears = finiteNumber(result.ArrearsBalance); + const credit = finiteNumber(result.CreditLimit); + if (available == null) { + return { + status: "error", + configured: true, + error: errorInfo("parse_error", "火山引擎余额响应缺少 AvailableBalance"), + balance: null, + today: localToday, + }; + } + + let officialToday = null; + let billWarning = null; + try { + const { period, date } = periodParts(); + let offset = 0; + let total = null; + let sum = 0; + let rows = 0; + for (let page = 0; page < 6; page += 1) { + const payload = JSON.stringify({ + BillPeriod: period, + ExpenseDate: date, + GroupPeriod: 1, + Limit: 300, + Offset: offset, + NeedRecordNum: 1, + IgnoreZero: 1, + }); + const bill = await volcRequest({ + action: "ListBillDetail", + version: "2022-01-01", + service: "billing", + region: billingRegion, + body: payload, + }); + const r = bill?.Result || {}; + const list = Array.isArray(r.List) ? r.List : Array.isArray(r.ListBillDetail) ? r.ListBillDetail : []; + total = finiteNumber(r.Total); + for (const item of list) { + const amount = finiteNumber(item.PayableAmount ?? item.DiscountBillAmount ?? item.OriginalBillAmount); + if (amount != null) sum += amount; + rows += 1; + } + if (!list.length) break; + offset += list.length; + if (total != null && offset >= total) break; + if (list.length < 300) break; + } + officialToday = { + amount: round2(sum), + currency: "CNY", + rows, + date, + note: rows ? "来自火山引擎当日账单" : "当日账单尚未生成", + }; + } catch (error) { + billWarning = errorInfo(error.code || "billing_error", error.message || String(error)); + } + + return { + status: "ok", + configured: true, + balance: { + amount: round2(available), + currency: "CNY", + cash: cash == null ? null : round2(cash), + arrears: arrears == null ? null : round2(arrears), + creditLimit: credit == null ? null : round2(credit), + }, + today: officialToday && officialToday.rows > 0 ? officialToday : localToday, + todaySource: officialToday && officialToday.rows > 0 ? "official_bill" : "local_estimate", + localToday, + officialToday, + error: billWarning, + region: billingRegion, + }; + } catch (error) { + const code = error.code || (error.name === "AbortError" ? "timeout" : "network_error"); + return { + status: "error", + configured: true, + error: errorInfo( + code, + error.name === "AbortError" ? "火山引擎账户查询超时" : error.message || String(error), + code === "auth_failed" ? "AK/SK 可能无效或没有账单/余额权限。" : "", + ), + balance: null, + today: localToday, + }; + } +} + +function normalizeAfpWindows(result) { + const windows = []; + const map = [ + ["AFPDaily", "daily", "日额度"], + ["AFPFiveHour", "fiveHour", "5 小时"], + ["AFPWeekly", "weekly", "周额度"], + ["AFPMonthly", "monthly", "月额度"], + ]; + for (const [key, id, label] of map) { + const item = result?.[key]; + if (!item || typeof item !== "object") continue; + const quota = finiteNumber(item.Quota); + const used = finiteNumber(item.Used); + if (quota == null || used == null || quota <= 0) continue; + windows.push({ + id, + label, + used, + quota, + remaining: Math.max(0, quota - used), + percent: Math.round(Math.min(100, Math.max(0, (used / quota) * 100)) * 10) / 10, + resetsAt: normalizeTimestamp(item.ResetTime), + }); + } + return windows; +} + +function normalizeCodingPlanWindows(result) { + const rows = result?.QuotaUsage || result?.Usages || result?.Details || result?.List || []; + const windows = []; + const seen = new Set(); + for (const row of Array.isArray(rows) ? rows : []) { + if (!row || typeof row !== "object") continue; + const level = String(row.Level || row.Type || row.Period || row.Label || row.Window || "") + .toLowerCase() + .replace(/[\s_-]/g, ""); + const id = /session|^5h|fivehour|rolling5h/.test(level) + ? "fiveHour" + : /weekly|week|7d/.test(level) + ? "weekly" + : /monthly|month/.test(level) + ? "monthly" + : /daily|day/.test(level) + ? "daily" + : null; + if (!id || seen.has(id)) continue; + let percent = finiteNumber(row.Percent ?? row.UsedPercent ?? row.UsagePercent); + if (percent == null) continue; + seen.add(id); + windows.push({ + id, + label: id === "fiveHour" ? "5 小时" : id === "weekly" ? "周额度" : id === "monthly" ? "月额度" : "日额度", + percent: Math.round(Math.min(100, Math.max(0, percent)) * 10) / 10, + resetsAt: normalizeTimestamp(row.ResetTime ?? row.ResetTimestamp), + }); + } + return windows; +} + +function normalizeTimestamp(value) { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return value < 1e12 ? value * 1000 : value; + } + if (typeof value === "string" && value.trim()) return value; + return null; +} + +async function getVolcanoCodingPlan(usage, force) { + const credentials = discoverVolcengineCredentials(); + const localToday = usageForBucket(usage, "volcanoCodingPlan", { estimateCost: false }); + if (!credentials) { + return { + status: "unconfigured", + configured: false, + plan: null, + windows: [], + today: localToday, + hint: "Coding Plan 额度需要火山引擎 AK/SK(在火山方舟控制台创建访问密钥)。也可用环境变量 VOLCENGINE_ACCESS_KEY_ID / VOLCENGINE_SECRET_ACCESS_KEY。", + }; + } + const region = credentials.region || "cn-beijing"; + try { + let windows = []; + let planName = ""; + let officialDaily = null; + try { + const afp = await cached("volcano-afp", force, () => + volcRequest({ action: "GetAFPUsage", version: "2024-01-01", service: "ark", region }), + ); + if (afp?.Result && typeof afp.Result === "object") { + windows = normalizeAfpWindows(afp.Result); + planName = "Agent Plan"; + officialDaily = windows.find((w) => w.id === "daily") || null; + } + } catch (error) { + if (error.code === "auth_failed") throw error; + log("GetAFPUsage unavailable", error.message); + } + if (!windows.length) { + const coding = await cached("volcano-coding", force, () => + volcRequest({ action: "GetCodingPlanUsage", version: "2024-01-01", service: "ark", region }), + ); + windows = normalizeCodingPlanWindows(coding?.Result || {}); + planName = "Coding Plan"; + } + if (!windows.length) { + return { + status: "no_plan", + configured: true, + plan: null, + windows: [], + today: localToday, + hint: "该账号下未检测到 Agent Plan 或 Coding Plan 额度。", + }; + } + return { + status: "ok", + configured: true, + plan: { name: planName || "Coding Plan" }, + windows, + today: localToday, + officialDaily, + region, + }; + } catch (error) { + const code = error.code || (error.name === "AbortError" ? "timeout" : "network_error"); + const message = error.name === "AbortError" ? "Coding Plan 额度查询超时" : error.message || String(error); + if (code !== "auth_failed" && /coding\s*plan|agent\s*plan|套餐|未开通|not\s*found|no\s*plan/i.test(message)) { + return { + status: "no_plan", + configured: true, + plan: null, + windows: [], + today: localToday, + hint: "该账号下未检测到 Agent Plan 或 Coding Plan 额度。", + }; + } + return { + status: "error", + configured: true, + error: errorInfo( + code, + message, + code === "auth_failed" ? "AK/SK 无效或没有 Ark Coding Plan 权限。" : "", + ), + plan: null, + windows: [], + today: localToday, + }; + } +} + +/* ------------------------------------------------------------------ */ +/* ZCode plan balance */ +/* ------------------------------------------------------------------ */ + +function startPlanWindows(balanceBody) { + if (!balanceBody || typeof balanceBody !== "object") return null; + if (balanceBody.success === false || (balanceBody.code !== undefined && balanceBody.code !== 0 && balanceBody.code !== 200)) { + return { error: errorInfo("business_error", balanceBody.msg || "Start Plan 接口返回错误") }; + } + const data = balanceBody.data; + if (!data || typeof data !== "object") return { error: errorInfo("parse_error", "Start Plan 响应缺少 data") }; + const serverTime = finiteNumber(data.server_time); + const now = serverTime != null ? (serverTime < 1e12 ? serverTime * 1000 : serverTime) : Date.now(); + const activePlans = (Array.isArray(data.plans) ? data.plans : []) + .filter((plan) => { + if (!plan || typeof plan !== "object") return false; + const end = normalizeTimestamp(plan.ends_at); + return String(plan.status || "").toLowerCase() === "active" && end && end > now; + }) + .sort((a, b) => (finiteNumber(b.priority) ?? -1) - (finiteNumber(a.priority) ?? -1)); + if (!activePlans.length) return { noPlan: true }; + const planIds = new Set(activePlans.map((plan) => String(plan.plan_id || ""))); + const buckets = (Array.isArray(data.balances) ? data.balances : []) + .filter((bucket) => { + if (!bucket || typeof bucket !== "object") return false; + if (String(bucket.meter || "") !== "model_usage") return false; + if (bucket.plan_id != null && !planIds.has(String(bucket.plan_id))) return false; + const total = finiteNumber(bucket.total_units); + const expires = normalizeTimestamp(bucket.expires_at); + return total != null && total > 0 && (!expires || expires > now); + }) + .map((bucket) => ({ + name: typeof bucket.show_name === "string" ? bucket.show_name : "", + used: finiteNumber(bucket.used_units) ?? 0, + total: finiteNumber(bucket.total_units) ?? 0, + expiresAt: normalizeTimestamp(bucket.expires_at), + })) + .sort((a, b) => b.total - a.total); + const used = buckets.reduce((sum, b) => sum + b.used, 0); + const total = buckets.reduce((sum, b) => sum + b.total, 0); + const expiresAt = buckets.reduce((max, b) => Math.max(max, b.expiresAt || 0), 0); + const top = activePlans[0]; + return { + kind: "start_plan", + label: top.name || "ZCode Start Plan", + percent: total > 0 ? Math.round(Math.min(100, Math.max(0, (used / total) * 100)) * 10) / 10 : 0, + used, + total, + remaining: Math.max(0, total - used), + expiresAt: expiresAt || null, + validEnd: normalizeTimestamp(top.ends_at), + windows: total > 0 + ? [{ id: "plan", label: "套餐额度", percent: total > 0 ? Math.round((used / total) * 1000) / 10 : 0, resetsAt: expiresAt || null }] + : [], + }; +} + +function codingPlanWindows(body) { + if (!body || typeof body !== "object") return null; + if (body.success === false) { + const message = String(body.msg || ""); + if (/coding\s*plan|套餐|订阅|plan/i.test(message) && /不存在|未开通|not|no|missing|required/i.test(message)) { + return { noPlan: true }; + } + return { error: errorInfo("business_error", message || "编码套餐接口返回错误") }; + } + const data = body.data; + if (!data || typeof data !== "object" || !Array.isArray(data.limits)) { + return { error: errorInfo("parse_error", "编码套餐响应缺少 data.limits") }; + } + const windows = []; + let tools = null; + for (const limit of data.limits) { + if (!limit || typeof limit !== "object") continue; + const type = String(limit.type || "").toUpperCase(); + if (type === "TIME_LIMIT") { + tools = { + used: finiteNumber(limit.currentValue), + total: finiteNumber(limit.usage), + resetsAt: normalizeTimestamp(limit.nextResetTime), + }; + continue; + } + if (type !== "TOKENS_LIMIT" && type !== "CREDIT_LIMIT") continue; + let percent = finiteNumber(limit.percent ?? limit.usedPercent ?? limit.usagePercent); + if (percent == null) { + const current = finiteNumber(limit.currentValue); + const total = finiteNumber(limit.usage); + if (current != null && total != null && total > 0) percent = (current / total) * 100; + } + if (percent == null) continue; + const unit = finiteNumber(limit.unit); + const id = unit === 3 ? "fiveHour" : unit === 6 ? "weekly" : windows.length === 0 ? "fiveHour" : "weekly"; + windows.push({ + id, + label: id === "fiveHour" ? "5 小时" : id === "weekly" ? "周额度" : "额度", + percent: Math.round(Math.min(100, Math.max(0, percent)) * 10) / 10, + resetsAt: normalizeTimestamp(limit.nextResetTime), + }); + } + if (!windows.length) return { noPlan: true }; + return { + kind: "coding_plan", + label: typeof data.level === "string" && data.level ? `${data.level} 编码套餐` : "编码套餐", + windows, + tools, + }; +} + +async function getZCodePlan(force) { + const creds = loadCredentials(); + const selection = loadPlanSelection(); + const results = []; + const warnings = []; + + // ZCode Start Plan + const options = loadPluginOptions(); + const startToken = + String(env.ZCODE_JWT_TOKEN || options.zcode_jwt_token || "").trim() || creds["zcodejwttoken"] || ""; + if (startToken) { + try { + const response = await cached("zcode-start-plan", force, async () => { + const deviceMid = readDeviceMid(); + const headers = { authorization: `Bearer ${startToken}` }; + if (deviceMid) headers["x-device-mid"] = deviceMid; + return fetchWithTimeout("https://zcode.z.ai/api/v1/zcode-plan/billing/balance", { headers }); + }); + if (!response.ok) { + warnings.push(errorInfo("start_plan_http", `Start Plan 查询失败(HTTP ${response.status})`)); + } else { + const parsed = startPlanWindows(response.json); + if (parsed?.error) warnings.push(parsed.error); + else if (parsed && !parsed.noPlan) results.push(parsed); + } + } catch (error) { + warnings.push( + errorInfo( + error.name === "AbortError" ? "timeout" : "network_error", + error.name === "AbortError" ? "Start Plan 查询超时" : error.message || String(error), + ), + ); + } + } + + // BigModel / Z.ai coding plan (individual subscription) + let family = selection.family === "zai" ? "zai" : selection.family === "bigmodel" ? "bigmodel" : ""; + if (!family) { + family = creds["oauth:zai:access_token"] ? "zai" : creds["oauth:bigmodel:access_token"] ? "bigmodel" : ""; + } + const familyTokenKey = family === "zai" ? "oauth:zai:access_token" : "oauth:bigmodel:access_token"; + const familyToken = creds[familyTokenKey] || ""; + if (family && familyToken) { + const origin = family === "zai" ? "https://api.z.ai" : "https://bigmodel.cn"; + const bearer = family === "zai"; + try { + const response = await cached(`zcode-${family}-plan`, force, () => + fetchWithTimeout(`${origin}/api/monitor/usage/quota/limit`, { + headers: { authorization: (bearer ? "Bearer " : "") + familyToken, "set-language": "zh" }, + }), + ); + if (response.ok) { + const parsed = codingPlanWindows(response.json); + if (parsed?.error) warnings.push(parsed.error); + else if (parsed && !parsed.noPlan) results.push(parsed); + } else { + warnings.push(errorInfo(`${family}_plan_http`, `${family} 编码套餐查询失败(HTTP ${response.status})`)); + } + } catch (error) { + warnings.push( + errorInfo( + error.name === "AbortError" ? "timeout" : "network_error", + error.name === "AbortError" ? "编码套餐查询超时" : error.message || String(error), + ), + ); + } + } + + if (results.length) { + const best = results.sort((a, b) => (b.percent ?? 0) - (a.percent ?? 0))[0]; + return { status: "ok", configured: true, plan: best, plans: results, warnings }; + } + return { + status: "no_plan", + configured: true, + plan: null, + plans: [], + warnings, + hint: "未检测到 ZCode Start Plan 或编码套餐订阅;今日消耗仍可统计。", + }; +} + +/* ------------------------------------------------------------------ */ +/* Cache */ +/* ------------------------------------------------------------------ */ + +const memoryCache = new Map(); +const inflight = new Map(); + +async function cached(key, force, factory) { + const now = Date.now(); + const hit = memoryCache.get(key); + if (!force && hit && now - hit.at < CACHE_TTL_MS) return hit.value; + if (!force && inflight.has(key)) return inflight.get(key); + const promise = Promise.resolve() + .then(factory) + .then((value) => { + memoryCache.set(key, { at: Date.now(), value }); + return value; + }) + .finally(() => inflight.delete(key)); + inflight.set(key, promise); + return promise; +} + +/* ------------------------------------------------------------------ */ +/* Aggregation */ +/* ------------------------------------------------------------------ */ + +function readSidebarStatus() { + const status = readJson(SIDEBAR_STATUS_PATH); + if (!status || typeof status !== "object") { + return { state: "unknown", message: "左侧组件状态未知(插件尚未完成注入检查)" }; + } + return status; +} + +export async function getBalances(force = false) { + const usage = await loadUsageRows(); + const [deepseek, volcanoAccount, volcanoCodingPlan, zcodePlan] = await Promise.all([ + getDeepSeek(usage, force), + getVolcanoAccount(usage, force), + getVolcanoCodingPlan(usage, force), + getZCodePlan(force), + ]); + + // The sidebar's ZCode row must match the analytics view, so it uses the + // "zcode" provider bucket only; the all-provider total is reported + // separately as todayAll. + const zcodeToday = usage?.available ? usageForBucket(usage, "zcode", { estimateCost: true }) : null; + const todayAll = usage?.available ? summarizeRows(usage.rows, { estimateCost: true }) : null; + + return { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + today: periodParts().date, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + usage: { + available: !!usage?.available, + path: usageDbPath(), + ...(usage?.error ? { error: usage.error } : {}), + }, + deepseek, + volcano: { + account: volcanoAccount, + codingPlan: volcanoCodingPlan, + }, + zcode: { + plan: zcodePlan, + today: zcodeToday, + }, + todayAll, + settings: settingsSnapshot(), + sidebar: readSidebarStatus(), + }; +} diff --git a/external_plugins/balance-dashboard/sidebar/inject-main.cjs b/external_plugins/balance-dashboard/sidebar/inject-main.cjs new file mode 100644 index 00000000..c1615a4f --- /dev/null +++ b/external_plugins/balance-dashboard/sidebar/inject-main.cjs @@ -0,0 +1,442 @@ +/* balance-dashboard sidebar loader (Electron main process). + * + * Loaded by the dynamic import line appended to app.asar's main entry. + * It injects a compact balance widget into ZCode's left sidebar and keeps it + * fed with data from a one-shot Node collector. + * + * Everything here is best-effort: any failure only writes a local log line and + * must never prevent ZCode from starting or rendering. + */ +"use strict"; +const fs = require("fs"); +const path = require("path"); +const { spawn } = require("child_process"); +const { app, webContents } = require("electron"); + +const HERE = __dirname; +const RUNTIME = path.resolve(HERE, ".."); +const CONFIG = readJson(path.join(RUNTIME, "config.json"), {}); +const COLLECT = path.join(RUNTIME, "scripts", "collect-once.mjs"); +const ANALYTICS = path.join(RUNTIME, "scripts", "query-analytics.mjs"); +const OPTIONS_FILE = path.join(RUNTIME, "options.json"); +const WIDGET = path.join(HERE, "sidebar-widget.js"); +const LOG_FILE = path.join(RUNTIME, "inject.log"); +const REFRESH_MS = 60 * 1000; +const COLLECT_TIMEOUT_MS = 45 * 1000; +const ANALYTICS_TIMEOUT_MS = 30 * 1000; +const REQUEST_POLL_MS = 700; +const MAX_VALUE_LENGTH = 8192; +const TEXT_SETTING_KEYS = new Set([ + "volc_access_key_id", + "volc_region", + "zcode_usage_db", + "zcode_device_mid", + "show_deepseek", + "show_volcano", + "show_zcode", + "hide_unconfigured", +]); +const SECRET_SETTING_KEYS = new Set(["deepseek_api_key", "volc_secret_access_key", "zcode_jwt_token"]); +const ALL_SETTING_KEYS = new Set([...TEXT_SETTING_KEYS, ...SECRET_SETTING_KEYS]); + +let widgetSource = ""; +try { + widgetSource = fs.readFileSync(WIDGET, "utf8"); + new Function(widgetSource); // reject a partially written file before it reaches the renderer +} catch (error) { + log("widget source unavailable:", error.message); +} + +let latest = null; +let collecting = false; +let lastCollectAt = 0; + +function log(...args) { + try { + const line = `[${new Date().toISOString()}] ${args + .map((value) => (value instanceof Error ? value.stack || value.message : String(value))) + .join(" ")}\n`; + try { + if (fs.statSync(LOG_FILE).size > 256 * 1024) fs.truncateSync(LOG_FILE, 0); + } catch { + // log file does not exist yet + } + fs.appendFileSync(LOG_FILE, line); + } catch { + // logging must never throw + } +} + +function readJson(file, fallback) { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + return fallback; + } +} + +function pluginAvailable() { + const root = CONFIG.pluginRoot; + if (!root) return true; // older runtime config: do not disable + try { + return fs.statSync(root).isDirectory(); + } catch { + return false; + } +} + +function isMainWindowState(wc) { + try { + return wc.getType() === "window"; + } catch { + return false; + } +} + +function isLocalHostPage(wc) { + try { + const url = wc.getURL(); + if (!url.startsWith("file:")) return false; + const relative = path.relative(process.resourcesPath, require("url").fileURLToPath(url)); + return !relative.startsWith("..") && !path.isAbsolute(relative); + } catch { + return false; + } +} + +function runNodeScript(script, args, timeoutMs = COLLECT_TIMEOUT_MS) { + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let settled = false; + let child; + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => { + try { + child && child.kill(); + } catch { + // ignore + } + finish({ ok: false, error: "script timeout" }); + }, timeoutMs); + try { + child = spawn(process.execPath, [script, ...args], { + cwd: RUNTIME, + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", BALANCE_RUNTIME_DIR: RUNTIME }, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + finish({ ok: false, error: error.message }); + return; + } + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => finish({ ok: false, error: error.message })); + child.on("close", (code) => { + const text = stdout.trim(); + if (!text) return finish({ ok: false, error: `script exited ${code}: ${stderr.slice(0, 300)}` }); + try { + finish({ ok: true, data: JSON.parse(text) }); + } catch (error) { + finish({ ok: false, error: `script returned invalid JSON: ${error.message}` }); + } + }); + }); +} + +async function runCollector() { + // Prefer the Electron runtime in Node mode (no PATH dependency); fall back + // to a system node when that runtime cannot load the sqlite builtin. + let result = await runNodeScript(COLLECT, []); + if (!result.ok) { + log("electron collector failed, retrying with node:", result.error); + result = await runCollectorWithNode(COLLECT, []); + } + return result; +} + +function runCollectorWithNode(script, args) { + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let settled = false; + let child; + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => { + try { + child && child.kill(); + } catch { + // ignore + } + finish({ ok: false, error: "script timeout" }); + }, COLLECT_TIMEOUT_MS); + try { + child = spawn(process.env.BALANCE_NODE || "node", ["--no-warnings", script, ...args], { + cwd: RUNTIME, + env: { ...process.env, BALANCE_RUNTIME_DIR: RUNTIME }, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + finish({ ok: false, error: error.message }); + return; + } + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => finish({ ok: false, error: error.message })); + child.on("close", (code) => { + const text = stdout.trim(); + if (!text) return finish({ ok: false, error: `script exited ${code}: ${stderr.slice(0, 300)}` }); + try { + finish({ ok: true, data: JSON.parse(text) }); + } catch (error) { + finish({ ok: false, error: `script returned invalid JSON: ${error.message}` }); + } + }); + }); +} + +async function refresh() { + if (collecting || !pluginAvailable()) return; + collecting = true; + try { + const result = await runCollector(); + if (result.ok) { + latest = result.data; + lastCollectAt = Date.now(); + pushAll(); + } else { + log("collect failed:", result.error); + if (!latest) { + latest = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + error: { code: "collect_failed", message: result.error }, + }; + pushAll(); + } + } + } finally { + collecting = false; + } +} + +function pushAll() { + if (!latest) return; + for (const wc of webContents.getAllWebContents()) push(wc); +} + +function push(wc) { + if (!latest || !isMainWindowState(wc) || wc.isDestroyed() || !isLocalHostPage(wc)) return; + const json = JSON.stringify(latest).replace(/[\u2028\u2029]/g, (c) => + c === "\u2028" ? "\\u2028" : "\\u2029", + ); + wc.executeJavaScript(`window.__balanceDashboardPush && window.__balanceDashboardPush(${json})`, true).catch( + () => {}, + ); +} + +/* ------------------------------------------------------------------ */ +/* Renderer requests: settings save + analytics query */ +/* ------------------------------------------------------------------ */ + +function sanitizeSettings(values) { + const out = {}; + if (!values || typeof values !== "object" || Array.isArray(values)) return out; + for (const [key, raw] of Object.entries(values)) { + if (!ALL_SETTING_KEYS.has(key)) continue; + if (typeof raw !== "string") continue; + const value = raw.trim(); + if (value.length > MAX_VALUE_LENGTH) continue; + if (!value && SECRET_SETTING_KEYS.has(key)) continue; // empty secret keeps the old value + out[key] = value; + } + return out; +} + +function saveSettings(values) { + const patch = sanitizeSettings(values); + if (!Object.keys(patch).length) { + throw new Error("没有可保存的配置项"); + } + let current = {}; + try { + current = JSON.parse(fs.readFileSync(OPTIONS_FILE, "utf8")); + } catch { + current = {}; + } + if (!current || typeof current !== "object" || Array.isArray(current)) current = {}; + const next = { ...current, ...patch }; + fs.mkdirSync(RUNTIME, { recursive: true }); + const tmp = `${OPTIONS_FILE}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 }); + try { + fs.chmodSync(tmp, 0o600); + } catch { + // best effort on Windows + } + fs.renameSync(tmp, OPTIONS_FILE); + return Object.keys(patch); +} + +function analyticsArgs(args) { + const out = []; + const start = Number(args && args.start); + const end = Number(args && args.end); + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) { + throw new Error("无效的时间范围"); + } + out.push("--start", String(Math.round(start)), "--end", String(Math.round(end))); + const bucket = ["hour", "day", "week"].includes(args.bucket) ? args.bucket : "day"; + out.push("--bucket", bucket); + if (typeof args.projectId === "string" && args.projectId) out.push("--project-id", args.projectId); + if (typeof args.directory === "string" && args.directory) out.push("--directory", args.directory); + if (typeof args.sessionId === "string" && args.sessionId) out.push("--session-id", args.sessionId); + if (typeof args.provider === "string" && args.provider) out.push("--provider", args.provider); + return out; +} + +async function runAnalytics(args) { + const argv = analyticsArgs(args); + let result = await runNodeScript(ANALYTICS, argv, ANALYTICS_TIMEOUT_MS); + if (!result.ok) { + log("electron analytics failed, retrying with node:", result.error); + result = await runCollectorWithNode(ANALYTICS, argv); + } + if (!result.ok) throw new Error(result.error || "统计查询失败"); + return result.data; +} + +async function handleRequest(req) { + if (!req || typeof req !== "object" || typeof req.id !== "string") return null; + try { + if (req.op === "save-settings") { + const saved = saveSettings(req.values); + refreshSoon(); + return { id: req.id, result: { ok: true, data: { saved } } }; + } + if (req.op === "analytics") { + const data = await runAnalytics(req.args || {}); + return { id: req.id, result: { ok: true, data } }; + } + return { id: req.id, result: { ok: false, error: { message: `未知操作:${String(req.op)}` } } }; + } catch (error) { + return { id: req.id, result: { ok: false, error: { message: error.message || String(error) } } }; + } +} + +let pumping = false; + +function refreshSoon(attempt = 0) { + if (!collecting) { + refresh(); + return; + } + if (attempt >= 6) return; + setTimeout(() => refreshSoon(attempt + 1), 2500); +} + +async function pumpRequests() { + if (pumping) return; + pumping = true; + try { + for (const wc of webContents.getAllWebContents()) { + if (!isMainWindowState(wc) || wc.isDestroyed() || !isLocalHostPage(wc)) continue; + let raw; + try { + raw = await wc.executeJavaScript( + "(function(){try{var q=window.__balanceDashboardRequests;if(!Array.isArray(q)||!q.length)return '';window.__balanceDashboardRequests=[];return JSON.stringify(q)}catch(e){return ''}})()", + true, + ); + } catch { + continue; + } + if (!raw) continue; + let requests; + try { + requests = JSON.parse(raw); + } catch { + continue; + } + for (const req of Array.isArray(requests) ? requests : []) { + const reply = await handleRequest(req); + if (!reply) continue; + const json = JSON.stringify(reply.result).replace(/[\u2028\u2029]/g, (c) => + c === "\u2028" ? "\\u2028" : "\\u2029", + ); + if (wc.isDestroyed()) continue; + wc.executeJavaScript( + `window.__balanceDashboardRequestResult && window.__balanceDashboardRequestResult(${JSON.stringify(reply.id)}, ${json})`, + true, + ).catch(() => {}); + } + } + } finally { + pumping = false; + } +} + +function inject(wc, reload) { + if (!widgetSource || !isMainWindowState(wc) || wc.isDestroyed() || !isLocalHostPage(wc)) return; + if (!pluginAvailable()) { + log("plugin root missing; sidebar widget disabled"); + return; + } + const cleanup = + "(function(){try{if(window.__balanceDashboardDispose)window.__balanceDashboardDispose()}catch(e){}})();"; + const config = { + runtime: RUNTIME, + pluginRoot: CONFIG.pluginRoot || "", + version: CONFIG.version || "0.3.0", + loaderVersion: 2, + }; + const source = `${cleanup}window.__balanceDashboardConfig=${JSON.stringify(config)};\n${widgetSource}`; + wc.executeJavaScript((reload ? cleanup : "") + source, true) + .then(() => push(wc)) + .catch((error) => log("executeJavaScript failed:", error.message)); +} + +function hook(wc) { + if (!isMainWindowState(wc)) return; + wc.on("dom-ready", () => inject(wc)); + wc.on("did-finish-load", () => inject(wc)); + wc.on("destroyed", () => {}); + if (!wc.isLoading()) inject(wc); +} + +app.on("web-contents-created", (_event, wc) => hook(wc)); +for (const wc of webContents.getAllWebContents()) hook(wc); + +app.whenReady() + .then(() => { + refresh(); + setInterval(refresh, REFRESH_MS); + setInterval(pumpRequests, REQUEST_POLL_MS); + }) + .catch((error) => log("whenReady failed:", error.message)); + +module.exports = { + refresh, + inject, + pumpRequests, + _state: () => ({ latest, collecting, lastCollectAt, runtime: RUNTIME }), +}; diff --git a/external_plugins/balance-dashboard/sidebar/sidebar-widget.js b/external_plugins/balance-dashboard/sidebar/sidebar-widget.js new file mode 100644 index 00000000..9d13d544 --- /dev/null +++ b/external_plugins/balance-dashboard/sidebar/sidebar-widget.js @@ -0,0 +1,1792 @@ +/* balance-dashboard left sidebar widget. + * + * Injected by the main-process loader into the ZCode renderer. It mounts a + * compact balance card above the profile footer and re-asserts itself when + * React re-renders the sidebar. Clicking a provider row opens the analytics + * modal; the gear opens the same modal on its settings tab. + * + * No network requests happen here. The main process pushes balance data via + * window.__balanceDashboardPush() and answers requests pushed to + * window.__balanceDashboardRequests. + */ +(function () { + "use strict"; + + if (window.__balanceDashboardDispose) { + try { + window.__balanceDashboardDispose(); + } catch { + // ignore a broken previous instance + } + } + + const HOST_ATTR = "data-balance-dashboard-widget"; + const MODAL_ATTR = "data-balance-dashboard-modal"; + const RETRY_MS = 1500; + const REQUEST_TIMEOUT_MS = 30 * 1000; + + let disposed = false; + let root = null; + let shadow = null; + let latest = null; + let expanded = false; + let retryTimer = null; + let visibilityTimer = null; + let observer = null; + let ensureTimer = null; + let themeObserver = null; + let themeTimer = null; + let lastThemeSignature = ""; + + let modalHost = null; + let modalShadow = null; + let dialog = null; + let modalBuilt = false; + + const pendingRequests = new Map(); + let requestSeq = 0; + + const PROVIDERS = [ + { id: "all", label: "全部供应商" }, + { id: "deepseek", label: "DeepSeek" }, + { id: "volcano", label: "火山(全部)" }, + { id: "volcanoCodingPlan", label: "火山 Coding Plan" }, + { id: "volcanoAccount", label: "火山账户" }, + { id: "zcode", label: "ZCode" }, + ]; + + const PROVIDER_ORDER = ["deepseek", "volcanoCodingPlan", "volcanoAccount", "zcode", "other"]; + const PROVIDER_LABEL = { + deepseek: "DeepSeek", + volcanoCodingPlan: "火山 Coding Plan", + volcanoAccount: "火山账户", + zcode: "ZCode", + other: "其他", + }; + + const modalState = { + tab: "settings", + analytics: { + range: "today", + customStart: "", + customEnd: "", + project: "", + task: "", + provider: "all", + metric: "tokens", + loading: false, + error: null, + data: null, + }, + settings: { + saving: false, + message: "", + messageKind: "", + }, + }; + + const esc = (value) => + String(value == null ? "" : value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); + + const cur = (currency) => (String(currency || "").toUpperCase() === "USD" ? "$" : "¥"); + + function money(amount, currency) { + const n = Number(amount); + if (!Number.isFinite(n)) return "—"; + const abs = Math.abs(n); + return n.toLocaleString("zh-CN", { + minimumFractionDigits: abs > 0 && abs < 1000 ? 2 : 0, + maximumFractionDigits: abs > 0 && abs < 1000 ? 2 : 0, + }); + } + + function moneyCny(amount) { + const n = Number(amount); + if (!Number.isFinite(n)) return "—"; + return `¥${n.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + } + + function tokens(n) { + const v = Number(n); + if (!Number.isFinite(v)) return "—"; + if (v >= 1e9) return `${(v / 1e9).toFixed(2).replace(/\.00$/, "")}B`; + if (v >= 1e6) return `${(v / 1e6).toFixed(2).replace(/\.00$/, "")}M`; + if (v >= 1e3) return `${(v / 1e3).toFixed(1).replace(/\.0$/, "")}K`; + return String(Math.round(v)); + } + + function pct(n) { + const v = Number(n); + return Number.isFinite(v) ? `${Math.max(0, Math.min(100, v)).toFixed(v % 1 ? 1 : 0)}%` : "—"; + } + + function hostVar(name, fallback) { + try { + const rootStyle = getComputedStyle(document.documentElement); + const bodyStyle = getComputedStyle(document.body); + return rootStyle.getPropertyValue(name).trim() || bodyStyle.getPropertyValue(name).trim() || fallback; + } catch { + return fallback; + } + } + + function safeColor(value, fallback) { + if (!value) return fallback; + try { + if (window.CSS && typeof window.CSS.supports === "function" && window.CSS.supports("color", value)) { + return value; + } + } catch { + // fall through + } + return fallback; + } + + function prefersDark() { + try { + const de = document.documentElement; + const body = document.body; + const classes = `${de.className || ""} ${body.className || ""}`; + if (/(^|\s)dark(\s|$)/.test(classes)) return true; + if (/(^|\s)light(\s|$)/.test(classes)) return false; + const attr = + de.getAttribute("data-theme") || + body.getAttribute("data-theme") || + de.getAttribute("data-color-scheme") || + body.getAttribute("data-color-scheme") || + ""; + if (/dark/i.test(attr)) return true; + if (/light/i.test(attr)) return false; + const scheme = `${getComputedStyle(de).colorScheme || ""} ${getComputedStyle(body).colorScheme || ""}`; + if (/dark/i.test(scheme) && !/light/i.test(scheme)) return true; + if (/light/i.test(scheme) && !/dark/i.test(scheme)) return false; + const bg = getComputedStyle(body).backgroundColor || getComputedStyle(de).backgroundColor; + const m = String(bg).match(/rgba?\((\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?/); + if (m && (m[4] === undefined || Number(m[4]) > 0.1)) { + const lum = 0.299 * Number(m[1]) + 0.587 * Number(m[2]) + 0.114 * Number(m[3]); + return lum < 128; + } + } catch { + // fall through + } + try { + return window.matchMedia("(prefers-color-scheme: dark)").matches; + } catch { + return true; + } + } + + function theme() { + const dark = prefersDark(); + return { + dark, + fg: safeColor(hostVar("--color-foreground", hostVar("--foreground", "")), dark ? "#e8eaf0" : "#15171c"), + muted: safeColor( + hostVar("--color-foreground-subtle", hostVar("--muted-foreground", "")), + dark ? "#98a0b3" : "#6b7180", + ), + border: safeColor(hostVar("--color-border", hostVar("--border", "")), dark ? "rgba(255,255,255,.12)" : "rgba(15,23,42,.10)"), + panel: safeColor(hostVar("--color-popover", hostVar("--card", "")), dark ? "#1c1f26" : "#ffffff"), + card: safeColor(hostVar("--color-card", ""), dark ? "rgba(255,255,255,.045)" : "rgba(255,255,255,.72)"), + accent: safeColor(hostVar("--primary", hostVar("--accent", "")), "#2563eb"), + radius: hostVar("--radius-2xl", "14px"), + shadow: hostVar("--shadow-md", "0 18px 48px rgba(0,0,0,.28)"), + chart: [ + safeColor(hostVar("--color-usage-chart-1", ""), "#2f81f7"), + safeColor(hostVar("--color-usage-chart-2", ""), "#f59e0b"), + safeColor(hostVar("--color-usage-chart-3", ""), "#22c55e"), + safeColor(hostVar("--color-usage-chart-4", ""), "#a855f7"), + safeColor(hostVar("--color-usage-chart-5", ""), "#ec4899"), + ], + }; + } + + function applyWidgetTheme() { + if (!root) return; + const t = theme(); + root.style.setProperty("--bd-fg", t.fg); + root.style.setProperty("--bd-muted", t.muted); + root.style.setProperty("--bd-border", t.border); + root.style.setProperty("--bd-panel", t.card); + root.style.setProperty("--bd-accent", t.accent); + } + + function applyModalTheme() { + if (!modalHost) return; + const t = theme(); + modalHost.style.setProperty("--bd-fg", t.fg); + modalHost.style.setProperty("--bd-muted", t.muted); + modalHost.style.setProperty("--bd-border", t.border); + modalHost.style.setProperty("--bd-panel", t.panel); + modalHost.style.setProperty("--bd-accent", t.accent); + modalHost.style.setProperty("--bd-radius", t.radius); + modalHost.style.setProperty("--bd-shadow", t.shadow); + } + + /* ------------------------------------------------------------------ */ + /* Sidebar mounting */ + /* ------------------------------------------------------------------ */ + + function visible(el) { + try { + const rect = el.getBoundingClientRect(); + return rect && rect.width >= 80 && rect.height >= 80; + } catch { + return true; + } + } + + function findAside() { + const list = [ + document.querySelector('aside[data-testid="sidebar"]'), + document.querySelector('[data-workspace-sidebar-panel="true"] aside'), + document.querySelector('nav[data-testid="sidebar"]'), + ].filter(Boolean); + return list.find(visible) || list[0] || null; + } + + /** + * Locate the compact profile row that hosts the avatar / settings buttons. + * + * The row is not always a direct child of `aside` and not always a `