diff --git a/assets/usage-detail/icon.png b/assets/usage-detail/icon.png new file mode 100644 index 00000000..18a5943d Binary files /dev/null and b/assets/usage-detail/icon.png differ diff --git a/external_plugins/usage-detail/.zcode-plugin/plugin.json b/external_plugins/usage-detail/.zcode-plugin/plugin.json new file mode 100644 index 00000000..3a051ee3 --- /dev/null +++ b/external_plugins/usage-detail/.zcode-plugin/plugin.json @@ -0,0 +1,111 @@ +{ + "name": "usage-detail", + "version": "0.23.5", + "description": "Per-request usage analytics for ZCode: live dashboard & side-panel with token/cost/cache breakdown, 5h windows, budget alerts, vendor quotas. Read-only, zero model overhead, zh/en.", + "author": { + "name": "axdlee", + "url": "https://github.com/axdlee" + }, + "commands": "./commands", + "skills": "./skills", + "userConfig": { + "dbPath": { + "type": "string", + "title": "用量数据库路径", + "description": "本地 SQLite 路径,留空使用 ~/.zcode/cli/db/db.sqlite", + "default": "" + }, + "panelLimit": { + "type": "string", + "title": "面板聚合条数", + "description": "侧栏与仪表盘默认聚合的最近请求条数(1-20000,-1 表示全部)", + "default": "-1" + }, + "quotaToken": { + "type": "string", + "title": "官方配额 API Key(可选)", + "description": "智谱开放平台 API Key(bigmodel.cn 创建)。配置后 MCP 工具 usage_quota 可查官方 coding-plan 配额(5小时/周池);不配置则该工具提示如何配置", + "default": "" + }, + "quotaBase": { + "type": "string", + "title": "官方配额接口地址(可选)", + "description": "默认 https://open.bigmodel.cn,一般无需修改", + "default": "" + }, + "deepseekKey": { + "type": "string", + "title": "DeepSeek API Key(可选)", + "description": "配置后配额卡与 usage_quota 显示 DeepSeek 官方余额。Key 仅发送到 api.deepseek.com,只读查询", + "default": "" + }, + "kimiKey": { + "type": "string", + "title": "Kimi API Key(可选)", + "description": "配置后配额卡与 usage_quota 显示 Kimi Code 的 5 小时/周窗口用量。Key 仅发送到 api.kimi.com,只读查询", + "default": "" + }, + "openrouterKey": { + "type": "string", + "title": "OpenRouter API Key(可选)", + "description": "配置后配额卡与 usage_quota 显示 OpenRouter 预付额度使用情况。Key 仅发送到 openrouter.ai,只读查询", + "default": "" + }, + "minimaxKey": { + "type": "string", + "title": "MiniMax API Key(可选)", + "description": "配置后配额卡与 usage_quota 显示 MiniMax Token Plan 窗口用量。Key 仅发送到 api.minimaxi.com,只读查询", + "default": "" + } + }, + "mcpServers": { + "panel": { + "type": "stdio", + "command": "node", + "args": [ + "${ZCODE_PLUGIN_ROOT}/scripts/mcp/panel-server.mjs" + ], + "env": { + "ZCODE_USAGE_DB": "${user_config.dbPath}", + "ZCODE_USAGE_LIMIT": "${user_config.panelLimit}", + "ZCODE_USAGE_QUOTA_TOKEN": "${user_config.quotaToken}", + "ZCODE_USAGE_QUOTA_BASE": "${user_config.quotaBase}", + "ZCODE_USAGE_DEEPSEEK_KEY": "${user_config.deepseekKey}", + "ZCODE_USAGE_KIMI_KEY": "${user_config.kimiKey}", + "ZCODE_USAGE_OPENROUTER_KEY": "${user_config.openrouterKey}", + "ZCODE_USAGE_MINIMAX_KEY": "${user_config.minimaxKey}" + } + } + }, + "ui": { + "surfaces": [ + { + "id": "usage", + "title": { + "zh-CN": "用量", + "en-US": "Usage" + }, + "icon": "chart-column", + "server": "panel", + "resourceUri": "ui://usage-detail/panel.html", + "availability": "session" + } + ] + }, + "hooks": "./hooks", + "description_i18n": { + "en": "Per-request usage analytics for ZCode: live dashboard & side-panel with token/cost/cache breakdown, 5h windows, budget alerts, vendor quotas. Read-only, zero model overhead, zh/en.", + "zh-CN": "逐条用量分析:实时仪表盘与会话侧栏,token 构成、折算成本、缓存率与 5h 计费窗口;预算燃烧告警与多厂商配额。只读本地数据、零模型开销、中英双语。" + }, + "license": "MIT", + "repository": "https://github.com/axdlee/zcode-usage-detail", + "homepage": "https://github.com/axdlee/zcode-usage-detail", + "keywords": [ + "usage", + "tokens", + "cost", + "dashboard", + "quota", + "analytics" + ] +} diff --git a/external_plugins/usage-detail/LICENSE b/external_plugins/usage-detail/LICENSE new file mode 100644 index 00000000..16b6d541 --- /dev/null +++ b/external_plugins/usage-detail/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 zcode-usage-detail contributors + +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/usage-detail/README.md b/external_plugins/usage-detail/README.md new file mode 100644 index 00000000..1e64fa5b --- /dev/null +++ b/external_plugins/usage-detail/README.md @@ -0,0 +1,65 @@ +# usage-detail + +Per-request usage analytics for ZCode, entirely local: every model request is one row — time, session, model, provider (official plan vs. custom BYOK), token breakdown (input / cache read / cache write / output), duration, TTFT, **output tok/s**, status, and optional price-converted cost. Zero runtime dependencies (Node built-ins only), database opened read-only. + +中文文档:[README_CN.md](./README_CN.md) + +![demo](https://raw.githubusercontent.com/axdlee/zcode-usage-detail/main/screenshots/dashboard-overview-dark.png) + +## Features + +> **Platform support**: full experience on macOS; Windows & Linux cover all core features (side panel / MCP / hooks / dashboard / CLI). CLI needs bash (on Windows, Git Bash — if `bash.exe` is not on PATH the service probes common Git for Windows install locations, or set `ZCODE_BASH`) and Node >= 22.13 (bundled with ZCode); when the sqlite3 CLI is missing a node:sqlite shim is used automatically (readfile + CSV export included). Notifications use native channels per platform (osascript / WinRT toast / notify-send), silently degrading when unavailable. On macOS they are attributed to ZCode (ZCode icon instead of the Script Editor scroll). With terminal-notifier installed (`brew install terminal-notifier`) clicks go straight to the target: budget/turn notifications open the local panel, permission-wait activates ZCode; otherwise clicking opens ZCode, falling back to the system default when ZCode is not running (0.22.2/0.22.3). Config files with a UTF-8 BOM are read fine; local writes retry and fall back when the target file is locked (0.22.1). + +- **UI language (i18n)**: zh/en follows the system language automatically (overridable in Settings: Auto / 中文 / English; the local panel reloads instantly, reopen the side panel to apply) — covers tabs, filters, KPIs, insights, table headers, buttons, empty states, the settings dialog, tooltips and the per-turn live banner; number units switch to K/M/B in English (万/亿 otherwise); missing entries fall back to Chinese. Language detection order: manual choice → **ZCode's own language preference** (`localePreference` in `~/.zcode/v2/setting.json`, side panel & local views) → browser / WebView language (browser mode always follows the browser). Known boundaries: CLI output, notification texts and SVG accessibility descriptions (`aria-label`/``) remain Chinese; session titles, model/provider names and quota bucket names are data and never translated (0.23.0, extended in 0.23.1/0.23.2) +- **Yesterday filter & text search**: new "Yesterday" time tier (insight badge becomes yesterday-vs-day-before); new search box filtering by session title / model / provider (case-insensitive, 200ms debounce) (0.23.0) +- **Today budget card**: once a daily budget is set, insights always show "spent / budget · %" for today (gold at 80%, red at 100%, mirroring the alert thresholds), unaffected by filters (0.23.0) +- **Auto refresh**: "Auto ON/OFF" toggle in the local panel (60s full refresh, remembered; tab hash preserved) for unattended monitoring (0.23.0) +- **Burn rate & pacing**: last-hour tok/min with converted cost always visible in insights, plus a pacing label (slowing / steady / accelerating) against the 7-day daily average (borrowed from coding-usage-bar). +- **Projects tab**: usage aggregated by project (sessions / requests / tokens / 7-day sparkline / last activity); click a project to filter the session table. Projects resolve to the **real git repository root** — sessions started in sub/sub-sub directories merge into their project; falls back to raw directories when a path is gone, non-git, or git is not installed (common on Windows) (0.22.0) +- **BYOK provider display names**: custom providers (provider_id is often `new-provider`) are shown under the name you set in `provider_config.json` (e.g. `hub-linuxdo`). Only the name mapping is read — API keys in that file are never read or sent anywhere (0.22.0) +- **Model name normalization**: the same model stored in mixed-case variants (`GLM-5.3-Flash` / `glm-5.3-flash`) merges into one row / one filter option, displayed under the most-used variant (0.22.0) +- **Billing windows (approximate)**: local 5-hour split (same rule as ccusage blocks) with the last 8 windows as bars; the active window shows a full-window token projection. +- **Port adaptive**: the local panel defaults to 7741; when occupied it shifts to 7742-7749 automatically (logs and the "open panel" entry always point at the real port), while an explicitly requested busy port fails with a clear error (0.22.4) +- **Statusline one-liner**: `usage.sh statusline` prints "today tokens · cost · burn rate · budget %" for tmux / SwiftBar. +- **Polished motion**: KPI count-up, tab fade, pulsing live banner, heatmap hover outline - all respecting reduced-motion. + +- **Interactive dashboard** (`/usage-ui`): six tabs — overview / daily trend / sessions / model efficiency / requests / failures. Global filters (provider, model, session, time range) apply everywhere; sortable tables, pagination, drill-downs, GitHub-style activity heatmap (day/week/month-calendar), dark & light themes. +- **Session side panel** (ZCode Desktop 3.15+): the same full dashboard as an MCP App tab, following the host theme. Per-turn stats banner refreshed in real time via MCP resource subscription (`resources/subscribe` → `notifications/resources/updated`) — computed locally, zero model overhead, never enters the conversation. +- **Session health score & waste forensics**: 0-100 per-session health score (error/cancel/retry weighted); insight card + `usage.sh waste [days]` showing tokens burned by failed/cancelled/retried requests, top failing sessions and most-retried models. +- **Model leaderboard**: average tok/s, average TTFT, error rate, cache rate, and converted cost per 1k tokens per model. +- **Budget & burn alerts**: when the local panel service is resident, a watchdog checks every minute — macOS notification at 80%/100% of the daily budget and when today's tokens exceed 1.5× the 7-day daily average (once per tier per day). +- **Permission-wait reminder** (opt-in): async `PermissionRequest` hook notifies you when the agent waits for approval. +- **Per-turn stats**: local Stop-hook stats pushed to the side-panel banner; optional macOS notification or in-conversation stats line (`usage.sh turnstats`). When the hook is off or the subscription is unavailable, the banner falls back to aggregating the latest `turn_id` group from loaded data (marked "as of HH:MM"), so the current turn is always visible (0.22.0). +- **Settings dialog**: unit prices (official Zhipu CNY prices for GLM 5.3 series built in; other models synced from models.dev with USD→CNY conversion), daily/monthly budgets, and all notification switches editable in the local panel UI. +- **Cost**: price table is editable; subscription plans don't bill per token — converted costs represent the equivalent API list-price value. Cache-hit tokens are billed at the cache price (never double-billed as uncached input); time-of-use models are priced per request timestamp. **Time-of-use (peak/off-peak) pricing**: add a `tariff` field to a model and each request is priced by its own local timestamp; the insights "current tier" row judges the active tier from the system clock. Built-in official DeepSeek peak/off-peak list prices (workday 9:00–12:00 & 14:00–18:00 peak, weekends/holidays off-peak half price, verified 2026-10) plus the official 2026 Chinese public-holiday calendar (incl. 6 adjusted working Saturdays) and a real-time GLM Coding Plan peak-hours hint (workday 14:00–18:00 credits at 1×, otherwise 0.5×) are included. + +## Install + +Search **usage-detail** in ZCode Settings → Plugins, or add the author's marketplace: + +``` +/plugin marketplace add axdlee/zcode-usage-detail +``` + +Then open `/usage` for the CLI summary or `/usage-ui` for the dashboard. After installing or updating, restart ZCode; hooks apply to newly opened sessions. + +## Components + +- **Commands / skills**: `/usage`, `/usage-ui`, `/usage-this`, `/usage-this-ui`, `/usage-today` backed by the `usage-detail` skill (read-only CLI: summary/recent/day/days/sessions/models/providers/errors/months/projects/blocks/waste/export…). +- **MCP server** (`usage-detail-panel`): tools `usage_summary/detail/trend/sessions/session/quota`, `open_full_panel`; resources for the side-panel page and the per-turn stats feed. +- **Multi-vendor quota card (opt-in)**: the dashboard and `usage_quota` aggregate GLM (zero-key, reads local client logs) with DeepSeek / Kimi / OpenRouter / MiniMax once you fill in each vendor's API key in plugin settings — keys are sent only to that vendor's official API, stored locally (mode 0600), never logged. +- **Hooks**: `UserPromptSubmit` (per-turn stats guard, silent unless enabled), `Stop` (async, writes a local turn-done marker; optional macOS notification), `PermissionRequest` (async, opt-in reminder). +- **Local panel service**: `usage.sh serve` (or `open_full_panel`) starts a read-only HTTP server bound to `127.0.0.1:7741`. + +## Privacy & side effects + +- **Reads** `~/.zcode/cli/db/db.sqlite` (read-only connection) and the plugin's own config files under `~/.zcode/`. +- **Writes** only its own config/state files: `~/.zcode/usage-prices.json`, `usage-budget.json`, `usage-alerts.json`, `usage-alerts-state.json`, `usage-turnstats.json`, `usage-turn-done.json`, `usage-serve.json`, `usage-vendors.json` (optional vendor quota keys, mode 0600). +- **Local network**: the panel service listens on `127.0.0.1` only. +- **Outbound network**: daily price sync from [models.dev](https://models.dev) (disable with `ZCODE_USAGE_NO_AUTOSYNC=1`); optional GLM quota lookup (reads local client logs, no credentials sent, or `open.bigmodel.cn` if you configure an API key); optional per-vendor quota lookups — DeepSeek `api.deepseek.com`, Kimi `api.kimi.com`, OpenRouter `openrouter.ai`, MiniMax `api.minimaxi.com` — each only if you configure that vendor's API key, read-only, keys stored locally and never logged. No telemetry, no other endpoints. +- **Notifications**: cross-platform system toasts — macOS `osascript`, Windows WinRT toast via PowerShell, Linux `notify-send` — silently skipped where unavailable. +- Third-party material: pricing data from models.dev (converted, cached locally); everything else is original code, MIT licensed. + +## License + +MIT diff --git a/external_plugins/usage-detail/README_CN.md b/external_plugins/usage-detail/README_CN.md new file mode 100644 index 00000000..8e869089 --- /dev/null +++ b/external_plugins/usage-detail/README_CN.md @@ -0,0 +1,246 @@ +# ZCode 用量明细 (usage-detail) + +[English](README.md) | 简体中文 + +一个 [ZCode](https://zcode.z.ai) 插件:查看**每一次模型请求**的本地用量明细——逐条记录、按来源(GLM 官方套餐 / 自定义 BYOK 接入)区分、多维筛选与交互式仪表盘。 + +纯本地只读查询,**不发起任何网络请求,不上传任何数据**。 + +![仪表盘总览](../screenshots/dashboard-overview-dark.png) + +会话排行支持点击展开:上下文压缩、模型占比、使用工具、上下文水位曲线一目了然: + +![会话排行与钻取](../screenshots/dashboard-sessions-dark.png) + +每日趋势按来源堆叠,图表下方附每日明细表(请求 / token / 缓存命中 / 生成速率),点击某天直接切换为该日的请求日志: + +![每日趋势](../screenshots/dashboard-trend-light.png) + +## 功能 + +> **平台支持**:macOS 完整支持;Windows 与 Linux 支持全部核心能力(侧栏面板 / MCP / 钩子 / 仪表盘 / CLI)——CLI 依赖 bash(Windows 用 Git Bash;服务未在 PATH 找到 bash.exe 时自动探测 Git for Windows 常见安装位置,也可用 `ZCODE_BASH` 环境变量指定)、无 sqlite3 时自动走 node 替身(含 readfile 与 CSV 导出)与 Node ≥ 22.13(ZCode 自带);系统通知按平台原生通道(osascript / WinRT toast / notify-send),不可用时静默;macOS 通知归因到 ZCode(显示 ZCode 图标而非「脚本编辑器」卷轴);已安装 terminal-notifier(brew install terminal-notifier)时点击直达——预算/本轮统计点击打开本地面板,权限等待点击激活 ZCode 去批准;无 terminal-notifier 时点击打开 ZCode,ZCode 未运行则回退系统默认(0.22.2/0.22.3)。配置文件(价格表/开关/凭据)带 UTF-8 BOM 也能正常读取;本地写入在文件被占用时自动重试兜底(0.22.1)。 + +- **界面语言 i18n**:跟随系统语言自动切换中/英(设置里可选「跟随系统 / 中文 / English」;本地面板切换即时生效,侧栏面板重开生效),页签、筛选、KPI、洞察、表头、按钮、空态、设置弹窗、tooltip 与「本轮」实时横幅全量双语;英文模式数字单位用 K/M/B(中文用 万/亿);缺失词条自动回退中文。语言判定顺序:手动选择 → **ZCode 自身界面语言偏好**(`~/.zcode/v2/setting.json` 的 `localePreference`,侧栏/本地视图)→ 浏览器/WebView 语言(浏览器打开的网页恒跟随浏览器)。已知边界:CLI 输出、系统通知文本、SVG 无障碍描述(aria-label/desc)暂为中文;会话标题、模型/来源名、配额桶名属数据不翻译(0.23.0,0.23.1/0.23.2 补全) +- **昨天筛选与文本搜索**:时间档位新增「昨天」(洞察环比自动变为「昨日 vs 前日」);控制条新增文本搜索框(会话标题 / 模型 / 来源大小写不敏感包含,200ms 防抖)(0.23.0) +- **今日预算进度卡**:设置日预算后洞察区常驻「今日折算 / 预算 · 百分比」,80% 金色、100% 红色,与系统告警阈值呼应;恒按今天全量口径(不随筛选变化)(0.23.0) +- **自动刷新**:本地面板控制条新增「Auto ON/OFF」开关(60s 整页刷新,状态记忆;页签 hash 保留),挂机监控不用手点刷新(0.23.0) +- **燃烧率与节奏**:近 1h tok/min + 折算金额常驻洞察前排,按近 7 天日均给出「放缓 / 平稳 / 加速」节奏标记 +- **项目用量页签**:按项目聚合(会话数 / 请求 / token / 近 7 天 sparkline),点击项目行筛选会话表。项目取**真实 git 仓库根**:在子目录 / 孙目录里开的会话自动归并到所在项目,不再出现深层目录冒充项目名;目录已删、非 git 仓库或未安装 git(Windows 常见)时退化为按原目录分组(0.22.0) +- **BYOK 供应商显示名**:设置里添加的自定义接入(provider_id 常为 `new-provider`)自动显示为你在 `provider_config.json` 里命名的名称(如 `hub-linuxdo`);插件只读取名称映射,不读取也不外发该文件中的 API Key(0.22.0) +- **模型名归一**:同一模型以多种大小写形态入库(如 `GLM-5.3-Flash` / `glm-5.3-flash`)时自动合并为一行/一个筛选项,显示名取用量最大的形态(0.22.0) +- **计费窗口(近似)**:本地 5h 切分(与 ccusage blocks 同口径),最近 8 个窗口横条对比,活跃窗口带全窗投影 +- **端口自适应**:本地面板默认 7741;被其他应用占用时自动顺延 7742-7749(日志与「打开面板」入口始终指向实际端口),你显式指定的端口被占则明确报错(0.22.4) +- **状态栏一行摘要**:`usage.sh statusline` 输出「今日 token · 折算 · 燃烧率 · 预算 %」,可挂 tmux / SwiftBar +- **动效**:KPI 数字滚动、页签淡入、横幅呼吸脉点、热力图悬停描边——尊重系统减弱动效设置 + +- **逐条明细**:每次模型请求一行——时间、会话、模型、来源、主会话/子代理、输入/缓存读/缓存写/生成/合计 token、耗时、首字延迟、**输出速率 tok/s**(六档变色)、状态、每行折算成本(API 牌价,可开关);分页浏览、点表头排序 +- **会话侧栏面板**(ZCode Desktop 3.15+):**与 `/usage-ui` 完整版同一份界面**(不是简化版)——六个标签页、热力图、趋势、每日明细、会话钻取、模型效率、请求分页/排序/钻取全部可用,明暗主题跟随宿主,由插件内置零依赖 MCP 服务按需生成(默认**内嵌全部历史**,插件设置里可自定义数据库路径与聚合条数)。图表悬停即时显示明细(含 token 与缓存率);时段分布支持「请求 / token」指标切换;明细分页支持页码直跳 +- **每轮用量实时卡**:每轮回合结束由本地脚本统计(零模型开销、不进上下文),经 MCP 资源订阅实时推送到侧栏面板顶部「⏱ 本轮」横幅;统计未开启或订阅不可用时,横幅自动退回按已加载数据的 `turn_id` 聚合展示最新一轮(标注「截至 HH:MM 数据」),任何环境下都有本轮可见;可选系统通知(macOS / Windows / Linux)或对话内统计行(`usage.sh turnstats`,默认仅侧栏卡)(0.22.0 补兜底) +- **预算 / 燃烧告警**(零模型开销):本地面板常驻时每分钟本地检查——日折算成本达日预算 80%/100% 时弹 macOS 通知(各档每日最多一次),今日 token 超近 7 天日均 50% 也会提醒;`usage.sh alerts budget off` 可关 +- **权限等待提醒**(默认关):Agent 弹出权限批准请求时发 macOS 通知,挂机跑长任务不漏确认;`usage.sh alerts permission on` 开启 +- **浪费取证**:失败 / 取消请求白耗的 token 一眼看穿——洞察卡带汇总,`usage.sh waste [N天]` 出近 N 天详单(失败/取消/重试口径 + 反复失败会话 Top5 + 重试最多模型) +- **模型效率排行**:仪表盘新「模型效率」页签——按模型对比平均 tok/s、平均首字延迟、失败率、缓存率与每 1k token 折算成本;会话排行新增 **0-100 健康分**(失败率/取消率/重试率加权,可解释口径) +- **两级钻取**:点击会话行(或总览「最活跃会话」行)展开该会话详情——项目目录、主会话/子代理用量拆分、压缩事件、模型占比**甜甜圈**、来源分布、**使用工具甜甜圈**、事件时间线(压缩/模型切换,竖轴彩点)、**上下文水位曲线**(起止融入图头,逐点悬停看输入/生成/模型/状态)、一键跳转该会话请求日志;点击请求行展开该条完整信息——Token 构成、时序、**输出速率**,本地面板模式还能查看 provider 上报的**原始用量 JSON** +- **当前会话**(感谢 [@Garfielk](https://github.com/Garfielk)):`/usage-this` 查看当前会话汇总、按模型/来源分组和最近失败/取消请求;`/usage-this-ui` 使用精简面板,首屏展示请求/输入/生成/缓存读,模型分布和请求明细按需展开,支持直接查看失败/取消记录 +- **来源区分**:自动区分 `account:*` 官方套餐(如 GLM Coding Plan)与自定义/BYOK 接入,仪表盘按来源堆叠展示每日趋势 +- **界面**:列表 token 与耗时人性化显示(小数字原样、大数 k/M,悬停看完整值);「设置」弹窗集成本地价格表 / 日月预算 / 通知开关(权限等待提醒、预算告警、每轮统计通知)——本地面板里点点即可配置,不再需要记命令;洞察/体检/KPI 为彩色内容卡片(值/标签层级 + 按信息类型固定的强调色);官方套餐来源显示中文名(如「GLM Coding Plan 个人版」,自定义来源保留原名);默认时间范围为「今天」 +- **交互式仪表盘**(`/usage-ui`):总览 / 每日趋势(含每日明细表)/ 会话排行 / 模型效率 / 请求明细 / 失败请求六个标签页;来源、模型、会话、时间范围(今天 / 近 7 天 / 30 天 / 全部)全局筛选即时生效——**明细里点击会话/模型名直接切换为该会话/该模型的日志**(筛选条一键清除);请求明细**分页浏览**(每页 50 条 + 页码直跳);表格点击表头排序;界面自动适配 ZCode 明暗主题。**默认内嵌全部历史**,`/usage-ui <条数>` 可限制(1–20000;会话内嵌渲染有体积上限,超大的历史建议用本地面板或侧栏) +- **缓存率全链路**:缓存命中率、缓存量(读+写)进洞察;来源构成、模型分布、会话排行、请求明细逐项带缓存率 +- **悬停明细**:热力图、趋势图、时段分布鼠标悬停即时显示完整用量分解 +- **洞察指标**:活跃天数、当前/最长连续活跃、峰值日、缓存命中率、缓存量(读+写)、任务完成率(todo)、重试/取消、平均耗时、主会话/子代理拆分、常用工具**甜甜圈图**(悬停看调用次数/均耗时/失败)、峰值时段(请求/token 切换,悬停含缓存率)、数据体量体检(日志/导出/库) +- **活跃热力图**:GitHub 提交风格,按天 token 四分位着色,悬停看当日明细;粒度「每日 / 每周 / 月历」三态切换(月历为最近 3 个月日历视图) +- **健康度卡片**:平均首字延迟、生成速率、错误率、峰值上下文水位、工具调用次数与总耗时,一行体检指标 +- **每日明细 + 每月汇总**:趋势页图表下方附逐日表(请求 / token / 缓存命中 / 生成速率)与逐月汇总表(含折算成本),点击某天直接切换为该日的请求日志 +- **多厂商配额聚合(可选接入)**:总览页配额卡与 MCP 工具 `usage_quota` 聚合显示各厂商用量——GLM 零密钥(自动读客户端日志观测);DeepSeek(余额)、Kimi(5 小时/周窗口)、OpenRouter(预付额度)、MiniMax(Token Plan)在设置里填入各自 API Key 后启用。Key 仅发送到对应厂商官方域名做只读查询,保存在本地 `~/.zcode/usage-vendors.json`(权限 0600),可随时清除;未配置时如实提示,不做猜测 +- **折算成本(API 牌价,CNY)**:GLM 5.3 系内置智谱官网权威牌价(¥/百万 token,与官方定价页一致);其余模型从 [models.dev](https://models.dev) 全量同步并按 USD→CNY 汇率换算(`ZCODE_USAGE_USD_CNY` 可改);自定义条目与官方权威条目不会被同步覆盖。订阅套餐请求不按 token 计费,金额仅表示等量 API 用量的牌价价值。仪表盘有"折算成本"开关与"同步价格"按钮 +- **分时计价(峰谷定价)**:按每条请求的本地时间取档计价(官方套餐高峰/低峰时段单价不同、其他模型同理),在价格表里加 `tariff` 字段即可生效: + + ```json + { "currency": "CNY", + "models": { "deepseek-flash": { "in": 2, "out": 8, "cache": 0.04, + "tariff": { "tz": 8, "weekend": 0.5, + "tiers": [ + { "hours": [[9, 12], [14, 18]], "mult": 1.0, "name": "高峰" }, + { "hours": [[0, 24]], "mult": 0.5, "name": "空闲" } ] } } } } + ``` + + `weekend` 为可选的周末统一倍率(周六/日全天,如 DeepSeek 周末按空闲价);`holidays` / `workdays` 为可选的法定节假日与调休上班日列表(`"YYYY-MM-DD"`,按 `tz` 口径)——节假日全天按 `weekend` 倍率计空闲价,调休上班的周末按正常分时档。历史请求按各自发生时刻取档。**洞察区「当前时段」行**按系统当前时间实时判定所处档位(取用量最大的分时模型为代表)。**内置 DeepSeek 官方峰谷牌价**(deepseek-flash / deepseek-v4-flash / deepseek-v4-pro,2026-10 核对官方价目页:工作日 9:00–12:00、14:00–18:00 高峰,周末与法定节假日全天空闲半价)与 **2026 年国务院办公厅节假日安排**(含 6 天调休上班日,2025-11-04 通知)——重跑 `prices sync` 即更新到本地价格表;新年份安排公布后随插件内置表更新,也可自行编辑 `tariff.holidays`。**GLM Coding Plan 积分峰谷**(工作日 14:00–18:00 按 1× 抵扣、其余 0.5×,2026-10 核对官方套餐文档)在配额卡的 GLM 厂商块实时提示。不配置 `tariff` 时按基础价计算,行为不变。 +- **套餐配额查询**(`quota`,CLI 可选):GLM 余量因账号/套餐类型差异较大(客户端凭据为加密托管,日志并非对所有套餐包含 5 小时池/周池观测),优先零密钥解析客户端日志里的额度观测;界面侧的多厂商聚合见上方「多厂商配额聚合」,本子命令面向 CLI 场景保留:写入 `~/.zcode/usage-quota-token.json`(`{"token":"你的KEY","base":"https://open.bigmodel.cn"}`)后可查监控接口 +- **本地常驻面板**(`serve`):一条命令在本机起一个只读 Web 服务,浏览器打开即得常驻看板——数据一次加载,燃烧率每 5 秒轻量轮询实时刷新,支持两级钻取与原始用量查看,不占会话上下文 +- **导出**:`export csv` / `export json` 全量记录(CSV 遵循 RFC 4180) +- **零依赖**:只用 macOS / Linux 自带的 `bash` + `sqlite3`,不需要 Node / Python + +数据来自 ZCode 客户端本地数据库 `~/.zcode/cli/db/db.sqlite`(只读)。 + +## 安装 + +### 方式一:添加 GitHub 市场源(推荐) + +1. 打开 ZCode 的 **插件市场** +2. 点击 **添加 → 添加插件市场**,粘贴本仓库地址: + + ``` + https://github.com/axdlee/zcode-usage-detail + ``` + +3. 添加后进入 **个人** 标签页,找到 **用量明细 (Usage Detail)**,点击 **安装** + +### 方式二:本地目录 + +```bash +git clone https://github.com/axdlee/zcode-usage-detail.git +``` + +然后在 **插件市场 → 添加 → 添加插件市场** 粘贴克隆出的目录路径(即包含 `marketplace.json` 的那一层),后续步骤同上。 + +### 方式三:AI 一键安装 + +把下面这段话直接发给 ZCode(或任何编码智能体),它会替你完成剩余步骤: + +```text +请帮我安装 GitHub 上的 zcode-usage-detail 插件: + +1. 执行 git clone https://github.com/axdlee/zcode-usage-detail.git ~/.zcode/plugins/zcode-usage-detail(目录已存在则 git -C 拉取更新) +2. 校验插件清单:node /Applications/ZCode.app/Contents/Resources/glm/zcode.cjs plugins validate ~/.zcode/plugins/zcode-usage-detail/usage-detail(没有这个 CLI 就跳过) +3. 读完插件 README 的安装章节后,告诉我手动步骤:在 ZCode 的「插件市场 → 添加 → 添加插件市场」里粘贴 ~/.zcode/plugins/zcode-usage-detail,然后在「个人」标签安装「用量明细」插件 +4. 最后给我一条验收提示:安装后新建任务运行 /usage-ui,应看到内嵌的用量仪表盘 +``` + +## 使用 + +安装后**新建任务**: + +| 输入 | 效果 | +|---|---| +| `/usage-ui` | 生成交互式仪表盘,内嵌渲染在会话中 | +| `/usage-ui 8000` | 同上,内嵌最近 8000 条 | +| `/usage-this` | 推断当前会话,显示身份、用量汇总、模型/来源与最近失败/取消请求 | +| `/usage-this-ui` | 当前会话的精简面板:四项用量统计、折叠模型分布与请求明细 | +| `/usage-this --session sess_xxx` | 显式指定会话(UI 命令同样支持) | +| `/usage-this --directory /绝对/项目路径` | 选择指定项目目录内最近请求的会话(UI 命令同样支持) | +| `/usage` | 文本总览(累计 + 今日) | +| `/usage blocks` | 5 小时窗口用量(CLI 近似口径;界面已移除该模块,官方配额看 Usage Stats → Coding Plan) | +| `/usage recent 50` | 最近 50 条逐条明细 | +| `/usage providers` | 按来源(官方套餐 / 自定义接入)汇总 | +| `/usage months` | 按月汇总 | +| `/usage projects 10` | 按项目目录汇总 Top 10 | +| `/usage tools` | 工具调用统计 | +| `/usage cost` | 按模型折算成本(官方牌价 CNY,可自定义价格表) | +| `/usage-today` | 今天用了多少:当日明细 + 折算金额 + 失败情况 | +| `/usage prices sync` | 从 models.dev 同步最新官方价 | +| `/usage quota` | 查询套餐配额余量(零密钥读客户端日志,可选 API Key 补充) | +| `/usage doctor` | 环境自检:数据库/表结构/价格表/配额数据源/依赖逐项排查("统计不见了?"先跑这个) | +| `/usage export csv` | 导出全部记录为 CSV | +| `/usage day 2026-10-14` | 某天的逐条明细 | +| `/usage sessions 10` | 会话用量排行 Top 10 | +| `/usage errors` | 最近的失败/取消请求 | +| “看看我哪个会话最烧 token” | 自然语言同样可以,会自动路由到对应查询 | + +### 当前会话定位 + +ZCode 命令环境尚未提供可靠的当前会话 ID,因此默认使用本地数据库中**最近一次模型请求的 `session_id`**,一次执行中固定该 ID。多窗口并行请求时可能定位到另一会话,请核对输出中的会话标题、ID 和最近请求时间;有歧义时用 `--session ID` 指定,或用 `--directory "项目绝对路径"` 限制候选范围(两者互斥)。目录匹配为精确匹配,可使用报告中的项目目录。 + +只统计所选 `session_id` 的请求,不递归合并其他 ID 的子会话。文本版统计全部状态,生成 token = 输出 + 思考;失败和取消单独计数,进行中的请求归入其他状态。正在进行的请求可能尚未写入完整 token,重新运行可刷新。 + +会话 UI 首屏展示标题、最近请求和四项用量;会话 ID、项目目录与创建时间可展开查看。模型分布和请求明细默认折叠,有失败/取消请求时可直接跳到对应筛选。会话排行、热力图与每日趋势仍在全量 `/usage-ui` 中查看。 + +UI 在会话过滤后默认内嵌该会话全部请求,可用 `ZCODE_USAGE_LIMIT` 限制为 1-20000(-1 为全部);超过时标注覆盖范围,卡片和明细均只统计已加载记录。结果是生成时的本地快照,不表示服务端套餐余量。 + +直接运行脚本: + +```bash +bash usage-detail/scripts/usage.sh this +bash usage-detail/scripts/usage.sh this --session sess_xxx +bash usage-detail/scripts/usage.sh dashboard-this /tmp/usage-this.html --directory "/path/to/project" +``` + +### 会话侧栏面板怎么打开 + +1. 安装或更新插件后,**重启 ZCode**(宿主在启动时加载插件清单) +2. 打开任意一个会话,在**右侧面板**区域点击 **「+」新增标签** +3. 在列表里选择「**用量**」——面板即作为侧栏标签常驻,随会话自动刷新 +4. 面板顶部「**完整面板 ↗**」会自动拉起本地常驻服务并打开完整仪表盘(分页明细、热力图、5 小时窗口、会话钻取等全部能力都在那里) + +若列表里没有「用量」:确认插件已启用、ZCode 为 3.15+ 且已重启;仍不出现请运行 `/usage doctor` 排查。 + +### 每轮用量统计(侧栏实时卡 + 可选通知/统计行) + +每轮回合结束,async Stop 钩子都会在本地(纯 node + SQLite,**零模型开销、不进上下文**)完成统计,并通过宿主 MCP 资源订阅(`resources/subscribe` → `notifications/resources/updated`)**实时推送到打开着的侧栏面板**——面板顶部「⏱ 本轮」横幅即时刷新速率/首字/缓存率/请求数/耗时,无需刷新、无需任何开关。这是统计「融入 ZCode」的正路:数字给人看,不经过模型。 + +`usage.sh turnstats <模式>` 控制面板之外的附加出口: + +- **notify(推荐,零开销)**:面板没开着时也能看到——每轮回复结束弹一条系统通知显示本轮统计(macOS osascript / Windows WinRT toast / Linux notify-send,均本地计算,不可用时静默)。 +- **inject(对话内统计行)**:每条调用了工具的回复末尾附一行 `> 本轮用量:⚡ 82 tok/s · …`。统计脚本本身零模型推理,但**文本进入对话必须经由模型**(ZCode 的 hook 无法向回复追加文本,也没有提交前裁剪历史的通道;实测宿主 Stop 钩子注入上下文会强制"继续回合",反而每轮多一次全量模型调用,更贵)。开销:注入指令约 0.15k token/轮(持续留在上下文)+ 一次工具调用约 0.1k + 统计行输出约 0.05k——相对百万级上下文 <0.1%,短会话约 1%。 +- **off(默认)**:仅保留侧栏面板实时卡与浏览器面板轮询(本地面板每 15s 拉一次 `/api/turn`),不通知、不进对话。 + +notify/inject 需重开会话生效(hook 配置按会话快照);侧栏实时卡随面板打开即生效。 + + +### 折算成本(官方牌价 CNY,可自定义) + +价格表解析顺序:`ZCODE_USAGE_PRICES` 环境变量 → `~/.zcode/usage-prices.json`(你的文件)→ 插件内置价。GLM 5.3 系内置智谱官网权威牌价(元/百万 token,与官方定价页一致);其余模型打包自 models.dev 并已按 USD→CNY 换算(汇率 7.2,`ZCODE_USAGE_USD_CNY` 可改)。开箱即用,无需配置。 + +- **每日自动同步**:生成仪表盘/启动面板时若价格表超过 1 天未更新,自动从 models.dev 全量同步(`ZCODE_USAGE_NO_AUTOSYNC=1` 关闭) +- `usage.sh prices show` 查看当前生效的价格表;`usage.sh prices sync` 手动全量同步所有供应商模型价(保留自定义条目;需要 node + curl) +- 仪表盘顶部"同步价格"按钮:会话内点击会请求 Agent 执行同步并重渲染,本地面板点击直接同步后刷新 +- **"价格 / 预算"面板**(本地面板):搜索并编辑任意模型的输入/输出/缓存读单价,保存后全历史立即重新折算;可新增/删除条目 +- **本地折算预算**:在价格面板里设日 / 月预算(元),总览洞察行显示「今日折算 / 日预算」进度条,超 100% 变红 +- **未定价提示**:折算成本后缀显示「未定价 N%」——缺价模型的 token 不计入合计,明确口径而非默默按 0 +- 自定义:编辑 `~/.zcode/usage-prices.json`,可加任意模型、改币种: + + ```json + { "currency": "USD", + "models": { "glm-5.3": {"in": 1.4, "out": 4.4, "cache": 0.26}, + "my-private-model": {"in": 1, "out": 3, "cache": 0.1} } } + ``` + +配置后 `/usage cost` 按模型拆分估算;仪表盘顶部出现"成本估算"开关,打开后会话排行与来源构成追加成本列。注意:订阅套餐(如 GLM Coding Plan)内的请求实际不按 token 计费,估算仅供 BYOK 按量计费或心理参考。 + +### 实时燃烧率监控 + +- 仪表盘"5 小时窗口"页:剩余百分比 + 重置时间点 + 燃烧率(token/小时)+ 与近 3 个完整窗口均值的对比和满窗预测 + **剩余请求估算**(按近 3 个完整窗口平均请求数,推算本窗口还可发起多少次) +- **本地常驻面板**:`usage.sh serve`(默认端口 7741,`serve 8080` 可换),自动打开浏览器。页面数据一次加载,燃烧率/今日用量每 5 秒轻量轮询刷新(只查最近 5h 数据,走索引,开销极低);也可让 ZCode 里的 Agent 后台启动后把 `http://127.0.0.1:7741` 发给你。仅监听 127.0.0.1、只读、不写任何文件 +- 终端监控(claude-monitor 风格):`usage.sh live [秒]`,默认 3s 刷新、Ctrl-C 退出,适合挂在一个终端角落 + +### 仪表盘预览 + +每日趋势按来源堆叠,图表下方附每日明细表(请求 / token / 缓存命中 / 生成速率),点击某天直接切换为该日的请求日志: + +![每日趋势](../screenshots/dashboard-trend-light.png) + +请求明细支持状态筛选与表头排序: + +![请求明细](../screenshots/dashboard-detail-light.png) + +## 数据说明与隐私 + +- 数据源是 ZCode 客户端自动写入的本地 SQLite(`~/.zcode/cli/db/db.sqlite` 的 `model_usage` 表),本插件只做**只读**查询 +- 不联网、不上传、不写库;仪表盘数据在生成时内嵌,仅存在于你自己机器上的一个 HTML 文件 +- 仪表盘默认内嵌最近 3000 条请求,`/usage-ui <条数>` 可调整(1–20000;超过约 8000 条可能超出会话内嵌渲染的体积限制,全量历史请用 `export json`) +- token 口径:输入列含缓存命中(缓存读单独列出),生成 = 输出 + 思考;成本按「未命中输入 × 牌价 in + 缓存命中 × 牌价 cache + 生成 × 牌价 out」计价(命中段不重复计费,含分时费率模型按各请求发生时刻取档) +- **套餐配额余量**(5 小时池 / 周额度)由服务端计量,不在本地库中:`/usage quota` 优先零密钥解析客户端日志里的额度观测;因账号/套餐类型而异,部分观测(如 5 小时池)可能为空,此时请以 ZCode 的 **Usage Stats → Coding Plan** 为准,或配置 API 凭据启用监控接口 +- **多厂商配额(全部可选,默认关闭)**:仅在插件设置里填写对应厂商 API Key 后,才会向该厂商**官方域名**发起只读查询——DeepSeek `api.deepseek.com`、Kimi `api.kimi.com`、OpenRouter `openrouter.ai`、MiniMax `api.minimaxi.com`;Key 保存在 `~/.zcode/usage-vendors.json`(权限 0600),不出现在日志与导出中;GLM 配额查询不发送任何凭据(解析本地客户端日志)。不填 Key 即零外联 + +## 已知限制 + +- 只统计**本机、本账号**在 ZCode 内的请求 +- 本地库结构可能随 ZCode 版本演进变化(当前在 0.16.x 验证通过) +- 界面基于 ZCode Desktop 的 Gen UI 内嵌渲染,需在桌面端使用 +- 5 小时窗口与成本均为**本地近似口径**,服务端套餐配额以 ZCode 的 Usage Stats → Coding Plan 为准 + +## 开发验证 + +运行会话过滤、聚合、空记录、参数转义及仪表盘回归测试(开发环境需要 Python 3;插件运行仍仅需 bash + sqlite3): + +```bash +python3 -m unittest discover -s tests -v +bash -n usage-detail/scripts/usage.sh +``` + +## 致谢 + +设计参考了这些优秀的同类项目:[ccusage](https://github.com/ccusage/ccusage)(5 小时窗口 / 成本报告)、[Claude-Code-Usage-Monitor](https://github.com/Maciek-roboblog/Claude-Code-Usage-Monitor)(燃烧率与耗尽预测)以及 DeepSeek Harness 社区的用量插件家族(活跃热力图、缓存命中率、连续活跃、导出等思路)。 + +## License + +[MIT](LICENSE) diff --git a/external_plugins/usage-detail/assets/session-dashboard.html b/external_plugins/usage-detail/assets/session-dashboard.html new file mode 100644 index 00000000..4bb064db --- /dev/null +++ b/external_plugins/usage-detail/assets/session-dashboard.html @@ -0,0 +1,73 @@ + +
+
+

+
+ 会话信息 +

+
+

+
+
+
请求次数
–
+
输入 token
–
含缓存命中
+
生成 token
–
输出 + 思考
+
缓存读 token
–
+
+
+ + +
+
+ 模型分布 +
+ + +
模型来源请求数输入生成缓存读
+
+
+ 请求明细 +
+
+ + + 点击列标题可排序 +
+
+ + +
+ +
+
+
diff --git a/external_plugins/usage-detail/commands/usage-this-ui.md b/external_plugins/usage-detail/commands/usage-this-ui.md new file mode 100644 index 00000000..482f6dfe --- /dev/null +++ b/external_plugins/usage-detail/commands/usage-this-ui.md @@ -0,0 +1,11 @@ +--- +description: 生成当前会话的精简用量面板,展示四项统计,可展开模型分布与请求明细。 +argument-hint: "[--session 会话ID | --directory 项目绝对路径]" +skills: usage-detail +--- + +用户要以图形界面查看当前会话用量。按 `usage-detail` 技能的「当前会话」流程执行 `dashboard-this` 子命令,参数为 $ARGUMENTS;输出文件使用宿主会话可视化目录中的 `usage-this-dashboard.html`。 + +面板首屏只展示会话标题、最近请求及请求数/输入/生成/缓存读;模型分布、请求明细和完整会话信息默认折叠,有失败/取消请求时提供筛选入口。使用会话专用面板。 + +按技能的仪表盘交付方式在最终回复引用生成文件,并注明解析到的会话标题和定位方式。自动定位时提示用户核对标题,多窗口下可用 `--session ID` 指定会话。 diff --git a/external_plugins/usage-detail/commands/usage-this.md b/external_plugins/usage-detail/commands/usage-this.md new file mode 100644 index 00000000..ccf14ab4 --- /dev/null +++ b/external_plugins/usage-detail/commands/usage-this.md @@ -0,0 +1,9 @@ +--- +description: 查看当前会话的本地用量汇总、模型/来源分组及最近失败或取消请求。 +argument-hint: "[--session 会话ID | --directory 项目绝对路径]" +skills: usage-detail +--- + +用户要查看当前会话用量。按 `usage-detail` 技能的「当前会话」流程运行插件脚本的 `this` 子命令,参数为 $ARGUMENTS。 + +以中文表格报告会话标题、ID、最近请求、定位方式,以及汇总、模型/来源、失败/取消请求。自动定位是基于最近请求的推断,多窗口时可能定位到其他会话,须提示用户核对标题;用户提供 `--session ID` 时使用指定会话。结果来自本地记录,套餐配额余量请查看 ZCode 的 Usage Stats → Coding Plan。 diff --git a/external_plugins/usage-detail/commands/usage-today.md b/external_plugins/usage-detail/commands/usage-today.md new file mode 100644 index 00000000..50fc9b7e --- /dev/null +++ b/external_plugins/usage-detail/commands/usage-today.md @@ -0,0 +1,9 @@ +--- +description: 查看今天的模型用量明细与折算金额。 +argument-hint: "" +skills: usage-detail +--- + +用户要查看**今天**的用量。运行插件脚本的 `day` 子命令(今天逐条明细)与 `summary`(今日汇总),整理成简洁中文汇报:今日请求次数、token 量(输入/生成/缓存)、折算金额(API 牌价)、失败/取消情况,并附最近几条请求。 + +只读本机 SQLite,不发起网络请求。用户若要图形界面,引导使用 `/usage-ui` 或侧栏「用量」面板。 diff --git a/external_plugins/usage-detail/commands/usage-ui.md b/external_plugins/usage-detail/commands/usage-ui.md new file mode 100644 index 00000000..4391bd9e --- /dev/null +++ b/external_plugins/usage-detail/commands/usage-ui.md @@ -0,0 +1,13 @@ +--- +description: 生成交互式 ZCode 用量仪表盘,内嵌渲染在会话中(总览/趋势/5小时窗口/会话/明细/失败)。 +argument-hint: "[内嵌条数,默认 3000;或改用 summary|recent|day|days|sessions|models|errors 文本查询]" +skills: usage-detail +--- + +用户要以图形界面查看 ZCode 本地用量。按 `usage-detail` 技能的「仪表盘界面(dashboard)」流程执行: + +1. 运行插件脚本 `dashboard` 子命令,输出文件写入宿主提供的会话可视化目录;$ARGUMENTS 为纯数字时作为内嵌条数传入(默认 3000,范围 1-20000)。 +2. 在最终回复中用 `::visualize` 引用生成的文件,内嵌渲染给用户。 +3. 界面元信息显示"已加载 X / Y 条"且 Y 明显更大时,主动提示:可指定更大条数重新生成,或用 `/usage export json` 导出全量。 + +仅当 $ARGUMENTS 指定了具体文本子命令(summary/recent/day/days/sessions/models/errors)时,才改为文本表格输出。查询为只读本地 SQLite,无网络请求。 diff --git a/external_plugins/usage-detail/commands/usage.md b/external_plugins/usage-detail/commands/usage.md new file mode 100644 index 00000000..c40ac030 --- /dev/null +++ b/external_plugins/usage-detail/commands/usage.md @@ -0,0 +1,11 @@ +--- +description: 查询 ZCode 本地用量明细(每次模型请求的 token、耗时、会话统计)。 +argument-hint: "[summary|recent|day|days|sessions|models|errors] [参数]" +skills: usage-detail +--- + +用户要查看 ZCode 本地用量明细。请求参数:$ARGUMENTS + +按 `usage-detail` 技能的方法运行插件自带的查询脚本(只读本地 SQLite,不发起网络请求),把输出整理成简洁的中文表格汇报。$ARGUMENTS 为空时默认执行 `summary`;无法识别的参数先向用户确认意图再选子命令。 + +注意:结果只覆盖本机记录;如用户问的是套餐配额余量(5 小时/周额度),提示其查看 ZCode 的 Usage Stats → Coding Plan。若用户想要图形界面/仪表盘,请改用本插件的 `usage-ui` 命令。 diff --git a/external_plugins/usage-detail/hooks/hooks.json b/external_plugins/usage-detail/hooks/hooks.json new file mode 100644 index 00000000..fed0655a --- /dev/null +++ b/external_plugins/usage-detail/hooks/hooks.json @@ -0,0 +1,46 @@ +{ + "description": "usage-detail: 每轮结束本地统计用量——侧栏面板「本轮」卡实时刷新(始终开启,零开销);可选系统通知或回复末尾统计行(usage.sh turnstats,默认关)", + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "process", + "command": "node", + "args": ["${ZCODE_PLUGIN_ROOT}/hooks/prompt-submit.mjs"], + "timeoutMs": 5000, + "statusMessage": "usage-detail: 准备本轮用量统计…" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "process", + "command": "node", + "args": ["${ZCODE_PLUGIN_ROOT}/hooks/stop-notify.mjs"], + "async": true, + "timeoutMs": 20000, + "statusMessage": "usage-detail: 本轮用量通知…" + } + ] + } + ], + "PermissionRequest": [ + { + "hooks": [ + { + "type": "process", + "command": "node", + "args": ["${ZCODE_PLUGIN_ROOT}/hooks/permission-notify.mjs"], + "async": true, + "timeoutMs": 10000, + "statusMessage": "usage-detail: 权限等待提醒…" + } + ] + } + ] + } +} diff --git a/external_plugins/usage-detail/hooks/permission-notify.mjs b/external_plugins/usage-detail/hooks/permission-notify.mjs new file mode 100644 index 00000000..3627e251 --- /dev/null +++ b/external_plugins/usage-detail/hooks/permission-notify.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// PermissionRequest hook(async: true):Agent 弹出权限批准请求时发 macOS 系统提醒。 +// 场景:挂机跑长任务时人不在屏幕前,批准请求会被静默挂起——这条通知把人拉回来。 +// 官方语义:async hook 的后台 stdout 不会注入上下文、不影响回合,零模型开销。 +// 开关:~/.zcode/usage-alerts.json {"permission":true}(默认关;usage.sh alerts permission on) +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const HERE_NOTIFY = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts'); + +try { + const v = JSON.parse(String(fs.readFileSync(path.join(os.homedir(), '.zcode', 'usage-alerts.json'), 'utf8')).replace(/^\uFEFF/, '')); + if (v.permission !== true) process.exit(0); + let tool = ''; + try { + const input = JSON.parse(fs.readFileSync(0, 'utf8')); + tool = String(input.tool_name || input.tool || '').replace(/"/g, ''); + } catch (e) {} + const msg = tool ? `正在等待你批准:${tool}` : 'Agent 正在等待你的批准'; + // 统一经 notify.mjs 分发:macOS / Windows / Linux,失败静默 + spawn(process.execPath, [path.join(HERE_NOTIFY, "notify.mjs"), "ZCode 用量", "需要你的确认", msg, "zcode"], + { detached: true, stdio: 'ignore' }).unref(); +} catch (e) {} +process.exit(0); diff --git a/external_plugins/usage-detail/hooks/prompt-submit.mjs b/external_plugins/usage-detail/hooks/prompt-submit.mjs new file mode 100644 index 00000000..1f2cfe25 --- /dev/null +++ b/external_plugins/usage-detail/hooks/prompt-submit.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +// UserPromptSubmit hook:每次用户发消息时 +// 1) 记录会话与提问时间戳到状态文件(turn-stats.mjs --current 守卫的唯一依据) +// 2) 注入「本轮统计指令」:模型在写最终回复正文之前自测本问速率, +// 把输出的统计行原样放进 Markdown 引用块附在回复最末尾。 +// 输出必须为严格 JSON;任何异常静默退出,不影响对话。 +// 机制参考 zcode-tps-monitor(MIT)实测经验: +// - promptTs 绝不在别处覆盖,否则 --current 守卫误判"本问无数据" +// - 统计行必须与完整回复正文同一条消息——客户端会把带工具调用的消息 +// 折叠进「模型轨迹」区,统计行单独成条时用户只能看到统计行本身 +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const STATS_SCRIPT = path.join(HERE, '..', 'scripts', 'turn-stats.mjs'); +const STATE_FILE = process.env.ZCODE_USAGE_TURN_STATE || path.join(os.homedir(), '.zcode', 'usage-detail-turn.json'); + +const sid = process.env.ZCODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || ''; +try { + fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); + const now = Date.now(); + fs.writeFileSync(STATE_FILE, JSON.stringify({ sessionId: sid, ts: now, promptTs: now, source: 'prompt-submit' })); +} catch (e) {} + +// 开关:~/.zcode/usage-turnstats.json {"enabled":true},缺省关闭。 +// 统计行机制必须经由模型(hook 无法直接向消息追加文本):注入指令约 0.5k token/轮 +// + 模型一次工具调用(结果约 0.1k)+ 输出统计行约 0.05k,默认关闭由用户显式开启。 +// 模式语义:notify(系统通知,零上下文)不注入指令;inject(对话内统计行)才注入。 +// 兼容旧 {"enabled":true}(无 mode 字段)= inject。 +function turnStatsMode() { + try { + const v = JSON.parse(String(fs.readFileSync(path.join(os.homedir(), '.zcode', 'usage-turnstats.json'), 'utf8')).replace(/^\uFEFF/, '')); + if (!v || v.enabled !== true) return null; + return v.mode === 'notify' ? 'notify' : 'inject'; + } catch (e) { return null; } +} +if (turnStatsMode() !== 'inject') { + process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit' } })); + process.exit(0); +} + +// 精简指令:注入文本每轮都会永久留在上下文里,是本功能最大的模型开销项 +// (约 150 token;统计行本身仅 ~50)。完整约束靠脚本输出格式与模型习惯兜底。 +const INSTRUCTION = [ + '', + '【统计】写正文前先运行一次:node "' + STATS_SCRIPT + '" --turn --current', + '把它输出的统计行原文放入「> 」引用块,附在本条回复最末尾;统计行必须与正文同一条消息,不要改写数字。', + '脚本无输出则直接省略,不要为此额外调用工具。', +].join('\n'); + +process.stdout.write(JSON.stringify({ + hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: INSTRUCTION }, +})); diff --git a/external_plugins/usage-detail/hooks/stop-notify.mjs b/external_plugins/usage-detail/hooks/stop-notify.mjs new file mode 100644 index 00000000..5d5bc168 --- /dev/null +++ b/external_plugins/usage-detail/hooks/stop-notify.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +// Stop hook(async: true,fire-and-forget):每轮回复结束时本地做两件事,全部零模型开销。 +// 1. 写回合结束标记(~/.zcode/usage-detail-turn-done.json):侧栏面板的 MCP 服务轮询到 +// 该文件变化后向宿主发 notifications/resources/updated,打开着的用量面板实时刷新「本轮」横幅。 +// 2. mode=notify 时(usage.sh turnstats notify)追加一条 macOS 系统通知,作为面板未打开时的兜底。 +// 官方语义:async hook 的后台 stdout 不会注入上下文、不影响回合;统计行不进对话(进对话必经模型)。 +// 开关:~/.zcode/usage-turnstats.json {"enabled":true,"mode":"notify"|"inject"};任何异常静默退出。 +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync, spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const STATS_SCRIPT = path.join(HERE, '..', 'scripts', 'turn-stats.mjs'); +const DONE_FILE = path.join(os.homedir(), '.zcode', 'usage-detail-turn-done.json'); + +function readSwitch() { + try { + const v = JSON.parse(String(fs.readFileSync(path.join(os.homedir(), '.zcode', 'usage-turnstats.json'), 'utf8')).replace(/^\uFEFF/, '')); + if (!v || v.enabled !== true) return null; + return v.mode === 'notify' ? 'notify' : (v.mode === 'inject' ? 'inject' : 'inject'); // 兼容旧 {"enabled":true}(无 mode)= inject + } catch (e) { return null; } +} + +try { + // 标记始终写(仅一次本地文件写):面板实时卡不依赖 turnstats 开关 + fs.writeFileSync(DONE_FILE, JSON.stringify({ ts: Date.now() }) + '\n'); + if (readSwitch() !== 'notify') process.exit(0); + const line = execFileSync(process.execPath, [STATS_SCRIPT, '--turn', '--current'], { encoding: 'utf8', timeout: 15000 }).trim(); + const msg = line.split('\n').filter((l) => l.includes('本轮用量'))[0] || line.split('\n')[0] || ''; + if (!msg) process.exit(0); + // 统一经 notify.mjs 分发:macOS=osascript / Windows=WinRT toast / Linux=notify-send,失败静默 + spawn(process.execPath, [path.join(HERE, '..', 'scripts', 'notify.mjs'), 'ZCode 用量', '本轮统计(零上下文开销)', msg], + { detached: true, stdio: 'ignore' }).unref(); +} catch (e) {} +process.exit(0); diff --git a/external_plugins/usage-detail/icon.png b/external_plugins/usage-detail/icon.png new file mode 100644 index 00000000..18a5943d Binary files /dev/null and b/external_plugins/usage-detail/icon.png differ diff --git a/external_plugins/usage-detail/scripts/default-prices.json b/external_plugins/usage-detail/scripts/default-prices.json new file mode 100644 index 00000000..e8ac3476 --- /dev/null +++ b/external_plugins/usage-detail/scripts/default-prices.json @@ -0,0 +1,337 @@ +{ + "currency": "CNY", + "source": "官方牌价(GLM 官网 / DeepSeek 官方价目)+ models.dev 补充", + "updated": "2026-10-17", + "models": { + "glm-4.6v": { + "in": 0.3, + "out": 0.9, + "cache": 0 + }, + "glm-4.5": { + "in": 0.6, + "out": 2.2, + "cache": 0.11 + }, + "glm-5v-turbo": { + "in": 5, + "out": 22, + "cache": 1.2 + }, + "glm-5.3-flash": { + "in": 0.8, + "out": 2.8, + "cache": 0.23, + "ctx": 1048576 + }, + "glm-4.6": { + "in": 0.6, + "out": 2.2, + "cache": 0.11 + }, + "glm-5": { + "in": 1, + "out": 3.2, + "cache": 0.2 + }, + "glm-4.6v-flash": { + "in": 0, + "out": 0, + "cache": 0 + }, + "glm-4.5v": { + "in": 0.6, + "out": 1.8, + "cache": 0 + }, + "glm-5.3-flashx": { + "in": 2, + "out": 7, + "cache": 0.57, + "ctx": 1048576 + }, + "glm-4.7-flash": { + "in": 0, + "out": 0, + "cache": 0 + }, + "glm-4.7": { + "in": 0.6, + "out": 2.2, + "cache": 0.11 + }, + "glm-4.5-flash": { + "in": 0, + "out": 0, + "cache": 0 + }, + "glm-5.2": { + "in": 1.4, + "out": 4.4, + "cache": 0.26 + }, + "glm-5.1": { + "in": 1.4, + "out": 4.4, + "cache": 0.26 + }, + "glm-4.7-flashx": { + "in": 0.07, + "out": 0.4, + "cache": 0.01 + }, + "glm-5-turbo": { + "in": 1.2, + "out": 4, + "cache": 0.24 + }, + "glm-4.5-air": { + "in": 0.2, + "out": 1.1, + "cache": 0.03 + }, + "glm-5.3": { + "in": 8, + "out": 28, + "cache": 2, + "ctx": 1048576 + }, + "deepseek-flash": { + "in": 2, + "out": 8, + "cache": 0.04, + "ctx": 1000000, + "tariff": { + "tz": 8, + "weekend": 0.5, + "tiers": [ + { + "hours": [ + [ + 9, + 12 + ], + [ + 14, + 18 + ] + ], + "mult": 1.0, + "name": "高峰" + }, + { + "hours": [ + [ + 0, + 24 + ] + ], + "mult": 0.5, + "name": "空闲" + } + ], + "holidays": [ + "2026-01-01", + "2026-01-02", + "2026-01-03", + "2026-02-15", + "2026-02-16", + "2026-02-17", + "2026-02-18", + "2026-02-19", + "2026-02-20", + "2026-02-21", + "2026-02-22", + "2026-02-23", + "2026-04-04", + "2026-04-05", + "2026-04-06", + "2026-05-01", + "2026-05-02", + "2026-05-03", + "2026-05-04", + "2026-05-05", + "2026-06-19", + "2026-06-20", + "2026-06-21", + "2026-09-25", + "2026-09-26", + "2026-09-27", + "2026-10-01", + "2026-10-02", + "2026-10-03", + "2026-10-04", + "2026-10-05", + "2026-10-06", + "2026-10-07" + ], + "workdays": [ + "2026-01-04", + "2026-02-14", + "2026-02-28", + "2026-05-09", + "2026-09-20", + "2026-10-10" + ] + } + }, + "deepseek-v4-flash": { + "in": 2, + "out": 8, + "cache": 0.04, + "ctx": 1000000, + "tariff": { + "tz": 8, + "weekend": 0.5, + "tiers": [ + { + "hours": [ + [ + 9, + 12 + ], + [ + 14, + 18 + ] + ], + "mult": 1.0, + "name": "高峰" + }, + { + "hours": [ + [ + 0, + 24 + ] + ], + "mult": 0.5, + "name": "空闲" + } + ], + "holidays": [ + "2026-01-01", + "2026-01-02", + "2026-01-03", + "2026-02-15", + "2026-02-16", + "2026-02-17", + "2026-02-18", + "2026-02-19", + "2026-02-20", + "2026-02-21", + "2026-02-22", + "2026-02-23", + "2026-04-04", + "2026-04-05", + "2026-04-06", + "2026-05-01", + "2026-05-02", + "2026-05-03", + "2026-05-04", + "2026-05-05", + "2026-06-19", + "2026-06-20", + "2026-06-21", + "2026-09-25", + "2026-09-26", + "2026-09-27", + "2026-10-01", + "2026-10-02", + "2026-10-03", + "2026-10-04", + "2026-10-05", + "2026-10-06", + "2026-10-07" + ], + "workdays": [ + "2026-01-04", + "2026-02-14", + "2026-02-28", + "2026-05-09", + "2026-09-20", + "2026-10-10" + ] + } + }, + "deepseek-v4-pro": { + "in": 9, + "out": 27, + "cache": 0.3, + "ctx": 1000000, + "tariff": { + "tz": 8, + "weekend": 0.5, + "tiers": [ + { + "hours": [ + [ + 9, + 12 + ], + [ + 14, + 18 + ] + ], + "mult": 1.0, + "name": "高峰" + }, + { + "hours": [ + [ + 0, + 24 + ] + ], + "mult": 0.5, + "name": "空闲" + } + ], + "holidays": [ + "2026-01-01", + "2026-01-02", + "2026-01-03", + "2026-02-15", + "2026-02-16", + "2026-02-17", + "2026-02-18", + "2026-02-19", + "2026-02-20", + "2026-02-21", + "2026-02-22", + "2026-02-23", + "2026-04-04", + "2026-04-05", + "2026-04-06", + "2026-05-01", + "2026-05-02", + "2026-05-03", + "2026-05-04", + "2026-05-05", + "2026-06-19", + "2026-06-20", + "2026-06-21", + "2026-09-25", + "2026-09-26", + "2026-09-27", + "2026-10-01", + "2026-10-02", + "2026-10-03", + "2026-10-04", + "2026-10-05", + "2026-10-06", + "2026-10-07" + ], + "workdays": [ + "2026-01-04", + "2026-02-14", + "2026-02-28", + "2026-05-09", + "2026-09-20", + "2026-10-10" + ] + } + } + } +} diff --git a/external_plugins/usage-detail/scripts/lib/cmd-cost.sh b/external_plugins/usage-detail/scripts/lib/cmd-cost.sh new file mode 100644 index 00000000..e87100a5 --- /dev/null +++ b/external_plugins/usage-detail/scripts/lib/cmd-cost.sh @@ -0,0 +1,115 @@ +# 成本估算与价格表子命令(由入口 usage.sh source) + +# 成本估算(价格表 × 分时计价 tariff) +cmd_cost() { + # 成本估算:默认用插件内置官方价(models.dev),用户文件 ~/.zcode/usage-prices.json 可覆盖/扩展 + pf=$(price_file) + if [[ -z "$pf" ]]; then + echo "未找到价格表(应随插件内置 scripts/default-prices.json)" >&2 + exit 1 + fi + q "WITH src AS (SELECT replace(CAST(readfile($(sql_quote "$pf")) AS text), char(65279), '') AS j), + n AS (SELECT CASE WHEN json_type(j,'\$.models')='object' THEN j ELSE json_object('models',json(j)) END AS j2, + COALESCE(json_extract(j,'\$.currency'),'CNY') AS cur FROM src), + p AS (SELECT je.key AS model, + CAST(json_extract(je.value,'\$.in') AS real) AS pin, + CAST(json_extract(je.value,'\$.out') AS real) AS pout, + CAST(json_extract(je.value,'\$.cache') AS real) AS pcache + FROM n, json_each(n.j2,'\$.models') je) + SELECT m.model_id AS 模型, + count(*) AS 请求数, + -- input_tokens 已包含缓存命中:未命中段按牌价 in、命中段按 cache,不重复计费 + round(sum(max(m.input_tokens - m.cache_read_input_tokens, 0))/1000000.0*p.pin, 2) AS 输入成本, + round(sum(m.output_tokens+m.reasoning_tokens)/1000000.0*p.pout, 2) AS 生成成本, + round(sum(m.cache_read_input_tokens)/1000000.0*p.pcache, 2) AS 缓存读成本, + round(sum(max(m.input_tokens - m.cache_read_input_tokens, 0))/1000000.0*p.pin + + sum(m.output_tokens+m.reasoning_tokens)/1000000.0*p.pout + + sum(m.cache_read_input_tokens)/1000000.0*p.pcache, 2) AS 合计, + (SELECT cur FROM n) AS 币种 + FROM model_usage m JOIN p ON lower(p.model) = lower(m.model_id) + GROUP BY m.model_id ORDER BY 合计 DESC;" + echo + echo "(折算口径:API 牌价(CNY)× 本地记录 token;含分时费率的模型按标准价折算,精确分时请在面板查看;订阅套餐内请求实际不按 token 计费,此金额仅表示等量 API 用量的牌价价值)" +} + +# prices show|sync +cmd_prices() { + # 官方价格表:show 查看 / sync 从 models.dev 同步(保留自定义条目) + sub="${2:-show}" + case "$sub" in + show) + pf=$(price_file) + [[ -n "$pf" ]] || { echo "未找到价格表" >&2; exit 1; } + echo "生效价格表:$pf" + echo + q "WITH src AS (SELECT replace(CAST(readfile($(sql_quote "$pf")) AS text), char(65279), '') AS j), + n AS (SELECT CASE WHEN json_type(j,'\$.models')='object' THEN j ELSE json_object('models',json(j)) END AS j2 FROM src) + SELECT je.key AS 模型, + json_extract(je.value,'\$.in') AS 输入, + json_extract(je.value,'\$.out') AS 生成, + json_extract(je.value,'\$.cache') AS 缓存读, + COALESCE(json_extract(j2.j2,'\$.currency'),'CNY') AS 币种 + FROM n j2, json_each(j2.j2,'\$.models') je + ORDER BY 模型;" + echo + echo "(单位:每百万 token)自定义请编辑 ~/.zcode/usage-prices.json;同步官方价:usage.sh prices sync" + ;; + sync) + command -v node >/dev/null 2>&1 || { echo "prices sync 需要 node(同步仅是可选操作,内置价格表已可用)" >&2; exit 1; } + tmp=$(mktemp) + trap 'rm -f "$tmp"' EXIT # node 解析失败(set -e 中断)也不残留 TMPDIR 里的 MB 级下载物 + curl -fsSL --max-time 30 https://models.dev/api.json -o "$tmp" || { echo "下载 models.dev 失败" >&2; rm -f "$tmp"; exit 1; } + node - "$tmp" "${ZCODE_USAGE_PRICES:-$HOME/.zcode/usage-prices.json}" "$SCRIPT_DIR/default-prices.json" <<'NODE' +const fs = require('fs'); +const md = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +const user = process.argv[3]; +let out = { currency: 'CNY', models: {} }; +try { + const old = JSON.parse(fs.readFileSync(user, 'utf8')); + if (old && typeof old.models === 'object') out = old; + else if (old && typeof old === 'object') out.models = old; +} catch (e) {} +out.currency = out.currency || 'CNY'; +// models.dev 全部是 USD 价;表是 CNY 口径时按汇率换算入库(ZCODE_USAGE_USD_CNY 覆盖) +const RATE = out.currency === 'CNY' ? (Number(process.env.ZCODE_USAGE_USD_CNY) || Number(out.usdCny) || 7.2) : 1; +// 智谱官方 CNY 权威价(与 bigmodel 官网定价页核对),models.dev 的全球口径与之有偏差,不同步覆盖 +const PINNED = ['glm-5.3', 'glm-5.3-flash', 'glm-5.3-flashx', + // DeepSeek 官方 CNY 高峰牌价 + 峰谷 tariff(2026-10 核对官方价目页:工作日 9-12/14-18 高峰,周末节假日空闲半价) + 'deepseek-flash', 'deepseek-v4-flash', 'deepseek-v4-pro']; +// 官方权威条目以内置表为准种子进用户文件:models.dev 的 DeepSeek 价已过期(且无 tariff), +// 重跑 sync 即把现行官方牌价 + 分时计价配置带给老用户 +let seeded = 0; +try { + const bn = JSON.parse(fs.readFileSync(process.argv[4], 'utf8')).models || {}; + for (const m of PINNED) { + if (bn[m]) { out.models[m] = JSON.parse(JSON.stringify(bn[m])); seeded++; } + } +} catch (e) {} +// BYOK:全量同步所有供应商的所有带价模型;同名模型先到先得,自定义条目不会被覆盖 +let n = 0; +for (const prov of Object.keys(md)) { + for (const [m, v] of Object.entries((md[prov] || {}).models || {})) { + const p = v.price || v.cost; + if (!p || (!p.input && !p.output)) continue; // 跳过订阅占位(全 0 价) + const ctx = v.limit && v.limit.context ? v.limit.context : undefined; + if (out.models[m]) { if (!out.models[m].ctx && ctx) out.models[m].ctx = ctx; continue; } + if (PINNED.includes(m.toLowerCase())) continue; // 官方权威价不覆盖;其 ctx 由内置表提供 + out.models[m] = { in: (p.input || 0) * RATE, out: (p.output || 0) * RATE, cache: (p.cache_read || 0) * RATE, ...(ctx ? { ctx } : {}) }; + n++; + } +} +out.source = 'models.dev'; +out.updated = new Date().toISOString().slice(0, 10); +fs.mkdirSync(require('path').dirname(user), { recursive: true }); +fs.writeFileSync(user, JSON.stringify(out, null, 2) + '\n'); +console.log('已同步 ' + n + ' 个官方模型价到 ' + user + '(币种 ' + out.currency + (RATE !== 1 ? ',USD 按 ' + RATE + ' 换算' : '') + ';官方权威条目 ' + seeded + ' 个按内置表更新)'); +NODE + rm -f "$tmp" + ;; + *) + echo "用法:usage.sh prices [show|sync]" >&2 + exit 1 + ;; + esac +} + diff --git a/external_plugins/usage-detail/scripts/lib/cmd-doctor.sh b/external_plugins/usage-detail/scripts/lib/cmd-doctor.sh new file mode 100644 index 00000000..676725aa --- /dev/null +++ b/external_plugins/usage-detail/scripts/lib/cmd-doctor.sh @@ -0,0 +1,47 @@ +# 环境自检:统计不见了吗?逐项排查数据源、依赖与配置(/usage doctor) +cmd_doctor() { + local ok=1 warn=0 + say() { printf ' %s\n' "$2"; [[ "$1" == ok ]] || { [[ "$1" == warn ]] && warn=$((warn+1)) || ok=0; }; } + echo "—— ZCode 用量明细 · 环境自检 ——" + # 1. 数据库 + if [[ -f "$DB" ]]; then + local cnt; cnt=$(qr "SELECT count(*) FROM model_usage;" 2>/dev/null) && cnt=${cnt:-0} + local latest; latest=$(qr "SELECT datetime(max(started_at)/1000,'unixepoch','localtime') FROM model_usage;" 2>/dev/null) + say ok "1. 数据库:${DB}(${cnt} 条请求,最新 ${latest})" + for t in session tool_usage todo part; do + qr "SELECT 1 FROM $t LIMIT 1;" >/dev/null 2>&1 && say ok " 表 $t 存在" || say warn " 表 $t 不存在(旧版客户端?相关洞察将缺失)" + done + else + say bad "1. 数据库不存在:${DB}(ZCODE_USAGE_DB 可覆盖路径)" + fi + # 2. 价格表 + local pf; pf=$(price_file) + if [[ -n "$pf" ]]; then + local mtime age + mtime=$(stat -f %m "$pf" 2>/dev/null || stat -c %Y "$pf" 2>/dev/null || echo 0) + age=$(( $(date +%s) - mtime )) + say ok "2. 价格表:${pf}(${age} 秒前更新)" + [[ $age -ge 86400 ]] && say warn " 价格表超过 1 天未更新(下次生成仪表盘时将自动同步)" + else + say bad "2. 找不到任何价格表(内置 default-prices.json 缺失?)" + fi + # 3. 配额数据源 + if command -v node >/dev/null 2>&1; then + local qout + qout=$(node "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/quota.mjs" 2>&1 | head -1 || true) + if [[ "$qout" == "{"*'"ok":true'* ]]; then + local src; src=$(printf '%s' "$qout" | sed -n 's/.*"source":"\([^"]*\)".*/\1/p') + say ok "3. 配额:可用(来源 ${src:-未知})" + else + say warn "3. 配额:暂不可用(客户端日志尚无额度观测,也未配置 API 凭据;不影响用量统计)" + fi + else + say bad "3. 未安装 node(配额、价格同步、serve 面板不可用)" + fi + # 4. 常驻面板依赖 + command -v node >/dev/null 2>&1 && say ok "4. 本地面板:node 可用(usage.sh serve 启动,默认 127.0.0.1:7741)" + # 5. 结果 + if [[ $ok -eq 1 && $warn -eq 0 ]]; then echo "—— 全部正常 ——" + elif [[ $ok -eq 1 ]]; then echo "—— 基本正常(${warn} 项提示)——" + else echo "—— 存在不可用项,请按上面信息处理 ——"; return 1; fi +} diff --git a/external_plugins/usage-detail/scripts/lib/cmd-monitor.sh b/external_plugins/usage-detail/scripts/lib/cmd-monitor.sh new file mode 100644 index 00000000..6da060df --- /dev/null +++ b/external_plugins/usage-detail/scripts/lib/cmd-monitor.sh @@ -0,0 +1,227 @@ +# 实时监控与本地服务子命令(由入口 usage.sh source) + +# 终端实时燃烧率监控 +cmd_live() { + # 实时燃烧率监控(终端):每 N 秒刷新(默认 3),Ctrl-C 退出。 + # 只查询最近 5h 与近 48h 数据,走 started_at 索引,开销极低。 + interval=$(num_arg 3 "${2:-}") + while true; do + printf '\033[H\033[2J' + echo "── ZCode 用量实时监控 · 每 ${interval}s 刷新 · Ctrl-C 退出 · $(date '+%H:%M:%S') ──" + echo + q "WITH cur AS (SELECT count(*) AS req, + COALESCE(sum(input_tokens),0) AS inn, + COALESCE(sum(output_tokens+reasoning_tokens),0) AS gen, + min(started_at) AS st + FROM model_usage WHERE started_at > (unixepoch()*1000 - 18000000)), + s AS (SELECT started_at, input_tokens, output_tokens+reasoning_tokens AS gen, + LAG(started_at) OVER (ORDER BY started_at) AS prev + FROM model_usage WHERE started_at > (unixepoch()*1000 - 172800000)), + b AS (SELECT started_at, input_tokens, gen, + SUM(CASE WHEN prev IS NULL OR started_at-prev>=18000000 THEN 1 ELSE 0 END) OVER (ORDER BY started_at) AS blk + FROM s), + hist AS (SELECT blk, sum(input_tokens)+sum(gen) AS tok + FROM b GROUP BY blk + HAVING min(started_at) <= (unixepoch()*1000 - 18000000) + ORDER BY min(started_at) DESC LIMIT 3) + SELECT c.req AS 本窗请求, + c.inn+c.gen AS 本窗token, + CAST(round((c.inn+c.gen) / max(0.1,(unixepoch()*1000 - COALESCE(c.st, unixepoch()*1000))/3600000.0)) AS int) AS 燃烧率token每小时, + (SELECT CAST(round(avg(tok)) AS int) FROM hist) AS 近3窗口均值token, + (SELECT count(*) FROM hist) AS 均值样本数 + FROM cur c;" + echo + q "SELECT $PV_LABEL AS 来源, count(*) AS 近24h请求, sum(input_tokens+output_tokens+reasoning_tokens) AS token + FROM model_usage WHERE started_at > (unixepoch()*1000 - 86400000) + GROUP BY provider_id ORDER BY 近24h请求 DESC;" + sleep "$interval" + done +} + +# 官方套餐配额(quota.mjs) +cmd_quota() { + # 官方配额:5 小时/周/MCP 池用量百分比(凭据解析见 quota.mjs) + command -v node >/dev/null 2>&1 || { echo "quota 需要 node" >&2; exit 1; } + exec node "$SCRIPT_DIR/quota.mjs" +} + +# 本地常驻面板(serve.mjs) +cmd_serve() { + # 本地常驻面板:浏览器打开,自动轮询燃烧率(只监听 127.0.0.1,只读) + command -v node >/dev/null 2>&1 || { echo "serve 需要 node" >&2; exit 1; } + port=$(num_arg 7741 "${2:-}") + exec node "$SCRIPT_DIR/serve.mjs" "$port" +} + + +# 每轮统计行开关(写 ~/.zcode/usage-turnstats.json;hook 每次提交/结束时读取) +# off(默认)| notify(每轮结束弹系统通知,零模型开销零上下文)| inject(对话内统计行,每轮约 0.3k token) +cmd_turnstats() { + local f="$HOME/.zcode/usage-turnstats.json" + case "${2:-status}" in + on|notify) + mkdir -p "$(dirname "$f")" + echo '{"enabled":true,"mode":"notify"}' > "$f" + echo "已开启:通知模式——每轮回复结束弹 macOS 系统通知显示本轮统计。" + echo "统计由本地脚本计算、经系统通知展示,不进入对话上下文,零模型开销。" + echo "(需重开会话生效;通知在系统设置里可关。对话内统计行:usage.sh turnstats inject,每轮约 0.3k token)" + ;; + inject) + echo '{"enabled":true,"mode":"inject"}' > "$f" + echo "已开启:注入模式——每轮回复末尾附统计行(统计行必须经由模型输出)。" + echo "开销提示:每轮注入指令约 0.15k token(持续保留在上下文)+ 一次工具调用(结果约 0.1k)+ 统计行输出约 0.05k;" + echo "相对百万级上下文占比 <0.1%,短会话约 1-2%。零开销替代:usage.sh turnstats notify" + ;; + off) + echo '{"enabled":false}' > "$f" + echo "已关闭:不再注入指令、不再发通知(侧栏面板的请求明细仍可查看每轮速率/首字/缓存率)。" + ;; + status) + local mode='关闭(默认)' + if [[ -f "$f" ]] && grep -q '"enabled"[: ]*true' "$f" 2>/dev/null; then + if grep -q '"mode"[: ]*"notify"' "$f" 2>/dev/null; then mode='通知模式(零模型开销)'; else mode='注入模式(每轮约 0.3k token)'; fi + fi + echo "当前:$mode" + echo "可选:usage.sh turnstats notify(零开销通知)/ inject(对话内统计行)/ off(关闭)" + ;; + *) + echo "用法:usage.sh turnstats [notify|inject|off|status]" >&2 + exit 1 + ;; + esac +} + +# 浪费取证:失败/取消请求与重试的 token 消耗(本地只读聚合,灵感:codeburn optimize) +cmd_waste() { + local days="${2:-7}" + is_num "$days" || { echo "参数应为数字:$days" >&2; exit 1; } + local since=$(( ($(date +%s) - days * 86400) * 1000 )) + local row + row=$(qr " + WITH W AS (SELECT * FROM model_usage WHERE started_at >= ${since}) + SELECT + (SELECT count(*) FROM W WHERE status='error') ||'|'|| + (SELECT COALESCE(sum(input_tokens+output_tokens+reasoning_tokens),0) FROM W WHERE status='error') ||'|'|| + (SELECT count(*) FROM W WHERE status='cancelled') ||'|'|| + (SELECT COALESCE(sum(input_tokens+output_tokens+reasoning_tokens),0) FROM W WHERE status='cancelled') ||'|'|| + (SELECT COALESCE(sum(retry_count),0) FROM W) ||'|'|| + (SELECT count(*) FROM W WHERE retry_count>0) ||'|'|| + (SELECT COALESCE(sum(input_tokens+output_tokens+reasoning_tokens),0) FROM W WHERE retry_count>0) ||'|'|| + (SELECT COALESCE(sum(input_tokens+output_tokens+reasoning_tokens),0) FROM W) + FROM (SELECT 1)") + local err_req err_tok cc_req cc_tok retries retry_req retry_tok all_tok + IFS='|' read -r err_req err_tok cc_req cc_tok retries retry_req retry_tok all_tok <<< "$row" + err_req=${err_req:-0}; err_tok=${err_tok:-0}; cc_req=${cc_req:-0}; cc_tok=${cc_tok:-0} + retries=${retries:-0}; retry_req=${retry_req:-0}; retry_tok=${retry_tok:-0}; all_tok=${all_tok:-0} + local waste_tok=$((err_tok + cc_tok)) + local pct=0 + [[ "$all_tok" -gt 0 ]] && pct=$(awk -v w="$waste_tok" -v a="$all_tok" 'BEGIN{printf "%.1f", w/a*100}') + echo "近 ${days} 天浪费取证(token 口径:失败/取消请求的输入与生成 token 同样已消耗)" + echo "----------------------------------------------------------" + echo "失败请求:${err_req} 次 · 白耗 $(fmt_n "$err_tok") token" + echo "取消请求:${cc_req} 次 · 白耗 $(fmt_n "$cc_tok") token" + echo "重试:${retries} 次(涉及 ${retry_req} 条请求 / $(fmt_n "$retry_tok") token)" + echo "合计浪费:$(fmt_n "$waste_tok") token(占同期 $pct%)" + echo "" + echo "反复失败会话 Top 5:" + local t + t=$(qr " + SELECT COALESCE((SELECT title FROM session WHERE id=m.session_id),'(无标题)') ||' | '|| + count(*) ||' 次失败 · '|| COALESCE(sum(input_tokens+output_tokens+reasoning_tokens),0) ||' tok' + FROM model_usage m WHERE m.status='error' AND m.started_at >= ${since} AND m.session_id IS NOT NULL + GROUP BY m.session_id ORDER BY count(*) DESC LIMIT 5") + [[ -n "$t" ]] && echo "$t" | sed 's/ | /:/;s/^/ · /' || echo " · 无" + echo "" + echo "重试最多的模型 Top 3:" + t=$(qr " + SELECT model_id ||':'|| sum(retry_count) ||' 次重试' + FROM model_usage WHERE started_at >= ${since} AND retry_count > 0 + GROUP BY model_id ORDER BY sum(retry_count) DESC LIMIT 3") + [[ -n "$t" ]] && echo "$t" | sed 's/^/ · /' || echo " · 无" + echo "" + echo "提示:失败/取消多为网络或权限问题;重试偏高常与超时相关,可关注对应时段稳定性。" +} +fmt_n() { printf '%s\n' "$1" | awk '{ n = $1; s = ""; while (length(n) > 3) { s = "," substr(n, length(n) - 2) s; n = substr(n, 1, length(n) - 3) } print n s }'; } + +# 通知类开关(~/.zcode/usage-alerts.json): +# budget 日预算 80%/100% 与用量偏高提醒(serve 常驻时本地检查,默认开) +# permission Agent 等待权限批准时弹系统提醒(默认关) +cmd_alerts() { + local f="$HOME/.zcode/usage-alerts.json" + local what="${2:-budget}" act="${3:-status}" + # 白名单校验:what/act 都是拼接进 node -e 的值,且 alerts 无参时缺省应显示 budget 状态 + case "$what" in budget|permission) ;; *) + echo "用法:usage.sh alerts [on|off|status]" >&2; exit 1 ;; esac + case "$act" in on|off|status) ;; *) + echo "用法:usage.sh alerts [on|off|status]" >&2; exit 1 ;; esac + [[ -f "$f" ]] || echo '{}' > "$f" + local cur + cur=$(node -e "const v=JSON.parse(require('fs').readFileSync('$f','utf8'));console.log(v['$what']==null?'(默认)':v['$what'])" 2>/dev/null || echo '(默认)') + case "$act" in + on|off) + local bv cn='开启'; [[ "$act" == off ]] && { bv=false; cn='关闭'; } || bv=true + ALERTS_VAL="$bv" node -e " + const fs=require('fs'); + const p='$f'; + let v={};try{v=JSON.parse(fs.readFileSync(p,'utf8'))}catch(e){} + v['$what']=process.env.ALERTS_VAL==='true'; + fs.writeFileSync(p,JSON.stringify(v)+'\n');" + echo "已${cn}:${what}" + ;; + status) + local def='开(默认)'; [[ "$what" == permission ]] && def='关(默认)' + local d="$def"; [[ "$cur" == false ]] && d='关'; [[ "$cur" == true ]] && d='开' + echo "${what}:${d}" + ;; + *) + echo "用法:usage.sh alerts [on|off|status]" >&2 + exit 1 + ;; + esac +} +# 一行式用量摘要(状态栏/终端提示通用):无色彩、单行、稳定字段。 +# 供 tmux status-right、SwiftBar、或任何接受一行文本的状态栏挂载; +# ZCode CLI 暂无 statusline 挂载点,留作通用出口。 +cmd_statusline() { + ZCODE_USAGE_LIMIT=1 node - <<'JS' 2>/dev/null +const os = require('node:os'), path = require('node:path'), fs = require('node:fs'); +const DB = process.env.ZCODE_USAGE_DB || path.join(os.homedir(), '.zcode/cli/db/db.sqlite'); +const { DatabaseSync } = require('node:sqlite'); +let prices = { models: {}, usdCny: null, currency: 'CNY' }; +const stripBom = (t) => String(t).replace(/^\uFEFF/, ''); +try { const v = JSON.parse(stripBom(fs.readFileSync(path.join(os.homedir(), '.zcode/usage-prices.json'), 'utf8'))); prices.models = v.models || {}; prices.usdCny = v.usdCny; prices.currency = v.currency || 'CNY'; } catch (e) {} +let budget = { daily: 0, monthly: 0 }; +try { budget = { ...budget, ...JSON.parse(stripBom(fs.readFileSync(path.join(os.homedir(), '.zcode/usage-budget.json'), 'utf8'))) }; } catch (e) {} +const rate = Number(process.env.ZCODE_USAGE_USD_CNY) || Number(prices.usdCny) || 7.2; +const db = new DatabaseSync(DB, { readOnly: true }); +const now = Date.now(), t0 = new Date(new Date().toDateString()).getTime(); +const q = (sql, ...a) => db.prepare(sql).all(...a); +const tok = q("SELECT COALESCE(SUM(input_tokens+output_tokens+reasoning_tokens),0) AS v FROM model_usage WHERE started_at >= ?", t0)[0].v; +const burn = q("SELECT COALESCE(SUM(input_tokens+output_tokens+reasoning_tokens),0) AS v FROM model_usage WHERE started_at >= ?", now - 3600e3)[0].v; +let cost = 0; +// 模型名归一匹配:精确 → 小写 → 遍历小写(与面板 rowCost/priceOf 同口径); +// 真实库存在 GLM-5.3-Flash 等大小写形态,直查会 miss 导致折算恒 0、预算告警失效 +const P = prices.models; +const priceOf = (n) => { + n = String(n || ''); + let p = P[n] || P[n.toLowerCase()]; + if (!p) { const l = n.toLowerCase(); for (const k in P) { if (k.toLowerCase() === l) { p = P[k]; break; } } } + return p; +}; +for (const r of q("SELECT input_tokens, output_tokens, reasoning_tokens, cache_read_input_tokens, model_id FROM model_usage WHERE started_at >= ?", t0)) { + const p = priceOf(r.model_id); + if (!p || !p.in) continue; + const inBillable = Math.max(0, (r.input_tokens || 0) - (r.cache_read_input_tokens || 0)); + cost += (inBillable * p.in + (r.output_tokens || 0) * p.out + (r.cache_read_input_tokens || 0) * (p.cache || 0)) / 1e6; +} +const f = (n) => n >= 1e8 ? (n / 1e8).toFixed(2) + '亿' : n >= 1e4 ? (n / 1e4).toFixed(1) + '万' : String(Math.round(n)); +const parts = ['今日 ' + f(tok) + ' tok']; +// 价格表已是 CNY 牌价时直接使用;仅 USD 价目(models.dev 同步)才乘汇率折算—— +// 否则 CNY 价 × usdCny 重复换算,金额虚高一个汇率倍数 +const kRate = String(prices.currency).toUpperCase() === 'USD' ? rate : 1; +if (cost > 0) parts.push('¥' + (cost * kRate).toFixed(2)); +if (burn > 0) parts.push('近1h ' + f(burn / 60) + ' tok/min'); +if (budget.daily > 0 && cost > 0) parts.push('日预算 ' + Math.min(999, Math.round(cost * kRate / budget.daily * 100)) + '%'); +console.log(parts.join(' · ')); +JS +} diff --git a/external_plugins/usage-detail/scripts/lib/cmd-query.sh b/external_plugins/usage-detail/scripts/lib/cmd-query.sh new file mode 100644 index 00000000..598cb0f1 --- /dev/null +++ b/external_plugins/usage-detail/scripts/lib/cmd-query.sh @@ -0,0 +1,246 @@ +# 只读查询子命令实现(由入口 usage.sh source) + +# 用量总览:累计 + 今日 +cmd_summary() { + q "SELECT count(*) AS 总请求数, + datetime(min(started_at)/1000,'unixepoch','localtime') AS 首次请求, + datetime(max(started_at)/1000,'unixepoch','localtime') AS 最近请求, + sum(input_tokens) AS 输入token, + sum(output_tokens+reasoning_tokens) AS 生成token, + sum(cache_read_input_tokens) AS 缓存读token + FROM model_usage;" + echo + echo "—— 今日 ——" + q "SELECT count(*) AS 请求数, + sum(input_tokens) AS 输入token, + sum(output_tokens+reasoning_tokens) AS 生成token, + sum(CASE WHEN status<>'completed' THEN 1 ELSE 0 END) AS 失败数 + FROM model_usage + WHERE date(started_at/1000,'unixepoch','localtime') = date('now','localtime');" +} + +# 按来源(官方套餐 / 自定义接入)汇总 +cmd_providers() { + q "SELECT $PV_LABEL AS 来源, + count(*) AS 请求数, + sum(input_tokens) AS 输入token, + sum(output_tokens+reasoning_tokens) AS 生成token, + sum(cache_read_input_tokens) AS 缓存读token, + round(sum(cache_read_input_tokens)*100.0/max(sum(input_tokens),1),1) AS 缓存率, + datetime(min(started_at)/1000,'unixepoch','localtime') AS 首次, + datetime(max(started_at)/1000,'unixepoch','localtime') AS 最近 + FROM model_usage GROUP BY provider_id ORDER BY sum(computed_total_tokens) DESC;" +} + +# 5 小时窗口用量 +cmd_blocks() { + # 5 小时窗口:相邻请求间隔 >= 5h 即开启新窗口(与 ccusage 口径一致)。末尾窗口若仍在 5h 内即为进行中。 + q "WITH s AS (SELECT started_at, input_tokens, output_tokens+reasoning_tokens AS gen, + LAG(started_at) OVER (ORDER BY started_at) AS prev FROM model_usage), + b AS (SELECT started_at, input_tokens, gen, + SUM(CASE WHEN prev IS NULL OR started_at-prev>=18000000 THEN 1 ELSE 0 END) OVER (ORDER BY started_at) AS blk FROM s) + SELECT blk AS 窗口, + datetime(min(started_at)/1000,'unixepoch','localtime') AS 开始, + datetime(max(started_at)/1000,'unixepoch','localtime') AS 最近活动, + count(*) AS 请求数, + sum(input_tokens) AS 输入token, + sum(gen) AS 生成token, + CASE WHEN unixepoch()*1000 - max(started_at) < 18000000 THEN '进行中' ELSE '已结束' END AS 状态 + FROM b GROUP BY blk ORDER BY min(started_at) DESC LIMIT 15;" +} + +# 按月汇总 +cmd_months() { + q "SELECT strftime('%Y-%m', started_at/1000,'unixepoch','localtime') AS 月份, + count(*) AS 请求数, + sum(input_tokens) AS 输入token, + sum(output_tokens+reasoning_tokens) AS 生成token, + sum(cache_read_input_tokens) AS 缓存读token, + count(DISTINCT date(started_at/1000,'unixepoch','localtime')) AS 活跃天 + FROM model_usage GROUP BY 月份 ORDER BY 月份 DESC;" +} + +# 按项目目录汇总 +cmd_projects() { + n=$(num_arg 15 "${2:-}") + q "SELECT s.directory AS 项目目录, + count(*) AS 请求数, + sum(m.input_tokens) AS 输入token, + sum(m.output_tokens+m.reasoning_tokens) AS 生成token, + datetime(max(m.started_at)/1000,'unixepoch','localtime') AS 最后活动 + FROM model_usage m JOIN session s ON s.id = m.session_id + GROUP BY s.directory ORDER BY sum(m.input_tokens+m.output_tokens+m.reasoning_tokens) DESC LIMIT $n;" +} + +# 工具调用统计 +cmd_tools() { + n=$(num_arg 15 "${2:-}") + q "SELECT tool_name AS 工具, + count(*) AS 调用, + sum(status<>'completed') AS 失败, + CAST(round(avg(duration_ms)) AS int) AS 平均耗时ms, + datetime(max(started_at)/1000,'unixepoch','localtime') AS 最近调用 + FROM tool_usage GROUP BY tool_name ORDER BY 调用 DESC LIMIT $n;" +} + +# 最近 N 条逐条明细 +cmd_recent() { + n=$(num_arg 20 "${2:-}") + q "SELECT datetime(m.started_at/1000,'unixepoch','localtime') AS 时间, + substr(s.title,1,28) AS 会话, + m.model_id AS 模型, + $PV_LABEL AS 来源, + CASE m.query_source WHEN 'subagent' THEN '子代理' WHEN 'main_turn' THEN '主会话' ELSE '系统' END AS 类型, + m.input_tokens AS 输入, + m.output_tokens+m.reasoning_tokens AS 生成, + m.cache_read_input_tokens AS 缓存读, + m.cache_creation_input_tokens AS 缓存写, + m.duration_ms AS 耗时ms, + m.status AS 状态 + FROM model_usage m LEFT JOIN session s ON s.id = m.session_id + ORDER BY m.started_at DESC LIMIT $n;" +} + +# 某天逐条明细 +cmd_day() { + d="${2:-$(date +%F)}" + [[ "$d" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { echo "日期格式应为 YYYY-MM-DD:$d" >&2; exit 1; } + q "SELECT datetime(m.started_at/1000,'unixepoch','localtime') AS 时间, + substr(s.title,1,28) AS 会话, + m.model_id AS 模型, + $PV_LABEL AS 来源, + CASE m.query_source WHEN 'subagent' THEN '子代理' WHEN 'main_turn' THEN '主会话' ELSE '系统' END AS 类型, + m.input_tokens AS 输入, + m.output_tokens+m.reasoning_tokens AS 生成, + m.cache_read_input_tokens AS 缓存读, + m.cache_creation_input_tokens AS 缓存写, + m.duration_ms AS 耗时ms, + m.status AS 状态 + FROM model_usage m LEFT JOIN session s ON s.id = m.session_id + WHERE date(m.started_at/1000,'unixepoch','localtime')='$d' + ORDER BY m.started_at;" +} + +# 最近 N 天按天汇总 +cmd_days() { + n=$(num_arg 14 "${2:-}") + q "SELECT date(started_at/1000,'unixepoch','localtime') AS 日期, + count(*) AS 请求数, + sum(input_tokens) AS 输入token, + sum(output_tokens+reasoning_tokens) AS 生成token, + sum(cache_read_input_tokens) AS 缓存读token, + CAST(round(avg(duration_ms)) AS int) AS 平均耗时ms + FROM model_usage WHERE status='completed' + GROUP BY 日期 ORDER BY 日期 DESC LIMIT $n;" +} + +# 会话用量排行 +cmd_sessions() { + n=$(num_arg 15 "${2:-}") + q "SELECT substr(s.title,1,32) AS 会话, + count(*) AS 请求数, + sum(m.input_tokens) AS 输入token, + sum(m.output_tokens+m.reasoning_tokens) AS 生成token, + sum(CASE WHEN m.query_source='subagent' THEN 1 ELSE 0 END) AS 子代理请求, + round(sum(m.cache_read_input_tokens)*100.0/max(sum(m.input_tokens),1),1) AS 缓存率, + datetime(max(m.started_at)/1000,'unixepoch','localtime') AS 最后活动 + FROM model_usage m JOIN session s ON s.id = m.session_id + GROUP BY m.session_id + ORDER BY sum(m.input_tokens+m.output_tokens+m.reasoning_tokens) DESC LIMIT $n;" +} + +# 按模型汇总 +cmd_models() { + q "SELECT max(model_id) AS 模型, + count(*) AS 请求数, + sum(input_tokens) AS 输入token, + sum(output_tokens+reasoning_tokens) AS 生成token, + sum(cache_read_input_tokens) AS 缓存读token, + round(sum(cache_read_input_tokens)*100.0/max(sum(input_tokens),1),1) AS 缓存率 + FROM model_usage WHERE status='completed' + GROUP BY lower(model_id) ORDER BY sum(computed_total_tokens) DESC;" +} + +# 最近失败/取消请求 +cmd_errors() { + n=$(num_arg 10 "${2:-}") + q "SELECT datetime(started_at/1000,'unixepoch','localtime') AS 时间, + model_id AS 模型, + status AS 状态, + coalesce(error_code,'-') AS 错误码, + substr(coalesce(error_message,'-'),1,60) AS 错误信息 + FROM model_usage WHERE status<>'completed' + ORDER BY started_at DESC LIMIT $n;" +} + +# 导出 csv/json +cmd_export() { + kind="${2:-csv}" + out="${3:-}" + case "$kind" in + csv) + out="${out:-zcode-usage-export.csv}" + # 全新环境表还不存在:导出表头空 CSV,而不是让 SQL 报 no such table(与 json 分支同守卫) + if [[ "$(qr "SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('model_usage','session')")" != "2" ]]; then + printf 'time,session,project,model,provider,input_tokens,output_tokens,cache_read_tokens,cache_write_tokens,duration_ms,status\n' > "$out" + echo "已导出 CSV(空库):$out" + return + fi + # qc():CLI 用 sqlite3 -csv,无 sqlite3 环境(Windows 常见)走 node 替身的 -csv 模式 + qc " + SELECT datetime(m.started_at/1000,'unixepoch','localtime') AS time, + substr(coalesce(s.title,''),1,80) AS session, + s.directory AS project, + m.model_id AS model, m.provider_id AS provider, + m.input_tokens AS input_tokens, + m.output_tokens+m.reasoning_tokens AS output_tokens, + m.cache_read_input_tokens AS cache_read_tokens, + m.duration_ms AS duration_ms, m.status AS status + FROM model_usage m LEFT JOIN session s ON s.id = m.session_id + ORDER BY m.started_at;" > "$out" + echo "已导出 CSV:$out" + ;; + json) + out="${out:-zcode-usage-export.json}" + # 全新环境表还不存在:导出空数据而不是让 SQL 崩 + if [[ "$(qr "SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('model_usage','session')")" != "2" ]]; then + printf '{"providers":[],"models":[],"sessions":[],"sessionDirs":[],"total":0,"covered":0,"rows":[]}\n' > "$out" + echo "已导出 JSON(空库):$out" + return + fi + # turn_id 是较新 ZCode 才有的列:老库直接 SELECT 会 prepare 失败,先探测再拼表达式 + turn_expr="NULL" + [[ "$(qr "SELECT count(*) FROM pragma_table_info('model_usage') WHERE name='turn_id'")" == "1" ]] && turn_expr="R.turn_id" + qr " + WITH P AS (SELECT provider_id AS v, row_number() OVER (ORDER BY provider_id)-1 AS i FROM (SELECT DISTINCT provider_id FROM model_usage)), + M AS (SELECT model_id AS v, row_number() OVER (ORDER BY model_id)-1 AS i FROM (SELECT DISTINCT model_id FROM model_usage)), + S AS (SELECT session_id AS v, row_number() OVER (ORDER BY session_id)-1 AS i FROM (SELECT DISTINCT session_id FROM model_usage)), + R AS (SELECT * FROM model_usage ORDER BY started_at DESC LIMIT 20000) + SELECT json_object( + 'providers',(SELECT json_group_array(v) FROM (SELECT v,i FROM P ORDER BY i)), + 'models',(SELECT json_group_array(v) FROM (SELECT v,i FROM M ORDER BY i)), + 'sessions',(SELECT json_group_array(coalesce(se.title,'')) FROM (SELECT v,i FROM S ORDER BY i) SI LEFT JOIN session se ON se.id=SI.v), + 'sessionDirs',(SELECT json_group_array(COALESCE(se.directory,'')) FROM (SELECT v,i FROM S ORDER BY i) SI LEFT JOIN session se ON se.id=SI.v), + 'total',(SELECT count(*) FROM model_usage), + 'covered',(SELECT count(*) FROM R), + 'gen',(SELECT datetime('now','localtime')), + 'rows',(SELECT json_group_array(json_array( + SI.i, R.started_at, MI.i, PI.i, + R.input_tokens, R.output_tokens+R.reasoning_tokens, R.cache_read_input_tokens, + R.duration_ms, + CASE R.status WHEN 'completed' THEN 0 WHEN 'error' THEN 1 WHEN 'cancelled' THEN 2 ELSE 3 END, + CASE R.query_source WHEN 'main_turn' THEN 0 WHEN 'subagent' THEN 1 ELSE 2 END, + R.cache_creation_input_tokens, R.time_to_first_token_ms, R.id, R.retry_count, ${turn_expr})) + FROM R + LEFT JOIN S SI ON SI.v = R.session_id + LEFT JOIN M MI ON MI.v = R.model_id + LEFT JOIN P PI ON PI.v = R.provider_id));" > "$out" + echo "已导出 JSON:$out" + ;; + *) + echo "用法:usage.sh export [输出路径]" >&2 + exit 1 + ;; + esac +} + diff --git a/external_plugins/usage-detail/scripts/lib/cmd-this.sh b/external_plugins/usage-detail/scripts/lib/cmd-this.sh new file mode 100644 index 00000000..d145247c --- /dev/null +++ b/external_plugins/usage-detail/scripts/lib/cmd-this.sh @@ -0,0 +1,35 @@ +# 当前会话子命令(由入口 usage.sh source) + +cmd_this() { + resolve_session "${@:2}" + session_info + echo + echo "—— 会话汇总(全部状态;输入含缓存读,生成=输出+思考)——" + q "SELECT count(*) AS 总请求数, + coalesce(sum(status='completed'),0) AS 完成数, + coalesce(sum(status='error'),0) AS 失败数, + coalesce(sum(status='cancelled'),0) AS 取消数, + coalesce(sum(status NOT IN ('completed','error','cancelled')),0) AS 其他状态数, + coalesce(sum(input_tokens),0) AS 输入token, + coalesce(sum(output_tokens+reasoning_tokens),0) AS 生成token, + coalesce(sum(cache_read_input_tokens),0) AS 缓存读token + FROM model_usage WHERE session_id=$SESSION_SQL;" + echo + echo "—— 按模型与来源(全部状态)——" + q "SELECT model_id AS 模型, $PV_LABEL AS 来源, + count(*) AS 请求数, + sum(input_tokens) AS 输入token, + sum(output_tokens+reasoning_tokens) AS 生成token, + sum(cache_read_input_tokens) AS 缓存读token + FROM model_usage WHERE session_id=$SESSION_SQL + GROUP BY model_id, provider_id ORDER BY sum(input_tokens+output_tokens+reasoning_tokens) DESC;" + echo + echo "—— 最近失败/取消请求(最多 10 条)——" + q "SELECT datetime(started_at/1000,'unixepoch','localtime') AS 时间, + model_id AS 模型, $PV_LABEL AS 来源, status AS 状态, + coalesce(error_code,'-') AS 错误码, + substr(coalesce(error_message,'-'),1,60) AS 错误信息 + FROM model_usage WHERE session_id=$SESSION_SQL AND status IN ('error','cancelled') + ORDER BY started_at DESC, id DESC LIMIT 10;" +} + diff --git a/external_plugins/usage-detail/scripts/lib/common.sh b/external_plugins/usage-detail/scripts/lib/common.sh new file mode 100644 index 00000000..0fe31f62 --- /dev/null +++ b/external_plugins/usage-detail/scripts/lib/common.sh @@ -0,0 +1,58 @@ +# 公共基础:数据库访问、参数校验、价格表定位、SQL 转义(由入口 usage.sh source) + +# 价格表解析顺序:ZCODE_USAGE_PRICES → 用户文件 ~/.zcode/usage-prices.json → 插件内置官方价(models.dev) +price_file() { + if [[ -n "${ZCODE_USAGE_PRICES:-}" && -f "$ZCODE_USAGE_PRICES" ]]; then + echo "$ZCODE_USAGE_PRICES"; return + fi + if [[ -f "$HOME/.zcode/usage-prices.json" ]]; then + echo "$HOME/.zcode/usage-prices.json"; return + fi + local d; d="$SCRIPT_DIR/default-prices.json" + if [[ -f "$d" ]]; then echo "$d"; fi +} + +if [[ ! -f "$DB" ]]; then + echo "未找到 ZCode 本地数据库:$DB" >&2 + exit 1 +fi + +# 数据库访问:优先本机 sqlite3 CLI;没有时(Windows Git Bash 常见)回退 node:sqlite 替身。 +# ZCODE_USAGE_SQLITE=node 可强制走替身(测试跨实现一致性)。 +if [[ "${ZCODE_USAGE_SQLITE:-}" == "node" ]] || ! command -v sqlite3 >/dev/null 2>&1; then + q() { node "$SCRIPT_DIR/sqlite.mjs" -header "$DB" "$1"; } + qr() { node "$SCRIPT_DIR/sqlite.mjs" "$DB" "$1"; } + qc() { node "$SCRIPT_DIR/sqlite.mjs" -csv -header "$DB" "$1"; } +else + q() { sqlite3 -readonly -header -column "$DB" "$1"; } + # 输出原始单值(供 JSON 组装),不带表格格式化 + qr() { sqlite3 -readonly "$DB" "$1"; } + qc() { sqlite3 -readonly -csv -header "$DB" "$1"; } +fi + +is_num() { [[ "${1:-}" =~ ^[0-9]+$ ]]; } + +# num_arg 默认值 实际参数 —— 校验数字参数,非法则报错退出 +num_arg() { + if [[ -n "${2:-}" ]]; then + is_num "$2" || { echo "参数应为数字:$2" >&2; exit 1; } + echo "$2" + else + echo "$1" + fi +} + +# 供应商标签:官方账号 / 自定义接入 +PV_LABEL="CASE WHEN provider_id LIKE 'account:%' THEN '官方·'||substr(provider_id,9) ELSE '自定义·'||provider_id END" + +# SQL 字符串字面量;会话 ID、目录、价格表路径都可能含单引号。 +sql_quote() { + local value="$1" + # Bash 3.2 的替换表达式置于双引号内会保留多余反斜杠。 + # 仅限短字符串(会话 ID / 路径 / 价格表路径);大文本禁止走此函数—— + # bash 3.2 的 ${var//} 在长串 + replacement 含反斜杠时是平方级(见 dashboard.sh META)。 + value=${value//\'/\'\'} + printf "'%s'" "$value" +} + +# 定位一次后固定 ID,后续查询不会因其他窗口的新请求切换会话。 diff --git a/external_plugins/usage-detail/scripts/lib/dashboard.sh b/external_plugins/usage-detail/scripts/lib/dashboard.sh new file mode 100644 index 00000000..e4b2d44e --- /dev/null +++ b/external_plugins/usage-detail/scripts/lib/dashboard.sh @@ -0,0 +1,2894 @@ +# 交互式仪表盘(dashboard / dashboard-this):META 组装与 HTML 片段输出(由入口 usage.sh source) + +cmd_dashboard() { + # 生成交互式用量界面(HTML 片段),输出路径必须由调用方指定。 + # 默认内嵌全部历史(-1);可用第三参数或 ZCODE_USAGE_LIMIT 限制条数。 + out="${2:-}" + [[ -n "$out" ]] || { echo "用法:usage.sh $cmd <输出.html> [条数]" >&2; exit 1; } + scope='' + session_meta='null' + limit_arg="${3:-}" + if [[ "$cmd" == dashboard-this ]]; then + limit_arg='' + resolve_session "${@:3}" + scope="WHERE session_id=$SESSION_SQL" + session_meta="(SELECT json_object('id',$SESSION_SQL, + 'title',coalesce(nullif(s.title,''),'(无标题或会话已删除)'), + 'directory',s.directory,'created',s.time_created, + 'latest',(SELECT max(started_at) FROM model_usage $scope), + 'selection',$(sql_quote "$SESSION_MODE")) + FROM (SELECT 1) LEFT JOIN session s ON s.id=$SESSION_SQL)" + fi + [[ "$limit_arg" == "-1" ]] && limit_arg='' # 显式 -1 = 全部 + limit=$(num_arg "${ZCODE_USAGE_LIMIT:--1}" "$limit_arg") + # 默认 -1 = 全部历史;显式给定条数时校验范围 + [[ "$limit" == "-1" ]] || { is_num "$limit" && [[ "$limit" -ge 1 && "$limit" -le 20000 ]] || { echo "条数应在 1-20000 之间或 -1(全部):${limit}" >&2; exit 1; }; } + # 价格表每日自动同步一次(需要 node + curl;ZCODE_USAGE_NO_AUTOSYNC=1 关闭) + if [[ -z "${ZCODE_USAGE_NO_AUTOSYNC:-}" ]] && command -v node >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then + upf="${ZCODE_USAGE_PRICES:-$HOME/.zcode/usage-prices.json}" + upf_mtime=0 + if [[ -f "$upf" ]]; then + upf_mtime=$(stat -f %m "$upf" 2>/dev/null || stat -c %Y "$upf" 2>/dev/null || echo 0) + fi + # autoSync=false(设置弹窗可关)时跳过每日自动同步 + if [[ $(( $(date +%s) - upf_mtime )) -ge 86400 ]] && ! grep -q '"autoSync"[: ]*false' "$upf" 2>/dev/null; then + bash "$SCRIPT_DIR/usage.sh" prices sync >/dev/null 2>&1 || true + fi + fi + pf=$(price_file) + if [[ -n "$pf" ]]; then + PJ_SQL="(SELECT replace(CAST(readfile($(sql_quote "$pf")) AS text), char(65279), '') AS pj)" + else + PJ_SQL="(SELECT NULL AS pj)" + fi + logs_mb=$(du -sm "$HOME/.zcode/v2/logs" 2>/dev/null | cut -f1 || true); logs_mb=${logs_mb:-0} + rollout_mb=$(du -sm "$HOME/.zcode/cli/rollout" 2>/dev/null | cut -f1 || true); rollout_mb=${rollout_mb:-0} + artifacts_mb=$(du -sm "$HOME/.zcode/cli/artifacts" 2>/dev/null | cut -f1 || true); artifacts_mb=${artifacts_mb:-0} + db_mb=$(du -m "$DB" 2>/dev/null | cut -f1 || true); db_mb=${db_mb:-0} + # turn_id 是较新 ZCode 才有的列:老库直接 SELECT 会 prepare 失败,先探测再拼表达式 + turn_expr="NULL" + [[ "$(qr "SELECT count(*) FROM pragma_table_info('model_usage') WHERE name='turn_id'")" == "1" ]] && turn_expr="R.turn_id" + # BYOK 供应商显示名(provider_config.json,只取名映射)+ 项目根归并(git 仓库根): + # node 一次性解析成两个映射表进 META;node/git 不可用或空目录时给空表,前端按原样回退。 + # dirs 走 stdin:Git Bash(MSYS) 会改写 argv 里以 / 开头的路径参数,JSON 清单会被篡改 + DIRS_JSON=$(qr "SELECT COALESCE(json_group_array(d),'[]') FROM (SELECT DISTINCT se.directory AS d FROM model_usage u LEFT JOIN session se ON se.id=u.session_id WHERE coalesce(se.directory,'')<>'')") + PMETA=$(printf '%s' "$DIRS_JSON" | node "$SCRIPT_DIR/lib/project-meta.mjs" 2>/dev/null || echo '{"providerNames":{},"projectMap":{}}') + PMETA=${PMETA//\'/\'\'} + # 预算值(洞察区「今日预算」进度卡用):node 读 usage-budget.json,只取两个数字 + BUDGET_JSON=$(node -e "try{const b=JSON.parse(require('fs').readFileSync(require('path').join(process.env.HOME||'', '.zcode', 'usage-budget.json'),'utf8'));process.stdout.write((Number(b.daily)||0)+' '+(Number(b.monthly)||0))}catch(e){process.stdout.write('0 0')}" 2>/dev/null || echo "0 0") + BUDGET_D=${BUDGET_JSON%% *}; BUDGET_M=${BUDGET_JSON##* } + [[ "$BUDGET_D" =~ ^[0-9.]+$ ]] || BUDGET_D=0 + [[ "$BUDGET_M" =~ ^[0-9.]+$ ]] || BUDGET_M=0 + # 界面语言跟随 ZCode 设置(~/.zcode/v2/setting.json 的 localePreference): + # 只取显式语言(system=跟随系统时留空,前端回退 navigator.language);严格校验格式防注入 + ZLOC=$(node -e "try{const s=JSON.parse(require('fs').readFileSync(require('path').join(process.env.HOME||'', '.zcode', 'v2', 'setting.json'),'utf8'));const p=String(s.localePreference||'');process.stdout.write(p&&p!=='system'?p:'')}catch(e){process.stdout.write('')}" 2>/dev/null || echo "") + [[ "$ZLOC" =~ ^[a-zA-Z]{2,8}(-[a-zA-Z0-9]{1,8})*$ ]] || ZLOC='' + # 全新环境:model_usage/session 表还不存在(ZCode 尚未写入)→ 直接给空数据,避免 META SQL 报 no such table + if [[ "$(qr "SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('model_usage','session')")" != "2" ]]; then + META='{"providers":[],"models":[],"sessions":[],"sessionDirs":[],"tools":[],"todos":[0,0],"stability":[0,0,0],"hours":{},"compactions":[],"sessionTools":[],"disk":{"logs":0,"rollout":0,"artifacts":0,"db":0},"providerNames":{},"projectMap":{},"budgetDaily":0,"budgetMonthly":0,"zcodeLocale":"","prices":{},"total":0,"covered":0,"rows":[]}' + else + META=$(qr " + WITH U AS (SELECT * FROM model_usage $scope), + P AS (SELECT provider_id AS v, row_number() OVER (ORDER BY provider_id)-1 AS i FROM (SELECT DISTINCT provider_id FROM U)), + M AS (SELECT model_id AS v, row_number() OVER (ORDER BY model_id)-1 AS i FROM (SELECT DISTINCT model_id FROM U)), + S AS (SELECT session_id AS v, row_number() OVER (ORDER BY session_id)-1 AS i FROM (SELECT DISTINCT session_id FROM U)), + PJ AS $PJ_SQL, + R AS (SELECT * FROM U ORDER BY started_at DESC, id DESC LIMIT $limit) + -- 输出前把 < 转义为 \u003c(防会话标题中的 提前闭合标签)。 + -- 必须在 SQL 层做:bash 3.2 的模式替换运算符在长串 + replacement 含反斜杠时 + -- 是平方级(实测 300KB+单匹配 2.6s、多匹配分钟级),sqlite replace() 是 C 线性实现。 + -- < 只会出现在 JSON 字符串值内部,替换为 u003c 转义后仍是合法 JSON 且语义不变。 + SELECT replace(json_object( + 'session',$session_meta, + 'providers',(SELECT json_group_array(v) FROM (SELECT v,i FROM P ORDER BY i)), + 'models',(SELECT json_group_array(v) FROM (SELECT v,i FROM M ORDER BY i)), + 'sessions',(SELECT json_group_array(coalesce(se.title,'')) FROM (SELECT v,i FROM S ORDER BY i) SI LEFT JOIN session se ON se.id=SI.v), + 'sessionDirs',(SELECT json_group_array(coalesce(se.directory,'')) FROM (SELECT v,i FROM S ORDER BY i) SI LEFT JOIN session se ON se.id=SI.v), + 'tools',(SELECT COALESCE(json_group_array(json_array(tool_name, c, f, d)),'[]') FROM (SELECT tool_name, count(*) AS c, sum(status<>'completed') AS f, CAST(round(avg(duration_ms)) AS int) AS d FROM tool_usage $scope GROUP BY tool_name ORDER BY c DESC LIMIT 8)), + 'todos',(SELECT json_array(sum(CASE WHEN status='completed' THEN 1 ELSE 0 END), count(*)) FROM todo), + 'stability',(SELECT json_array(COALESCE(sum(retry_count),0), COALESCE(sum(cancelled_by_user),0), COALESCE(sum(CASE WHEN status='error' THEN 1 ELSE 0 END),0)) FROM U), + 'hours',(SELECT COALESCE(json_group_object(hr, c),'{}') FROM (SELECT strftime('%H', started_at/1000,'unixepoch','localtime') AS hr, count(*) AS c FROM U GROUP BY hr)), + 'compactions',(SELECT COALESCE(json_group_array(json_array(SI.i, c.cnt, c.maxpre, json(c.ts))),'[]') FROM (SELECT p.session_id AS sid, count(*) AS cnt, max(COALESCE(json_extract(p.data,'$.preCompactTokenCount'),0)) AS maxpre, json_group_array(strftime('%m-%d %H:%M', p.time_created/1000,'unixepoch','localtime')) AS ts FROM part p WHERE json_extract(p.data,'$.type')='compaction' GROUP BY p.session_id) c LEFT JOIN S SI ON SI.v = c.sid WHERE SI.i IS NOT NULL), + 'sessionTools',(SELECT COALESCE(json_group_array(json_array(SI.i, t.tool, t.c, t.ms)),'[]') FROM (SELECT session_id AS sid, tool_name AS tool, count(*) AS c, CAST(sum(duration_ms) AS int) AS ms FROM tool_usage GROUP BY session_id, tool_name) t LEFT JOIN S SI ON SI.v = t.sid WHERE SI.i IS NOT NULL), + 'disk', json('{"logs":$logs_mb,"rollout":$rollout_mb,"artifacts":$artifacts_mb,"db":$db_mb}'), + 'providerNames', json_extract(json('$PMETA'),'$.providerNames'), + 'projectMap', json_extract(json('$PMETA'),'$.projectMap'), + 'budgetDaily', $BUDGET_D, + 'budgetMonthly', $BUDGET_M, + 'zcodeLocale', '$ZLOC', + 'prices',(SELECT CASE + WHEN pj IS NULL OR NOT json_valid(pj) THEN json('{}') + ELSE json_object('currency', COALESCE(json_extract(pj,'$.currency'),'CNY'), + 'models', COALESCE(CASE WHEN json_type(pj,'$.models')='object' + THEN (SELECT json_group_object(lower(je.key), json(je.value)) FROM json_each(pj,'$.models') je + WHERE lower(je.key) IN (SELECT lower(v) FROM M) AND json_type(je.value)='object') + ELSE (SELECT json_group_object(lower(je.key), json(je.value)) FROM json_each(pj) je + WHERE lower(je.key) IN (SELECT lower(v) FROM M) AND json_type(je.value)='object') + END, json('{}'))) + END FROM PJ), + 'total',(SELECT count(*) FROM U), + 'covered',(SELECT count(*) FROM R), + 'gen',(SELECT datetime('now','localtime')), + 'rows',(SELECT json_group_array(json_array( + SI.i, R.started_at, MI.i, PI.i, + R.input_tokens, R.output_tokens+R.reasoning_tokens, R.cache_read_input_tokens, + R.duration_ms, + CASE R.status WHEN 'completed' THEN 0 WHEN 'error' THEN 1 WHEN 'cancelled' THEN 2 ELSE 3 END, + CASE R.query_source WHEN 'main_turn' THEN 0 WHEN 'subagent' THEN 1 ELSE 2 END, + R.cache_creation_input_tokens, R.time_to_first_token_ms, R.id, R.retry_count, ${turn_expr})) + FROM R + LEFT JOIN S SI ON SI.v = R.session_id + LEFT JOIN M MI ON MI.v = R.model_id + LEFT JOIN P PI ON PI.v = R.provider_id)), '<', '\u003c');") + fi + mkdir -p "$(dirname "$out")" + { + cat <<'UD_HEAD' +
+ +UD_HEAD + if [[ "$cmd" == dashboard-this ]]; then + cat "$SCRIPT_DIR/../assets/session-dashboard.html" + else + cat <<'UD_ALL_HEAD' + +
+ + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+
+
–
请求次数
+
–
输入 token
+
–
生成 token
+
–
活跃会话
+
+
用量洞察
+
性能体检
+ +
+
+

来源构成

+
+
+
+
+

模型分布

+
+
+
+

常用工具

+
+
+
+

最活跃会话(Top 5)

+
+
+

时段分布

+ + + + + +
+
+
+

活跃热力图

+
+ + + + +
+
+
+
+ + + + + + +UD_ALL_HEAD + fi + cat <<'UD_DATA_HEAD' + +
+UD_TAIL + } > "$out" + echo "已生成用量界面:$out" +} + diff --git a/external_plugins/usage-detail/scripts/lib/project-meta.mjs b/external_plugins/usage-detail/scripts/lib/project-meta.mjs new file mode 100644 index 00000000..c268a510 --- /dev/null +++ b/external_plugins/usage-detail/scripts/lib/project-meta.mjs @@ -0,0 +1,62 @@ +// 项目根与 BYOK 供应商名解析(面板增强数据,只读本地配置,不外发): +// - providerNames():读 ~/.zcode/v2/provider_config.json 的 providerConfigRules, +// 只提取 providerId → providerName 映射。该文件含 apiKey 明文,其余字段一律不读不出。 +// - projectMap(dirs):对每个会话目录跑 git -C rev-parse --show-toplevel 解析真实 +// 仓库根,子/孙目录会话因此归并到同一项目;目录不存在或非 git 仓库退化空串(前端回退原目录)。 +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +let nameCache = { at: 0, v: null }; +export function providerNames() { + if (nameCache.v && Date.now() - nameCache.at < 60000) return nameCache.v; + const v = {}; + try { + const cfg = JSON.parse(String(fs.readFileSync(path.join(os.homedir(), '.zcode', 'v2', 'provider_config.json'), 'utf8')).replace(/^\uFEFF/, '')); + for (const r of ((cfg.config && cfg.config.providerConfigRules && cfg.config.providerConfigRules.providerRules) || [])) { + const id = String(r.providerId || ''); + const name = String(r.providerName || '').trim(); + if (id && name && name !== id) v[id] = name; + } + } catch (e) { /* 配置不存在或非 JSON:无 BYOK 命名 */ } + nameCache = { at: Date.now(), v }; + return v; +} + +const rootCache = new Map(); +export function projectRoot(dir) { + const d = String(dir || '').trim(); + if (!d) return ''; + if (rootCache.has(d)) return rootCache.get(d); + let root = ''; + try { + // Windows 的 git 输出正斜杠风格(C:/repo),与会话目录的反斜杠并存时归一为正斜杠, + // 保证同项目不同目录归并到同一字符串、显示风格一致 + root = String(execFileSync('git', ['-C', d, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 3000 })).trim().replace(/\\/g, '/'); + } catch (e) { root = ''; } // 目录已删除 / 非 git 仓库 / git 未安装(Windows 常见):退化,前端按原目录分组 + rootCache.set(d, root); + return root; +} + +export function projectMap(dirs) { + const m = {}; + for (const d of (dirs || [])) m[d] = projectRoot(d); + return m; +} + +// CLI 入口(bash 版 dashboard 消费):echo '' | node project-meta.mjs +// → {"providerNames":{...},"projectMap":{...}} +// dirs 走 stdin:Git Bash(MSYS) 会把 argv 里以 / 开头的路径参数自动改写成 Windows +// 安装路径(/home/x → C:/Program Files/Git/home/x),JSON 目录清单会被篡改;stdin 不经转换。 +// 仅作为主模块运行——被 import 时 argv 是宿主脚本的参数,绝不能碰。 +const asMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href + || (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(path.resolve(process.argv[1]))).href); // 符号链接安装:Node 对 ESM 主入口默认 realpath 化,两侧都要归一后再比 +if (asMain) { + let raw = ''; + try { if (!process.stdin.isTTY) raw = fs.readFileSync(0, 'utf8'); } catch (e) {} + let dirs = []; + try { dirs = JSON.parse(raw.trim() || process.argv[2] || '[]'); } catch (e) {} + process.stdout.write(JSON.stringify({ providerNames: providerNames(), projectMap: projectMap(dirs) })); +} diff --git a/external_plugins/usage-detail/scripts/lib/session.sh b/external_plugins/usage-detail/scripts/lib/session.sh new file mode 100644 index 00000000..701ce25d --- /dev/null +++ b/external_plugins/usage-detail/scripts/lib/session.sh @@ -0,0 +1,48 @@ +# 会话定位:按最近请求推断 / --session / --directory(由入口 usage.sh source) +resolve_session() { + local requested='' directory='' directory_sql='' + SESSION_MODE='最近请求推断(多窗口时请核对)' + while [[ $# -gt 0 ]]; do + case "$1" in + --session|--directory) + [[ $# -ge 2 && -n "$2" ]] || { echo "$1 需要非空参数" >&2; exit 1; } + [[ -z "$requested$directory" ]] || { echo "--session 与 --directory 只能指定一个且不能重复" >&2; exit 1; } + if [[ "$1" == --session ]]; then requested="$2"; else directory="$2"; fi + shift 2 + ;; + *) echo "未知会话参数:${1}(可用 --session ID 或 --directory DIR)" >&2; exit 1 ;; + esac + done + # 全新环境表还不存在(ZCode 尚未写入):没有任何会话可定位,明确报错而不是让 SQL 崩 + if [[ "$(qr "SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('model_usage','session')")" != "2" ]]; then + echo "未找到可用于定位会话的模型请求(数据库尚未初始化,先在 ZCode 里跑一轮对话)${directory:+(目录:${directory})}" >&2; exit 1 + fi + if [[ -n "$requested" ]]; then + SESSION_ID="$requested" + SESSION_MODE='显式指定会话' + SESSION_SQL=$(sql_quote "$SESSION_ID") + [[ "$(qr "SELECT EXISTS(SELECT 1 FROM session WHERE id=$SESSION_SQL) OR EXISTS(SELECT 1 FROM model_usage WHERE session_id=$SESSION_SQL);")" == 1 ]] || { + echo "未找到指定会话:$SESSION_ID" >&2; exit 1; + } + else + if [[ -n "$directory" ]]; then + directory_sql="AND session_id IN (SELECT id FROM session WHERE directory=$(sql_quote "$directory"))" + SESSION_MODE='指定目录内最近请求推断(多窗口时请核对)' + fi + SESSION_ID=$(qr "SELECT session_id FROM model_usage WHERE session_id IS NOT NULL AND session_id<>'' $directory_sql ORDER BY started_at DESC, id DESC LIMIT 1;") + [[ -n "$SESSION_ID" ]] || { echo "未找到可用于定位会话的模型请求${directory:+(目录:${directory})};可用 --session ID 指定会话" >&2; exit 1; } + SESSION_SQL=$(sql_quote "$SESSION_ID") + fi +} + +session_info() { + q "SELECT $SESSION_SQL AS 会话ID, + coalesce(nullif(s.title,''),'(无标题或会话已删除)') AS 会话标题, + coalesce(s.directory,'-') AS 项目目录, + datetime(s.time_created/1000,'unixepoch','localtime') AS 创建时间, + (SELECT datetime(max(started_at)/1000,'unixepoch','localtime') FROM model_usage WHERE session_id=$SESSION_SQL) AS 最近请求 + FROM (SELECT 1) LEFT JOIN session s ON s.id=$SESSION_SQL;" + echo "定位方式:${SESSION_MODE};仅统计该 session_id 的记录。" +} + +cmd="${1:-summary}" diff --git a/external_plugins/usage-detail/scripts/mcp/panel-server.mjs b/external_plugins/usage-detail/scripts/mcp/panel-server.mjs new file mode 100644 index 00000000..98ee2439 --- /dev/null +++ b/external_plugins/usage-detail/scripts/mcp/panel-server.mjs @@ -0,0 +1,943 @@ +#!/usr/bin/env node +// ZCode 用量明细 · 会话侧栏面板的 MCP 服务(零依赖,手写最小 MCP stdio 协议) +// +// 与宿主的会话: +// initialize → tools/list → tools/call / resources/read(newline-delimited JSON-RPC) +// 页面(ui/panel.html)通过 window.zcode 兼容层 callTool('usage_summary') 取数渲染。 +// 只读数据库:node:sqlite 只读连接 + 配额日志尾随;仅按页面操作写 ~/.zcode 下的插件配置文件,不联网。 +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +const DB = process.env.ZCODE_USAGE_DB || path.join(os.homedir(), '.zcode/cli/db/db.sqlite'); +const USER_PRICE = process.env.ZCODE_USAGE_PRICES || path.join(os.homedir(), '.zcode/usage-prices.json'); +const HERE = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')); +const DEFAULT_LIMIT = process.env.ZCODE_USAGE_LIMIT && Number(process.env.ZCODE_USAGE_LIMIT) + ? Math.max(1, Math.min(20000, Number(process.env.ZCODE_USAGE_LIMIT))) + : -1; // 默认全部 + +let db = null; +function openDb() { + if (db) return db; + db = new DatabaseSync(DB, { readOnly: true }); + return db; +} +// 单行单值查询(与 usage.sh 的 qr 语义一致) +function q1(sql) { + const row = openDb().prepare(sql).get(); + if (!row) return ''; + return String(row[Object.keys(row)[0]]); +} +// turn_id 是较新 ZCode 才有的列:老库直接 SELECT 会 prepare 失败,先探测再拼表达式 +let turnExprCache = null; +function turnExpr() { + if (turnExprCache == null) { + try { turnExprCache = q1("SELECT count(*) FROM pragma_table_info('model_usage') WHERE name='turn_id'") === '1' ? 'R.turn_id' : 'NULL'; } + catch (e) { turnExprCache = 'NULL'; } + } + return turnExprCache; +} + +function priceText() { + const p = USER_PRICE; + try { return fs.existsSync(p) ? stripBom(fs.readFileSync(p, 'utf8')) : null; } catch (e) { return null; } +} + +// 全量 META(与 serve.mjs 同一形状:行数组 + 索引映射,前端聚合) +function metaSQL(limit, disk, extraJson) { + const pt = priceText(); + const PJ = pt != null ? `CAST('${pt.replace(/'/g, "''")}' AS text)` : 'NULL'; + return ` + WITH P AS (SELECT provider_id AS v, row_number() OVER (ORDER BY provider_id)-1 AS i FROM (SELECT DISTINCT provider_id FROM model_usage)), + M AS (SELECT model_id AS v, row_number() OVER (ORDER BY model_id)-1 AS i FROM (SELECT DISTINCT model_id FROM model_usage)), + S AS (SELECT session_id AS v, row_number() OVER (ORDER BY session_id)-1 AS i FROM (SELECT DISTINCT session_id FROM model_usage)), + PJ AS (SELECT ${PJ} AS pj), + R AS (SELECT * FROM model_usage ORDER BY started_at DESC, id DESC LIMIT ${limit}) + SELECT json_object( + 'providers',(SELECT json_group_array(v) FROM (SELECT v,i FROM P ORDER BY i)), + 'models',(SELECT json_group_array(v) FROM (SELECT v,i FROM M ORDER BY i)), + 'sessions',(SELECT json_group_array(coalesce(se.title,'')) FROM (SELECT v,i FROM S ORDER BY i) SI LEFT JOIN session se ON se.id=SI.v), + 'sessionDirs',(SELECT json_group_array(coalesce(se.directory,'')) FROM (SELECT v,i FROM S ORDER BY i) SI LEFT JOIN session se ON se.id=SI.v), + 'tools',(SELECT COALESCE(json_group_array(json_array(tool_name, c, f, d)),'[]') FROM (SELECT tool_name, count(*) AS c, sum(status<>'completed') AS f, CAST(round(avg(duration_ms)) AS int) AS d FROM tool_usage GROUP BY tool_name ORDER BY c DESC LIMIT 8)), + 'todos',(SELECT json_array(sum(CASE WHEN status='completed' THEN 1 ELSE 0 END), count(*)) FROM todo), + 'stability',(SELECT json_array(COALESCE(sum(retry_count),0), COALESCE(sum(cancelled_by_user),0), COALESCE(sum(CASE WHEN status='error' THEN 1 ELSE 0 END),0)) FROM model_usage), + 'hours',(SELECT COALESCE(json_group_object(hr, c),'{}') FROM (SELECT strftime('%H', started_at/1000,'unixepoch','localtime') AS hr, count(*) AS c FROM model_usage GROUP BY hr)), + 'compactions',(SELECT COALESCE(json_group_array(json_array(SI.i, c.cnt, c.maxpre, json(c.ts))),'[]') FROM (SELECT p.session_id AS sid, count(*) AS cnt, max(COALESCE(json_extract(p.data,'$.preCompactTokenCount'),0)) AS maxpre, json_group_array(strftime('%m-%d %H:%M', p.time_created/1000,'unixepoch','localtime')) AS ts FROM part p WHERE json_extract(p.data,'$.type')='compaction' GROUP BY p.session_id) c LEFT JOIN S SI ON SI.v = c.sid WHERE SI.i IS NOT NULL), + 'sessionTools',(SELECT COALESCE(json_group_array(json_array(SI.i, t.tool, t.c, t.ms)),'[]') FROM (SELECT session_id AS sid, tool_name AS tool, count(*) AS c, CAST(sum(duration_ms) AS int) AS ms FROM tool_usage GROUP BY session_id, tool_name) t LEFT JOIN S SI ON SI.v = t.sid WHERE SI.i IS NOT NULL), + 'disk', json('${JSON.stringify(disk).replace(/'/g, "''")}'), + 'providerNames', json_extract(json('${extraJson.replace(/'/g, "''")}'),'$."providerNames"'), + 'projectMap', json_extract(json('${extraJson.replace(/'/g, "''")}'),'$."projectMap"'), + 'budgetDaily', json_extract(json('${extraJson.replace(/'/g, "''")}'),'$."budgetDaily"'), + 'budgetMonthly', json_extract(json('${extraJson.replace(/'/g, "''")}'),'$."budgetMonthly"'), + 'prices',(SELECT CASE + WHEN pj IS NULL OR NOT json_valid(pj) THEN json('{}') + ELSE json_object('currency', COALESCE(json_extract(pj,'$.currency'),'CNY'), + 'models', COALESCE(CASE WHEN json_type(pj,'$.models')='object' + THEN (SELECT json_group_object(lower(je.key), json(je.value)) FROM json_each(pj,'$.models') je + WHERE lower(je.key) IN (SELECT lower(v) FROM M) AND json_type(je.value)='object') + ELSE (SELECT json_group_object(lower(je.key), json(je.value)) FROM json_each(pj) je + WHERE lower(je.key) IN (SELECT lower(v) FROM M) AND json_type(je.value)='object') + END, json('{}'))) + END FROM PJ), + 'total',(SELECT count(*) FROM model_usage), + 'covered',(SELECT count(*) FROM R), + 'gen',(SELECT datetime('now','localtime')), + 'rows',(SELECT json_group_array(json_array( + SI.i, R.started_at, MI.i, PI.i, + R.input_tokens, R.output_tokens+R.reasoning_tokens, R.cache_read_input_tokens, + R.duration_ms, + CASE R.status WHEN 'completed' THEN 0 WHEN 'error' THEN 1 WHEN 'cancelled' THEN 2 ELSE 3 END, + CASE R.query_source WHEN 'main_turn' THEN 0 WHEN 'subagent' THEN 1 ELSE 2 END, + R.cache_creation_input_tokens, R.time_to_first_token_ms, R.id, R.retry_count, ${turnExpr()})) + FROM R + LEFT JOIN S SI ON SI.v = R.session_id + LEFT JOIN M MI ON MI.v = R.model_id + LEFT JOIN P PI ON PI.v = R.provider_id));`; +} + +let metaCache = { at: 0, v: null }; +function dataJSON(limit) { + // -1 = 全部(LIMIT -1),0/空 = 默认,其余钳到 1-20000 + if (limit == null || Number(limit) === 0) limit = DEFAULT_LIMIT; + else if (Number(limit) > 0) limit = Math.max(1, Math.min(20000, Number(limit))); + if (metaCache.v && metaCache.v.limit === limit && Date.now() - metaCache.at < 15000) return metaCache.v.data; + const disk = { logs: duMB(path.join(os.homedir(), '.zcode/v2/logs')), rollout: duMB(path.join(os.homedir(), '.zcode/cli/rollout')), artifacts: duMB(path.join(os.homedir(), '.zcode/cli/artifacts')), db: duMB(DB) }; + // 全新环境表还不存在(ZCode 尚未写入):直接空数据,避免 META SQL 报 no such table + if (q1("SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('model_usage','session')") !== '2') { + return { providers: [], models: [], sessions: [], sessionDirs: [], tools: [], todos: [0, 0], stability: [0, 0, 0], hours: {}, compactions: [], sessionTools: [], disk, providerNames: {}, projectMap: {}, budgetDaily: 0, budgetMonthly: 0, prices: {}, total: 0, covered: 0, rows: [] }; + } + let dirsList = []; + try { dirsList = JSON.parse(q1(`SELECT COALESCE(json_group_array(d),'[]') FROM (SELECT DISTINCT se.directory AS d FROM model_usage u LEFT JOIN session se ON se.id=u.session_id WHERE coalesce(se.directory,'')<>'')`) || '[]'); } catch (e) {} + const bg = readBudgetCfg(); + const extra = JSON.stringify({ providerNames: vendorDisplayNames(), projectMap: buildProjectMap(dirsList), budgetDaily: Number(bg && bg.daily) || 0, budgetMonthly: Number(bg && bg.monthly) || 0 }); + const data = JSON.parse(q1(metaSQL(limit, disk, extra))); + metaCache = { at: Date.now(), v: { limit, data } }; + return data; +} + +let duCache = {}; +function duMB(dir) { + if (duCache[dir] && Date.now() - duCache[dir].at < 600000) return duCache[dir].v; + let v = 0; + try { v = parseInt(execDu(dir), 10) || 0; } catch (e) {} + duCache[dir] = { at: Date.now(), v }; + return v; +} +import { execFileSync } from 'node:child_process'; +function execDu(dir) { return execFileSync('du', ['-sm', dir], { encoding: 'utf8' }).split('\t')[0]; } + +// 时间范围 → [fromMs, toMs](today/yesterday/7d/30d/month/last_month/all 或 from/to ISO 日期) +function rangeToSpan(a) { + const now = new Date(); + const d0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const addDays = (d, n) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n); + let from = null, to = null; + const range = a && a.range || '7d'; + if (a && a.from) from = new Date(a.from + 'T00:00:00').getTime(); + if (a && a.to) to = new Date(a.to + 'T00:00:00').getTime() + 86400000; + if (from || to) return [from || 0, to || Date.now() + 86400000]; + switch (range) { + case 'today': return [d0.getTime(), Date.now()]; + case 'yesterday': return [addDays(d0, -1).getTime(), d0.getTime()]; + case '7d': return [addDays(d0, -6).getTime(), Date.now()]; + case '30d': return [addDays(d0, -29).getTime(), Date.now()]; + case 'month': return [new Date(now.getFullYear(), now.getMonth(), 1).getTime(), Date.now()]; + case 'last_month': return [new Date(now.getFullYear(), now.getMonth() - 1, 1).getTime(), new Date(now.getFullYear(), now.getMonth(), 1).getTime()]; + default: return [0, Date.now() + 86400000]; + } +} + +// 按时间范围聚合:KPI/每日/时段/来源/模型(rows 内存聚合,行来自全量 META 缓存) +function buildTrend(a) { + const [from, to] = rangeToSpan(a); + const D = dataJSON(-1); + const R = (D.rows || []).filter((r) => r[1] >= from && r[1] < to); + const MD = D.models || [], PV = D.providers || []; + let inn = 0, out = 0, ca = 0, genMs = 0, gen = 0; + const dayMap = {}, hourMap = {}, pvMap = {}, mdMap = {}; + const states = { completed: 0, error: 0, cancelled: 0 }; + for (const r of R) { + inn += r[4]; out += r[5]; ca += r[6]; + if (r[8] === 0) { states.completed++; gen += r[5]; genMs += Math.max(1, r[7] - (r[11] || 0)); } + else if (r[8] === 1) states.error++; else if (r[8] === 2) states.cancelled++; + const d = new Date(r[1]); + const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); + dayMap[key] = (dayMap[key] || 0) + r[4] + r[5]; + hourMap[d.getHours()] = (hourMap[d.getHours()] || 0) + 1; + pvMap[PV[r[3]] || '?'] = (pvMap[PV[r[3]] || '?'] || 0) + r[4] + r[5]; + mdMap[MD[r[2]] || '?'] = (mdMap[MD[r[2]] || '?'] || 0) + r[4] + r[5]; + } + const pct = (m) => Object.keys(m).map((k) => ({ name: k, tokens: m[k], pct: Math.round(m[k] / (inn + out) * 1000) / 10 })).sort((x, y) => y.tokens - x.tokens); + const dayKeys = Object.keys(dayMap).sort(); + return { + range: (a && a.range) || (a && (a.from || a.to) ? 'custom' : '7d'), + from: dayKeys[0] || null, to: dayKeys[dayKeys.length - 1] || null, + kpi: { requests: R.length, inTokens: inn, outTokens: out, cacheRead: ca, cacheRate: inn ? Math.round(ca / inn * 100) : null, avgTps: genMs ? Math.round(gen / genMs * 1000) : null, ...states }, + daily: dayKeys.map((k) => ({ day: k.slice(5), tokens: dayMap[k] })), + hours: Object.keys(hourMap).map((h) => ({ h: Number(h), req: hourMap[h] })).sort((x, y) => x.h - y.h), + providers: pct(pvMap).slice(0, 6), + models: pct(mdMap).slice(0, 8), + }; +} + +// 会话列表(真 session_id,按 token Top N,可按时间范围过滤) +function buildSessions(a) { + const [from, to] = rangeToSpan(a); + const rows = openDb().prepare(` + SELECT m.session_id AS sid, MAX(s.title) AS title, MAX(s.directory) AS dir, + COUNT(*) AS req, SUM(m.input_tokens) AS inn, + SUM(m.output_tokens + m.reasoning_tokens) AS out, + SUM(m.cache_read_input_tokens) AS ca, + MAX(m.started_at) AS last + FROM model_usage m LEFT JOIN session s ON s.id = m.session_id + WHERE m.started_at >= ? AND m.started_at < ? AND m.session_id IS NOT NULL AND m.session_id <> '' + GROUP BY m.session_id ORDER BY SUM(m.input_tokens + m.output_tokens + m.reasoning_tokens) DESC LIMIT 20 + `).all(from, to); + return rows.map((r) => ({ + id: r.sid, title: r.title || '(无标题)', dir: r.dir || null, + req: r.req, tokens: (r.inn || 0) + (r.out || 0), + cacheRate: r.inn ? Math.round((r.ca || 0) / r.inn * 100) : null, last: r.last, + })); +} + +// 单会话详情:元信息 + 聚合 + 最近 15 条请求 + 压缩/模型切换事件 +function buildSessionDetail(id) { + if (!id) return { error: '缺少会话 id' }; + const db = openDb(); + const meta = db.prepare(`SELECT id, title, directory, time_created FROM session WHERE id = ?`).get(id) || { id, title: '(无标题)', directory: null, time_created: null }; + const agg = db.prepare(` + SELECT COUNT(*) AS req, + COALESCE(SUM(input_tokens),0) AS inn, + COALESCE(SUM(output_tokens+reasoning_tokens),0) AS out, + COALESCE(SUM(cache_read_input_tokens),0) AS ca, + MIN(started_at) AS first, MAX(started_at) AS last, + SUM(CASE WHEN status='error' THEN 1 ELSE 0 END) AS errors + FROM model_usage WHERE session_id = ?`).get(id); + const perf = db.prepare(` + SELECT COALESCE(SUM(output_tokens+reasoning_tokens),0) AS gen, + COALESCE(SUM(max(duration_ms - COALESCE(time_to_first_token_ms, 0), 1)), 0) AS ms + FROM model_usage WHERE session_id = ? AND status='completed'`).get(id); + const models = db.prepare(`SELECT model_id AS name, SUM(input_tokens+output_tokens+reasoning_tokens) AS tokens FROM model_usage WHERE session_id = ? GROUP BY model_id ORDER BY tokens DESC`).all(id); + const providers = db.prepare(`SELECT provider_id AS name, COUNT(*) AS req FROM model_usage WHERE session_id = ? GROUP BY provider_id ORDER BY req DESC`).all(id); + const tools = db.prepare(`SELECT tool_name AS name, COUNT(*) AS req FROM tool_usage WHERE session_id = ? GROUP BY tool_name ORDER BY req DESC LIMIT 6`).all(id); + const compactions = db.prepare(`SELECT COUNT(*) AS cnt, MAX(COALESCE(json_extract(data,'$.preCompactTokenCount'),0)) AS pre FROM part WHERE session_id = ? AND json_extract(data,'$.type')='compaction'`).get(id); + const recent = db.prepare(` + SELECT datetime(started_at/1000,'unixepoch','localtime') AS t, model_id AS model, + input_tokens AS inTok, output_tokens+reasoning_tokens AS outTok, + duration_ms AS dur, time_to_first_token_ms AS ttft, + CASE status WHEN 'completed' THEN 0 WHEN 'error' THEN 1 WHEN 'cancelled' THEN 2 ELSE 3 END AS status + FROM model_usage WHERE session_id = ? ORDER BY started_at DESC LIMIT 15`).all(id); + let avgTps = null; + if (perf && perf.ms > 0) avgTps = Math.round(perf.gen / perf.ms * 1000); + return { + id, title: meta.title, dir: meta.directory, + created: meta.time_created, requests: agg.req, + inTokens: agg.inn, outTokens: agg.out, + cacheRate: agg.inn ? Math.round(agg.ca / agg.inn * 100) : null, + avgTps, first: agg.first, last: agg.last, errors: agg.errors || 0, + models, providers, tools, + compactions: compactions && compactions.cnt ? { cnt: compactions.cnt, pre: compactions.pre } : null, + recent, + }; +} + +// ---- 侧栏面板摘要:服务端预聚合(侧栏窄、结构化输出须 ≤64KiB,不做全行下发)---- +function buildSummary() { + const D = dataJSON(DEFAULT_LIMIT); + const R = D.rows || [], MD = D.models || [], PV = D.providers || [], TL = D.sessions || []; + const PVN = D.providerNames || {}; // BYOK 真实供应商名(provider_config.json,只取名映射) + const today0 = new Date(); today0.setHours(0, 0, 0, 0); + let inn = 0, out = 0, ca = 0, todayReq = 0; + const pvAgg = {}, mdAgg = {}; + const sessAgg = {}; + for (const r of R) { + inn += r[4]; out += r[5]; ca += r[6]; + if (r[1] >= today0.getTime()) todayReq++; + const pv = PVN[PV[r[3]]] || PV[r[3]] || '?', md = MD[r[2]] || '?'; + pvAgg[pv] = (pvAgg[pv] || 0) + r[4] + r[5]; + mdAgg[md] = (mdAgg[md] || 0) + r[4] + r[5]; + const s = sessAgg[r[0]] || (sessAgg[r[0]] = { title: TL[r[0]] || '(无标题)', req: 0, tot: 0, last: 0 }); + s.req++; s.tot += r[4] + r[5]; if (r[1] > s.last) s.last = r[1]; + } + const pct = (m) => Object.keys(m).map((k) => ({ name: k, tokens: m[k], pct: Math.round(m[k] / (inn + out) * 1000) / 10 })) + .sort((a, b) => b.tokens - a.tokens); + // 近 14 天每日 token(供面板 sparkline) + const dayMap = {}; + for (const r of R) { + const d = new Date(r[1]); + const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); + dayMap[key] = (dayMap[key] || 0) + r[4] + r[5]; + } + const daily = Object.keys(dayMap).sort().slice(-14).map((k) => ({ day: k.slice(5), tokens: dayMap[k] })); + const hourMap = D.hours || {}; + const hours = Object.keys(hourMap).map((h) => ({ h: Number(h), req: hourMap[h] })).sort((a, b) => a.h - b.h); + const failures = (D.stability || [0, 0, 0])[2] || 0; + const tools = (D.tools || []).slice(0, 5).map((t) => ({ name: String(t[0]).replace(/^mcp__/, ''), req: t[1] })); + const states = { completed: 0, error: 0, cancelled: 0 }; + for (const r of R) { if (r[8] === 0) states.completed++; else if (r[8] === 1) states.error++; else if (r[8] === 2) states.cancelled++; } + const topSess = Object.values(sessAgg).sort((a, b) => b.tot - a.tot).slice(0, 5); + // 当前 5h 窗口(与 usage.sh blocks 口径一致:R 按时间降序,从最新往旧,相邻间隔 ≥5h 即断窗) + let win = { req: 0, tok: 0, start: 0 }; + for (let i = 0; i < R.length; i++) { + if (i === 0) { win.start = R[0][1]; win.req = 1; win.tok = R[0][4] + R[0][5]; continue; } + if (R[i - 1][1] - R[i][1] >= 18000000) break; + win.req++; win.tok += R[i][4] + R[i][5]; win.start = R[i][1]; + } + // ⑤ 剩余请求估算:按最近 3 个完整窗口的平均请求数,推算当前窗口还可发起多少次 + const wins = []; + let cur = { req: 0 }; + for (let i = 0; i < R.length; i++) { + cur.req++; + if (i < R.length - 1 && R[i][1] - R[i + 1][1] >= 18000000) { wins.push(cur); cur = { req: 0 }; } + } + if (cur.req) wins.push(cur); + // R 按时间降序:wins[0] 是最新(进行中),其后是完整窗口 + const full = wins.slice(1, 4); + const avgReq = full.length ? Math.round(full.reduce((a, w) => a + w.req, 0) / full.length) : 0; + const windowEstimate = avgReq > 0 && win.start + ? { avgReq, basedOn: full.length, remainingReq: Math.max(0, avgReq - win.req) } + : null; + return { + kpi: { + covered: D.covered, total: D.total, gen: D.gen, + requests: inn + out ? R.length : 0, + inTokens: inn, outTokens: out, cacheRead: ca, + cacheRate: inn ? Math.round(ca / inn * 100) : null, + todayRequests: todayReq, + }, + providers: pct(pvAgg).slice(0, 5), + models: pct(mdAgg).slice(0, 6), + topSessions: topSess.map((s) => ({ title: s.title.slice(0, 40), req: s.req, tokens: s.tot, last: s.last })), + window: win, + windowEstimate, + daily, hours, tools, failures, states, + disk: D.disk || {}, + // 常驻面板实际地址:端口可能已顺延(usage-serve.json 是 serve 写下的事实源) + serveUrl: (() => { const m = servePidFile(); return 'http://127.0.0.1:' + (m && m.port ? m.port : 7741); })(), + }; +} + +function buildDetail(args) { + const limit = Math.max(1, Math.min(200, Number(args && args.limit) || 30)); + const offset = Math.max(0, Number(args && args.offset) || 0); + const status = args && args.status != null ? Number(args.status) : null; + const where = status != null && status >= 0 + ? `WHERE CASE status WHEN 'completed' THEN 0 WHEN 'error' THEN 1 WHEN 'cancelled' THEN 2 ELSE 3 END = ${status | 0} ` + : ''; + const rows = JSON.parse(q1(`SELECT json_group_array(json_array( + datetime(R.started_at/1000,'unixepoch','localtime'), + R.model_id, R.provider_id, + R.input_tokens, R.output_tokens+R.reasoning_tokens, R.cache_read_input_tokens, + R.cache_creation_input_tokens, R.duration_ms, R.time_to_first_token_ms, + CASE R.status WHEN 'completed' THEN 0 WHEN 'error' THEN 1 WHEN 'cancelled' THEN 2 ELSE 3 END, + COALESCE((SELECT title FROM session WHERE id = R.session_id),''))) + FROM (SELECT * FROM model_usage ${where} ORDER BY started_at DESC, id DESC LIMIT ${limit} OFFSET ${offset}) R`)); + return { offset, limit, rows: rows || [] }; +} + +// ---------- 最小 MCP stdio 协议 ---------- +const SERVER_INFO = { name: 'usage-detail-panel', version: '0.23.4' }; +// 完整版仪表盘作为侧栏页面(与 /usage-ui、serve 本地面板同源同界面,单一维护点): +// 调 usage.sh dashboard 生成自包含片段(默认全量历史),包上独立壳(主题变量+页签行为)。 +import { execFile } from 'node:child_process'; +const { SHELL_CSS, TAB_JS } = await import('../shell.mjs'); +const USAGE_SH = path.join(HERE, '..', 'usage.sh'); +import { providerNames as vendorDisplayNames, projectMap as buildProjectMap } from '../lib/project-meta.mjs'; + +// bash 可执行解析(Windows 的 Git for Windows 默认只把 git.exe 加入 PATH,bash.exe 常缺; +// 侧栏面板与价格同步都依赖 bash,按常见安装位置兜底查找)——与 serve.mjs 同逻辑 +let bashCmdCache; +function resolveBash() { + if (bashCmdCache) return bashCmdCache; + let cmd = 'bash'; + if (process.platform === 'win32') { + const candidates = [ + process.env.ZCODE_BASH, + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + path.join(os.homedir(), 'AppData', 'Local', 'Programs', 'Git', 'bin', 'bash.exe'), + ].filter(Boolean); + cmd = candidates.find((c) => { try { return fs.existsSync(c); } catch (e) { return false; } }) || 'bash'; + } + bashCmdCache = cmd; + return cmd; +} + +let dashCache = { at: 0, html: null }; +function getPanelHtml() { + return new Promise((resolve, reject) => { + if (dashCache.html && Date.now() - dashCache.at < 60000) return resolve(dashCache.html); + const gen = (limitEnv) => new Promise((res2, rej2) => { + // 唯一临时名:并发 resources/read 会同时起多个 gen,固定 pid 名会互相 unlink/读错文件 + const tmp = path.join(os.tmpdir(), 'usage-detail-panel-' + process.pid + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7) + '.html'); + execFile(resolveBash(), [USAGE_SH, 'dashboard', tmp], { timeout: 120000, env: { ...process.env, ZCODE_USAGE_LIMIT: limitEnv } }, (err) => { + if (err) return rej2(new Error(err && err.code === 'ENOENT' + ? '未找到 bash(Windows 需安装 Git for Windows,或设置 ZCODE_BASH 指向 bash.exe)' + : '生成仪表盘失败:' + (err.message || err))); + try { + const frag = fs.readFileSync(tmp, 'utf8'); + fs.unlinkSync(tmp); + res2(frag); + } catch (e) { rej2(e); } + }); + }); + const fullLimit = process.env.ZCODE_USAGE_LIMIT || '-1'; + gen(fullLimit).then((frag) => { + const finish = (f, notice) => { + const html = 'ZCode 用量面板' + (notice || '') + '' + f + '`; + } + return html; +} + + +// 独立页面样式壳:Gen UI 宿主不在浏览器里,工具类与主题变量由本壳提供(明暗随系统) +const { SHELL_CSS, TAB_JS } = await import('./shell.mjs'); + + +let frag = { html: null, limit: 0, at: 0 }; +function getFragment(limit) { + limit = clampLimit(limit); + if (!frag.html || frag.limit !== limit || Date.now() - frag.at > 30000) { + frag = { html: renderFragment(limit), limit, at: Date.now() }; + } + return frag.html; +} + +// 启动时价格表每日自动同步一次(与 dashboard 口径一致,可 env 关闭;尊重价格表 autoSync:false 的用户 opt-out) +try { + if (process.env.ZCODE_USAGE_NO_AUTOSYNC !== '1') { + let stale = !fs.existsSync(USER_PRICE); + if (!stale) stale = Date.now() - fs.statSync(USER_PRICE).mtimeMs >= 86400e3; + let autoOff = false; + try { autoOff = /"autoSync"[: ]*false/.test(stripBom(fs.readFileSync(USER_PRICE, 'utf8'))); } catch (e) {} + if (stale && !autoOff) { + spawn(resolveBash(), [USAGE_SH, 'prices', 'sync'], { detached: true, stdio: 'ignore' }).unref(); + } + } +} catch (e) {} + +// ---------- 预算 / 燃烧告警(macOS 本地通知,零模型开销;本服务常驻时生效)---------- +// 成本按模型均价折算(不含分时 tariff,告警用途足够);token = 输入 + 生成。 +// 开关 ~/.zcode/usage-alerts.json {"budget":true}(缺省开);当日提醒状态 ~/.zcode/usage-alerts-state.json +const ALERTS_FILE = path.join(os.homedir(), '.zcode', 'usage-alerts.json'); +const ALERTS_STATE = path.join(os.homedir(), '.zcode', 'usage-alerts-state.json'); +const nn = (v) => Number(v) || 0; +function alertsEnabled() { + try { return JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8')).budget !== false; } catch (e) { return true; } +} +function notifyMac(sub, msg) { + // 统一经 notify.mjs 分发(macOS / Windows / Linux),失败静默 + try { + spawn(process.execPath, [path.join(HERE, 'notify.mjs'), 'ZCode 用量', sub, msg], { detached: true, stdio: 'ignore' }).unref(); + } catch (e) {} +} +function alertOnce(key, sub, msg) { + const today = new Date().toISOString().slice(0, 10); + let st = {}; + try { st = JSON.parse(fs.readFileSync(ALERTS_STATE, 'utf8')); } catch (e) {} + if (st.date !== today) st = { date: today }; + if (st[key]) return; + st[key] = true; + try { fs.writeFileSync(ALERTS_STATE, JSON.stringify(st)); } catch (e) {} + notifyMac(sub, msg); +} +function fmtK(n) { return n >= 1e8 ? (n / 1e8).toFixed(2) + '亿' : n >= 1e4 ? (n / 1e4).toFixed(1) + '万' : String(Math.round(n)); } +function budgetWatchTick() { + try { + if (!alertsEnabled()) return; + let budget = { daily: 0, monthly: 0 }; + try { budget = readBudget() || budget; } catch (e) {} + const D = dataJSON(-1); + const t0 = new Date(); t0.setHours(0, 0, 0, 0); + const w0 = t0.getTime() - 7 * 86400000; + const PR = (D.prices && D.prices.models) || {}; + const sym = ((D.prices && D.prices.currency) || 'CNY') === 'CNY' ? '¥' : '$'; + let tok = 0, cost = 0, base = 0; + for (const r of (D.rows || [])) { // rows 按时间降序,越界即断 + if (r[1] < w0) break; + const t = nn(r[4]) + nn(r[5]); + if (r[1] >= t0.getTime()) { + tok += t; + const pj = PR[String(D.models[r[2]] || '').toLowerCase()]; + if (pj && Number(pj.in) >= 0) { + const miss = Math.max(0, nn(r[4]) - nn(r[6])); // input 含缓存命中,命中段按 cache 价(与前端 rowCost 同口径) + cost += (miss * nn(pj.in) + nn(r[5]) * nn(pj.out) + nn(r[6]) * nn(pj.cache)) / 1e6; + } + } else base += t; + } + const daily = nn(budget.daily); + if (daily > 0 && cost > 0) { + const pct = Math.round(cost / daily * 100); + if (pct >= 100) alertOnce('b100', '日预算已超出', '今日折算 ' + sym + cost.toFixed(2) + ',已超日预算 ' + sym + daily.toFixed(2)); + else if (pct >= 80) alertOnce('b80', '日预算 80%', '今日折算 ' + sym + cost.toFixed(2) + ' / ' + sym + daily.toFixed(2) + '(' + pct + '%)'); + } + const avg7 = base / 7; + if (avg7 > 0 && tok > avg7 * 1.5 && tok > 100000) { + alertOnce('burn', '今日用量偏高', fmtK(tok) + ' token,已达近 7 天日均的 ' + Math.round(tok / avg7 * 100) + '%'); + } + } catch (e) { /* 告警失败静默:不能影响常驻服务 */ } +} + +const server = http.createServer(async function (req, res) { + const u = new URL(req.url, 'http://127.0.0.1'); + try { + // 防 DNS rebinding 与跨站写(CSRF):Host 必须为本机;带 Origin 的写请求必须同源。 + // Origin 缺失(curl/地址栏导航)放行——浏览器发起的跨站 fetch 一定带 Origin。 + const host = String(req.headers.host || '').toLowerCase(); + if (host !== '127.0.0.1:' + listenPort && host !== 'localhost:' + listenPort) { + res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Forbidden'); + return; + } + if (req.method === 'POST') { + const origin = String(req.headers.origin || ''); + if (origin && origin !== 'http://127.0.0.1:' + listenPort && origin !== 'http://localhost:' + listenPort) { + res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Forbidden'); + return; + } + } + if (req.method === 'GET' && (u.pathname === '/' || u.pathname === '/index.html')) { + const limit = u.searchParams.get('limit'); + const fragHtml = getFragment(limit); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end('ZCode 用量面板' + fragHtml + '