From 0aeb1558effd2896946cd5c318a1ab9d67705d0d Mon Sep 17 00:00:00 2001 From: lixiaodong Date: Fri, 16 Oct 2026 15:24:21 +0800 Subject: [PATCH 1/2] feat: add usage-detail plugin 0.15.2 (local per-request usage analytics) Read-only local SQLite analytics for ZCode: per-request drill-down (token breakdown, tok/s, cache rate), live session side-panel dashboard with editable settings (prices/budget/notifications via app-only MCP tools), session health scores, model leaderboard, waste forensics, budget/burn alerts, list-price converted costs, activity heatmap (hourly/day/week/ month) and report export. Cross-platform (macOS/Windows/Linux), zero model overhead, network access only for optional models.dev price sync and optional official quota lookup (both off by default). UI Plugin: ui.surfaces (session side panel) + _meta.ui.resourceUri / visibility app tools. Includes icon, bilingual README, and marketplace entry (validated with scripts/validate.py: 47 plugins OK). --- assets/usage-detail/icon.png | Bin 0 -> 34889 bytes .../usage-detail/.zcode-plugin/plugin.json | 72 + external_plugins/usage-detail/LICENSE | 21 + external_plugins/usage-detail/README.md | 51 + external_plugins/usage-detail/README_CN.md | 232 +++ .../assets/session-dashboard.html | 73 + .../commands/commands/usage-this-ui.md | 11 + .../commands/commands/usage-this.md | 9 + .../commands/commands/usage-today.md | 9 + .../commands/commands/usage-ui.md | 13 + .../usage-detail/commands/commands/usage.md | 11 + .../usage-detail/commands/usage-this-ui.md | 11 + .../usage-detail/commands/usage-this.md | 9 + .../usage-detail/commands/usage-today.md | 9 + .../usage-detail/commands/usage-ui.md | 13 + .../usage-detail/commands/usage.md | 11 + .../usage-detail/hooks/hooks.json | 46 + .../usage-detail/hooks/hooks/hooks.json | 46 + .../hooks/hooks/permission-notify.mjs | 27 + .../hooks/hooks/prompt-submit.mjs | 55 + .../usage-detail/hooks/hooks/stop-notify.mjs | 37 + .../usage-detail/hooks/permission-notify.mjs | 27 + .../usage-detail/hooks/prompt-submit.mjs | 55 + .../usage-detail/hooks/stop-notify.mjs | 37 + .../usage-detail/scripts/default-prices.json | 97 + .../usage-detail/scripts/lib/cmd-cost.sh | 100 + .../usage-detail/scripts/lib/cmd-doctor.sh | 47 + .../usage-detail/scripts/lib/cmd-monitor.sh | 176 ++ .../usage-detail/scripts/lib/cmd-query.sh | 230 ++ .../usage-detail/scripts/lib/cmd-this.sh | 35 + .../usage-detail/scripts/lib/common.sh | 54 + .../usage-detail/scripts/lib/dashboard.sh | 1849 +++++++++++++++++ .../usage-detail/scripts/lib/session.sh | 44 + .../usage-detail/scripts/mcp/panel-server.mjs | 795 +++++++ .../usage-detail/scripts/notify.mjs | 32 + .../usage-detail/scripts/quota-log.mjs | 167 ++ .../usage-detail/scripts/quota.mjs | 105 + .../usage-detail/scripts/serve.mjs | 478 +++++ .../usage-detail/scripts/shell.mjs | 102 + .../usage-detail/scripts/sqlite.mjs | 23 + .../usage-detail/scripts/turn-stats.mjs | 88 + .../usage-detail/scripts/usage.sh | 87 + .../skills/skills/usage-detail/SKILL.md | 99 + .../usage-detail/skills/usage-detail/SKILL.md | 99 + marketplace.json | 87 +- 45 files changed, 5667 insertions(+), 12 deletions(-) create mode 100644 assets/usage-detail/icon.png create mode 100644 external_plugins/usage-detail/.zcode-plugin/plugin.json create mode 100644 external_plugins/usage-detail/LICENSE create mode 100644 external_plugins/usage-detail/README.md create mode 100644 external_plugins/usage-detail/README_CN.md create mode 100644 external_plugins/usage-detail/assets/session-dashboard.html create mode 100644 external_plugins/usage-detail/commands/commands/usage-this-ui.md create mode 100644 external_plugins/usage-detail/commands/commands/usage-this.md create mode 100644 external_plugins/usage-detail/commands/commands/usage-today.md create mode 100644 external_plugins/usage-detail/commands/commands/usage-ui.md create mode 100644 external_plugins/usage-detail/commands/commands/usage.md create mode 100644 external_plugins/usage-detail/commands/usage-this-ui.md create mode 100644 external_plugins/usage-detail/commands/usage-this.md create mode 100644 external_plugins/usage-detail/commands/usage-today.md create mode 100644 external_plugins/usage-detail/commands/usage-ui.md create mode 100644 external_plugins/usage-detail/commands/usage.md create mode 100644 external_plugins/usage-detail/hooks/hooks.json create mode 100644 external_plugins/usage-detail/hooks/hooks/hooks.json create mode 100644 external_plugins/usage-detail/hooks/hooks/permission-notify.mjs create mode 100644 external_plugins/usage-detail/hooks/hooks/prompt-submit.mjs create mode 100644 external_plugins/usage-detail/hooks/hooks/stop-notify.mjs create mode 100644 external_plugins/usage-detail/hooks/permission-notify.mjs create mode 100644 external_plugins/usage-detail/hooks/prompt-submit.mjs create mode 100644 external_plugins/usage-detail/hooks/stop-notify.mjs create mode 100644 external_plugins/usage-detail/scripts/default-prices.json create mode 100644 external_plugins/usage-detail/scripts/lib/cmd-cost.sh create mode 100644 external_plugins/usage-detail/scripts/lib/cmd-doctor.sh create mode 100644 external_plugins/usage-detail/scripts/lib/cmd-monitor.sh create mode 100644 external_plugins/usage-detail/scripts/lib/cmd-query.sh create mode 100644 external_plugins/usage-detail/scripts/lib/cmd-this.sh create mode 100644 external_plugins/usage-detail/scripts/lib/common.sh create mode 100644 external_plugins/usage-detail/scripts/lib/dashboard.sh create mode 100644 external_plugins/usage-detail/scripts/lib/session.sh create mode 100644 external_plugins/usage-detail/scripts/mcp/panel-server.mjs create mode 100644 external_plugins/usage-detail/scripts/notify.mjs create mode 100644 external_plugins/usage-detail/scripts/quota-log.mjs create mode 100755 external_plugins/usage-detail/scripts/quota.mjs create mode 100644 external_plugins/usage-detail/scripts/serve.mjs create mode 100644 external_plugins/usage-detail/scripts/shell.mjs create mode 100644 external_plugins/usage-detail/scripts/sqlite.mjs create mode 100644 external_plugins/usage-detail/scripts/turn-stats.mjs create mode 100755 external_plugins/usage-detail/scripts/usage.sh create mode 100644 external_plugins/usage-detail/skills/skills/usage-detail/SKILL.md create mode 100644 external_plugins/usage-detail/skills/usage-detail/SKILL.md diff --git a/assets/usage-detail/icon.png b/assets/usage-detail/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..18a5943d8654a63124e77157de16ccf26e7d1984 GIT binary patch literal 34889 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 **Platform support**: full on macOS; Windows and Linux cover all core capabilities (side panel / MCP / hooks / dashboard / CLI). The CLI needs bash (Git Bash on Windows) and Node >= 22.13 (bundled with ZCode); when the `sqlite3` CLI is absent it automatically falls back to a node:sqlite shim. System notifications use each platform's native channel and are silently skipped when unavailable. + +- **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`). +- **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. + +## 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. +- **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`. +- **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 official quota lookup at `open.bigmodel.cn` only if you configure an API key. 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..31b98cc1 --- /dev/null +++ b/external_plugins/usage-detail/README_CN.md @@ -0,0 +1,232 @@ +# ZCode 用量明细 (usage-detail) + +[English](#english) | 简体中文 + +一个 [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,无 sqlite3 时自动走 node 替身)与 Node ≥ 22.13(ZCode 自带);系统通知按平台原生通道(osascript / WinRT toast / notify-send),不可用时静默。 + +- **逐条明细**:每次模型请求一行——时间、会话、模型、来源、主会话/子代理、输入/缓存读/缓存写/生成/合计 token、耗时、首字延迟、**输出速率 tok/s**(六档变色)、状态、每行折算成本(API 牌价,可开关);分页浏览、点表头排序 +- **会话侧栏面板**(ZCode Desktop 3.15+):**与 `/usage-ui` 完整版同一份界面**(不是简化版)——六个标签页、热力图、趋势、每日明细、会话钻取、模型效率、请求分页/排序/钻取全部可用,明暗主题跟随宿主,由插件内置零依赖 MCP 服务按需生成(默认**内嵌全部历史**,插件设置里可自定义数据库路径与聚合条数)。图表悬停即时显示明细(含 token 与缓存率);时段分布支持「请求 / token」指标切换;明细分页支持页码直跳 +- **每轮用量实时卡**:每轮回合结束由本地脚本统计(零模型开销、不进上下文),经 MCP 资源订阅实时推送到侧栏面板顶部「⏱ 本轮」横幅;可选系统通知(macOS / Windows / Linux)或对话内统计行(`usage.sh turnstats`,默认仅侧栏卡) +- **预算 / 燃烧告警**(零模型开销):本地面板常驻时每分钟本地检查——日折算成本达日预算 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 / 缓存命中 / 生成速率)与逐月汇总表(含折算成本),点击某天直接切换为该日的请求日志 +- **官方配额(可选接入)**:默认不显示(官方口径看 ZCode 的 Usage Stats → Coding Plan);在插件设置里填入智谱开放平台 API Key(quotaToken)后,本地面板显示官方配额卡、MCP 工具 `usage_quota` 可查询;均未配置时如实提示,不做猜测 +- **折算成本(API 牌价,CNY)**:GLM 5.3 系内置智谱官网权威牌价(¥/百万 token,与官方定价页一致);其余模型从 [models.dev](https://models.dev) 全量同步并按 USD→CNY 汇率换算(`ZCODE_USAGE_USD_CNY` 可改);自定义条目与官方权威条目不会被同步覆盖。订阅套餐请求不按 token 计费,金额仅表示等量 API 用量的牌价价值。仪表盘有"折算成本"开关与"同步价格"按钮 +- **分时计价**:支持按请求本地时间取档的峰/谷/夜间价格(官方套餐高峰/低峰时段单价不同、其他模型同理),在价格表里加 `tariff` 字段即可生效: + + ```json + { "currency": "USD", + "models": { "glm-5.3": { "in": 1.4, "out": 4.4, "cache": 0.26, + "tariff": { "tz": 8, + "tiers": [ + { "hours": [[9, 12], [14, 18]], "mult": 1.0, "name": "峰" }, + { "hours": [[0, 8]], "mult": 0.5, "name": "谷" } ] } } } } + ``` + + 不配置 `tariff` 时按基础价计算,行为不变。 +- **套餐配额查询**(`quota`,CLI 可选):余量显示因账号/套餐类型差异较大(客户端凭据为加密托管,日志并非对所有套餐包含 5 小时池/周池观测),**界面里不再渲染配额卡**——套餐余量请直接查看 ZCode 的 **Usage Stats → Coding Plan**(官方口径最准)。本子命令保留给需要它的 API Key 用户:写入 `~/.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 口径:输入列含缓存命中(缓存读单独列出),生成 = 输出 + 思考 +- **套餐配额余量**(5 小时池 / 周额度)由服务端计量,不在本地库中:`/usage quota` 优先零密钥解析客户端日志里的额度观测;因账号/套餐类型而异,部分观测(如 5 小时池)可能为空,此时请以 ZCode 的 **Usage Stats → Coding Plan** 为准,或配置 API 凭据启用监控接口 + +## 已知限制 + +- 只统计**本机、本账号**在 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/commands/usage-this-ui.md b/external_plugins/usage-detail/commands/commands/usage-this-ui.md new file mode 100644 index 00000000..482f6dfe --- /dev/null +++ b/external_plugins/usage-detail/commands/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/commands/usage-this.md b/external_plugins/usage-detail/commands/commands/usage-this.md new file mode 100644 index 00000000..ccf14ab4 --- /dev/null +++ b/external_plugins/usage-detail/commands/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/commands/usage-today.md b/external_plugins/usage-detail/commands/commands/usage-today.md new file mode 100644 index 00000000..50fc9b7e --- /dev/null +++ b/external_plugins/usage-detail/commands/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/commands/usage-ui.md b/external_plugins/usage-detail/commands/commands/usage-ui.md new file mode 100644 index 00000000..4391bd9e --- /dev/null +++ b/external_plugins/usage-detail/commands/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/commands/usage.md b/external_plugins/usage-detail/commands/commands/usage.md new file mode 100644 index 00000000..c40ac030 --- /dev/null +++ b/external_plugins/usage-detail/commands/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/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/hooks/hooks.json b/external_plugins/usage-detail/hooks/hooks/hooks.json new file mode 100644 index 00000000..fed0655a --- /dev/null +++ b/external_plugins/usage-detail/hooks/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/hooks/permission-notify.mjs b/external_plugins/usage-detail/hooks/hooks/permission-notify.mjs new file mode 100644 index 00000000..d5561ee1 --- /dev/null +++ b/external_plugins/usage-detail/hooks/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(fs.readFileSync(path.join(os.homedir(), '.zcode', 'usage-alerts.json'), 'utf8')); + 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], + { detached: true, stdio: 'ignore' }).unref(); +} catch (e) {} +process.exit(0); diff --git a/external_plugins/usage-detail/hooks/hooks/prompt-submit.mjs b/external_plugins/usage-detail/hooks/hooks/prompt-submit.mjs new file mode 100644 index 00000000..2b4e200d --- /dev/null +++ b/external_plugins/usage-detail/hooks/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(fs.readFileSync(path.join(os.homedir(), '.zcode', 'usage-turnstats.json'), 'utf8')); + 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/hooks/stop-notify.mjs b/external_plugins/usage-detail/hooks/hooks/stop-notify.mjs new file mode 100644 index 00000000..3250237c --- /dev/null +++ b/external_plugins/usage-detail/hooks/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(fs.readFileSync(path.join(os.homedir(), '.zcode', 'usage-turnstats.json'), 'utf8')); + 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/hooks/permission-notify.mjs b/external_plugins/usage-detail/hooks/permission-notify.mjs new file mode 100644 index 00000000..d5561ee1 --- /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(fs.readFileSync(path.join(os.homedir(), '.zcode', 'usage-alerts.json'), 'utf8')); + 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], + { 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..2b4e200d --- /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(fs.readFileSync(path.join(os.homedir(), '.zcode', 'usage-turnstats.json'), 'utf8')); + 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..3250237c --- /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(fs.readFileSync(path.join(os.homedir(), '.zcode', 'usage-turnstats.json'), 'utf8')); + 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/scripts/default-prices.json b/external_plugins/usage-detail/scripts/default-prices.json new file mode 100644 index 00000000..9d54fbd1 --- /dev/null +++ b/external_plugins/usage-detail/scripts/default-prices.json @@ -0,0 +1,97 @@ +{ + "currency": "CNY", + "source": "models.dev", + "updated": "2026-10-15", + "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 + }, + "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 + }, + "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 + } + } +} \ No newline at end of file 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..dc5778af --- /dev/null +++ b/external_plugins/usage-detail/scripts/lib/cmd-cost.sh @@ -0,0 +1,100 @@ +# 成本估算与价格表子命令(由入口 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 CAST(readfile('$pf') AS text) 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 请求数, + round(sum(m.input_tokens)/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(m.input_tokens)/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 CAST(readfile('$pf') AS text) 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) + 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}" <<'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']; +// 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 价) + if (out.models[m] || PINNED.includes(m.toLowerCase())) continue; + out.models[m] = { in: (p.input || 0) * RATE, out: (p.output || 0) * RATE, cache: (p.cache_read || 0) * RATE }; + 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 + ' 换算' : '') + ';自定义与官方权威条目保留)'); +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..0486968a --- /dev/null +++ b/external_plugins/usage-detail/scripts/lib/cmd-monitor.sh @@ -0,0 +1,176 @@ +# 实时监控与本地服务子命令(由入口 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:-status}" act="${3:-}" + [[ -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 +} 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..5f8d10d3 --- /dev/null +++ b/external_plugins/usage-detail/scripts/lib/cmd-query.sh @@ -0,0 +1,230 @@ +# 只读查询子命令实现(由入口 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 WHERE status='completed';" + 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}" + sqlite3 -readonly -csv "$DB" " + 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}" + 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)) + 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..bd9752b3 --- /dev/null +++ b/external_plugins/usage-detail/scripts/lib/common.sh @@ -0,0 +1,54 @@ +# 公共基础:数据库访问、参数校验、价格表定位、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"; } +else + q() { sqlite3 -readonly -header -column "$DB" "$1"; } + # 输出原始单值(供 JSON 组装),不带表格格式化 + qr() { sqlite3 -readonly "$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 的替换表达式置于双引号内会保留多余反斜杠。 + 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..7ff3e8a6 --- /dev/null +++ b/external_plugins/usage-detail/scripts/lib/dashboard.sh @@ -0,0 +1,1849 @@ +# 交互式仪表盘(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 CAST(readfile($(sql_quote "$pf")) AS text) 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} + 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) + SELECT 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}'), + '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)) + 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));") + 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' + 提前闭合标签。 + printf '%s\n' "${META//"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); }; + function fmt(n) { n = N(n); if (n >= 1e8) return (n / 1e8).toFixed(2) + '亿'; if (n >= 1e4) return (n / 1e4).toFixed(1) + '万'; return String(Math.round(n)); } + function fmtI(n) { return N(n).toLocaleString('zh-CN'); } + function pad(n) { return n < 10 ? '0' + n : '' + n; } + function dayKey(ts) { var d = new Date(ts); return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); } + function fullTs(ts) { var d = new Date(ts); return dayKey(ts) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); } + + if (D.session) { + $('ud-session-title').textContent = '会话用量 · ' + D.session.title; + $('ud-session-last').textContent = '最近请求:' + (D.session.latest == null ? '暂无请求' : fullTs(D.session.latest)) + ' · 会话信息'; + $('ud-session-selection').textContent = '定位:' + D.session.selection; + $('ud-session-info').textContent = '会话 ID:' + D.session.id + '\n项目目录:' + (D.session.directory || '–') + + '\n创建:' + (D.session.created == null ? '–' : fullTs(D.session.created)) + + '\n仅统计该 session_id 的记录。'; + } + + var STAT = ['完成', '失败', '已取消', '进行中']; + var SCOLOR = ['var(--green)', 'var(--red)', 'var(--yellow)', 'var(--blue)']; + var QT = ['主会话', '子代理', '系统']; + // tok/s:生成 = 输出+思考;生成耗时 ≈ 总耗时 - 首字(首字缺失按总耗时近似) + function tpsOf(r) { + var gen = Math.max(1, N(r[7]) - (r[11] == null ? 0 : N(r[11]))); + return Math.round(N(r[5]) / gen * 1000); + } + function tpsHtml(t) { + var c = t >= 320 ? 'var(--viz-series-5)' : t >= 240 ? 'var(--red)' : t >= 160 ? 'var(--yellow)' : t >= 80 ? 'var(--green)' : 'var(--viz-series-1)'; + return '' + fmtI(t) + ''; + } + // 甜甜圈图:items=[{name, value, extra}],悬停显示名称/值/占比(点击不响应,纯展示) + var PALETTE = ['var(--viz-series-1)', 'var(--viz-series-2)', 'var(--viz-series-3)', 'var(--viz-series-4)', 'var(--viz-series-5)', 'var(--viz-series-6)']; + function donut(items, centerLabel) { + if (!items || !items.length) return '

暂无数据

'; + var total = 0; + items.forEach(function (it) { total += it.value; }); + if (!total) return '

暂无数据

'; + var R1 = 52, R0 = 30, CX = 60, CY = 60; + var a0 = -Math.PI / 2, paths = '', legend = ''; + items.slice(0, 6).forEach(function (it, i) { + var frac = it.value / total; + var a1 = a0 + frac * Math.PI * 2; + var large = frac > 0.5 ? 1 : 0; + var color = it.color || PALETTE[i % 6]; + var x0 = CX + R1 * Math.cos(a0), y0 = CY + R1 * Math.sin(a0); + var x1 = CX + R1 * Math.cos(a1), y1 = CY + R1 * Math.sin(a1); + var xi = CX + R0 * Math.cos(a1), yi = CY + R0 * Math.sin(a1); + var xj = CX + R0 * Math.cos(a0), yj = CY + R0 * Math.sin(a0); + var tipTxt = it.tip || (it.name + ' · ' + fmtI(it.value) + '(' + Math.round(frac * 100) + '%)'); + if (frac >= 0.999) { + paths += '' + esc(tipTxt) + ''; + } else { + paths += '' + esc(tipTxt) + ''; + } + legend += '
' + esc(it.name) + '' + Math.round(frac * 100) + '%
'; + a0 = a1; + }); + return '
' + + '' + paths + + '' + esc(centerLabel || '') + '' + + '' + esc(fmt(total)) + '' + + '
' + legend + '
'; + } + function stHtml(s) { return '● ' + STAT[s] + ''; } + + // 供应商:官方账号(account:*) / 自定义接入,按总 token 降序分配固定系列色 + var pvTot = PV.map(function (p, i) { var t = 0; R.forEach(function (r) { if (r[3] === i) t += N(r[4]) + N(r[5]); }); return { i: i, t: t }; }) + .sort(function (a, b) { return b.t - a.t; }); + var pvOrder = pvTot.map(function (x) { return x.i; }); + function pvColor(p) { var k = pvOrder.indexOf(p); return 'var(--viz-series-' + ((k % 6) + 1) + ')'; } + // 官方套餐 provider id → 官方中文名(GLM Coding Plan 档位命名见 bigmodel.cn/glm-coding);未知 id 与自定义来源保留原名 + var PV_CN = { + 'bigmodel-individual-coding-plan': 'GLM Coding Plan 个人版', + 'bigmodel-coding-plan': 'GLM Coding Plan', + 'bigmodel-lite-coding-plan': 'GLM Coding Plan Lite', + 'bigmodel-pro-coding-plan': 'GLM Coding Plan Pro', + 'bigmodel-max-coding-plan': 'GLM Coding Plan Max', + 'bigmodel-start-plan': 'GLM Coding Plan 入门版', + 'zai-start-plan': 'GLM Coding Plan 入门版' + }; + function pvLabel(p) { + var raw = String(PV[p] || '?'); + if (/^account:/.test(raw)) { + var id = raw.replace(/^account:/, ''); + return esc(PV_CN[id] || id); + } + return esc(raw); + } + function pvTag(p) { return /^account:/.test(String(PV[p] || '')) ? '官方账号' : '自定义接入'; } + + function priceOf(mi) { + if (!PMODELS) return null; + var name = String(MD[mi] || ''); + var p = PMODELS[name] || PMODELS[name.toLowerCase()]; + if (!p) { for (var k in PMODELS) { if (k.toLowerCase() === name.toLowerCase()) { p = PMODELS[k]; break; } } } + return p || null; + } + function tierMult(ts, t) { + if (!t || !t.tiers) return 1; + var d = new Date(ts); + var h = d.getHours(); + if (typeof t.tz === 'number') h = ((d.getUTCHours() + t.tz) % 24 + 24) % 24; + for (var i = 0; i < t.tiers.length; i++) { + var tier = t.tiers[i] || {}; + var ws = tier.hours || []; + for (var j = 0; j < ws.length; j++) { + var b = ws[j][0], e = ws[j][1]; + if (b <= e ? (h >= b && h < e) : (h >= b || h < e)) return tier.mult == null ? 1 : tier.mult; + } + } + return 1; + } + function rowCost(r) { + var p = priceOf(r[2]); + if (!p) return null; + var base = N(r[4]) / 1e6 * N(p.in) + N(r[5]) / 1e6 * N(p.out) + N(r[6]) / 1e6 * N(p.cache); + return base * tierMult(r[1], p.tariff); + } + function fmtMB(mb) { return mb >= 1024 ? (mb / 1024).toFixed(1) + ' GB' : N(mb) + ' MB'; } + function fmtCost(c) { return c == null ? '–' : CURSYM + (c >= 100 ? Math.round(c).toLocaleString('zh-CN') : c.toFixed(2)); } + // 列表用短格式:小数字原样、大数 k/M,悬停(data-tip 即时层)看完整值;fmt() 的万/亿保留给卡片与图表 + function fmtK(n) { + n = N(n); + if (n < 1000) return String(Math.round(n)); + if (n < 1e6) return (n / 1000).toFixed(n < 10000 ? 2 : 1) + 'k'; + return (n / 1e6).toFixed(2) + 'M'; + } + function tokCell(n) { n = N(n); return '' + fmtK(n) + ''; } + function fmtDur(ms) { + ms = N(ms); + if (ms <= 0) return '0 ms'; + if (ms < 1000) return String(Math.round(ms)) + ' ms'; + if (ms < 60000) return (ms / 1000).toFixed(1) + ' s'; + var m = Math.floor(ms / 60000), s = Math.round((ms % 60000) / 1000); + return m + 'm ' + (s < 10 ? '0' : '') + s + 's'; + } + function durCell(ms) { ms = N(ms); return '' + fmtDur(ms) + ''; } + + var maxTs = 0; + R.forEach(function (r) { if (r[1] > maxTs) maxTs = r[1]; }); + var cmpMap = {}, toolsMap = {}; + (D.compactions || []).forEach(function (c) { cmpMap[c[0]] = { cnt: c[1], pre: c[2], ts: c[3] || [] }; }); + (D.sessionTools || []).forEach(function (t) { (toolsMap[t[0]] || (toolsMap[t[0]] = [])).push([t[1], t[2], t[3]]); }); + + var st = { p: -1, m: -1, s: -1, rng: 'today', day: null, hidePv: {} }; + var sortS = { k: 4, d: -1 }, sortD = { k: 0, d: -1 }; + var page = 1, pageSize = 50, sessShown = 50, detailStat = -1, curMetric = 'req', costOn = false; + var heatMode = 'hour', heatLastW = -1, sessExp = -1, detExp = -1; + + function cutOf() { + if (st.rng === 'all') return 0; + if (st.rng === 'today') { var d = new Date(); d.setHours(0, 0, 0, 0); return d.getTime(); } + return maxTs - (st.rng === '7' ? 7 : 30) * 864e5; + } + function frows(ignorePv) { + var cut = cutOf(); + return R.filter(function (r) { + if (!ignorePv && st.p >= 0 && r[3] !== st.p) return false; + if (st.m >= 0 && r[2] !== st.m) return false; + if (st.s >= 0 && r[0] !== st.s) return false; + if (st.day && dayKey(r[1]) !== st.day) return false; + if (cut && r[1] < cut) return false; + return true; + }); + } + function agg(rows) { + var a = { req: rows.length, inn: 0, out: 0, ca: 0, ccw: 0, dur: 0, sub: 0, sids: {} }; + rows.forEach(function (r) { + a.inn += N(r[4]); a.out += N(r[5]); a.ca += N(r[6]); a.ccw += N(r[10]); a.dur += N(r[7]); + if (r[9] === 1) a.sub++; + a.sids[r[0]] = 1; + }); + a.sess = 0; for (var k in a.sids) a.sess++; + a.tok = a.inn + a.out; + return a; + } + function dayList(rows) { + var s = {}; + rows.forEach(function (r) { s[dayKey(r[1])] = 1; }); + return Object.keys(s).sort(); + } + + function renderSession(rows) { + var a = agg(rows), states = [0, 0, 0, 0]; + rows.forEach(function (r) { states[r[8]]++; }); + $('ud-k1').textContent = fmtI(a.req); + $('ud-k1c').textContent = '完成 ' + fmtI(states[0]) + (states[3] ? ' · 进行中 ' + fmtI(states[3]) : ''); + $('ud-k2').textContent = fmt(a.inn); + $('ud-k3').textContent = fmt(a.out); + $('ud-k4').textContent = fmt(a.ca); + $('ud-k4c').textContent = '缓存命中率 ' + (a.inn ? Math.round(a.ca / a.inn * 100) + '%' : '–'); + $('ud-meta').textContent = '本地快照 ' + (D.gen || '') + ' · 已加载 ' + fmtI(D.covered) + ' / ' + fmtI(D.total) + ' 条' + + (D.covered < D.total ? ',统计仅含已加载记录' : ''); + var failures = states[1] + states[2]; + $('ud-failures').hidden = failures === 0; + $('ud-failures').textContent = '查看失败 / 取消(' + fmtI(failures) + ')'; + $('ud-request-count').textContent = fmtI(rows.length) + ' 条'; + + var groups = {}, models = {}; + rows.forEach(function (r) { + var key = r[2] + ':' + r[3]; + var g = groups[key] || (groups[key] = { m: r[2], p: r[3], req: 0, inn: 0, out: 0, ca: 0 }); + g.req++; g.inn += N(r[4]); g.out += N(r[5]); g.ca += N(r[6]); + models[r[2]] = true; + }); + $('ud-model-count').textContent = Object.keys(models).length + ' 个模型'; + var list = Object.keys(groups).map(function (key) { return groups[key]; }) + .sort(function (a, b) { return (b.inn + b.out) - (a.inn + a.out); }); + $('ud-session-models').innerHTML = list.length ? list.map(function (g) { + return '' + esc(MD[g.m]) + '' + pvLabel(g.p) + '' + fmtI(g.req) + + '' + fmtI(g.inn) + '' + fmtI(g.out) + + '' + fmtI(g.ca) + ''; + }).join('') : '该会话暂无模型请求'; + } + + function renderKpis(rows) { + var a = agg(rows); + var dcount = dayList(rows).length || 1; + $('ud-k1').textContent = fmtI(a.req); + $('ud-k1c').textContent = '日均 ' + fmt(a.req / dcount) + ' · 覆盖 ' + dcount + ' 天'; + $('ud-k2').textContent = fmt(a.inn); + $('ud-k2c').textContent = '缓存读 ' + fmt(a.ca) + ' · 缓存写 ' + fmt(a.ccw); + $('ud-k3').textContent = fmt(a.out); + $('ud-k3c').textContent = '输出 + 思考 token'; + $('ud-k4').textContent = fmtI(a.sess); + $('ud-k4c').textContent = a.sub ? '含子代理请求 ' + fmtI(a.sub) + ' 次' : '合计 ' + fmt(a.tok) + ' token'; + $('ud-meta').textContent = '数据截至 ' + (D.gen || '') + ' · 已加载最近 ' + fmtI(D.covered) + ' / ' + fmtI(D.total) + ' 条 · 仅统计本机'; + } + + function renderInsights(rows) { + var a = agg(rows); + var days = dayList(rows); + // 连续天数:当前连续(今天/昨天截止)与最长连续 + var cur = 0, longest = 0, run = 0; + var dayMs = days.map(function (k) { return new Date(k + 'T00:00:00').getTime(); }); + var lastMs = dayMs.length ? dayMs[dayMs.length - 1] : 0; + var todayMs = new Date(new Date().toDateString()).getTime(); + for (var i = 0; i < dayMs.length; i++) { + run = (i > 0 && dayMs[i] - dayMs[i - 1] === 864e5) ? run + 1 : 1; + if (run > longest) longest = run; + } + cur = lastMs && (todayMs - lastMs <= 864e5) ? run : 0; + var peak = null, peakV = -1; + var perDay = {}; + rows.forEach(function (r) { var k = dayKey(r[1]); perDay[k] = (perDay[k] || 0) + N(r[4]) + N(r[5]); }); + for (var k in perDay) { if (perDay[k] > peakV) { peakV = perDay[k]; peak = k; } } + var hit = a.inn ? Math.round(a.ca / a.inn * 100) : 0; + // 内容卡片:accent 按信息类型固定(蓝=活跃/耗时,绿=缓存/任务,黄=重试,红=浪费,紫=峰值/拆分,橙=成本,灰=体量) + var C = { blue: 'var(--blue)', green: 'var(--green)', yellow: 'var(--yellow)', red: 'var(--red)', purple: '#8b5cf6', gold: 'var(--viz-series-2,var(--yellow))', gray: 'var(--muted-foreground)' }; + function card(v, l, ac, s) { + return '
' + v + '
' + + (s ? '
' + s + '
' : '') + '
' + l + '
'; + } + var out = ''; + out += card(days.length + ' 天', '活跃天数', 'blue'); + out += card(cur + ' 天', '连续活跃', 'blue', '最长 ' + longest + ' 天'); + out += card(peak ? peak.slice(5) : '–', '峰值日', 'purple', peak ? fmt(peakV) + ' token' : ''); + out += card(hit + '%', '缓存命中率', 'green', '缓存读 ' + fmt(a.ca) + ' token'); + out += card(fmt(a.ca + a.ccw), '缓存量(读+写)', 'green'); + out += card(D.todos && D.todos[1] > 0 ? N(D.todos[0]) + '/' + D.todos[1] : '–', '任务完成', 'green', + D.todos && D.todos[1] > 0 ? Math.round(N(D.todos[0]) / D.todos[1] * 100) + '% 完成' : ''); + out += card(D.stability ? fmtI(D.stability[0]) + ' / ' + fmtI(D.stability[1]) : '–', '重试 / 取消', 'yellow'); + out += card(a.req ? (a.dur / a.req / 1000).toFixed(1) + ' s' : '–', '平均耗时', 'blue'); + out += card(fmtI(a.req - a.sub) + ' / ' + fmtI(a.sub), '主会话 / 子代理', 'purple', fmtI(a.req - a.sub) + ' 主 · ' + fmtI(a.sub) + ' 子代理'); + if (D.hours) { + var pk = -1, pv2 = -1; + for (var hh = 0; hh < 24; hh++) { var kk2 = (hh < 10 ? '0' : '') + hh; var c2 = N(D.hours[kk2]); if (c2 > pv2) { pv2 = c2; pk = hh; } } + if (pk >= 0) out += card(pk + ' 点', '峰值时段', 'blue', fmtI(pv2) + ' 次请求'); + } + if (D.disk && D.disk.logs) out += card(fmtMB(D.disk.logs), '数据体量', 'gray', '导出 ' + fmtMB(D.disk.rollout) + ' · 库 ' + fmtMB(D.disk.db)); + var totalCost = 0, anyCost = false, unpricedTok = 0; + rows.forEach(function (r) { var c = rowCost(r); if (c != null) { totalCost += c; anyCost = true; } else unpricedTok += N(r[4]) + N(r[5]); }); + if (anyCost) { + var aTok = a.inn + a.out; + out += card(fmtCost(totalCost), '折算成本(API 牌价)', 'gold', unpricedTok > 0 && aTok ? '未定价 ' + Math.round(unpricedTok / aTok * 100) + '%' : ''); + } + // 浪费取证:失败/取消请求白耗的 token(本地口径,CLI 详单见 usage.sh waste) + (function () { + var wReq = 0, wTok = 0; + rows.forEach(function (r) { if (r[8] === 1 || r[8] === 2) { wReq++; wTok += N(r[4]) + N(r[5]); } }); + if (wReq > 0) out += card(fmt(wTok) + ' token', '浪费取证', 'red', fmtI(wReq) + ' 次失败/取消'); + })(); + // 本地预算:日 / 30 天折算金额进度(价格/预算对话框里设置) + if (st.budget && (N(st.budget.daily) > 0 || N(st.budget.monthly) > 0)) { + var nowD = new Date(); + var dayStart = new Date(nowD.toDateString()).getTime(); + var mStart = new Date(nowD.getFullYear(), nowD.getMonth(), 1).getTime(); + var dayCost = 0, mCost = 0; + rows.forEach(function (r) { var c = rowCost(r); if (c == null) return; if (r[1] >= dayStart) dayCost += c; if (r[1] >= mStart) mCost += c; }); + var bd = N(st.budget.daily), bm = N(st.budget.monthly); + function budgetCard(v, tot, pd, l) { + return '
' + v + + ' / ' + fmtCost(tot) + '
' + + '
' + + '
' + l + '
'; + } + if (bd > 0) out += budgetCard(fmtCost(dayCost), bd, Math.min(100, Math.round(dayCost / bd * 100)), '今日折算 / 日预算'); + if (bm > 0) out += budgetCard(fmtCost(mCost), bm, Math.min(100, Math.round(mCost / bm * 100)), '本月折算 / 月预算'); + } + $('ud-insights').innerHTML = out; + } + + // 健康度卡片:性能与稳定性体检(全部本地聚合) + function renderHealth(rows) { + var host = $('ud-health'); + if (!host) return; + var a = agg(rows); + var CB = { blue: 'var(--blue)', green: 'var(--green)', yellow: 'var(--yellow)', red: 'var(--red)', purple: '#8b5cf6', gray: 'var(--muted-foreground)' }; + function hcard(v, l, ac, s) { + return '
' + v + '
' + + (s ? '
' + s + '
' : '') + '
' + l + '
'; + } + var out = ''; + var ttft = 0, tn = 0, genTok = 0, genMs = 0; + rows.forEach(function (r) { var t = N(r[11]); if (t > 0) { ttft += t; tn++; } var d = N(r[7]) - t; if (d > 0) { genMs += d; genTok += N(r[5]); } }); + if (tn) out += hcard((ttft / tn / 1000).toFixed(2) + ' s', '平均首字', 'yellow', '到首个 token 的延迟'); + if (genMs > 0) out += hcard(fmtI(Math.round(genTok / (genMs / 1000))) + ' tok/s', '生成速率', 'blue'); + if (D.stability && a) { + var errPct = a.req ? Math.round(D.stability[2] / a.req * 1000) / 10 : 0; + out += hcard(errPct + '%', '错误率', 'red', '失败请求 / 全部请求'); + } + var peakCtx = 0; + (D.compactions || []).forEach(function (c) { if (N(c[2]) > peakCtx) peakCtx = N(c[2]); }); + if (peakCtx > 0) out += hcard(fmt(peakCtx), '峰值上下文', 'purple', '压缩前最高 token'); + var toolMs = 0, toolC = 0; + (D.sessionTools || []).forEach(function (t) { toolC += N(t[2]); toolMs += N(t[3]); }); + if (toolC) out += hcard(fmtI(toolC) + ' 次', '工具调用', 'gray', '累计 ' + (toolMs / 1000).toFixed(0) + ' s'); + host.innerHTML = out; + } + + function renderProviders(rows) { + var per = PV.map(function (p, i) { return { i: i, req: 0, tok: 0, inn: 0, ca: 0, cost: 0, hasCost: false }; }); + rows.forEach(function (r) { var o = per[r[3]]; o.req++; o.tok += N(r[4]) + N(r[5]); o.inn += N(r[4]); o.ca += N(r[6]); var c = rowCost(r); if (c != null) { o.cost += c; o.hasCost = true; } }); + var tot = per.reduce(function (a, x) { return a + x.tok; }, 0); + var act = per.filter(function (x) { return x.req > 0; }).sort(function (a, b) { return b.tok - a.tok; }); + if (!act.length) { $('ud-pv-stack').innerHTML = '

当前筛选下暂无数据

'; $('ud-pv-rows').innerHTML = ''; return; } + $('ud-pv-stack').innerHTML = donut(act.slice(0, 6).map(function (x) { + return { name: pvLabel(x.i), value: x.tok, color: pvColor(x.i), tip: pvLabel(x.i) + ' · ' + fmtI(x.req) + ' 次请求 · ' + fmt(x.tok) + ' tok · 缓存 ' + (x.inn ? Math.round(x.ca / x.inn * 100) : 0) + '%' + (costOn && x.hasCost ? ' · ' + fmtCost(x.cost) : '') }; + }), 'token'); + $('ud-pv-rows').innerHTML = act.map(function (x) { + return '
' + + '' + pvLabel(x.i) + '' + pvTag(x.i) + '' + + '' + fmtI(x.req) + ' 次 · ' + fmt(x.tok) + ' token · ' + (tot ? (x.tok / tot * 100).toFixed(1) : 0) + '% · 缓存 ' + (x.inn ? Math.round(x.ca / x.inn * 100) : 0) + '%' + (costOn && x.hasCost ? ' · ' + fmtCost(x.cost) : '') + '
'; + }).join(''); + } + + function renderModels(rows) { + var per = {}; + rows.forEach(function (r) { + var o = per[r[2]] || (per[r[2]] = { req: 0, tok: 0, inn: 0, ca: 0 }); + o.req++; o.tok += N(r[4]) + N(r[5]); o.inn += N(r[4]); o.ca += N(r[6]); + }); + var list = Object.keys(per).map(function (m) { return { m: m, req: per[m].req, tok: per[m].tok, hit: per[m].inn ? Math.round(per[m].ca / per[m].inn * 100) : 0 }; }) + .sort(function (a, b) { return b.tok - a.tok; }).slice(0, 8); + var tot = list.reduce(function (a, x) { return a + x.tok; }, 0); + $('ud-model-bars').innerHTML = list.length ? donut(list.slice(0, 6).map(function (x) { + return { name: String(MD[x.m]), value: x.tok, tip: MD[x.m] + ' · ' + fmtI(x.req) + ' 次请求 · ' + fmt(x.tok) + ' tok · 缓存 ' + x.hit + '%' }; + }), 'token') + list.map(function (x) { + var p = tot ? Math.round(x.tok / tot * 100) : 0; + return '
' + esc(MD[x.m]) + '' + fmtI(x.req) + ' 次 · ' + fmt(x.tok) + ' token · ' + p + '% · 缓存 ' + x.hit + '%
'; + }).join('') : '

当前筛选下暂无数据

'; + } + + function sessAgg(rows) { + var per = {}; + rows.forEach(function (r) { + var o = per[r[0]] || (per[r[0]] = { tl: TL[r[0]] || '', req: 0, inn: 0, out: 0, ca: 0, sub: 0, last: 0, cost: 0, hasCost: false, err: 0, cancel: 0, retry: 0 }); + o.req++; o.inn += N(r[4]); o.out += N(r[5]); o.ca += N(r[6]); if (r[1] > o.last) o.last = r[1]; + if (r[9] === 1) o.sub++; + if (r[8] === 1) o.err++; else if (r[8] === 2) o.cancel++; + o.retry += N(r[13]); + var c = rowCost(r); if (c != null) { o.cost += c; o.hasCost = true; } + }); + return Object.keys(per).map(function (g) { + var o = per[g]; o.g = +g; o.tot = o.inn + o.out; + o.rate = o.inn ? Math.round(o.ca / o.inn * 100) : 0; + // 健康分:100 − 失败率×60 − 取消率×25 − 每请求重试率×10(封顶 15),可解释的纯本地加权 + o.health = Math.max(0, Math.min(100, Math.round(100 - + (o.req ? o.err / o.req * 60 : 0) - (o.req ? o.cancel / o.req * 25 : 0) - + Math.min(15, o.req ? o.retry / o.req * 10 : 0)))); + return o; + }); + } + function sessTitle(o) { return o.tl ? esc(o.tl) : '(无标题)'; } + + function renderTopSessions(rows) { + var list = sessAgg(rows).sort(function (a, b) { return b.tot - a.tot; }).slice(0, 5); + $('ud-top-sessions').innerHTML = list.length ? '
' + + list.map(function (o) { + return ''; + }).join('') + '
会话请求数token 合计最后活动
' + sessTitle(o) + '' + fmtI(o.req) + '' + tokCell(o.tot) + '' + fullTs(o.last) + '
' : '

当前筛选下暂无数据

'; + var donutEl = $('ud-tools-donut'); + if (donutEl) { + donutEl.innerHTML = donut((D.tools || []).slice(0, 6).map(function (t) { + return { name: String(t[0]).replace(/^mcp__/, ''), value: t[1], tip: String(t[0]).replace(/^mcp__/, '') + ' · ' + fmtI(t[1]) + ' 次调用' + (t[3] ? ' · 均 ' + fmtI(t[3]) + ' ms' : '') + (t[2] ? ' · 失败 ' + t[2] : '') }; + }), '调用'); + } + Array.prototype.forEach.call($('ud-top-sessions').querySelectorAll('tr.ud-click'), function (tr) { + tr.addEventListener('click', function () { + sessExp = +tr.getAttribute('data-sg'); + var btn = document.querySelector('.nav-pills [role=tab][aria-controls="ud-sessions"]'); + if (btn) btn.click(); + renderSessions(frows(false)); + var host = $('ud-sessions'); + if (host) host.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + }); + } + + var hourMetric = 'req'; + function renderHours(rows) { + var host = $('ud-hours'); + if (!host) return; + var w = host.clientWidth; + if (!w) return; + // 逐小时聚合:请求数 / token(输入+生成)/ 缓存率(与热力图同口径,随全局筛选) + var H = D.hours || {}; + var reqArr = [], tokArr = [], caArr = [], innArr = []; + for (var i = 0; i < 24; i++) { var kk = (i < 10 ? '0' : '') + i; reqArr.push(N(H[kk])); tokArr.push(0); caArr.push(0); innArr.push(0); } + rows.forEach(function (r) { + var hr = new Date(r[1]).getHours(); + tokArr[hr] += N(r[4]) + N(r[5]); + caArr[hr] += N(r[6]); + innArr[hr] += N(r[4]); + }); + var arr = hourMetric === 'tok' ? tokArr : reqArr; + var peak = 0; + for (var i2 = 1; i2 < 24; i2++) if (arr[i2] > arr[peak]) peak = i2; + var max = Math.max.apply(null, arr) || 1; + var cw = Math.floor((w - 4) / 24); + var bw = Math.max(3, cw - 3); + var Hh = 92; + var out = ''; + for (var i3 = 0; i3 < 24; i3++) { + var bh = Math.round((Hh - 20) * arr[i3] / max); + var x = 2 + i3 * cw; + var cr = innArr[i3] ? Math.round(caArr[i3] / innArr[i3] * 100) + '%' : '–'; + out += '' + i3 + ':00–' + i3 + ':59 · ' + fmtI(reqArr[i3]) + ' 次请求 · ' + fmt(tokArr[i3]) + ' tok · 缓存 ' + cr + ''; + if (i3 % 3 === 0) out += '' + i3 + ''; + } + host.innerHTML = '峰值 ' + peak + ' 点' + out + ''; + var note = $('ud-hours-note'); + if (note) note.textContent = hourMetric === 'tok' ? '峰值 ' + peak + ' 点 · ' + fmt(arr[peak]) + ' tok' : '峰值 ' + peak + ' 点 · ' + fmtI(arr[peak]) + ' 次请求'; + } + function wireHoursToggle() { + var wrap = $('ud-hours-toggle'); + if (!wrap) return; + Array.prototype.forEach.call(wrap.querySelectorAll('.ud-toggle'), function (b) { + b.addEventListener('click', function () { + hourMetric = b.dataset.m; + Array.prototype.forEach.call(wrap.querySelectorAll('.ud-toggle'), function (x) { x.setAttribute('aria-pressed', String(x === b)); }); + renderHours(frows(false)); + }); + }); + } + function renderHeatmap(rows) { + var host = $('ud-heatmap'); + if (!host) return; + var w = host.clientWidth; + if (!w) return; + var per = {}; + rows.forEach(function (r) { var k = dayKey(r[1]); var o = per[k] || (per[k] = { req: 0, tok: 0 }); o.req++; o.tok += N(r[4]) + N(r[5]); }); + var days = Object.keys(per).sort(); + if (!days.length) { host.innerHTML = '

当前筛选下暂无数据

'; heatLastW = w; return; } + var start = new Date(days[0] + 'T00:00:00'), end = new Date(days[days.length - 1] + 'T00:00:00'); + var cells = []; + for (var d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + var k = dayKey(d.getTime()); + cells.push({ k: k, v: per[k] ? per[k].tok : 0, req: per[k] ? per[k].req : 0 }); + } + var gap = 3, L = 2, T = 2; + var out = ''; + function shade(v, qs) { + if (v <= 0) return { fill: 'var(--muted)', op: '' }; + var f = 'var(--viz-series-1)'; + if (v <= qs[0]) return { fill: f, op: ' fill-opacity="0.35"' }; + if (v <= qs[1]) return { fill: f, op: ' fill-opacity="0.6"' }; + if (v <= qs[2]) return { fill: f, op: ' fill-opacity="0.85"' }; + return { fill: f, op: '' }; + } + function rect(x, y, cw, ch, st2, tip) { + return '' + esc(tip) + ''; + } + if (heatMode === 'hour') { + // 近 7 天 × 24 小时矩阵:行=天、列=小时,悬停看该小时请求/token + // 近 7 天 = 今天回推 7 个自然日(不从数据取日期,停用日也要成行) + var dayKeys7 = []; + for (var di7 = 6; di7 >= 0; di7--) dayKeys7.push(dayKey(Date.now() - di7 * 86400000)); + var daySet = {}; + dayKeys7.forEach(function (k) { daySet[k] = 1; }); + var cells7 = dayKeys7.map(function (k) { var arr = []; for (var i = 0; i < 24; i++) arr.push({ tok: 0, req: 0 }); return { k: k, h: arr }; }); + var idx7 = {}; + dayKeys7.forEach(function (k, i) { idx7[k] = i; }); + rows.forEach(function (r) { + var k = dayKey(r[1]); + if (!daySet[k]) return; + var c = cells7[idx7[k]].h[new Date(r[1]).getHours()]; + c.tok += N(r[4]) + N(r[5]); c.req++; + }); + var hv = []; + cells7.forEach(function (c) { c.h.forEach(function (x) { if (x.tok > 0) hv.push(x.tok); }); }); + hv.sort(function (a, b) { return a - b; }); + var hq = [0.25, 0.5, 0.75].map(function (pp) { return hv.length ? hv[Math.min(hv.length - 1, Math.floor(hv.length * pp))] : 1; }); + var CW = 16, CG = 2, LX = 46, TY = 20; + var W4 = LX + 24 * (CW + CG) + 4, H4 = TY + 7 * (CW + CG) + 6; + var out4 = ''; + [0, 6, 12, 18].forEach(function (hh) { out4 += '' + hh + '时'; }); + cells7.forEach(function (c, di) { + var lbl = c.k.slice(5) + ' ' + '日一二三四五六'[new Date(c.k + 'T00:00:00').getDay()]; + out4 += '' + lbl + ''; + for (var hh = 0; hh < 24; hh++) { + var x = c.h[hh]; + out4 += rect(LX + hh * (CW + CG), TY + di * (CW + CG), CW, CW, shade(x.tok, hq), + c.k + ' ' + (hh < 10 ? '0' : '') + hh + ':00 · ' + fmtI(x.req) + ' 次请求 · ' + fmt(x.tok) + ' token'); + } + }); + host.innerHTML = '行=日期,列=0-23 时' + out4 + '' + + '
近 7 天,每天按 0–23 时切分;着色深浅按该小时 token 用量,悬停查看明细。
'; + heatLastW = w; + return; + } + if (heatMode === 'month') { + // 最近 3 个月月历:每格一天,着色同日热力(按 token 分位),悬停明细 + var vAll = cells.map(function (c) { return c.v; }).filter(function (v) { return v > 0; }).sort(function (a, b) { return a - b; }); + var qs3 = [0.25, 0.5, 0.75].map(function (pp) { return vAll.length ? vAll[Math.min(vAll.length - 1, Math.floor(vAll.length * pp))] : 1; }); + var endD = new Date(days[days.length - 1] + 'T00:00:00'); + var mHtml = ''; + for (var mi = 2; mi >= 0; mi--) { + var m0 = new Date(endD.getFullYear(), endD.getMonth() - mi, 1); + var dim = new Date(m0.getFullYear(), m0.getMonth() + 1, 0).getDate(); + var lead = (new Date(m0.getFullYear(), m0.getMonth(), 1).getDay() + 6) % 7; + var g = '
' + (m0.getFullYear() + ' 年 ' + (m0.getMonth() + 1) + ' 月') + '
'; + '一二三四五六'.split('').forEach(function (w) { g += '
' + w + '
'; }); + for (var e = 0; e < lead; e++) g += '
'; + for (var dd = 1; dd <= dim; dd++) { + var mk = m0.getFullYear() + '-' + pad(m0.getMonth() + 1) + '-' + pad(dd); + var c = cells.find(function (x) { return x.k === mk; }); + var vv = c ? c.v : 0; + var st2 = shade(vv, qs3); + g += '
0 ? 1 : 0.35)) + ';font-size:9px;line-height:22px;text-align:center;color:var(--muted-foreground)">' + dd + '
'; + } + g += '
'; + mHtml += g; + } + host.innerHTML = '
' + mHtml + '
着色深浅按当日 token 用量,悬停查看明细。
'; + heatLastW = w; + return; + } + if (heatMode === 'week') { + var lead0 = (new Date(cells[0].k + 'T00:00:00').getDay() + 6) % 7; + var weeks = [], wk = null; + cells.forEach(function (c, i) { + var wi = Math.floor((lead0 + i) / 7); + if (!wk || wk.i !== wi) { wk = { i: wi, k: c.k, v: 0, req: 0 }; weeks.push(wk); } + wk.v += c.v; wk.req += c.req; + }); + var wv = weeks.map(function (x) { return x.v; }).filter(function (v) { return v > 0; }).sort(function (a, b) { return a - b; }); + var wq = [0.25, 0.5, 0.75].map(function (pp) { return wv.length ? wv[Math.min(wv.length - 1, Math.floor(wv.length * pp))] : 1; }); + var cw = Math.min(40, Math.floor((w - L - (weeks.length - 1) * gap) / Math.max(1, weeks.length))); + var ch = Math.min(28, Math.max(14, cw)); + var W2 = L + weeks.length * (cw + gap), H2 = T + ch + 24; + var lastM = ''; + weeks.forEach(function (x, i) { + var x0 = L + i * (cw + gap); + var mk = x.k.slice(0, 7); + if (mk !== lastM) { lastM = mk; out += '' + Number(x.k.slice(5, 7)) + '月'; } + out += rect(x0, T, cw, ch, shade(x.v, wq), x.k + ' 当周 · ' + fmtI(x.req) + ' 次请求 · ' + fmt(x.v) + ' token'); + }); + host.innerHTML = '共 ' + weeks.length + ' 周' + out + ''; + heatLastW = w; + return; + } + var minCw = 6; + var maxCols = Math.max(1, Math.floor((w - L - minCw) / (minCw + gap))); + var maxCells = maxCols * 7; + if (cells.length > maxCells) { cells = cells.slice(-maxCells); } + start = new Date(cells[0].k + 'T00:00:00'); + var lead = (start.getDay() + 6) % 7; + var cols = Math.ceil((lead + cells.length) / 7); + var cw = Math.min(16, Math.floor((w - L - (cols - 1) * gap) / cols)); + var ch = cw; + var W2 = L + cols * (cw + gap), H2 = T + 7 * (ch + gap) + 24; + var dv = cells.map(function (c) { return c.v; }).filter(function (v) { return v > 0; }).sort(function (a, b) { return a - b; }); + var dq = [0.25, 0.5, 0.75].map(function (pp) { return dv.length ? dv[Math.min(dv.length - 1, Math.floor(dv.length * pp))] : 1; }); + var lastM = ''; + cells.forEach(function (c, i) { + var pos = lead + i; + var col = Math.floor(pos / 7), row = pos % 7; + var x = L + col * (cw + gap), y = T + row * (ch + gap); + if (row === 0) { + var mk = c.k.slice(0, 7); + if (mk !== lastM) { + lastM = mk; + out += '' + Number(c.k.slice(5, 7)) + '月'; + } + } + out += rect(x, y, cw, ch, shade(c.v, dq), c.k + ' · ' + fmtI(c.req) + ' 次请求 · ' + fmt(c.v) + ' token'); + }); + host.innerHTML = '共 ' + cells.length + ' 天,' + dv.length + ' 天有用量' + out + ''; + heatLastW = w; + } + + function sessionDetailHTML(o) { + var rs = R.filter(function (r) { return r[0] === o.g; }).sort(function (a, b) { return a[1] - b[1]; }); + var dir = SD[o.g] ? esc(SD[o.g]) : '–'; + var mm = {}, pv2 = {}; + rs.forEach(function (r) { + var nm = String(MD[r[2]] || '?').toLowerCase(); + mm[nm] = (mm[nm] || 0) + N(r[4]) + N(r[5]); + pv2[r[3]] = (pv2[r[3]] || 0) + 1; + }); + var mix = donut(Object.keys(mm).sort(function (a, b) { return mm[b] - mm[a]; }).map(function (k) { + return { name: k, value: mm[k], tip: k + ' · ' + fmt(mm[k]) + ' tok(' + Math.round(mm[k] / (mtot() || 1) * 100) + '%)' }; + }), 'token'); + function mtot() { var t = 0; for (var k2 in mm) t += mm[k2]; return t; } + var pvm = Object.keys(pv2).map(function (k) { return '' + pvLabel(k) + ' ×' + pv2[k] + ''; }).join(' · '); + var tl = (toolsMap[o.g] || []).slice().sort(function (a, b) { return b[1] - a[1]; }).slice(0, 6) + .map(function (t) { return '' + esc(String(t[0]).replace(/^mcp__/, '')) + ' ×' + fmtI(t[1]) + ''; }).join(' · '); + var cm = cmpMap[o.g]; + var cmpHtml = cm ? '压缩 ' + cm.cnt + ' 次 · 压缩前水位最高 ' + fmt(cm.pre) + ' token' : '无压缩记录'; + // 事件时间线:压缩事件(META 带时间)+ 模型切换(相邻请求模型变化推导),按时间归并 + var events = []; + (cm ? cm.ts : []).forEach(function (t) { events.push({ t: t, label: '上下文压缩' }); }); + var prevM = -1; + rs.forEach(function (r) { + if (prevM !== -1 && r[2] !== prevM) { + var d = new Date(r[1]); + events.push({ t: pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()), label: '切换模型 → ' + (MD[r[2]] || '?') }); + } + prevM = r[2]; + }); + events.sort(function (a, b) { return a.t < b.t ? -1 : a.t > b.t ? 1 : 0; }); + var tl8 = events.length + ? '
事件时间线
' + + events.slice(-12).map(function (e) { + var cls = e.label.indexOf('压缩') >= 0 ? 'ud-tl-c' : 'ud-tl-m'; + return '
' + esc(e.t) + '' + esc(e.label) + '
'; + }).join('') + + (events.length > 12 ? '
…共 ' + events.length + ' 个事件
' : '') + + '
' + : ''; + // 主会话 / 子代理拆分(rows r[9]:0 主会话 1 子代理 2 系统) + var subTok = 0, subReq = 0, mainTok = 0, mainReq = 0; + rs.forEach(function (r) { + var t = N(r[4]) + N(r[5]); + if (r[9] === 1) { subTok += t; subReq++; } else { mainTok += t; mainReq++; } + }); + var W = 600, H = 96, maxIn = 1; + rs.forEach(function (r) { if (N(r[4]) > maxIn) maxIn = N(r[4]); }); + var pts = [], dots = []; + rs.forEach(function (r, i) { + var x = rs.length > 1 ? i / (rs.length - 1) * (W - 8) + 4 : W / 2; + var y = H - 8 - (H - 16) * N(r[4]) / maxIn; + pts.push(x.toFixed(1) + ',' + y.toFixed(1)); + var d = new Date(r[1]); + var tt = pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); + var st = r[8] === 0 ? '完成' : r[8] === 1 ? '失败' : r[8] === 2 ? '已取消' : '进行中'; + dots.push('' + esc(tt) + ' · 输入 ' + fmt(N(r[4])) + ' tok · 生成 ' + fmt(N(r[5])) + ' tok · ' + esc(MD[r[2]] || '?') + ' · ' + st + (r[9] === 1 ? ' · 子代理' : '') + ''); + }); + var startT = fullTs(rs.length ? rs[0][1] : 0), endT = fullTs(o.last); + var curve = '' + + '' + dots.join('') + ''; + // 起止时间直接标在水位曲线两端上方 + var curveWrap = '
' + + '' + esc(startT) + '' + + '' + esc(endT) + '' + + '
' + curve + '
'; + var curveHead = '
按请求输入 token 近似 · 峰值 ' + fmt(maxIn) + ' · 悬停看点明细
'; + var toolDonut = (toolsMap[o.g] || []).length + ? donut((toolsMap[o.g] || []).slice().sort(function (a, b) { return b[1] - a[1]; }).slice(0, 6).map(function (t) { + return { name: String(t[0]).replace(/^mcp__/, ''), value: t[1], tip: String(t[0]).replace(/^mcp__/, '') + ' · ' + fmtI(t[1]) + ' 次调用' + (t[3] ? ' · 累计 ' + fmtI(t[3]) + ' ms' : '') }; + }), '调用') + : '无工具调用'; + function kv(l, v) { return '
' + l + '
' + v + '
'; } + // 主/子代理 token 拆分条(双色堆叠,悬停看明细;rows r[9]:0 主会话 1 子代理) + var tot2 = mainTok + subTok; + var subBar = tot2 > 0 + ? '
' + + (mainTok > 0 ? '
' : '') + + (subTok > 0 ? '
' : '') + + '
' + + (mainReq ? '■ 主会话 ' + fmt(mainTok) + ' tok × ' + fmtI(mainReq) : '') + + (subReq ? (mainReq ? ' ' : '') + '■ 子代理 ' + fmt(subTok) + ' tok × ' + fmtI(subReq) : '') + '
' + : ''; + return '
项目目录
' + dir + '
' + + (subReq ? subBar : '') + + (pvm ? '
来源:' + pvm + '
' : '') + '
' + + '
' + + kv('请求 / 缓存率', fmtI(o.req) + ' 次 · ' + o.rate + '%') + + (function () { + var gen = 0, ms = 0; + rs.forEach(function (r) { gen += N(r[5]); ms += Math.max(1, N(r[7]) - (r[11] == null ? 0 : N(r[11]))); }); + return ms > 0 ? kv('平均输出速率', tpsHtml(Math.round(gen / ms * 1000)) + ' tok/s(加权)') : ''; + })() + + (subReq ? kv('主会话 / 子代理', fmtI(mainReq) + ' 次 · ' + fmt(mainTok) + ' tok / ' + fmtI(subReq) + ' 次 · ' + fmt(subTok) + ' tok') : '') + + kv('上下文压缩', cmpHtml) + + (costOn && o.hasCost ? kv('折算成本', fmtCost(o.cost)) : '') + + '
' + + '
' + + '
模型占比(按 token)
' + mix + + '
' + + '
使用工具(调用次数占比)
' + toolDonut + '
' + + '
' + (tl8 || '
事件时间线
无压缩 / 模型切换事件') + '
' + + '
' + + '
' + curveHead + curveWrap + '
'; + } + function renderModelBoard(rows) { + var headEl = $('ud-models-head'), bodyEl = $('ud-models-body'); + if (!headEl || !bodyEl) return; + var per = {}; + rows.forEach(function (r) { + var o = per[r[2]] || (per[r[2]] = { req: 0, inn: 0, out: 0, ca: 0, err: 0, genMs: 0, ttft: 0, ttftN: 0, cost: 0, hasCost: false }); + o.req++; o.inn += N(r[4]); o.out += N(r[5]); o.ca += N(r[6]); + if (r[8] === 1) o.err++; + if (r[8] === 0) o.genMs += Math.max(1, N(r[7]) - (r[11] == null ? 0 : N(r[11]))); + if (r[11] != null) { o.ttft += N(r[11]); o.ttftN++; } + var c = rowCost(r); if (c != null) { o.cost += c; o.hasCost = true; } + }); + var list = Object.keys(per).map(function (m) { + var o = per[m]; o.m = m; o.tot = o.inn + o.out; + o.tps = o.genMs > 0 ? Math.round(o.out / o.genMs * 1000) : null; + o.avgTtft = o.ttftN ? Math.round(o.ttft / o.ttftN) : null; + o.rate = o.inn ? Math.round(o.ca / o.inn * 100) : 0; + o.errRate = o.req ? Math.round(o.err / o.req * 100) : 0; + o.per1k = o.hasCost && o.tot > 0 ? o.cost / (o.tot / 1000) : null; + return o; + }).sort(function (a, b) { return b.tot - a.tot; }); + headEl.innerHTML = '模型请求数平均 tok/s平均首字失败率输入 token生成 token合计缓存率' + (costOn ? '折算成本成本 / 1k tok' : '') + ''; + bodyEl.innerHTML = list.length ? list.map(function (o) { + return '' + esc(MD[o.m]) + '' + + '' + fmtI(o.req) + '' + + '' + (o.tps == null ? '–' : tpsHtml(o.tps)) + '' + + '' + (o.avgTtft == null ? '–' : durCell(o.avgTtft)) + '' + + '' + o.errRate + '%' + + '' + tokCell(o.inn) + '' + tokCell(o.out) + '' + tokCell(o.tot) + '' + + '' + o.rate + '%' + + (costOn ? '' + (o.hasCost ? fmtCost(o.cost) : '–') + '' + (o.per1k == null ? '–' : fmtCost(o.per1k)) + '' : '') + + ''; + }).join('') : '当前筛选下暂无数据'; + if (!bodyEl.dataset.flinkBound) { + bodyEl.dataset.flinkBound = '1'; + bodyEl.addEventListener('click', function (e) { + var fl = e.target.closest ? e.target.closest('.ud-flink') : null; + if (!fl) return; + st.m = (st.m === +fl.dataset.v ? -1 : +fl.dataset.v); + var ms = $('ud-f-model'); + if (ms) ms.value = String(st.m); + apply(); + }); + } + } + function healthBadge(h) { + var c = h >= 90 ? 'var(--green)' : h >= 70 ? 'var(--yellow)' : 'var(--red)'; + return '' + h + ''; + } + function renderSessions(rows) { + var keys = { 1: 'req', 2: 'inn', 3: 'out', 4: 'tot', 5: 'last', 6: 'cost', 7: 'rate', 8: 'health' }; + var list = sessAgg(rows).sort(function (a, b) { return ((a[keys[sortS.k]] || 0) - (b[keys[sortS.k]] || 0)) * sortS.d; }); + var hasCmp = (D.compactions || []).length > 0; + var cols = [ + { h: '会话', k: null }, { h: '请求数', k: 1, end: 1 }, { h: '输入 token', k: 2, end: 1 }, + { h: '生成 token', k: 3, end: 1 }, { h: 'token 合计', k: 4, end: 1 }, + { h: '缓存率', k: 7, end: 1 }, { h: '健康', k: 8, end: 1 }, + hasCmp ? { h: '压缩', k: null, end: 1 } : null, + costOn ? { h: '折算成本', k: 6, end: 1 } : null, + { h: '最后活动', k: 5, end: 1 } + ].filter(Boolean); + var span = cols.length; + $('ud-sessions-head').innerHTML = '' + cols.map(function (c) { + var arrow = sortS.k === c.k ? (sortS.d < 0 ? ' ↓' : ' ↑') : ''; + return c.k == null ? '' + c.h + '' : '' + c.h + arrow + ''; + }).join('') + ''; + var shown = list.slice(0, 30); + var body = ''; + shown.forEach(function (o) { + body += '' + sessTitle(o) + (o.sub ? '子代理 ×' + fmtI(o.sub) + '' : '') + '' + fmtI(o.req) + '' + tokCell(o.inn) + '' + tokCell(o.out) + '' + tokCell(o.tot) + '' + o.rate + '%' + healthBadge(o.health) + '' + (hasCmp ? '' + (cmpMap[o.g] ? cmpMap[o.g].cnt + ' 次' : '–') + '' : '') + (costOn ? '' + (o.hasCost ? fmtCost(o.cost) : '–') + '' : '') + '' + fullTs(o.last) + ''; + if (o.g === sessExp) body += '' + sessionDetailHTML(o) + ''; + }); + $('ud-sessions-body').innerHTML = body || '当前筛选下暂无数据'; + Array.prototype.forEach.call($('ud-sessions-body').querySelectorAll('tr.ud-click'), function (tr) { + tr.addEventListener('click', function () { + var g = +tr.getAttribute('data-sg'); + sessExp = (sessExp === g) ? -1 : g; + renderSessions(frows(false)); + }); + }); + var bodyEl = $('ud-sessions-body'); + if (!bodyEl.dataset.logsBtnBound) { + bodyEl.dataset.logsBtnBound = '1'; + bodyEl.addEventListener('click', function (e) { + var btn = e.target.closest ? e.target.closest('.ud-sess-logs') : null; + if (!btn) return; + st.s = +btn.getAttribute('data-sg'); + page = 1; sessExp = -1; detExp = -1; + var dbtn = document.querySelector('.nav-pills [role=tab][aria-controls="ud-detail"]'); + if (dbtn) dbtn.click(); + apply(); + }); + } + Array.prototype.forEach.call($('ud-sessions-head').querySelectorAll('.ud-sortable'), function (th) { + th.addEventListener('click', function () { + var k = +th.getAttribute('data-k'); + if (sortS.k === k) sortS.d = -sortS.d; else { sortS.k = k; sortS.d = -1; } + sessExp = -1; + renderSessions(frows(false)); + }); + }); + } + + function requestDetailHTML(r) { + // 列里已有的信息不再重复;展开区只放增量:生成耗时拆解、首字占比、(serve)provider 原始用量 + var genMs = fmtI(Math.max(1, N(r[7]) - (r[11] == null ? 0 : N(r[11])))); + var ttftpct = r[11] != null && r[7] ? Math.round(r[11] / r[7] * 100) + '%' : '–'; + return '
生成耗时 ' + genMs + ' ms · 首字占比 ' + ttftpct + + (window.__USAGE_SERVE__ ? ' · 以下为 provider 上报的原始用量' : '') + '
' + + (window.__USAGE_SERVE__ ? '
正在拉取 provider 上报的原始用量…
' : ''); + } + function fillRawUsage(id) { + var el = $('ud-reqraw'); + if (!el) return; + fetch('/api/request?id=' + encodeURIComponent(id)).then(function (x) { return x.json(); }).then(function (j) { + if (j.error) { el.innerHTML = '
原始用量:' + esc(j.error) + '
'; return; } + var usage = '(无)'; + try { usage = j.raw_usage_json ? JSON.stringify(JSON.parse(j.raw_usage_json), null, 1) : usage; } catch (e) { usage = j.raw_usage_json || usage; } + var extra = []; + if (j.finish_reason) extra.push('finish_reason: ' + j.finish_reason); + if (j.error_code || j.error_message) extra.push('错误: ' + (j.error_code || '') + ' ' + (j.error_message || '')); + if (j.agent) extra.push('agent: ' + j.agent); + el.innerHTML = '
provider 上报的原始用量
' + esc(usage) + '
' + (extra.length ? '
' + esc(extra.join(' · ')) + '
' : ''); + }).catch(function (e) { el.innerHTML = '
拉取失败:' + esc(String(e)) + '
'; }); + } + function renderDetail(rows) { + var ACC = { + 0: function (r) { return r[1]; }, + 4: function (r) { return N(r[4]); }, + 5: function (r) { return N(r[5]); }, + 6: function (r) { return N(r[6]); }, + 7: function (r) { return N(r[7]); }, + 10: function (r) { return N(r[10]); }, + 11: function (r) { return N(r[11]); }, + 12: function (r) { return N(r[4]) + N(r[5]); }, + 13: function (r) { return rowCost(r) || 0; }, + 14: function (r) { return tpsOf(r); } + }; + var list = rows.filter(function (r) { return detailStat === -2 ? (r[8] === 1 || r[8] === 2) : (detailStat < 0 || r[8] === detailStat); }) + .sort(function (a, b) { return (ACC[sortD.k](a) - ACC[sortD.k](b)) * sortD.d; }); + var cols = [ + { h: '时间', k: 0 }, { h: '会话', k: null }, { h: '模型', k: null }, { h: '来源', k: null }, { h: '类型', k: null }, + { h: '输入', k: 4, end: 1 }, { h: '缓存读', k: 6, end: 1 }, { h: '缓存写', k: 10, end: 1 }, + { h: '生成', k: 5, end: 1 }, { h: '合计', k: 12, end: 1 }, { h: '耗时', k: 7, end: 1 }, { h: '首字', k: 11, end: 1 }, + { h: 'tok/s', k: 14, end: 1 }, + { h: '状态', k: null }, + costOn ? { h: '成本', k: 13, end: 1 } : null + ].filter(Boolean); + if (D.session) { cols = cols.filter(function (c) { return c.h !== '会话' && c.h !== '类型'; }); } + var span = cols.length; + $('ud-detail-head').innerHTML = '' + cols.map(function (c) { + var arrow = sortD.k === c.k ? (sortD.d < 0 ? ' ↓' : ' ↑') : ''; + return c.k == null ? '' + c.h + '' : '' + c.h + arrow + ''; + }).join('') + ''; + var totalPages = Math.max(1, Math.ceil(list.length / pageSize)); + if (page > totalPages) page = totalPages; + if (page < 1) page = 1; + var view = list.slice((page - 1) * pageSize, page * pageSize); + var body = ''; + if (D.session) { + view = list.slice(0, sessShown); + view.forEach(function (r) { + body += '' + fullTs(r[1]) + '' + + '' + esc(MD[r[2]]) + '' + pvLabel(r[3]) + '' + + '' + tokCell(r[4]) + '' + + '' + tokCell(r[6]) + '' + + '' + tokCell(r[10]) + '' + + '' + tokCell(r[5]) + '' + + '' + tokCell(N(r[4]) + N(r[5])) + '' + + '' + durCell(r[7]) + '' + + '' + (r[11] == null ? '–' : durCell(r[11])) + '' + + '' + tpsHtml(tpsOf(r)) + '' + + '' + stHtml(r[8]) + ''; + }); + } else { + view.forEach(function (r, i) { + body += '' + fullTs(r[1]) + '' + + '' + (TL[r[0]] ? esc(TL[r[0]]) : '(无标题)') + '' + + '' + esc(MD[r[2]]) + '' + pvLabel(r[3]) + '' + QT[r[9]] + '' + + '' + tokCell(r[4]) + '' + + '' + tokCell(r[6]) + '' + + '' + tokCell(r[10]) + '' + + '' + tokCell(r[5]) + '' + + '' + tokCell(N(r[4]) + N(r[5])) + '' + + '' + durCell(r[7]) + '' + + '' + (r[11] == null ? '–' : durCell(r[11])) + '' + + '' + tpsHtml(tpsOf(r)) + '' + + '' + stHtml(r[8]) + '' + + (costOn ? '' + (rowCost(r) == null ? '–' : fmtCost(rowCost(r))) + '' : '') + + ''; + if (detExp === i) body += '' + requestDetailHTML(r) + ''; + }); + } + $('ud-detail-body').innerHTML = body || '当前筛选下暂无数据'; + if (D.session) { + var more = $('ud-more'); + if (more) { + if (sessShown >= list.length) { more.hidden = true; } + else { more.hidden = false; more.textContent = '加载更多(已显示 ' + sessShown + ' / ' + list.length + ')'; } + } + } else { + var pageSel = ''; + if (totalPages > 1) { + pageSel = ''; + } + $('ud-pager').innerHTML = '' + + pageSel + + '共 ' + fmtI(list.length) + ' 条' + + ''; + var prevBtn = $('ud-prev'), nextBtn = $('ud-next'); + if (prevBtn) prevBtn.addEventListener('click', function () { page--; detExp = -1; renderDetail(frows(false)); }); + if (nextBtn) nextBtn.addEventListener('click', function () { page++; detExp = -1; renderDetail(frows(false)); }); + var pageSelEl = $('ud-page-sel'); + if (pageSelEl) pageSelEl.addEventListener('change', function () { page = +this.value; detExp = -1; renderDetail(frows(false)); }); + var tbodyEl = $('ud-detail-body'); + if (!tbodyEl.dataset.flinkBound) { + tbodyEl.dataset.flinkBound = '1'; + tbodyEl.addEventListener('click', function (e) { + var fl = e.target.closest ? e.target.closest('.ud-flink') : null; + if (!fl) return; + e.stopPropagation(); + var k = fl.dataset.k, v = +fl.dataset.v; + if (k === 's') st.s = (st.s === v ? -1 : v); + if (k === 'm') st.m = (st.m === v ? -1 : v); + page = 1; sessExp = -1; detExp = -1; + var ms = $('ud-f-model'); + if (k === 'm' && ms) ms.value = String(st.m); + apply(); + }); + } + Array.prototype.forEach.call($('ud-detail-body').querySelectorAll('tr.ud-click'), function (tr) { + tr.addEventListener('click', function () { + var i = +tr.getAttribute('data-di'); + detExp = (detExp === i) ? -1 : i; + renderDetail(frows(false)); + if (detExp >= 0 && window.__USAGE_SERVE__) fillRawUsage(view[detExp][12]); + }); + }); + } + Array.prototype.forEach.call($('ud-detail-head').querySelectorAll('.ud-sortable'), function (th) { + th.addEventListener('click', function () { + var k = +th.getAttribute('data-k'); + if (sortD.k === k) sortD.d = -sortD.d; else { sortD.k = k; sortD.d = -1; } + detExp = -1; + renderDetail(frows(false)); + }); + }); + } + + function renderErrors(rows) { + var list = rows.filter(function (r) { return r[8] === 1 || r[8] === 2; }).sort(function (a, b) { return b[1] - a[1]; }).slice(0, 30); + $('ud-errors-body').innerHTML = list.length ? list.map(function (r) { + return '' + fullTs(r[1]) + '' + (TL[r[0]] ? esc(TL[r[0]]) : '(无标题)') + '' + esc(MD[r[2]]) + '' + pvLabel(r[3]) + '' + QT[r[9]] + '' + stHtml(r[8]) + ''; + }).join('') : '当前筛选下没有失败或取消的请求'; + } + + function drawTrend() { + var host = $('ud-trend-chart'); + if (!host) return; + var w = host.clientWidth; + if (!w) return; + var rows = frows(false); + if (!rows.length) { host.innerHTML = '

当前筛选下暂无数据

'; return; } + var days = dayList(rows); + var perDay = {}; + rows.forEach(function (r) { + var k = dayKey(r[1]); + var o = perDay[k] || (perDay[k] = {}); + var m = o[r[3]] || (o[r[3]] = { req: 0, inn: 0, out: 0, ca: 0 }); + m.req++; m.inn += N(r[4]); m.out += N(r[5]); m.ca += N(r[6]); + }); + var active = {}; + rows.forEach(function (r) { active[r[3]] = 1; }); + var pvs = pvOrder.filter(function (p) { return active[p] && !st.hidePv[p]; }); + var legendRows = frows(true).reduce(function (acc, r) { + var o = acc[r[3]] || (acc[r[3]] = { req: 0, inn: 0, out: 0, ca: 0 }); + o.req++; o.inn += N(r[4]); o.out += N(r[5]); o.ca += N(r[6]); + return acc; + }, {}); + $('ud-pv-legend').innerHTML = pvOrder.filter(function (p) { return active[p]; }).map(function (p) { + var o = legendRows[p] || { req: 0, inn: 0, out: 0, ca: 0 }; + var total = o[curMetric]; + return ''; + }).join(''); + Array.prototype.forEach.call(document.querySelectorAll('#ud-pv-legend input'), function (cb) { + cb.addEventListener('change', function () { + st.hidePv[+cb.getAttribute('data-pv')] = !cb.checked; + drawTrend(); + }); + }); + var H = 240, P = { t: 12, r: 12, b: 28, l: 64 }; + var iw = w - P.l - P.r, ih = H - P.t - P.b; + var slot = iw / days.length, bw = Math.max(2, Math.min(slot - 2, 40)); + var ymax = 1; + days.forEach(function (k) { + var o = perDay[k] || {}; + var t = 0; + pvs.forEach(function (p) { t += N((o[p] || {})[curMetric]); }); + if (t > ymax) ymax = t; + }); + var out = ''; + for (var i = 0; i <= 4; i++) { + var v = ymax * i / 4, y = P.t + ih - ih * i / 4; + out += ''; + out += '' + fmt(v) + ''; + } + var kStep = Math.ceil(days.length / 8); + days.forEach(function (k, di) { + if (di % kStep === 0) { + out += '' + esc(k.slice(5)) + ''; + } + var o = perDay[k] || {}, yBase = P.t + ih; + pvs.forEach(function (p) { + var v = N((o[p] || {})[curMetric]); + if (!v) return; + var bh = Math.round(ih * v / ymax); + var tip = k + '\n' + pvLabel(p) + '\n请求 ' + fmtI((o[p] || {}).req || 0) + ' · 输入 ' + fmt((o[p] || {}).inn) + '\n生成 ' + fmt((o[p] || {}).out) + ' · 缓存读 ' + fmt((o[p] || {}).ca); + out += '' + esc(tip) + ''; + yBase -= bh; + }); + }); + host.innerHTML = '按天按来源堆叠展示 ' + days.length + ' 天数据' + out + ''; + var total = agg(rows); + $('ud-trend-readout').textContent = '当前指标合计:请求数 ' + fmtI(total.req) + ' · 输入 ' + fmt(total.inn) + ' · 生成 ' + fmt(total.out) + ' · 缓存读 ' + fmt(total.ca); + // 每日明细表:与图表同口径,点击某天切换全站日期筛选(再点一次取消) + var perDur = {}; + rows.forEach(function (r) { var k = dayKey(r[1]); perDur[k] = (perDur[k] || 0) + N(r[7]); }); + var dayRows = days.slice().reverse().map(function (k) { + var o = perDay[k] || {}, req = 0, inn = 0, out = 0, ca = 0; + for (var p in o) { var m = o[p]; req += m.req; inn += m.inn; out += m.out; ca += m.ca; } + var dur = perDur[k] || 0; + var hit = inn ? Math.round(ca / inn * 100) : 0; + var rate = dur > 0 ? fmtI(Math.round(out / (dur / 1000))) + ' tok/s' : '–'; + var sel = st.day === k; + return '' + k + (sel ? ' 已选' : '') + '' + fmtI(req) + '' + fmt(inn) + '' + fmt(out) + '' + fmt(ca) + '' + hit + '%' + rate + ''; + }); + var mAgg = {}; + rows.forEach(function (r) { + var mk = dayKey(r[1]).slice(0, 7); + var o = mAgg[mk] || (mAgg[mk] = { req: 0, inn: 0, out: 0, ca: 0, cost: 0, hasCost: false }); + o.req++; o.inn += N(r[4]); o.out += N(r[5]); o.ca += N(r[6]); + var cc = rowCost(r); if (cc != null) { o.cost += cc; o.hasCost = true; } + }); + var mRows = Object.keys(mAgg).sort().reverse().map(function (mk) { + var o = mAgg[mk]; + var hit2 = o.inn ? Math.round(o.ca / o.inn * 100) : 0; + return '' + mk + '' + fmtI(o.req) + '' + fmt(o.inn) + '' + fmt(o.out) + '' + fmt(o.ca) + '' + hit2 + '%' + (o.hasCost ? '' + fmtCost(o.cost) + '' : '') + ''; + }).join(''); + $('ud-daily-wrap').innerHTML = '

每日明细

' + dayRows.join('') + '
日期请求输入生成缓存读缓存命中生成速率
' + + '

每月汇总

' + (costOn ? '' : '') + '' + mRows + '
月份请求输入生成缓存读缓存命中折算成本
'; + Array.prototype.forEach.call($('ud-daily-wrap').querySelectorAll('tr.ud-click'), function (tr) { + tr.addEventListener('click', function () { + var d = tr.getAttribute('data-day'); + st.day = st.day === d ? null : d; + page = 1; sessExp = -1; detExp = -1; + if (st.day) { + var btn = document.querySelector('.nav-pills [role=tab][aria-controls="ud-detail"]'); + if (btn) btn.click(); + } + apply(); + }); + }); + } + + function fillSelect(el, allLabel, items) { + el.innerHTML = '' + items.map(function (it) { + return ''; + }).join(''); + } + + function apply() { + var sf = $('ud-sess-filter'); + if (sf) { + sf.hidden = st.s < 0; + if (st.s >= 0) sf.textContent = '会话:' + (TL[st.s] ? TL[st.s].slice(0, 24) : '#' + st.s) + ' ✕'; + } + var df = $('ud-day-filter'); + if (df) { + df.hidden = !st.day; + if (st.day) df.textContent = '日期:' + st.day + ' ✕'; + } + var rows = frows(false); + if (D.session) { renderSession(rows); renderDetail(rows); return; } + renderKpis(rows); + renderInsights(rows); + renderHealth(rows); + renderProviders(frows(true)); + renderModels(rows); + renderTopSessions(rows); + renderHours(rows); + if (!wireHoursToggle.done) { wireHoursToggle.done = true; wireHoursToggle(); } + renderHeatmap(rows); + renderSessions(rows); + renderModelBoard(rows); + renderDetail(rows); + renderErrors(rows); + drawTrend(); + } + + // 会话视图只初始化明细交互;全量分析与筛选控件留给 dashboard。 + if (D.session) { + $('ud-f-stat').addEventListener('change', function () { detailStat = +this.value; sessShown = 50; renderDetail(R); }); + $('ud-more').addEventListener('click', function () { sessShown += 50; renderDetail(R); }); + $('ud-failures').addEventListener('click', function () { + detailStat = -2; sessShown = 50; + $('ud-request-section').open = true; + $('ud-f-stat').value = '-2'; + renderDetail(R); + $('ud-f-stat').focus(); + }); + apply(); + return; + } + + // 全量仪表盘筛选控件 + fillSelect($('ud-f-pv'), '全部来源', PV.map(function (p, i) { return { v: i, label: pvLabel(i) + '(' + pvTag(i) + ')' }; })); + fillSelect($('ud-f-model'), '全部模型', MD.map(function (m, i) { return { v: i, label: String(m) }; })); + if (PRICE && Object.keys(PRICE).length) { $('ud-cost-wrap').hidden = false; } + (function () { + var sf = $('ud-sess-filter'); + if (sf) sf.addEventListener('click', function () { st.s = -1; page = 1; sessExp = -1; detExp = -1; apply(); }); + })(); + (function () { + var df = $('ud-day-filter'); + if (df) df.addEventListener('click', function () { st.day = null; page = 1; sessExp = -1; detExp = -1; apply(); }); + })(); + $('ud-f-pv').addEventListener('change', function () { st.p = +this.value; page = 1; sessExp = -1; detExp = -1; apply(); }); + $('ud-f-model').addEventListener('change', function () { st.m = +this.value; page = 1; sessExp = -1; detExp = -1; apply(); }); + $('ud-f-stat').addEventListener('change', function () { detailStat = +this.value; page = 1; renderDetail(frows(false)); }); + $('ud-f-cost').addEventListener('change', function () { costOn = this.checked; renderProviders(frows(true)); renderSessions(frows(false)); renderInsights(frows(false)); renderDetail(frows(false)); }); + Array.prototype.forEach.call(document.querySelectorAll('#ud-f-range .ud-toggle'), function (b) { + b.addEventListener('click', function () { + st.rng = b.getAttribute('data-r'); + Array.prototype.forEach.call(document.querySelectorAll('#ud-f-range .ud-toggle'), function (x) { + x.setAttribute('aria-pressed', String(x === b)); + }); + page = 1; sessExp = -1; detExp = -1; apply(); + }); + }); + Array.prototype.forEach.call(document.querySelectorAll('#ud-series-toggle .ud-toggle'), function (b) { + b.addEventListener('click', function () { + curMetric = b.getAttribute('data-m'); + Array.prototype.forEach.call(document.querySelectorAll('#ud-series-toggle .ud-toggle'), function (x) { + x.setAttribute('aria-pressed', String(x === b)); + }); + drawTrend(); + }); + }); + Array.prototype.forEach.call(document.querySelectorAll('#ud-heat-toggle .ud-toggle'), function (b) { + b.addEventListener('click', function () { + heatMode = b.getAttribute('data-hm'); + Array.prototype.forEach.call(document.querySelectorAll('#ud-heat-toggle .ud-toggle'), function (x) { + x.setAttribute('aria-pressed', String(x === b)); + }); + heatLastW = -1; + renderHeatmap(frows(false)); + }); + }); + $('ud-price-sync').addEventListener('click', function () { + var btn = this; + btn.disabled = true; + btn.textContent = '同步中…'; + if (window.__USAGE_SERVE__) { + fetch('/api/sync-prices', { method: 'POST' }).then(function () { location.reload(); }).catch(function () { + btn.textContent = '同步失败'; btn.disabled = false; + }); + return; + } + var zc = window.zcode || null; + var reset = function (t) { btn.textContent = t; setTimeout(function () { btn.textContent = '同步价格'; btn.disabled = false; }, 3000); }; + if (zc && typeof zc.callTool === 'function') { + // 直调 App-only 同步工具(后台 execFile prices sync,约数秒),完成后按新价软刷新 + zc.callTool('usage_prices_sync', {}).then(function (r) { + var sc = (r && r.structuredContent) || {}; + if (sc.ok && sc.prices) { mgrState.prices = sc.prices; softReloadPrices(sc.prices); reset('已同步'); } + else if (r && r.isError) { reset('同步失败'); } + else reset('已同步'); + }).catch(function () { reset('同步失败'); }); + } else { + reset('当前环境不支持'); + } + }); + // ---- 价格 / 预算管理(serve 模式可编辑并即时重算;其他环境只读引导) ---- + var mgrState = { q: '', prices: null, budget: null, switches: null }; + function mgrRow(m, e, used) { + return '' + esc(m) + (used ? ' 当前用量' : '') + '' + + '' + + '' + + '' + + ' '; + } + function swRow(id, name, desc, on) { + return '
' + name + '
' + desc + '
' + + '
'; + } + function renderMgr() { + var body = $('ud-mgr-body'); + var p = mgrState.prices, b = mgrState.budget || {}, sw = mgrState.switches || {}; + var models = Object.keys(p.models || {}); + var used = {}; + frows(false).forEach(function (r) { used[String(MD[r[2]] || '').toLowerCase()] = 1; }); + var hit = models.filter(function (m) { return mgrState.q ? m.indexOf(mgrState.q) >= 0 : (used[m.toLowerCase()] || mgrState.builtins.indexOf(m) >= 0); }); + var list = (mgrState.q ? models.filter(function (m) { return m.indexOf(mgrState.q) >= 0; }) : hit).slice(0, 60); + var tsMode = sw.turnstats || 'off'; + body.innerHTML = + '
成本单价(元 / 每百万 token)
' + + '
GLM 5.3 系为智谱官网权威价;其余来自 models.dev(USD 按 7.2 换算)。默认列出当前用量相关模型,搜索可查全量;修改保存后按新价重算。
' + + '
' + list.length + ' / ' + models.length + ' 个模型' + (p.modelsSlim && p.modelsTotal != null ? '(全量 ' + p.modelsTotal + ')' : '') + (list.length >= 60 ? '(仅显示前 60)' : '') + '
' + + '
' + + list.map(function (m) { return mgrRow(m, p.models[m], !mgrState.q && used[m.toLowerCase()]); }).join('') + + '
模型输入输出缓存读
' + + '
新增模型价:
' + + '
通用
' + + '
' + + '' + + '' + + '' + + '
' + + '
预算(折算金额,超 80% 黄色进度、100% 红色)
' + + '
' + + '' + + '' + + '' + + '
' + + '
通知(macOS 系统通知,全部本地计算、零模型开销)
' + + swRow('ud-sw-permission', '权限等待提醒', 'Agent 弹出批准请求时通知你——挂机跑长任务不漏确认', sw.permissionAlerts === true) + + swRow('ud-sw-budget', '预算 / 燃烧告警', '日折算成本达预算 80% / 100% 时提醒;今日用量超近 7 天日均 50% 也会提示', sw.budgetAlerts !== false) + + '
每轮用量统计
每轮回合结束弹通知显示本轮速率 / 缓存率 / 耗时(不进入对话上下文)。选「关」时统计仍显示在侧栏面板顶部横幅
' + + '
' + + '
权限提醒与每轮统计依赖会话钩子,修改后重开会话生效;预算告警立即生效。
'; + var qi = $('ud-mgr-q'); + var qTimer = null, qSeq = 0; + qi.addEventListener('input', function () { + mgrState.q = this.value.trim().toLowerCase(); + renderMgr(); var q2 = $('ud-mgr-q'); q2.focus(); q2.setSelectionRange(q2.value.length, q2.value.length); + // 侧栏拿到的是瘦身价格表(宿主对工具响应有 64KiB 上限):搜索时经 callTool 按名拉取匹配项 + if (window.__USAGE_SERVE__ || !(window.zcode && typeof window.zcode.callTool === 'function')) return; + var seq = ++qSeq, kw = this.value.trim(); + clearTimeout(qTimer); + qTimer = setTimeout(function () { + window.zcode.callTool('usage_settings_get', { query: kw }).then(function (r) { + if (seq !== qSeq) return; // 已有更新的输入,丢弃过期响应 + var sc = (r && r.structuredContent) || {}; + if (sc.prices) { mgrState.prices = sc.prices; mgrState.q = ($('ud-mgr-q').value || '').trim().toLowerCase(); renderMgr(); } + }).catch(function () {}); + }, 300); + }); + Array.prototype.forEach.call(body.querySelectorAll('tr[data-m]'), function (tr) { + var m = tr.getAttribute('data-m'); + tr.querySelector('.ud-mgr-save').addEventListener('click', function () { + var get = function (k) { return tr.querySelector('input[data-k="' + k + '"]').value; }; + priceSave({ url: '/api/prices/set', data: { model: m, in: get('in'), out: get('out'), cache: get('cache') }, tool: { price: { model: m, in: get('in'), out: get('out'), cache: get('cache') } } }).then(afterPriceSave); + }); + tr.querySelector('.ud-mgr-del').addEventListener('click', function () { + priceSave({ url: '/api/prices/remove', data: { model: m }, tool: { priceRemove: m } }).then(afterPriceSave); + }); + }); + $('ud-mgr-add').addEventListener('click', function () { + var nm = $('ud-mgr-newname').value.trim(); + if (!nm) return; + priceSave({ url: '/api/prices/set', data: { model: nm, in: $('ud-mgr-newin').value, out: $('ud-mgr-newout').value, cache: $('ud-mgr-newcache').value }, tool: { price: { model: nm, in: $('ud-mgr-newin').value, out: $('ud-mgr-newout').value, cache: $('ud-mgr-newcache').value } } }).then(afterPriceSave); + }); + $('ud-mgr-bsave').addEventListener('click', function () { + var nb = { daily: $('ud-mgr-bd').value, monthly: $('ud-mgr-bm').value }; + var applyB = function () { st.budget = { daily: N(nb.daily), monthly: N(nb.monthly) }; apply(); }; + if (window.__USAGE_SERVE__) postJSON('/api/budget', nb).then(applyB); + else settingsCall({ budget: { daily: N(nb.daily), monthly: N(nb.monthly) } }).then(applyB); + }); + // 通用设置(币种口径下的汇率与自动同步) + $('ud-mgr-gsave').addEventListener('click', function () { + var g = { currency: mgrState.prices.currency || 'CNY', usdCny: $('ud-mgr-rate').value, autoSync: !$('ud-sw-autosync').checked ? false : true }; + priceSave({ url: '/api/prices/general', data: g, tool: { priceGeneral: g } }).then(afterPriceSave); + }); + function postSwitch(patch) { + return settingsSave(patch).then(function (j) { if (j && j.switches) mgrState.switches = j.switches; }); + } + $('ud-sw-permission').addEventListener('change', function () { postSwitch({ permissionAlerts: this.checked }); }); + $('ud-sw-budget').addEventListener('change', function () { postSwitch({ budgetAlerts: this.checked }); }); + $('ud-sw-turnstats').addEventListener('change', function () { postSwitch({ turnstats: this.value }); }); + } + function postJSON(url, data) { + return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }).then(function (r) { return r.json(); }); + } + // 侧栏(MCP App)无 HTTP 服务:经 callTool 直调 App-only 设置工具,不经模型 + function settingsCall(setArgs) { + var zc = window.zcode || null; + return zc && typeof zc.callTool === 'function' + ? Promise.resolve(zc.callTool('usage_settings_set', setArgs)).then(function (r) { return (r && r.structuredContent) || { ok: true }; }) + : Promise.reject(new Error('no-calltool')); + } + function settingsSave(patch) { + if (window.__USAGE_SERVE__) return postJSON('/api/switches', patch); + return settingsCall({ switches: patch }); + } + function priceSave(payload) { + if (window.__USAGE_SERVE__) return postJSON(payload.url, payload.data); + return settingsCall(payload.tool); + } + // 价格类保存后的统一收尾。MCP App 沙箱内禁止页面级导航/重载(location.reload、location.href + // 都会让宿主把面板判为崩溃,报「MCP App page stopped」),侧栏改软刷新:用保存响应里的最新 + // 瘦身价格表重绑页内价格并整体重渲染;仅本地面板(serve,普通浏览器页面)保留整页 reload。 + function softReloadPrices(np) { + if (np && typeof np === 'object') { + PRICE = np; + PMODELS = PRICE.models || PRICE; + CURSYM = ({ USD: '$', CNY: '¥', EUR: '€', GBP: '£', JPY: '¥' })[PRICE.currency] || (PRICE.currency ? PRICE.currency + ' ' : '$'); + } + apply(); + } + function afterPriceSave(sc) { + if (window.__USAGE_SERVE__) { location.reload(); return; } + var finish = function (p) { + if (p) { mgrState.prices = p; softReloadPrices(p); } + else if (typeof apply === 'function') apply(); + var mask = $('ud-mgr-mask'); if (mask) mask.hidden = true; + }; + if (sc && sc.prices) { finish(sc.prices); return; } + var zc = window.zcode || null; + if (zc && typeof zc.callTool === 'function') { + zc.callTool('usage_settings_get', {}).then(function (r) { + finish((r && r.structuredContent && r.structuredContent.prices) || null); + }).catch(function () { finish(null); }); + } else finish(null); + } + mgrState.builtins = ['glm-5.3', 'glm-5.3-flash', 'glm-5.3-flashx']; + $('ud-price-mgr').addEventListener('click', function () { + mgrState.q = ''; // 重开弹框不残留上次搜索词(瘦身表按旧词过滤会得到空列表) + $('ud-mgr-mask').hidden = false; + var body = $('ud-mgr-body'); + var zc = window.zcode || null; + function renderFrom(sc) { + mgrState.prices = sc.prices || { currency: 'CNY', models: {} }; + mgrState.budget = sc.budget || {}; + mgrState.switches = sc.switches || {}; + if (!st.budget) st.budget = mgrState.budget; + renderMgr(); + } + function loadServe() { + body.innerHTML = '

加载中…

'; + Promise.all([ + fetch('/api/prices').then(function (r) { return r.json(); }), + fetch('/api/budget').then(function (r) { return r.json(); }), + fetch('/api/switches').then(function (r) { return r.json(); }).catch(function () { return {}; }) + ]).then(function (rs) { + renderFrom({ prices: rs[0], budget: rs[1], switches: rs[2] || {} }); + }); + } + if (window.__USAGE_SERVE__) { loadServe(); return; } + // 侧栏(MCP App):callTool 直调设置工具,不经模型 + if (zc && typeof zc.callTool === 'function') { + body.innerHTML = '

加载中…

'; + Promise.resolve(zc.callTool('usage_settings_get', {})).then(function (r) { + renderFrom((r && r.structuredContent) || {}); + }).catch(function () { openPanelFallback(body, zc); }); + return; + } + openPanelFallback(body, zc); + }); + function openPanelFallback(body, zc) { + body.innerHTML = + '

当前环境不支持面板内编辑。两种方式:

' + + '
    ' + + '
  1. 打开本地面板编辑(推荐,同样的界面)
  2. ' + + '
  3. 直接编辑 ~/.zcode/usage-prices.json(字段 in / out / cache,每百万 token)
' + + '' + + '或运行 usage.sh serve'; + var btn = $('ud-mgr-open-serve'); + btn.addEventListener('click', function () { + btn.disabled = true; + btn.textContent = '启动中…'; + var call = (zc && typeof zc.callTool === 'function') + ? Promise.resolve(zc.callTool('open_full_panel', {})) + : Promise.reject(new Error('no-calltool')); + call.then(function () { + btn.textContent = '已启动(浏览器将自动打开)'; + }).catch(function () { + btn.disabled = false; + btn.textContent = '打开本地面板'; + alert('启动失败:请在终端运行 usage.sh serve'); + }); + }); + } + $('ud-mgr-close').addEventListener('click', function () { $('ud-mgr-mask').hidden = true; }); + $('ud-mgr-mask').addEventListener('click', function (e) { if (e.target === this) this.hidden = true; }); + $('ud-export-report').hidden = false; + $('ud-export-report').addEventListener('click', function () { + var btn = this; + if (window.__USAGE_SERVE__) { location.href = '/report.html'; return; } + // 侧栏(MCP App)禁止页面级导航(宿主会报 App page stopped): + // 确保本地 serve 面板运行后,交系统浏览器下载报表(Content-Disposition 附件)。 + btn.disabled = true; + btn.textContent = '准备中…'; + var zc = window.zcode || null; + var done = function () { setTimeout(function () { btn.disabled = false; btn.textContent = '导出报表'; }, 2500); }; + var call = (zc && typeof zc.callTool === 'function') + ? Promise.resolve(zc.callTool('open_full_panel', {})) + : Promise.reject(new Error('no-calltool')); + call.then(function (r) { + var base = ((r && r.structuredContent && r.structuredContent.url) || 'http://127.0.0.1:7741').replace(/\/$/, ''); + if (!(zc && typeof zc.openExternal === 'function')) { btn.textContent = '当前环境不支持导出'; return; } + zc.openExternal({ href: base + '/report.html' }); + btn.textContent = '已交浏览器下载'; + }).catch(function () { + btn.textContent = '导出失败(需本地面板)'; + }).then(done, done); + }); + // 官方配额(可选):配置 API Key 后 serve 模式显示官方口径配额卡 + if (window.__USAGE_SERVE__) { + fetch('/api/quota').then(function (r) { return r.json(); }).then(function (j) { + if (!j || !j.ok || !Array.isArray(j.limits) || !j.limits.length) return; + var host = $('ud-quota-card'); + host.hidden = false; + host.innerHTML = '

官方套餐配额' + (j.stale ? ' 观测时间:' + (j.logDate || '–') + '' : '') + '

' + + j.limits.map(function (L) { + var pct = Math.max(0, Math.min(100, Math.round(N(L.percentage)))); + return '
' + esc(L.type || '配额') + '已用 ' + pct + '%' + (L.resetTime ? ' · ' + esc(String(L.resetTime)) + ' 重置' : '') + '
' + + '
'; + }).join('') + '
'; + }).catch(function () {}); + } + $('ud-tab-trend').addEventListener('click', function () { requestAnimationFrame(drawTrend); }); + if (typeof ResizeObserver === 'function') { + new ResizeObserver(function () { drawTrend(); }).observe($('ud-trend-chart')); + new ResizeObserver(function () { + var w = $('ud-heatmap').clientWidth; + if (w && w !== heatLastW) renderHeatmap(frows(false)); + }).observe($('ud-heatmap')); + } + + + apply(); + // 图表悬停提示:SVG rect 的 title 即入即显(原生 title 有 1~2s 延迟;全环境通用) + var tip = document.createElement('div'); + tip.id = 'ud-tip'; + tip.setAttribute('role', 'tooltip'); + tip.style.cssText = 'position:fixed;display:none;z-index:999;pointer-events:none;background:var(--card);color:var(--foreground);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:12px;line-height:1.5;box-shadow:0 6px 20px rgba(0,0,0,.22);white-space:pre;max-width:360px;'; + document.body.appendChild(tip); + document.addEventListener('mousemove', function (e) { + var t = e.target; + // SVG 图元读内嵌 ;任意 data-tip 元素(月历格、拆分条)读 data-tip(统一即时 tooltip) + var el2 = t && t.closest ? t.closest('svg rect, svg circle, svg path, [data-tip]') : null; + var text = ''; + if (el2) { + var dt = el2.getAttribute && el2.getAttribute('data-tip'); + if (dt) text = dt; + else { var tt = el2.querySelector && el2.querySelector('title'); text = (tt && tt.textContent) || ''; } + } + if (text) { + tip.textContent = text; + tip.style.display = 'block'; + var x = Math.min(e.clientX + 14, window.innerWidth - tip.offsetWidth - 10); + var y = Math.min(e.clientY + 14, window.innerHeight - tip.offsetHeight - 10); + tip.style.left = Math.max(6, x) + 'px'; + tip.style.top = Math.max(6, y) + 'px'; + } else { + tip.style.display = 'none'; + } + }); + // 支持 #tab hash 定位(本地面板跳转:#trend/#sessions/#detail/#errors) + var h = (location.hash || '').replace('#', ''); + if (h) { + var btn = document.querySelector('.nav-pills [role=tab][aria-controls="ud-' + h + '"]'); + if (btn) btn.click(); + } + window.__usageBootDone = true; +} +if (window.__USAGE_DATA_URL__) { + fetch(window.__USAGE_DATA_URL__).then(function (r) { return r.json(); }).then(function (j) { __usageBoot(j); }); +} else { + __usageBoot(DATA || {}); +} +// 「本轮」实时横幅:侧栏面板内走宿主资源订阅(MCP resources/updated 推送,零轮询); +// 浏览器/serve 模式退化为轮询本机 /api/turn。统计由本地脚本计算,不经过模型。 +(function () { + var el = document.getElementById('ud-turn'); + if (!el) return; + function render(text) { + text = String(text || '').trim(); + if (!text) { el.hidden = true; return; } + var d = new Date(); + var p2 = function (n) { return n < 10 ? '0' + n : '' + n; }; + el.innerHTML = '<span>⏱</span><span>' + text.replace(/[&<>]/g, function (c) { return { '&': '&', '<': '<', '>': '>' }[c]; }) + + '</span><span class="ud-turn-t">' + p2(d.getHours()) + ':' + p2(d.getMinutes()) + ' 更新</span>'; + el.hidden = false; + } + var zc = window.zcode || null; + function refreshZc() { + try { + zc.readResource({ uri: 'ui://usage-detail/turn.txt' }).then(function (res) { + render(res && res.contents && res.contents[0] && res.contents[0].text); + }).catch(function () {}); + } catch (e) {} + } + function refreshHttp() { + fetch('/api/turn', { cache: 'no-store' }).then(function (r) { return r.ok ? r.json() : null; }) + .then(function (j) { render(j && j.text); }).catch(function () {}); + } + if (zc && typeof zc.subscribeResource === 'function' && typeof zc.onResourceUpdated === 'function') { + try { + zc.subscribeResource({ uri: 'ui://usage-detail/turn.txt' }); + zc.onResourceUpdated(function (u) { if (!u || !u.uri || u.uri === 'ui://usage-detail/turn.txt') refreshZc(); }); + } catch (e) {} + refreshZc(); + } else if (location.protocol === 'http:' || location.protocol === 'https:') { + refreshHttp(); + setInterval(refreshHttp, 15000); + } +})(); +</script> +</div> +UD_TAIL + } > "$out" + echo "已生成用量界面:$out" +} + 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..5a35caf3 --- /dev/null +++ b/external_plugins/usage-detail/scripts/lib/session.sh @@ -0,0 +1,44 @@ +# 会话定位:按最近请求推断 / --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 + 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..0778a978 --- /dev/null +++ b/external_plugins/usage-detail/scripts/mcp/panel-server.mjs @@ -0,0 +1,795 @@ +#!/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 PANEL_HTML = path.join(HERE, '..', 'ui', 'panel.html'); +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]]); +} + +function priceText() { + const p = USER_PRICE; + try { return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null; } catch (e) { return null; } +} + +// 全量 META(与 serve.mjs 同一形状:行数组 + 索引映射,前端聚合) +function metaSQL(limit, disk) { + 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, "''")}'), + '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)) + 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) }; + const data = JSON.parse(q1(metaSQL(limit, disk))); + 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 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 = 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 || {}, + serveUrl: 'http://127.0.0.1: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.15.2' }; +// 完整版仪表盘作为侧栏页面(与 /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'); +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 tmp = path.join(os.tmpdir(), 'usage-detail-panel-' + process.pid + '.html'); + execFile('bash', [USAGE_SH, 'dashboard', tmp], { timeout: 120000, env: { ...process.env, ZCODE_USAGE_LIMIT: process.env.ZCODE_USAGE_LIMIT || '-1' } }, (err) => { + if (err) return reject(new Error('生成仪表盘失败:' + (err.message || err))); + try { + const frag = fs.readFileSync(tmp, 'utf8'); + fs.unlinkSync(tmp); + const html = '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ZCode 用量面板' + frag + '`; + 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 关闭) +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; + if (stale && fs.existsSync(process.env.ZCODE_USAGE_PRICES || '/dev/null') === false || stale) { + spawn('bash', [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) cost += (nn(r[4]) * 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 { + 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 + '