diff --git a/plugins/yanhy2000/mcode-usage-monitor/.minimax-plugin/plugin.json b/plugins/yanhy2000/mcode-usage-monitor/.minimax-plugin/plugin.json index bc1f7f0..ae5ed3d 100644 --- a/plugins/yanhy2000/mcode-usage-monitor/.minimax-plugin/plugin.json +++ b/plugins/yanhy2000/mcode-usage-monitor/.minimax-plugin/plugin.json @@ -2,16 +2,20 @@ "schemaVersion": 1, "name": "mcode-usage-monitor", "displayName": "Token 用量看板", - "version": "1.2.0", + "version": "1.3.0", "description": "统计本机 MiniMax Code 会话的 token 消耗,可按模型和会话筛选。", "author": "yanhy2000", "icon": "icon.png", "category": "Business", "exampleQueries": [ "打开 Token 用量看板", - "看看 MiniMax Code 最近 24 小时的 token 用量" + "查一下这个对话用了多少 token", + "统计最近 7 天的 token 消耗与缓存命中率", + "看看历史上哪个模型用得最多" ], "apps": [], "mcpServers": [], - "skills": [] + "skills": [ + "skills/usage-query/SKILL.md" + ] } diff --git a/plugins/yanhy2000/mcode-usage-monitor/README.md b/plugins/yanhy2000/mcode-usage-monitor/README.md index e346115..afd3642 100644 --- a/plugins/yanhy2000/mcode-usage-monitor/README.md +++ b/plugins/yanhy2000/mcode-usage-monitor/README.md @@ -4,7 +4,7 @@ English | [简体中文](README.zh-CN.md) Watch Token usage from your local MiniMax Code runtime in near real time: consumption over time, output speed, cache hit rate, per-model comparison, per-project breakdown, tool-call stats, and recent requests. Filter by time range, model, and session. -Author: [yanhy2000](https://github.com/yanhy2000) · Version: `1.2.0` +Author: [yanhy2000](https://github.com/yanhy2000) · Version: `1.3.0` ![Token Usage Board with synthetic data](docs/preview.png) @@ -26,9 +26,11 @@ Do not leave out the hidden `.minimax-plugin` directory; the plugin needs it to Then restart a version of MiniMax Code that supports MiniApps, confirm that the plugin is enabled, and open "Token 用量看板" or ask the Agent to open it. If MiniMax Code uses a custom data directory (`MINIMAX_DATA_DIR`), put it under `plugins/` there instead. -The page opens on the last 24 hours. You can switch between today (since local midnight), 1 hour, 12 hours, 24 hours, 7 days, 30 days, and all time, or enter a custom whole-hour range (1–8760 hours; only the most recent entry is kept). Model and session filtering is multi-select, and the session list only lists sessions with usage inside the selected range. The per-project breakdown groups usage by session workspace directory, and tool-call stats come from the tool calls recorded per request; both follow the current filters. Every card can be collapsed or expanded, and a collapsed card can be dragged to reorder (expanded cards cannot). The "重置布局" button at the top restores all cards to expanded and the default order without touching the filters. Filters, time range, refresh interval, theme, card collapse state, and card order are remembered between visits. The page refreshes every 10 seconds by default (5 s / 10 s / 30 s / manual). +The page opens on the last 24 hours. You can switch between today (since local midnight), 1 hour, 12 hours, 24 hours, 7 days, 30 days, and all time, or enter a custom whole-hour range (1–8760 hours; only the most recent entry is kept). Model and session filtering is multi-select, and the two lists constrain each other: the model list only shows models that appear in the selected sessions, and the session list only shows sessions that used the selected models; a side shows its full range list only when the other side is set to all. The select-all action is dimmed only when everything is selected, invert is always available, and selecting nothing is allowed (the page then shows zero data). The per-project breakdown groups usage by session workspace directory, and tool-call stats come from the tool calls recorded per request; both follow the current filters. Every card can be collapsed or expanded. Clicking "编辑布局" enters edit mode: the KPI tiles at the top and the cards below (collapsed or not) can be reordered by dragging their ⠿ handle and removed with ✕. "保存布局" exits and remembers the arrangement, "取消保存" discards the changes, and "重置布局" restores everything shown in the default order — none of these touch the filters. Filters, time range, refresh interval, theme, and the visibility and order of tiles and cards are remembered between visits. The page refreshes every 10 seconds by default (5 s / 10 s / 30 s / manual). -This app requires **Python 3.8+** on the local machine to read the local database. It uses only the standard library, so no `pip install` is needed. No API key or other configuration is required. +This app requires **Python 3.8+** on the local machine to read the local database. It uses only the standard library, so no `pip install` is needed. The bundled SQLite must have the JSON1 extension enabled (the default for SQLite 3.38+ and all standard Python builds; the page reports the exact cause if it is missing). No API key or other configuration is required. + +The package also ships one Agent skill (`skills/usage-query/`). When a usage question comes up in a conversation — "how many tokens did this chat use?", "which model do I use most?" — the Agent can run the bundled data backend (`miniapp/node/api.py`) directly and answer, without opening the dashboard first. That path is the same read-only snapshot as the page and needs the same local Python. Open the dashboard itself when you want charts, or want to switch filters and rearrange the layout yourself. ## Data access and counting @@ -50,7 +52,7 @@ The runtime sends nothing to external services and has no telemetry. It writes a The page is in `miniapp/client/index.html` (ECharts is bundled locally), the Node entry is `miniapp/node/server.mjs`, and the data backend is `miniapp/node/api.py`. No build step is required. -Verified environment: MiniMax Code desktop `3.0.73.166` on Windows (10.0.26200, x64). Verified during development: plugin install and open, aggregation and de-duplication, model/session filtering, preference persistence, auto refresh, theme switching, chart and table rendering, time-range presets and custom-range validation, select-all/invert gating, and card collapse and drag reordering. macOS and Linux are unverified. +Verified environment: MiniMax Code desktop `3.1.0` on Windows (10.0.26200, x64). Verified during development: plugin install and open, aggregation and de-duplication, model/session filtering, preference persistence, auto refresh, theme switching, chart and table rendering, time-range presets with custom-range validation, selecting-no-filter, layout editing (drag reordering and removal), the icon-only header controls, the recent-calls table tweaks, cross-filtering between the model and session lists (narrowing in both directions), the model comparison card empty state, Esc closing dropdown panels, atomic preference writes, and the bundled Agent skill answering usage questions from the data backend. The dashboard and the bundled skill were re-tested on the `3.1.0` release. macOS and Linux are unverified. Third-party components: [ECharts](https://echarts.apache.org/) (Apache License 2.0), bundled locally for offline use. diff --git a/plugins/yanhy2000/mcode-usage-monitor/README.zh-CN.md b/plugins/yanhy2000/mcode-usage-monitor/README.zh-CN.md index d9d49da..85c92a4 100644 --- a/plugins/yanhy2000/mcode-usage-monitor/README.zh-CN.md +++ b/plugins/yanhy2000/mcode-usage-monitor/README.zh-CN.md @@ -4,7 +4,7 @@ 近实时查看本机 MiniMax Code 的 Token 用量:消耗趋势、输出速度、缓存命中率、模型对比、项目用量排行、工具调用统计和最近请求,可按时间范围、模型和会话筛选。 -作者:[yanhy2000](https://github.com/yanhy2000) · 版本:`1.2.0` +作者:[yanhy2000](https://github.com/yanhy2000) · 版本:`1.3.0` ![Token 用量看板,使用合成数据](docs/preview.png) @@ -26,9 +26,11 @@ 完成后重启支持 MiniApp 的 MiniMax Code,确认插件已启用,然后打开「Token 用量看板」,或在对话里让 Agent 打开。如果给 MiniMax Code 设置了自定义数据目录(环境变量 `MINIMAX_DATA_DIR`),则放进该目录下的 `plugins/` 里。 -页面默认显示最近 24 小时,可切换今天(自当天 00:00 起)/ 1 小时 / 12 小时 / 24 小时 / 7 天 / 30 天 / 全部,也可输入整数小时自定义范围(1–8760 小时,只保留最近输入的一条)。支持按模型和会话多选筛选,会话列表只列出所选范围内有用量的会话;项目用量排行按会话工作区目录汇总,工具调用统计来自每次请求的工具调用记录,两者都跟随当前筛选。每张卡片可收起 / 展开,收起后可拖拽调整排列顺序(展开时不可拖动);顶部「重置布局」可一键恢复全部展开与默认排列,不影响上方的筛选条件。筛选条件、时间范围、刷新间隔、主题、卡片收起与排列都会被记住。页面默认每 10 秒自动刷新(可切换 5 秒 / 10 秒 / 30 秒或手动刷新)。 +页面默认显示最近 24 小时,可切换今天(自当天 00:00 起)/ 1 小时 / 12 小时 / 24 小时 / 7 天 / 30 天 / 全部,也可输入整数小时自定义范围(1–8760 小时,只保留最近输入的一条)。支持按模型和会话多选筛选,两侧列表互相牵制:模型列表只显示所选会话中出现过的模型,会话列表只显示所选模型出现过的会话,对应一侧选「全部」时才展示该侧完整列表;全选按钮在全部选中时置灰,反选始终可用,也允许全部不选(此时页面数据为 0)。项目用量排行按会话工作区目录汇总,工具调用统计来自每次请求的工具调用记录,两者都跟随当前筛选。每张卡片可收起 / 展开;点击「编辑布局」进入编辑模式后,顶部数字方块与下方卡片(无论展开还是收起)都可以拖拽排序,也可以点 ✕ 从页面移除,「保存布局」退出并记住排列,「取消保存」放弃本次修改,「重置布局」一键恢复全部显示与默认排列,均不影响上方的筛选条件。筛选条件、时间范围、刷新间隔、主题、方块与卡片的显示和排列都会被记住。页面默认每 10 秒自动刷新(可切换 5 秒 / 10 秒 / 30 秒或手动刷新)。 -本插件需要本机已安装 **Python 3.8+** 用于读取本地数据库;只用标准库,无需 `pip install`。不需要 API Key 或其他配置。 +本插件需要本机已安装 **Python 3.8+** 用于读取本地数据库;只用标准库,无需 `pip install`。其自带 SQLite 需启用 JSON1 扩展(SQLite 3.38+ 与 Python 官方构建均默认启用,缺失时页面会提示具体原因)。不需要 API Key 或其他配置。 + +插件还内置一个 Agent 技能(`skills/usage-query/`):在对话里问到用量类问题(如「这个对话用了多少 token」「哪个模型用得最多」)时,Agent 可以直接调用打包的数据后端(`miniapp/node/api.py`)查询后回答,不必先打开看板;这条查询路径与页面一样是只读快照,依赖同样的本机 Python。想看趋势图或自己切换筛选、调整布局时,再打开看板页面。 ## 数据与统计口径 @@ -50,7 +52,7 @@ 页面位于 `miniapp/client/index.html`(ECharts 已本地化打包),Node 入口位于 `miniapp/node/server.mjs`,数据后端位于 `miniapp/node/api.py`,无需构建。 -已验证环境:MiniMax Code 桌面端 `3.0.73.166`,Windows(10.0.26200,x64)。开发过程中已验证:插件安装与打开、数据聚合与去重、模型/会话筛选、偏好记忆、自动刷新、主题切换、图表与列表渲染、时间范围预设与自定义输入校验、全选/反选置灰、卡片收起与拖拽排序。macOS 与 Linux 未验证。 +已验证环境:MiniMax Code 桌面端 `3.1.0` 正式版,Windows(10.0.26200,x64)。开发过程中已验证:插件安装与打开、数据聚合与去重、模型/会话筛选、偏好记忆、自动刷新、主题切换、图表与列表渲染、时间范围预设与自定义输入校验、筛选全不选、编辑布局(拖拽排序与移除)、顶栏图标化与最近调用表格优化、模型与会话列表交叉筛选(双向收窄)、模型对比卡空态提示、Esc 收起下拉面板、偏好文件原子写入、Agent 通过内置技能直接查询用量;看板与内置技能在 `3.1.0` 正式版下复测通过。macOS 与 Linux 未验证。 第三方组件:[ECharts](https://echarts.apache.org/)(Apache License 2.0),已本地化打包以便离线使用。 diff --git a/plugins/yanhy2000/mcode-usage-monitor/docs/preview.png b/plugins/yanhy2000/mcode-usage-monitor/docs/preview.png index ca0ef0c..06d5ae3 100644 Binary files a/plugins/yanhy2000/mcode-usage-monitor/docs/preview.png and b/plugins/yanhy2000/mcode-usage-monitor/docs/preview.png differ diff --git a/plugins/yanhy2000/mcode-usage-monitor/miniapp/client/index.html b/plugins/yanhy2000/mcode-usage-monitor/miniapp/client/index.html index d5501d4..5b51271 100644 --- a/plugins/yanhy2000/mcode-usage-monitor/miniapp/client/index.html +++ b/plugins/yanhy2000/mcode-usage-monitor/miniapp/client/index.html @@ -31,13 +31,24 @@ Roboto,Helvetica,Arial,sans-serif; max-width:1240px;margin:0 auto; padding:clamp(12px,2vw,20px) clamp(14px,3vw,24px) 40px} -header{display:flex;align-items:flex-start;justify-content:space-between; - gap:12px 16px;flex-wrap:wrap;margin-bottom:16px} +header{display:flex;flex-direction:column;align-items:stretch;gap:8px; + margin-bottom:16px} +.htop{display:flex;align-items:flex-start;justify-content:space-between; + gap:12px;flex-wrap:wrap} .hgroup{display:flex;flex-direction:column;gap:2px} h1{font-size:18px;font-weight:600;line-height:28px} .sub{color:var(--mcode-text-muted);font-size:12px;line-height:16px} #dbinfo{color:var(--mcode-text-subtle)} -.hctl{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding-top:2px} +/* 标题行右端的裸图标组: 无边框按钮, hover 才有淡背景 */ +.hicons{display:flex;align-items:center;gap:2px} +.hicons button{background:transparent;border:0;width:28px;height:28px;padding:0; + display:inline-flex;align-items:center;justify-content:center; + border-radius:7px;color:var(--mcode-text-muted)} +.hicons button:hover{background:var(--mcode-bg-secondary); + color:var(--mcode-text)} +.hicons button:focus-visible{outline:2px solid var(--mcode-accent);outline-offset:1px} +.hicons .dd{position:relative} +.hctl{display:flex;align-items:center;gap:10px;flex-wrap:wrap} .spacer{flex:1} select,button{background:var(--mcode-bg-secondary);color:var(--mcode-text); border:1px solid var(--mcode-border);border-radius:8px;padding:4px 10px; @@ -53,7 +64,10 @@ /* --- 多选下拉 --- */ .dd{position:relative} -.ddbtn{display:flex;align-items:center;gap:7px;min-width:150px;justify-content:space-between} +.ddbtn{display:flex;align-items:center;gap:7px;min-width:150px;max-width:240px; + justify-content:space-between} +.ddbtn>span:first-child{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis; + white-space:nowrap;text-align:left} .ddbtn .cnt{color:var(--mcode-accent);font-weight:600} .ddbtn .caret{color:var(--mcode-text-muted);display:inline-flex} .ddpanel{position:absolute;top:calc(100% + 6px);left:0;z-index:50; @@ -75,6 +89,17 @@ .ddrow .mname{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .ddrow .mmeta{color:var(--mcode-text-muted);font-size:11px;font-variant-numeric:tabular-nums} .swatch{width:9px;height:9px;border-radius:2px;flex:none} +/* 图标按钮通用尺寸(标题行右端使用, 外观由 .hicons 定义) */ +.iconbtn{width:28px;min-width:28px;padding:0;display:inline-flex; + align-items:center;justify-content:center} +.iconbtn svg{flex:none} +/* 加 .ddpanel 前缀提高优先级: 面板位于 .hicons 内, 否则被 .hicons button 的 + 28px 图标规则压成小块横排 */ +.ddpanel .ivrow{display:block;width:100%;padding:6px 10px;border:0;background:transparent; + color:var(--mcode-text);font-size:13px;font-family:inherit;cursor:pointer; + border-radius:8px;text-align:left;height:auto} +.ddpanel .ivrow:hover{background:var(--mcode-bg-secondary)} +.ddpanel .ivrow.on{color:var(--mcode-accent);font-weight:600} .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px;margin-bottom:16px} @@ -84,8 +109,10 @@ .kpi .value{font-size:22px;font-weight:600;font-variant-numeric:tabular-nums} .kpi .hint{color:var(--mcode-text-muted);font-size:11px;margin-top:2px} section.panel{background:var(--mcode-surface);border:1px solid var(--mcode-border); - border-radius:12px;padding:14px 16px;margin-bottom:16px} -.phead{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap} + border-radius:12px;padding:14px 16px;margin-bottom:16px;position:relative} +.phead{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap; + padding-right:40px} /* 右上角收起按钮占位 */ +body.editing .phead{padding-right:100px} /* 编辑模式三个按钮占位 */ .panel h2{font-size:15px;font-weight:500;color:var(--mcode-text)} .toggle{display:flex;border:1px solid var(--mcode-border);border-radius:8px;overflow:hidden} .toggle button{border:0;border-radius:0;background:transparent;padding:3px 10px; @@ -102,36 +129,49 @@ #cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px} #cards>section.panel{margin-bottom:0;grid-column:1/-1;min-width:0} #cards>section.panel.narrow{grid-column:span 1} -/* 卡片收起按钮与拖拽态 */ -.cbtns{display:flex;align-items:center;gap:2px;margin-left:auto} +/* 卡片收起/编辑按钮: 固定在卡片右上角, 不受头部内容换行影响 */ +.cbtns{display:flex;align-items:center;gap:2px;position:absolute;top:12px;right:12px} .cbtn{padding:0;width:24px;height:24px;display:inline-flex;align-items:center; justify-content:center;background:transparent;border-color:transparent; border-radius:6px;color:var(--mcode-text-muted)} .cbtn:hover{background:var(--mcode-bg-secondary);border-color:var(--mcode-border); color:var(--mcode-text)} +.cbtn.grab{cursor:grab} .cbtn .chev{transition:transform .15s} .panel.collapsed .cbtn .chev{transform:rotate(-90deg)} -.draghint{display:none;color:var(--mcode-text-subtle);font-size:12px;letter-spacing:0; - user-select:none;cursor:grab} -.panel.collapsed .draghint{display:inline} .panel.collapsed .phead{margin-bottom:0} -.panel.collapsed{cursor:grab} +/* 收起态头部保留标题与简介(便于识别卡片), 只收起切换按钮与动态统计小字 */ +.panel.collapsed .phead .toggle, +.panel.collapsed .phead #bucketInfo{display:none!important} .panel.dragging{opacity:.55;cursor:grabbing} +/* 编辑布局模式: KPI 方块与卡片出现拖拽/移除按钮, 重置按钮让位给取消按钮 */ +.kpi{position:relative} +.kbtns{position:absolute;top:5px;right:5px;display:none;align-items:center;gap:2px} +body.editing .kbtns{display:flex} +.cbtns .ebtn,#layoutCancel{display:none} +body.editing .cbtns .ebtn{display:inline-flex} +body.editing #layoutCancel{display:inline-block} +body.editing #layoutReset{display:none} +.kpi.dragging{opacity:.55} +.kpi.drop-before{box-shadow:inset 0 3px 0 var(--mcode-accent)} +.kpi.drop-after{box-shadow:inset 0 -3px 0 var(--mcode-accent)} +#kpis:empty{display:none} /* KPI 删光时隐藏占位容器 */ .panel.drop-before{box-shadow:inset 0 3px 0 var(--mcode-accent)} .panel.drop-after{box-shadow:inset 0 -3px 0 var(--mcode-accent)} body.dragging-card{user-select:none;cursor:grabbing} .empty{padding:40px;text-align:center;color:var(--mcode-text-muted);font-size:13px} table{width:100%;border-collapse:collapse;font-size:13px; font-variant-numeric:tabular-nums} -th{color:var(--mcode-text-muted);font-weight:500;text-align:right;padding:6px 10px; +th{color:var(--mcode-text-muted);font-weight:500;text-align:center;padding:6px 10px; border-bottom:1px solid var(--mcode-border);position:sticky;top:0; - background:var(--mcode-surface)} -th:first-child,td:first-child{text-align:left} + background:var(--mcode-surface);white-space:nowrap} /* 表头不换行, 列宽随内容自适应 */ td{text-align:right;padding:5px 10px;border-bottom:1px solid var(--mcode-border); - color:var(--mcode-text)} + color:var(--mcode-text);white-space:nowrap} +td:first-child,td:nth-child(2),td:nth-child(3){text-align:left} /* 时间/模型/会话 */ tr:hover td{background:var(--mcode-bg-secondary)} .tag{display:inline-block;padding:1px 8px;border-radius:10px;font-size:12px; - border:1px solid var(--mcode-border);white-space:nowrap} + border:1px solid var(--mcode-border);white-space:nowrap;max-width:160px; + overflow:hidden;text-overflow:ellipsis;vertical-align:middle} .tablewrap{max-height:420px;overflow:auto} .tfoot{color:var(--mcode-text-muted);font-size:12px;text-align:center;padding:8px 2px 2px} footer{color:var(--mcode-text-muted);font-size:12px;text-align:center;margin-top:8px} @@ -139,7 +179,8 @@ footer a:hover{text-decoration:underline} #toast{position:fixed;top:14px;right:14px;background:var(--mcode-bg-secondary); border:1px solid var(--mcode-danger);color:var(--mcode-danger);border-radius:8px; - padding:8px 14px;font-size:13px;display:none;max-width:420px;z-index:99} + padding:8px 14px;font-size:13px;display:none;max-width:420px;z-index:99; + pointer-events:none} /* 纯通知, 不拦截其下顶栏图标的点击 */ /* ---------- 响应式 ---------- */ @media (max-width:720px){ @@ -165,10 +206,27 @@
-
-

Token 用量看板

-

统计本机 MiniMax Code 会话的 token 消耗,可按模型和会话筛选

-

正在读取本地用量数据…

+
+
+

Token 用量看板

+

统计本机 MiniMax Code 会话的 token 消耗,可按模型和会话筛选

+

正在读取本地用量数据…

+
+ +
+ +
+ + +
+ +
@@ -224,16 +282,9 @@

Token 用量看板

- - - - - + + +
@@ -244,6 +295,7 @@

Token 用量看板

Token 消耗时序

+ 按时间跨度自动分桶
@@ -259,6 +311,7 @@

Token 消耗时序

模型对比

点击柱子可只看该模型,再次点击取消
+
@@ -275,7 +328,7 @@

Token 消耗时序

Token 速度

- tokens/sec · 每次完成请求
+ tokens/sec · 最近 60 次完成请求
@@ -295,8 +348,9 @@

Token 消耗时序

+ - + diff --git a/plugins/yanhy2000/mcode-usage-monitor/miniapp/node/api.py b/plugins/yanhy2000/mcode-usage-monitor/miniapp/node/api.py index 2b5a26e..33958f0 100644 --- a/plugins/yanhy2000/mcode-usage-monitor/miniapp/node/api.py +++ b/plugins/yanhy2000/mcode-usage-monitor/miniapp/node/api.py @@ -39,8 +39,12 @@ def default_db_path() -> Path: return data_dir() / "v2" / "sqlite" / "runtime-state.sqlite" -def open_snapshot(db_path: Path) -> sqlite3.Connection: - """只读快照: 官方 backup API 优先, 极端锁场景回退到复制三件套。""" +def open_snapshot(db_path: Path): + """只读快照: 官方 backup API 优先, 极端锁场景回退到复制三件套。 + + 返回 (con, cleanup): cleanup 需在 con.close() 之后调用, 用于清理回退路径 + 产生的临时目录; 走 backup 路径时为 None。""" + src = None try: uri = f"file:{db_path.as_posix()}?mode=ro" src = sqlite3.connect(uri, uri=True, timeout=3) @@ -48,22 +52,24 @@ def open_snapshot(db_path: Path) -> sqlite3.Connection: with dst: src.backup(dst) src.close() - return dst + return dst, None except sqlite3.Error: - tmpdir = Path(tempfile.mkdtemp(prefix="mcode-usage-monitor-")) - try: - base = tmpdir / "snap.db" - shutil.copy2(db_path, base) - for suffix in ("-wal", "-shm"): - side = Path(str(db_path) + suffix) - if side.exists(): - shutil.copy2(side, Path(str(base) + suffix)) - con = sqlite3.connect(str(base)) - con.execute("PRAGMA journal_mode=DELETE") - return con - except Exception: - shutil.rmtree(tmpdir, ignore_errors=True) - raise + if src is not None: + src.close() + tmpdir = Path(tempfile.mkdtemp(prefix="mcode-usage-monitor-")) + try: + base = tmpdir / "snap.db" + shutil.copy2(db_path, base) + for suffix in ("-wal", "-shm"): + side = Path(str(db_path) + suffix) + if side.exists(): + shutil.copy2(side, Path(str(base) + suffix)) + con = sqlite3.connect(str(base)) + con.execute("PRAGMA journal_mode=DELETE") + return con, lambda: shutil.rmtree(tmpdir, ignore_errors=True) + except Exception: + shutil.rmtree(tmpdir, ignore_errors=True) + raise MSG_SQL = """ @@ -86,41 +92,27 @@ def open_snapshot(db_path: Path) -> sqlite3.Connection: ORDER BY m.created_at_ms, m.id """ -LEDGER_SQL = """ -SELECT COUNT(*), SUM(input_tokens), SUM(output_tokens), SUM(reasoning_tokens), - SUM(cache_read_tokens), SUM(cache_write_tokens), MIN(ts), MAX(ts), - COUNT(DISTINCT session_id), COUNT(DISTINCT turn_id) -FROM local_runtime_token_usage -""" - - def dedup_by_msg_id(rows): - """按 msg_id 跨会话去重(保留最早一条), 返回 (去重后的行, 丢弃行数)。""" + """按 msg_id 跨会话去重(保留最早一条)。""" seen = set() out = [] - dropped = 0 for r in rows: mid = r[-1] if mid is None or mid not in seen: if mid is not None: seen.add(mid) out.append(r) - else: - dropped += 1 - return out, dropped + return out def fetch_all(db_path: Path): - con = open_snapshot(db_path) + con, cleanup = open_snapshot(db_path) try: - rows, dropped = dedup_by_msg_id(con.execute(MSG_SQL).fetchall()) - try: - ledger = con.execute(LEDGER_SQL).fetchone() - except sqlite3.Error: - ledger = None + return dedup_by_msg_id(con.execute(MSG_SQL).fetchall()) finally: con.close() - return rows, ledger, dropped + if cleanup: + cleanup() RANGE_MS = {"1h": 3600_000, "12h": 12 * 3600_000, "24h": 86400_000, @@ -236,15 +228,26 @@ def project_label(segs, all_segs): def build_payload(db_path: Path, range_key: str, models_filter=None, sessions_filter=None) -> dict: - rows_all, ledger, dedup_dropped = fetch_all(db_path) + rows_all = fetch_all(db_path) now_ms = int(time.time() * 1000) cutoff = range_cutoff_ms(range_key, now_ms) if cutoff is not None: rows_all = [r for r in rows_all if r[0] >= cutoff] span = (now_ms - cutoff) if cutoff is not None else None + # 交叉筛选: 模型清单只受会话筛选牵制, 会话清单只受模型筛选牵制; + # 牵制方为 None(全部)时, 该侧展示当前范围内的完整清单。 + rows_for_models = rows_all + if sessions_filter is not None: + wanted_s = set(sessions_filter) + rows_for_models = [r for r in rows_for_models if r[2] in wanted_s] + rows_for_sessions = rows_all + if models_filter is not None: + wanted_m = set(models_filter) + rows_for_sessions = [r for r in rows_for_sessions if (r[1] or "unknown") in wanted_m] + avail = {} - for r in rows_all: + for r in rows_for_models: key = r[1] or "unknown" m = avail.setdefault(key, {"model": key, "calls": 0, "output": 0}) m["calls"] += 1 @@ -253,7 +256,7 @@ def build_payload(db_path: Path, range_key: str, models_filter=None, # 会话清单以磁盘上的真实会话文件为准(与产品内展示口径一致) stats = {} - for r in rows_all: + for r in rows_for_sessions: d = stats.setdefault(r[2], {"title": r[8] or "", "calls": 0, "tokens": 0, "last_ts": r[0]}) d["calls"] += 1 d["tokens"] += (r[4] or 0) + (r[5] or 0) + (r[6] or 0) @@ -262,7 +265,6 @@ def build_payload(db_path: Path, range_key: str, models_filter=None, files = discover_session_files(db_path) available_sessions = [] if files: - session_source = "files" for sid, meta in files.items(): st = stats.get(sid) if not st: # 当前时间范围内没有用量: 不列出(此时标题也无从取得) @@ -277,7 +279,6 @@ def build_payload(db_path: Path, range_key: str, models_filter=None, }) available_sessions.sort(key=lambda x: -x["file_mtime"]) else: # 兜底:一个会话文件都没有时,退回按库内会话列举 - session_source = "db" for sid, st in stats.items(): available_sessions.append({ "session_id": sid, @@ -287,10 +288,10 @@ def build_payload(db_path: Path, range_key: str, models_filter=None, available_sessions.sort(key=lambda x: -x["last_ts"]) rows = rows_all - if models_filter: + if models_filter is not None: # None=不过滤; 空集=显式全不选(0 数据) wanted = set(models_filter) rows = [r for r in rows if (r[1] or "unknown") in wanted] - if sessions_filter: + if sessions_filter is not None: wanted_s = set(sessions_filter) rows = [r for r in rows if r[2] in wanted_s] @@ -409,24 +410,10 @@ def build_payload(db_path: Path, range_key: str, models_filter=None, tool_list = [{"tool": k, "calls": v} for k, v in sorted(tool_counter.items(), key=lambda x: -x[1])[:10]] - ledger_obj = None - if ledger: - ledger_obj = { - "rows": ledger[0], "input": ledger[1], "output": ledger[2], - "reasoning": ledger[3], "cache_read": ledger[4], - "cache_write": ledger[5], - "sessions": ledger[8], "turns": ledger[9], - } - if ledger[6]: - ledger_obj["first_ts"] = ledger[6] - ledger_obj["last_ts"] = ledger[7] - return { "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"), "range": range_key, "available_models": available_models, - "selected_models": sorted(models_filter) if models_filter else [], - "dedup_dropped": dedup_dropped, "overview": { "calls": n, "sessions": len(sessions), @@ -442,13 +429,10 @@ def build_payload(db_path: Path, range_key: str, models_filter=None, "split": split, "models": model_list, "available_sessions": available_sessions, - "selected_sessions": sorted(sessions_filter) if sessions_filter else [], "session_files": len(available_sessions), - "session_source": session_source, "recent": recent, "projects": project_list, "tools": tool_list, - "ledger": ledger_obj, } @@ -456,8 +440,8 @@ def main(): ap = argparse.ArgumentParser() ap.add_argument("--range", default="all", dest="range_key", help="all | today | 1h | 12h | 24h | 7d | 30d | h(1..8760)") - ap.add_argument("--models", default="", help="逗号分隔的模型列表, 留空为全部") - ap.add_argument("--sessions", default="", help="逗号分隔的会话 id 列表, 留空为全部") + ap.add_argument("--models", default="", help="逗号分隔的模型列表, 留空为全部, __none__ 为空选") + ap.add_argument("--sessions", default="", help="逗号分隔的会话 id 列表, 留空为全部, __none__ 为空选") ap.add_argument("--db", default=str(default_db_path())) args = ap.parse_args() @@ -466,12 +450,33 @@ def emit(obj, code): sys.exit(code) rng = args.range_key if is_valid_range(args.range_key) else "all" - models = [p.strip() for p in args.models.split(",") if p.strip()] or None - sessions = [p.strip() for p in args.sessions.split(",") if p.strip()] or None + + def parse_ids(raw): + """空串 -> None(不过滤); '__none__' -> [](显式空选, 页面 0 数据)。""" + if raw == "__none__": + return [] + return [p.strip() for p in raw.split(",") if p.strip()] or None + + models = parse_ids(args.models) + sessions = parse_ids(args.sessions) db = Path(args.db) if not db.exists(): emit({"error": f"database not found: {db}"}, 1) + + # JSON 函数在 SQLite < 3.38 的构建里是编译期可选扩展(-DSQLITE_ENABLE_JSON1), + # 未启用的构建上 json_extract/json_each 直接报 no such function; 提前给出可自查的提示 + try: + probe = sqlite3.connect(":memory:") + try: + probe.execute("SELECT json('{}')") + finally: + probe.close() + except sqlite3.Error: + emit({"error": f"sqlite JSON1 extension not enabled " + f"(sqlite3.sqlite_version={sqlite3.sqlite_version}); " + f"queries need a Python build with JSON1-enabled SQLite"}, 1) + try: payload = build_payload(db, rng, models, sessions) except Exception as e: # 结构化错误交给 Node, 不打 traceback diff --git a/plugins/yanhy2000/mcode-usage-monitor/miniapp/node/server.mjs b/plugins/yanhy2000/mcode-usage-monitor/miniapp/node/server.mjs index fa16522..b6c2a05 100644 --- a/plugins/yanhy2000/mcode-usage-monitor/miniapp/node/server.mjs +++ b/plugins/yanhy2000/mcode-usage-monitor/miniapp/node/server.mjs @@ -1,16 +1,32 @@ // @ts-check -import { spawn } from 'node:child_process'; -import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { spawn, spawnSync } from 'node:child_process'; +import { readFile, writeFile, rename, mkdir } from 'node:fs/promises'; import { createServer } from 'node:http'; import { join } from 'node:path'; /** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ /** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ -const PYTHON = process.platform === 'win32' ? 'python' : 'python3'; +/** 探测可用的 Python 命令(Windows 商店别名桩会静默失败, 需回退 py -3)。 */ +function pickPython() { + const candidates = process.platform === 'win32' + ? [['python'], ['py', '-3']] + : [['python3'], ['python']]; + for (const cmd of candidates) { + try { + const r = spawnSync(cmd[0], [...cmd.slice(1), '--version'], + { timeout: 5000, windowsHide: true, encoding: 'utf8' }); + if (r.status === 0) return cmd; + } catch { /* 尝试下一个候选 */ } + } + return candidates[0]; // 全部失败时保留首选, 保留可见的报错路径 +} +const PYTHON_CMD = pickPython(); + const QUERY_TIMEOUT_MS = 30000; const CACHE_TTL_MS = 2000; +const CACHE_MAX = 24; // 条目上限, 防自定义范围×筛选组合无限增长 const MAX_OUTPUT_BYTES = 20 * 1024 * 1024; const RANGE_PRESETS = ['today', '1h', '12h', '24h', '7d', '30d', 'all']; const CUSTOM_RANGE_RE = /^(\d+)h$/; @@ -43,21 +59,33 @@ export async function start(context) { const apiPyPath = join(context.pluginRoot, 'miniapp/node/api.py'); const prefsPath = join(context.dataDir, 'prefs.json'); - const indexHtml = await readFile(join(clientRoot, 'index.html')); + let indexHtml = await readFile(join(clientRoot, 'index.html'), 'utf8'); + // 页脚版本号占位符由插件清单注入, 升版本只需改 plugin.json + try { + const pluginJson = JSON.parse( + await readFile(join(context.pluginRoot, '.minimax-plugin', 'plugin.json'), 'utf8')); + if (pluginJson && typeof pluginJson.version === 'string') { + indexHtml = indexHtml.replaceAll('__VERSION__', pluginJson.version); + } + } catch { /* 清单读取失败时保留占位符, 不影响页面其余功能 */ } const echartsJs = await readFile(join(clientRoot, 'echarts.min.js')); // ---- 偏好持久化(存到 Host 分配的插件数据目录) ---- const PREF_INTERVALS = [0, 5, 10, 30]; const PREF_THEMES = ['auto', 'light', 'dark']; const PREF_CARDS = ['main', 'model', 'proj', 'tool', 'speed', 'recent']; // 与 index.html CARD_IDS 一致 + const PREF_KPIS = ['total', 'input', 'output', 'cache', 'hit', 'calls', 'speed']; // 与 index.html KPI_KEYS 一致 function sanitizePrefs(input) { const out = {}; if (!input || typeof input !== 'object') return out; - if (Array.isArray(input.models)) { + // 筛选选择: null=全部(显式清除旧值), 'none'=空选(页面 0 数据), 数组=具体选择 + if (input.models === null || input.models === 'none') out.models = input.models; + else if (Array.isArray(input.models)) { out.models = input.models.filter((x) => typeof x === 'string').slice(0, 50); } - if (Array.isArray(input.sessions)) { + if (input.sessions === null || input.sessions === 'none') out.sessions = input.sessions; + else if (Array.isArray(input.sessions)) { out.sessions = input.sessions.filter((x) => typeof x === 'string').slice(0, 50); } const range = normalizeRange(input.range); @@ -71,15 +99,20 @@ export async function start(context) { } out.collapsed = c; } - if (Array.isArray(input.order)) { + // 布局顺序: 仅含可见项(空数组=全部移除, 也需显式保存); 缺失项视为已移除 + const pickList = (arr, allow) => { + if (!Array.isArray(arr)) return null; const seen = new Set(); const order = []; - for (const id of input.order) { - if (PREF_CARDS.includes(id) && !seen.has(id)) { seen.add(id); order.push(id); } + for (const id of arr) { + if (allow.includes(id) && !seen.has(id)) { seen.add(id); order.push(id); } } - for (const id of PREF_CARDS) { if (!seen.has(id)) order.push(id); } // 补齐缺失项 - out.order = order.slice(0, PREF_CARDS.length); - } + return order; + }; + const order = pickList(input.order, PREF_CARDS); + if (order !== null) out.order = order; // 空数组=全部移除, 也要落盘 + const kpis = pickList(input.kpis, PREF_KPIS); + if (kpis !== null) out.kpis = kpis; return out; } @@ -93,10 +126,17 @@ export async function start(context) { } } + let prefsOp = Promise.resolve(); // 串行化读-改-写, 避免并发合并互相覆盖 async function mergePrefs(patch) { - const next = { ...(await readPrefs()), ...sanitizePrefs(patch) }; - await mkdir(context.dataDir, { recursive: true }); - await writeFile(prefsPath, JSON.stringify(next), 'utf8'); + const next = prefsOp.then(async () => { + const merged = { ...(await readPrefs()), ...sanitizePrefs(patch) }; + await mkdir(context.dataDir, { recursive: true }); + const tmp = `${prefsPath}.tmp`; + await writeFile(tmp, JSON.stringify(merged), 'utf8'); + await rename(tmp, prefsPath); // 原子替换, 避免写一半截断 + return merged; + }); + prefsOp = next.catch(() => {}); return next; } @@ -125,7 +165,7 @@ export async function start(context) { return new Promise((resolve, reject) => { let proc; try { - proc = spawn(PYTHON, [apiPyPath, ...args], { + proc = spawn(PYTHON_CMD[0], [...PYTHON_CMD.slice(1), apiPyPath, ...args], { cwd: context.pluginRoot, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], @@ -172,15 +212,22 @@ export async function start(context) { }); } - function queryBackend(rangeKey, models, sessions) { - const key = `${rangeKey}|${models ? models.join(',') : ''}|${sessions ? sessions.join(',') : ''}`; + function queryBackend(rangeKey, models = null, sessions = null) { + // models/sessions: null=不过滤(全部), []=显式空选(0 数据, 传哨兵给 python) + const ms = models === null ? '' : (models.length ? models.join(',') : '__none__'); + const ss = sessions === null ? '' : (sessions.length ? sessions.join(',') : '__none__'); + const key = `${rangeKey}|${ms}|${ss}`; const hit = cache.get(key); if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.promise; const promise = queue.then(() => - runPython(['--range', rangeKey, - '--models', models ? models.join(',') : '', - '--sessions', sessions ? sessions.join(',') : ''])); + runPython(['--range', rangeKey, '--models', ms, '--sessions', ss])); cache.set(key, { at: Date.now(), promise }); + if (cache.size > CACHE_MAX) { // 超上限按插入顺序淘汰最旧条目 + for (const k of cache.keys()) { + cache.delete(k); + if (cache.size <= CACHE_MAX) break; + } + } promise.catch(() => cache.delete(key)); // 失败不缓存 queue = promise.catch(() => {}); // 链条继续 return promise; @@ -230,28 +277,38 @@ export async function start(context) { if (req.method === 'GET' && url.pathname === '/api/data') { const rawRange = url.searchParams.get('range') ?? 'all'; const rangeKey = normalizeRange(rawRange) ?? 'all'; + // '__none__' = 显式空选(空数组), 不传 = 全部(null) const rawModels = url.searchParams.get('models'); - const models = rawModels - ? rawModels.split(',').map((s) => s.trim()).filter(Boolean) - : null; + let models = null; + if (rawModels === '__none__') models = []; + else if (rawModels) models = rawModels.split(',').map((s) => s.trim()).filter(Boolean); const rawSessions = url.searchParams.get('sessions'); - const sessions = rawSessions - ? rawSessions.split(',').map((s) => s.trim()).filter(Boolean) - : null; - queryBackend(rangeKey, models && models.length ? models : null, - sessions && sessions.length ? sessions : null) + let sessions = null; + if (rawSessions === '__none__') sessions = []; + else if (rawSessions) sessions = rawSessions.split(',').map((s) => s.trim()).filter(Boolean); + queryBackend(rangeKey, models, sessions) .then((payload) => sendJson(res, 200, payload)) - .catch((err) => sendJson(res, 502, { error: `backend unavailable: ${err.message}` })); + .catch((err) => { + // python 缺失时给出可自查的提示, 而非裸 ENOENT + const hint = /ENOENT/i.test(String(err.message)) + ? ` (python unavailable, tried: ${PYTHON_CMD.join(' ')}; install Python 3.8+)` + : ''; + sendJson(res, 502, { error: `backend unavailable: ${err.message}${hint}` }); + }); return; } sendJson(res, 404, { error: 'not_found' }); }); + // 默认 keepAliveTimeout(5s)与 5s 自动刷新同拍, 空闲连接恰在复用时被关, + // 会偶发 "Failed to fetch"; 拉长空闲存活避开该竞态。 + server.keepAliveTimeout = 65_000; + server.headersTimeout = 70_000; await listen(server, context.listen.host, context.listen.port); context.logger.info('miniapp.runtime.listening'); // 预热首次查询(不阻塞就绪) - queryBackend('all', null).catch((err) => { + queryBackend('all', null, null).catch((err) => { context.logger.warn('miniapp.backend.warmup_failed', { message: err.message }); }); diff --git a/plugins/yanhy2000/mcode-usage-monitor/skills/usage-query/SKILL.md b/plugins/yanhy2000/mcode-usage-monitor/skills/usage-query/SKILL.md new file mode 100644 index 0000000..809b6a4 --- /dev/null +++ b/plugins/yanhy2000/mcode-usage-monitor/skills/usage-query/SKILL.md @@ -0,0 +1,87 @@ +--- +name: usage-query +description: 查询本机 MiniMax Code 的 token 用量——总量、输入/输出/缓存、缓存命中率、调用次数、平均速度,可按时间范围、模型、会话筛选,也给出最近调用明细、项目分布与工具调用统计。当用户问"用了多少 token""这个对话消耗多少""哪个模型用得多""缓存命中率多少""最近调用情况""统计一下用量"等与本机用量有关的问题时使用。需要看趋势图或自己切筛选时,再引导用户打开「Token 用量看板」页面。 +--- + +# Token 用量查询 + +本插件自带一个一次性的数据后端 `api.py`:它只读打开本机运行时数据库的快照,去重、聚合后把结果以单行 JSON 写到 stdout。查询不需要打开看板页面,也不会锁库或干扰正在运行的 MiniMax Code。 + +## 1. 定位脚本 + +数据后端在本 skill 目录向上两级: + +``` +<本 skill 目录>/../../miniapp/node/api.py +``` + +拿不到 skill 绝对路径时,用插件的默认安装位置: + +| 系统 | 路径 | +| --- | --- | +| Windows | `%USERPROFILE%\.minimax\plugins\mcode-usage-monitor\miniapp\node\api.py` | +| macOS / Linux | `~/.minimax/plugins/mcode-usage-monitor/miniapp/node/api.py` | + +数据目录被 `MINIMAX_DATA_DIR` 覆盖时,把其中的 `.minimax` 换成该变量指向的目录。调用前先确认文件存在;两处都不存在时,说明插件未装在该位置,请用户确认安装路径。 + +## 2. 调用 + +```bash +python "" --range today +``` + +Windows 上按本机情况使用 `python` 或 `py -3`。 + +| 参数 | 取值 | 说明 | +| --- | --- | --- | +| `--range` | `today` / `1h` / `12h` / `24h` / `7d` / `30d` / `all` / `h` | 时间范围;`h` 为自定义整数小时(1–8760) | +| `--models` | 逗号分隔模型名 | 省略即全部 | +| `--sessions` | 逗号分隔会话 id | 省略即全部;`mvs_` 开头的 id | +| `--db` | 数据库路径 | 一般不用,默认指向本机运行时数据库 | + +常用组合: + +```bash +# 本对话的用量(session id 用当前会话的) +python "" --range all --sessions <当前 session id> +# 最近 7 天,只看某个模型 +python "" --range 7d --models glm-5.3 +# 全部历史概览 +python "" --range all +``` + +## 3. 输出结构 + +stdout 是一行 JSON;出错时只有一个 `{"error": "..."}`。 + +| 字段 | 内容 | +| --- | --- | +| `generated_at` / `range` | 取数时间与本查询范围 | +| `overview` | 汇总:`calls` 调用次数、`sessions` 会话数、`input_tokens`、`output_tokens`、`cache_read_tokens`、`total_tokens`、`hit_rate_pct` 缓存命中率、`avg_tok_s` 加权输出速度、`sum_dur_s` 累计生成时长 | +| `available_models` | 范围内的模型清单(`model` / `calls` / `output`) | +| `available_sessions` | 会话清单(`session_id` / `title` / `calls` / `tokens` / `last_ts`) | +| `models` | 逐模型明细,含 `hit_rate`、`avg_dur_ms`、`tok_s` | +| `series` | 按时间跨度自动分桶的时序(`input` / `output` / `cache_read` / `calls` / `tok_s`) | +| `recent` | 最近 60 条调用明细(`time` / `model` / `session` / `input` / `output` / `cache_read` / `dur_ms` / `tok_s`) | +| `projects` | 按会话工作区目录汇总的 Top 8 | +| `tools` | 工具调用次数 Top 10 | + +## 4. 口径 + +- Token 消耗 = 输入 + 缓存读取 + 输出;缓存命中率 = 缓存读取 ÷(缓存读取 + 输入)。 +- 按 `msg_id` 跨会话去重:会话重建时历史消息会被复制进新会话,同一批调用只计一次。 +- 一个会话通常只用一个模型;模型清单与会话清单互相牵制,筛选一侧会同时收窄另一侧。 +- 这是本机近实时观测,计费与额度以产品内「用量」页为准(服务端统计有延迟,口径也可能不同)。报数字时如果用户关心准确性,说明这一差异。 + +## 5. 怎么回答 + +- 问概览就报 `overview` 的关键数字;问"哪个模型/会话用得多"就读 `models`、`available_sessions` 排序后回答;问"最近在忙什么"可读 `recent` 里的 `session` 与 `model`。 +- "本对话"指当前会话:把当前 session id 传给 `--sessions`;不确定时先不带筛选跑一次,再从 `available_sessions` 的标题里匹配。 +- 用户想看趋势图、想自己切换筛选或调整布局时,引导打开「Token 用量看板」页面(在 Mini App 面板中打开,或直接说"打开 Token 用量看板")。 + +## 6. 故障 + +- `python` 找不到:本插件需要本机 Python 3.8+,只用标准库,无需 `pip install`。 +- `{"error": "database not found: ..."}`:本机还没有运行时数据,或数据目录被 `MINIMAX_DATA_DIR` 改过。 +- `{"error": "sqlite JSON1 extension not enabled ..."}`:本机 Python 自带的 SQLite 未启用 JSON1 扩展,属环境问题。 +- 每次调用会启动一个新进程(实测几十到几百毫秒),不要循环高频调用。