diff --git a/external_plugins/session-stats/.zcode-plugin/plugin.json b/external_plugins/session-stats/.zcode-plugin/plugin.json new file mode 100644 index 00000000..5481f30c --- /dev/null +++ b/external_plugins/session-stats/.zcode-plugin/plugin.json @@ -0,0 +1,49 @@ +{ + "name": "session-stats", + "version": "0.3.0", + "description": "Session statistics for ZCode: turns/steps, model vs tool time, TTFT, TPS (average + latest request), token usage and cache hit rate. Zero third-party dependencies.", + "description_i18n": { + "en": "Session statistics for ZCode: turns/steps, model vs tool time, TTFT, TPS (average + latest request), token usage and cache hit rate. Zero third-party dependencies.", + "zh-CN": "会话统计:轮次/步数、模型/工具耗时、TTFT、TPS(会话平均+最近请求)、token 用量与缓存命中率。零第三方依赖。" + }, + "author": { + "name": "gary.liu" + }, + "keywords": [ + "stats", + "usage", + "tokens", + "cache", + "panel", + "mcp-apps" + ], + "license": "MIT", + "mcpServers": { + "session-stats": { + "type": "stdio", + "command": "python", + "args": [ + "${ZCODE_PLUGIN_ROOT}/server/stats_server.py" + ], + "env": { + "PYTHONUTF8": "1", + "PYTHONIOENCODING": "utf-8" + } + } + }, + "ui": { + "surfaces": [ + { + "id": "stats-panel", + "title": { + "en": "Session Stats", + "zh-CN": "会话统计" + }, + "server": "session-stats", + "resourceUri": "ui://session-stats/panel.html", + "availability": "session" + } + ] + }, + "commands": "./commands" +} diff --git a/external_plugins/session-stats/README.md b/external_plugins/session-stats/README.md new file mode 100644 index 00000000..64b29c18 --- /dev/null +++ b/external_plugins/session-stats/README.md @@ -0,0 +1,66 @@ +# session-stats + +Session statistics for ZCode: turns/steps, model vs tool time, TTFT, TPS (average + +latest request), token usage and cache hit rate. Zero third-party dependencies. + +## Components + +- **MCP tool `get_stats`** — statistics for the current session (including subagents, + aggregated via `session.parent_id`) or today's aggregate across sessions. +- **MCP App side panel** (`ui.surfaces`) — stat pills with a details popup + (timings, token buckets, cache hit, per-model breakdown), auto-refresh every 5 s, + follows session switching and the live theme. +- **`/stats` command** — text card rendering of the same numbers. +- **SessionStart hook** — writes the current session id into the plugin data dir so + the server can resolve "current session" without extra round-trips. + +## Data source and metric definitions + +Everything is read **read-only** from the local session database +`~/.zcode/cli/db/db.sqlite` (SQLite URI `mode=ro`, `busy_timeout=5000`, WAL-safe). +Measured against ZCode 3.17.1: + +| Metric | Definition | +| --- | --- | +| Turns | distinct `turn_id` of the main session | +| Steps | distinct `logical_request_id` (retries excluded; failed/cancelled counted) | +| Model time | Σ `duration_ms` | +| Tool time | Σ `tool_usage.duration_ms` | +| TTFT avg | AVG(`time_to_first_token_ms`) | +| TPS | Σ output ÷ Σ(completed_at − first_token_at); "live" TPS = latest completed request | +| Tokens | Σ input + Σ output (`input_tokens` already includes cache reads — measured) | +| Cache hit | cache_read ÷ input | + +The database schema is an implementation detail of the host (not a stable API) and +usage rows are retained for 30 days; queries degrade gracefully when columns are +missing. + +## Side effects + +- Reads the local session database (read-only). +- Starts a loopback-only HTTP service (`127.0.0.1:31710`) that serves the statistics + JSON and the panel's dynamic script to ZCode's renderer. It answers only requests + without a cross-origin `Origin` header (i.e. the app's own `file://` pages); other + origins get 403. +- The SessionStart hook writes one small file (`current_session`) into the + plugin-owned data directory. +- No network access, no credentials, no telemetry. + +## Install + +ZCode Desktop → **Plugin Marketplace** → add the marketplace that carries this plugin +(the official `zcode-plugins` marketplace after review, or a local directory) → +**Install session-stats** → restart or open a new session → run `/stats` or open the +"Session Stats" side panel. + +## Optional composer pills (host patch, experimental) + +A stats strip centered under the composer input can be enabled with a patch to the +host `app.asar` (see the upstream project's `patches/` directory). The patch is +idempotent, backs up the host file per version and supports restore; it must be +re-applied after ZCode updates. This is **not** part of the marketplace plugin and +is purely optional. + +## License + +MIT diff --git a/external_plugins/session-stats/README_CN.md b/external_plugins/session-stats/README_CN.md new file mode 100644 index 00000000..17d568d0 --- /dev/null +++ b/external_plugins/session-stats/README_CN.md @@ -0,0 +1,51 @@ +# session-stats — ZCode 会话统计插件 + +ZCode 会话统计插件:轮次/步数、模型/工具耗时、 +TTFT、TPS(会话平均 + 最近请求)、token 用量与缓存命中率。零第三方依赖。 + +## 组件 + +- **MCP 工具 `get_stats`**:当前会话(含子代理,经 `session.parent_id` 聚合) + 或今日汇总的完整统计。 +- **MCP App 侧栏面板**(`ui.surfaces`):统计卡片 + 明细弹窗(耗时、token 分桶、 + 缓存命中、模型分布),5 秒轮询自动刷新,跟随会话切换与主题。 +- **`/stats` 命令**:同样数字的文字卡片输出。 +- **SessionStart hook**:把当前会话 id 写入插件数据目录,供服务端解析「当前会话」。 + +## 数据源与指标口径 + +全部**只读**读取本地会话数据库 `~/.zcode/cli/db/db.sqlite`(SQLite URI `mode=ro` + +`busy_timeout=5000`,WAL 下并发安全)。口径对 ZCode 3.17.1 实测定案: + +| 指标 | 定义 | +|---|---| +| 轮次 | 主会话 `model_usage.turn_id` 去重数 | +| 步数 | `logical_request_id` 去重数(重试不计步;失败/取消计步) | +| 模型用时 | Σ `duration_ms` | +| 工具用时 | Σ `tool_usage.duration_ms` | +| TTFT 均值 | AVG(`time_to_first_token_ms`) | +| TPS | Σ output ÷ Σ(completed_at − first_token_at);「实时」= 最近一次完成请求 | +| token | Σ input + Σ output(实测 `input_tokens` 已含缓存读) | +| 缓存命中率 | cache_read ÷ input | + +数据库 schema 属宿主实现细节(非稳定 API),usage 行保留 30 天;查询对缺列做了 +优雅降级。 + +## 副作用说明 + +- 只读读取本地会话数据库。 +- 启动仅绑定回环地址的本地服务(`127.0.0.1:31710`),向 ZCode 渲染层提供统计 + JSON 与面板动态脚本;只响应无跨域 `Origin` 头的请求(即应用自身 `file://` 页面), + 其余 Origin 一律 403。 +- SessionStart hook 在插件数据目录写一个小文件(`current_session`)。 +- 无网络访问、无凭证、无遥测。 + +## 安装 + +ZCode Desktop → **插件市场** → 添加收录本插件的市场(审核通过后为官方 +`zcode-plugins` 市场,审核前可用本地目录)→ **安装 session-stats** → 重启或新开会话 +→ 输入 `/stats` 或打开「会话统计」侧栏面板。 + +## 许可 + +MIT diff --git a/external_plugins/session-stats/commands/stats.md b/external_plugins/session-stats/commands/stats.md new file mode 100644 index 00000000..f54bc05e --- /dev/null +++ b/external_plugins/session-stats/commands/stats.md @@ -0,0 +1,26 @@ +--- +description: 显示会话统计(轮次/步数/耗时/TTFT/TPS/token/缓存命中) +--- + +请调用 session-stats MCP 服务器的 get_stats 工具查询会话统计。scope 取值:当参数含「今日」「today」时用 `today`,否则用 `current`。 + +拿到 structuredContent 后,**严格按数据输出**下面的中文统计卡片(不要编造/估算任何数据里没有的数值,缺失显示「—」): + +``` +⏱ {turns} 轮 {steps} 步 · {tps} tok/s +Σ {tokens.total} tok · 缓存命中 {cache_hit_pct}% +──────────────────────── +模型用时 {model_ms 格式化为 时/分/秒} +工具调用用时 {tool_ms} +首 token 平均 {ttft_ms_avg} +输出速度(TPS) {tps} tok/s +输入(含缓存) {tokens.input}(缓存读 {tokens.cache_read} / 缓存写 {tokens.cache_write}) +输出 {tokens.output}(含推理 {tokens.reasoning}) +总计 {tokens.total} +``` + +- `today` 口径:开头注明「今日 {sessions} 个会话汇总」。 +- `current` 口径:结尾注明「会话 {session_id} · 含子代理 {subagent_count} 个」。 +- 若返回 isError 或 error 字段,如实转述错误并提示检查插件 server 日志。 + +参数:$ARGUMENTS diff --git a/external_plugins/session-stats/hooks/hooks.json b/external_plugins/session-stats/hooks/hooks.json new file mode 100644 index 00000000..74c8481c --- /dev/null +++ b/external_plugins/session-stats/hooks/hooks.json @@ -0,0 +1,18 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "process", + "command": "python", + "args": ["${ZCODE_PLUGIN_ROOT}/hooks/write_session.py"], + "timeoutMs": 3000, + "statusMessage": "session-stats: 记录当前会话 id" + } + ] + } + ] + } +} diff --git a/external_plugins/session-stats/hooks/write_session.py b/external_plugins/session-stats/hooks/write_session.py new file mode 100644 index 00000000..cde024fb --- /dev/null +++ b/external_plugins/session-stats/hooks/write_session.py @@ -0,0 +1,32 @@ +"""SessionStart hook:把当前 session_id 写进插件数据目录,供 MCP 服务器解析「当前会话」。 + +输入:stdin 一行 JSON(含 session_id);输出:无(保持 stdout 干净,退出码恒 0, +绝不阻塞会话)。 +""" + +import json +import os +import sys +from pathlib import Path + + +def main() -> int: + try: + req = json.loads(sys.stdin.read() or "{}") + except Exception: # noqa: BLE001 + req = {} + sid = req.get("session_id") or os.environ.get("ZCODE_SESSION_ID") or "" + data_dir = os.environ.get("ZCODE_PLUGIN_DATA", "") + if not sid or not data_dir: + return 0 # 静默跳过,由 MCP 服务器走 env/db 兜底 + try: + p = Path(data_dir) + p.mkdir(parents=True, exist_ok=True) + (p / "current_session").write_text(sid, encoding="utf-8") + except OSError: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/external_plugins/session-stats/server/pills.js b/external_plugins/session-stats/server/pills.js new file mode 100644 index 00000000..6bcea527 --- /dev/null +++ b/external_plugins/session-stats/server/pills.js @@ -0,0 +1,594 @@ +/* session-stats pills v4 — 贴 chips 行(电脑操作之后)、悬浮弹层含模型分布、支持热更新 */ +;(function () { + "use strict"; + if (window.__SSPILLS__) { try { window.__SSPILLS_TEARDOWN__(); } catch (e) {} } + window.__SSPILLS__ = true; + + var ENDPOINT = "http://127.0.0.1:31710"; + var POLL_MS = 5000; + var data = null, lastOk = 0, fails = 0, popupOpen = false, pillMode = "full", openedAt = 0, lastToggleAt = 0; + var rowEl = null, popEl = null, css = null; + + function fmtDur(ms) { + if (ms == null) return "—"; + if (ms < 1000) return Math.round(ms) + " 毫秒"; + var s = ms / 1000; + if (s < 60) return (s < 10 ? s.toFixed(1) : Math.round(s)) + " 秒"; + var m = Math.floor(s / 60), rs = Math.round(s % 60); + if (m < 60) return m + " 分 " + rs + " 秒"; + return Math.floor(m / 60) + " 时 " + (m % 60) + " 分 " + rs + " 秒"; + } + function fmtTok(n) { + if (n == null) return "—"; + if (n < 1e3) return String(n); + if (n < 1e6) return (n / 1e3).toFixed(1) + "K"; + if (n < 1e9) return (n / 1e6).toFixed(1) + "M"; + return (n / 1e9).toFixed(2) + "B"; + } + function esc(s) { + return String(s).replace(/[&<>"]/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; + }); + } + function shortId(id) { + if (!id) return "—"; + return id.length > 26 ? id.slice(0, 23) + "…" : id; + } + + css = document.createElement("style"); + css.textContent = + ".__sspills-row{position:absolute;left:50%;transform:translateX(-50%);bottom:0;" + + "max-width:calc(100% - 24px);overflow:hidden;display:inline-flex;align-items:center;gap:6px;z-index:30;}" + + ".__sspills-pill{display:inline-flex;align-items:center;gap:6px;height:20px;padding:1px 8px;" + + "border-radius:6px;background:transparent;border:0;color:rgba(232,235,241,.55);" + + "font:500 12px/20px 'Segoe UI','Microsoft YaHei',system-ui,sans-serif;font-variant-numeric:tabular-nums;" + + "cursor:pointer;white-space:nowrap;transition:background .15s,color .15s;}" + + ".__sspills-pill:hover{background:rgba(255,255,255,.06);color:rgba(232,235,241,.82);}" + + ".__sspills-pill .ico{display:inline-flex;align-items:center;}" + + ".__sspills-pill .ico svg{width:14px;height:14px;display:block;}" + + ".__sspills-pill b{font-weight:600;color:rgba(232,235,241,.72);}" + + ".__sspills-pill .sep{opacity:.45;margin:0 6px;}" + + ".__sspills-pill .dv{opacity:.25;margin:0 5px;border-left:1px solid currentColor;height:11px;}" + + ".__sspills-pill .live{color:#9ece6a;font-size:8px;margin-left:4px;}" + + ".__sspills-row.stale .__sspills-pill{opacity:.4;}" + + ".__sspills-pop{position:fixed;display:none;width:400px;max-width:calc(100vw - 16px);z-index:2147483000;" + + "background:#1f2128;border:1px solid rgba(255,255,255,.12);border-radius:12px;" + + "box-shadow:0 10px 36px rgba(0,0,0,.5);padding:12px 14px;" + + "font:12px/1.5 'Segoe UI','Microsoft YaHei',system-ui,sans-serif;color:rgba(232,235,241,.9);}" + + ".__sspills-pop .h{font-weight:600;font-size:12.5px;margin-bottom:6px;}" + + ".__sspills-pop table{width:100%;border-collapse:collapse;table-layout:fixed;}" + + ".__sspills-pop td{padding:2.5px 0;vertical-align:top;}" + + ".__sspills-pop td.k{color:rgba(232,235,241,.52);padding-right:12px;white-space:nowrap;" + + "overflow:hidden;text-overflow:ellipsis;}" + + ".__sspills-pop td.v{font-family:Consolas,'Cascadia Mono',monospace;text-align:right;white-space:nowrap;}" + + ".__sspills-pop table.models td.k{width:118px;}" + + ".__sspills-pop table.models td.v{overflow:hidden;text-overflow:ellipsis;}" + + ".__sspills-pop .div{height:1px;background:rgba(255,255,255,.09);margin:7px 0;}" + + ".__sspills-pop .foot{color:rgba(232,235,241,.4);font-size:11px;margin-top:2px;}" + + "@media (prefers-color-scheme: light){" + + ".__sspills-pill{color:rgba(28,30,36,.55);}" + + ".__sspills-pill:hover{background:rgba(0,0,0,.05);color:rgba(28,30,36,.85);}" + + ".__sspills-pill b{color:rgba(28,30,36,.72);}" + + ".__sspills-pop{background:#fff;border-color:rgba(0,0,0,.14);color:rgba(28,30,36,.9);" + + "box-shadow:0 10px 36px rgba(0,0,0,.18);}" + + ".__sspills-pop td.k{color:rgba(28,30,36,.52);}" + + ".__sspills-pop .div{background:rgba(0,0,0,.09);}}"; + document.documentElement.appendChild(css); + + function popupHtml() { + var d = data, t = (d && d.tokens) || {}; + if (!d) return '
会话统计
加载中…
'; + if (d.draft) return '
会话统计
新会话 · 发起第一条消息后开始统计
'; + if (d.error) return '
会话统计
' + esc(d.error) + "
"; + function mk(rows) { + return rows.map(function (r) { + return '' + esc(r[0]) + + '' + r[1] + ""; + }).join(""); + } + var lt = liveTps(d); + var r1 = [ + ["模型用时", fmtDur(d.model_ms)], + ["工具调用用时", fmtDur(d.tool_ms)], + ["首 token 平均(TTFT)", fmtDur(d.ttft_ms_avg)], + ]; + if (lt) { + r1.push(["实时 TPS", Math.round(lt.v * 10) / 10 + " tok/s" + (lt.inflight ? "(进行中)" : "(最近请求)")]); + } + r1.push(["平均 TPS(会话累计)", d.tps != null ? d.tps + " tok/s" : "—"]); + var r2 = [ + ["输入(含缓存)", fmtTok(t.input) + "(读 " + fmtTok(t.cache_read) + " / 写 " + fmtTok(t.cache_write) + ")"], + ["输出", fmtTok(t.output) + "(含推理 " + fmtTok(t.reasoning) + ")"], + ["总 token", fmtTok(t.total)], + ["未命中缓存 token", fmtTok(Math.max(0, (t.input || 0) - (t.cache_read || 0)))], + ["缓存命中率", d.cache_hit_pct != null ? d.cache_hit_pct + "%" : "—"], + ]; + // 最近一次完成的请求:输入/输出/缓存命中 + var lr = null; + for (var ri = 0; ri < (d.recent || []).length; ri++) { + if (d.recent[ri].done_at != null) { lr = d.recent[ri]; break; } + } + if (lr && lr["in"] > 0) { + r2.push(["最近请求 输入", fmtTok(lr["in"])]); + r2.push(["最近请求 输出", fmtTok(lr.out)]); + r2.push(["最近请求 未命中缓存", fmtTok(Math.max(0, lr["in"] - (lr.cread || 0)))]); + r2.push(["最近请求 缓存命中", Math.round((lr.cread || 0) * 1000 / lr["in"]) / 10 + "%"]); + } + var r3 = (d.models || []).slice(0, 5).map(function (m) { + return [ + m.model_id || "—", + (m.steps != null ? m.steps : "—") + "步 · " + + fmtTok((m.t_in || 0) + (m.t_out || 0)) + " tok · " + + (m.tps != null ? m.tps : "—") + " tok/s", + ]; + }); + var foot = "会话 " + esc(shortId(d.session_id)) + + (d.subagent_count ? " · 子代理 " + d.subagent_count + " 个" : "") + + " · 5 秒自动刷新"; + return '
会话统计
' + + "" + mk(r1) + "
" + + '
' + + "" + mk(r2) + "
" + + (r3.length ? '
模型分布
' + mk(r3) + "
" : "") + + '
' + foot + "
"; + } + + /* 自愈:僵尸实例可能把 popEl 从 body 里删掉,用前先确保挂载 */ + function ensurePopEl() { + if (!popEl) { + popEl = document.createElement("div"); + popEl.className = "__sspills-pop"; + } + if (!popEl.isConnected) document.body.appendChild(popEl); + } + + function renderPopup() { + ensurePopEl(); + if (!popEl) return; + popEl.innerHTML = popupHtml(); + if (popupOpen && rowEl) { + var pill = rowEl.querySelector(".__sspills-pill"); + if (pill) { + var r = pill.getBoundingClientRect(); + /* 弹窗水平中心对准 pill 中心(自适应居中),越界钳制防出窗 */ + var pw = popEl.offsetWidth; + var left = r.left + r.width / 2 - pw / 2; + left = Math.max(8, Math.min(left, window.innerWidth - pw - 8)); + popEl.style.left = left + "px"; + popEl.style.top = "auto"; + popEl.style.bottom = (window.innerHeight - r.top + 8) + "px"; + /* 上方放不下 → 翻到 pill 下方;再放不下 → 钳回视口内 */ + var ph = popEl.offsetHeight; + if (r.top - ph - 8 < 8) { + var topBelow = r.bottom + 8; + if (topBelow + ph > window.innerHeight - 8) { + popEl.style.top = Math.max(8, window.innerHeight - ph - 8) + "px"; + popEl.style.bottom = "auto"; + } else { + popEl.style.top = topBelow + "px"; + popEl.style.bottom = "auto"; + } + } + } + } + } + + function setPopup(open) { + popupOpen = open; + ensurePopEl(); + if (!popEl) return; + popEl.style.display = open ? "block" : "none"; + openedAt = open ? Date.now() : 0; + if (open) renderPopup(); + } + + /* 当前标签页的会话状态: + 返回正式会话 id(sess_/session_ 前缀)、"draft"(新会话未发消息)或 null。 + 只信可见视图;可见视图里只有占位值时明确返回 draft,绝不回落到隐藏的旧会话。 */ + function currentSessionId() { + var els = document.querySelectorAll("[data-session-id]"); + var visible = [], all = [], i, v; + function isReal(s) { + return s.indexOf("sess_") === 0 || s.indexOf("session_") === 0; + } + function pick(list) { + for (var a = 0; a < list.length; a++) { + if (isReal(list[a])) return list[a]; + } + for (var b = 0; b < list.length; b++) { + if (list[b] && list[b] !== "draft") return list[b]; + } + return list.indexOf("draft") >= 0 ? "draft" : null; + } + for (i = 0; i < els.length; i++) { + v = els[i].getAttribute("data-session-id") || ""; + all.push(v); + if (els[i].offsetParent !== null) visible.push(v); + } + var fromVisible = pick(visible); + if (fromVisible) return fromVisible; + if (visible.length) return "draft"; // 有可见会话视图但 id 是占位 → 新会话 + return pick(all); // 视图尚未挂载完成的兜底 + } + + var lastSid = null; + function poll() { + var sid = currentSessionId(); + lastSid = sid; + requestLayout(); // 每次轮询统一走调度器(宽度比对交给 ResizeObserver) + if (sid === "draft") { + // 新会话(尚未产生请求):显示空态,不去借别的会话的数据 + data = { draft: true, session_source: "draft" }; + lastOk = Date.now(); fails = 0; + if (rowEl) rowEl.style.display = ""; + render(); + if (popupOpen) renderPopup(); + return; + } + var url = ENDPOINT + "/stats?scope=current" + (sid ? "&session_id=" + encodeURIComponent(sid) : ""); + fetch(url, { cache: "no-store" }) + .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) + .then(function (j) { + data = j; lastOk = Date.now(); fails = 0; + render(); + requestLayout(); + if (popupOpen) renderPopup(); + }) + .catch(function () { + fails++; + if (fails >= 3 && rowEl) rowEl.style.display = "none"; + }); + } + + /* 实时 TPS:优先取进行中的请求(token/(now-首token)),否则取最近一次完成的请求 */ + var MIN_DECODE_MS = 500; // decode 窗口下限:脏 fta(≈completed_at)会把分母打到毫秒级,TPS 爆表 + function liveTps(d) { + if (!d || !d.recent || !d.recent.length) return null; + var now = d.generated_at; + var i, r; + for (i = 0; i < d.recent.length; i++) { + r = d.recent[i]; + if (r.done_at == null && r.fta != null && r.out > 0 && now - r.fta >= MIN_DECODE_MS) { + return { v: r.out * 1000 / (now - r.fta), inflight: true }; + } + } + for (i = 0; i < d.recent.length; i++) { + r = d.recent[i]; + if (r.done_at != null && r.fta != null && r.out > 0 && r.done_at - r.fta >= MIN_DECODE_MS) { + return { v: r.out * 1000 / (r.done_at - r.fta), inflight: false }; + } + } + return null; + } + + /* 最近一次完整(已完成)请求:底部横条展示它的输入+输出 token 与缓存命中率 */ + function latestReq(d) { + if (!d || !d.recent) return null; + for (var i = 0; i < d.recent.length; i++) { + var r = d.recent[i]; + if (r.done_at != null && r["in"] > 0) return r; + } + return null; + } + function reqHitPct(r) { + if (!r || !(r["in"] > 0)) return null; + return Math.round((r.cread || 0) * 1000 / r["in"]) / 10; + } + + function pillBody(mode) { + // 三档内容的内层 HTML(外层 pill 壳由 makeRow 常驻创建,切换只改 display,零重建) + if (!data) return "加载中…"; + if (data.draft) return "新会话 · 暂无统计"; + if (data.error) return "统计不可用"; + var lt = liveTps(data); + var shown = lt ? Math.round(lt.v * 10) / 10 : data.tps; + var shownHtml = shown != null + ? shown + ' tok/s' + (lt && lt.inflight ? '●' : "") + : "—"; + var lr = latestReq(data); + var hit = reqHitPct(lr); + // 最近请求 token 段:入 X / 出 Y(无数据时退回会话累计) + var reqTokHtml = lr + ? "输入 " + fmtTok(lr["in"]) + " · 输出 " + fmtTok(lr.out) + : "累计 " + fmtTok((data.tokens || {}).total) + " tok"; + if (mode === "micro") { + // 最窄档:只剩实时速度(参照同款统计条的 compact 形态),保证窄输入框也放得下 + return shownHtml; + } + if (mode === "mini") { + return shownHtml + " " + reqTokHtml; + } + if (mode === "mid") { + return "" + (data.steps != null ? data.steps : 0) + " 步" + + '·' + shownHtml + + " " + reqTokHtml + + '·' + + "缓存命中 " + (hit != null ? hit : "—") + "%"; + } + return "" + (data.turns != null ? data.turns : "—") + " 轮 " + (data.steps != null ? data.steps : 0) + " 步" + + '·' + shownHtml + + '' + + reqTokHtml + + '·' + + "缓存命中 " + (hit != null ? hit : "—") + "%"; + } + + /* 三档内容常驻为三个子 span,档位切换只改 display(无 innerHTML 重建 → 无闪现土壤) */ + var modeSpans = null; // { full, mid, mini } + var lastBody = {}; // 每档上次渲染的内容缓存 + function ensureModeSpans() { + if (modeSpans && modeSpans.full && modeSpans.full.isConnected) return; + var pill = rowEl.querySelector(".__sspills-pill"); + if (!pill) return; + modeSpans = {}; + var modes = ["full", "mid", "mini", "micro"]; + for (var i = 0; i < modes.length; i++) { + var s = pill.querySelector(".__sspills-mode[data-mode='" + modes[i] + "']"); + if (!s) { // 不存在才创建,防热更新边界下重复追加 + s = document.createElement("span"); + s.className = "__sspills-mode"; + s.setAttribute("data-mode", modes[i]); + s.style.display = "none"; + pill.appendChild(s); + } + modeSpans[modes[i]] = s; + } + } + function render() { + if (!rowEl) return; + var stale = Date.now() - lastOk > 9e5; + rowEl.classList.toggle("stale", stale); + ensureModeSpans(); + if (!modeSpans) return; + var modes = ["full", "mid", "mini", "micro"]; + for (var i = 0; i < modes.length; i++) { + var m = modes[i]; + var body = pillBody(m); + if (body !== lastBody[m]) { modeSpans[m].innerHTML = body; lastBody[m] = body; } + } + if (rowEl.style.display !== "none") setModeSpans(pillMode); + } + function setModeSpans(mode) { + if (!modeSpans) return; + var modes = ["full", "mid", "mini", "micro"]; + for (var i = 0; i < modes.length; i++) { + modeSpans[modes[i]].style.display = modes[i] === mode ? "" : "none"; + } + } + + function makeRow() { + var el = document.createElement("div"); + el.className = "__sspills-row"; + el.style.cursor = "pointer"; + el.addEventListener("click", function (ev) { + // 点击 pill 任意位置都开合弹窗;250ms 去抖防宿主合成重复点击(关了又开的闪烁) + if (Date.now() - lastToggleAt < 250) { ev.stopPropagation(); return; } + lastToggleAt = Date.now(); + setPopup(!popupOpen); + ev.stopPropagation(); + }); + // pill 外壳(含图标):三档内容 span 由 ensureModeSpans 挂进来 + var pill = document.createElement("span"); + pill.className = "__sspills-pill"; + pill.setAttribute("data-act", "detail"); + var ico = document.createElement("span"); + ico.className = "ico"; + // 仪表盘图标(gauge-outline,16 viewBox / 1px 描边),与应用内统计图标同源 + ico.innerHTML = + '' + + '' + + '' + + '' + + ""; + pill.appendChild(ico); + el.appendChild(pill); + return el; + } + + /* 只取「可见」的输入条容器——多标签页时隐藏标签页的 dock 也在 DOM 里。 + 不能用 offsetParent 判断(fixed 定位/特殊容器下恒为 null,可见也会误判为隐藏), + 改用实测宽度 > 0。 */ + function visibleDock() { + var docks = document.querySelectorAll("[data-v4-composer-dock-content]"); + for (var i = 0; i < docks.length; i++) { + var r = docks[i].getBoundingClientRect(); + if (r.width > 0 && r.height > 0) return docks[i]; + } + return docks[0] || null; + } + + /* 布局调度器:所有触发点只置脏标记,界面稳定后才执行一次完整布局。 + - 尾随防抖 180ms:滚轮滑动/侧栏动画等过渡帧全部跳过,只在静止后布局一次 + - 统计行挂在输入条下方(fixed),宽度只取决于输入条宽度:按「宽→窄」挑第一个放得下的档位 */ + var dead = false; // teardown 后置 true:一切异步回调早退,死实例不碰 DOM + var layoutTimer = 0; + var lastScrollAt = 0; // 滚动豁免:滚动后 700ms 内不布局(滚动中会话顶栏/输入条有过渡动画) + + function requestLayout() { + if (dead) return; + if (layoutTimer) clearTimeout(layoutTimer); + layoutTimer = setTimeout(function () { + layoutTimer = 0; + if (dead) return; + var quiet = Date.now() - lastScrollAt; + if (quiet < 700) { + // 滚动豁免期内:推迟到豁免期结束再布局 + layoutTimer = setTimeout(function () { + layoutTimer = 0; + if (dead) return; + layout(); + }, 700 - quiet + 20); + return; + } + layout(); + }, 180); + } + + /* 清扫幽灵实例:React 重建 dock 会泄漏旧 rowEl(代码只追新不删旧), + 泄漏元素停留旧档位与新 pill 叠加闪现。每次布局前清掉一切非当前实例。 */ + function purgeForeign() { + var rows = document.querySelectorAll(".__sspills-row"); + for (var i = 0; i < rows.length; i++) { + if (rows[i] !== rowEl && rows[i].parentNode) rows[i].parentNode.removeChild(rows[i]); + } + var pops = document.querySelectorAll(".__sspills-pop"); + for (var j = 0; j < pops.length; j++) { + if (pops[j] !== popEl && pops[j].parentNode) pops[j].parentNode.removeChild(pops[j]); + } + } + + /* 输入条卡片底部原本只剩 ~7px,放不下一整行;给它补 24px 内边距, + 统计行就落在卡片内新增的这条底带里(宿主重渲染清掉内联样式时自动补回)。 + 同时把 dock 设为定位上下文,统计行用 left:50% 居中——拖拽侧栏时位置由浏览器 + 随卡片一起重算,不再依赖 JS 逐帧改坐标(那会慢一帧,看起来就是「闪一下」)。 */ + var dockHost = null, dockOrigPad = "", dockOrigPos = ""; + function ensureDockHost(dock) { + if (dockHost && dockHost !== dock) restoreDockHost(); + if (!dockHost) { + dockOrigPad = dock.style.paddingBottom || ""; + dockOrigPos = dock.style.position || ""; + dockHost = dock; + } + if (dock.style.paddingBottom !== "24px") dock.style.paddingBottom = "24px"; + if (dock.style.position !== "relative") dock.style.position = "relative"; + } + function restoreDockHost() { + if (!dockHost) return; + try { + if (dockHost.style.paddingBottom === "24px") dockHost.style.paddingBottom = dockOrigPad; + if (dockHost.style.position === "relative") dockHost.style.position = dockOrigPos; + } catch (e) {} + dockHost = null; + } + + function layout() { + if (dead) return; + purgeForeign(); + var dock = visibleDock(); + if (!dock) { + restoreDockHost(); + if (rowEl) { rowEl.remove(); } + return; + } + ensureDockHost(dock); + var dockRect = dock.getBoundingClientRect(); + if (!(dockRect.width > 0 && dockRect.height > 0)) { + if (rowEl) rowEl.style.display = "none"; + return; + } + if (!rowEl || !rowEl.isConnected) { + if (!rowEl) rowEl = makeRow(); + } + if (rowEl.parentElement !== dock) dock.appendChild(rowEl); // 挂回 dock:位置/居中全交给 CSS + ensureModeSpans(); + var avail = Math.round(dockRect.width - 24); // 左右各留 12px,与输入条左缘对齐 + var all = ["full", "mid", "mini", "micro"]; + var chosen = null; + for (var ai = 0; ai < all.length; ai++) { + var mode = all[ai]; + setModeSpans(mode); // 纯 display 切换,零重建 + rowEl.style.display = ""; + // 换档时多留 8px 余量,防止内容在临界宽度上反复升降档 + var need = rowEl.offsetWidth + (mode === pillMode ? 0 : 8); + if (avail >= need) { chosen = mode; break; } + } + if (!chosen) { + rowEl.style.display = "none"; // 连 micro 都放不下:宁可隐藏也不压住界面 + return; + } + pillMode = chosen; + setModeSpans(chosen); + rowEl.style.display = ""; + } + + var scheduled = false; + function schedule() { + if (scheduled) return; + scheduled = true; + requestAnimationFrame(function () { + scheduled = false; + requestLayout(); + var sid = currentSessionId(); + if (sid !== lastSid) poll(); + }); + } + + document.addEventListener("click", function (ev) { + if (!popupOpen) return; + if (Date.now() - openedAt < 400) return; // 刚开启:忽略宿主合成的迟到点击,防秒关 + if (rowEl && !rowEl.contains(ev.target) && popEl && !popEl.contains(ev.target)) { + setPopup(false); + // 关闭后短暂锁定 pill 布局,避免点击穿透触发的宿主重排让 pill 闪一下 + lastScrollAt = Date.now(); + } + }, true); + window.addEventListener("resize", function () { clampNow(); requestLayout(); if (popupOpen) renderPopup(); }); + document.addEventListener("scroll", function () { + lastScrollAt = Date.now(); + if (popupOpen) renderPopup(); + clampNow(); // 统计行跟随输入条移动(fixed 坐标,滚轮时不能等防抖) + requestLayout(); // 记录豁免;滚动停稳后会补执行一次 + }, true); + + var mo = new MutationObserver(schedule); + mo.observe(document.documentElement, { + childList: true, subtree: true, attributes: true, attributeFilter: ["data-session-id"], + }); + /* 立即贴位:输入框尺寸/位置突变(侧栏开合、滚动)时先把统计行跟到位, + 防抖布局随后再决定档位。坐标是视口坐标,fixed 定位直接用,没有包含块换算问题。 */ + function clampNow() { + if (dead || !rowEl) return; + var dock = visibleDock(); + if (!dock) return; + ensureDockHost(dock); + if (rowEl.parentElement !== dock) dock.appendChild(rowEl); + var dockRect = dock.getBoundingClientRect(); + if (!(dockRect.width > 0)) return; + var avail = Math.round(dockRect.width - 24); + if (rowEl.style.display === "none" || rowEl.offsetWidth <= avail) return; + /* 拖拽变窄:就地降档而不是先隐藏再显示(那会闪一下) */ + var all = ["full", "mid", "mini", "micro"]; + var found = null; + for (var i = 0; i < all.length; i++) { + setModeSpans(all[i]); + rowEl.style.display = ""; + if (rowEl.offsetWidth <= avail) { found = all[i]; break; } + } + if (!found) { rowEl.style.display = "none"; return; } + pillMode = found; + setModeSpans(found); + } + + /* 输入框容器尺寸变化(侧栏开合/拖分隔条不触发 window resize): + 立即钳制防留痕 + 防抖触发完整布局 */ + var ro = null; + var roTimer = 0; + if (typeof ResizeObserver !== "undefined") { + ro = new ResizeObserver(function () { clampNow(); requestLayout(); }); + roTimer = setInterval(function () { + if (dead) { clearInterval(roTimer); roTimer = 0; return; } + var d = visibleDock(); + if (d) { ro.observe(d); clearInterval(roTimer); roTimer = 0; } + }, 2000); + } + requestLayout(); + popEl = document.createElement("div"); + popEl.className = "__sspills-pop"; + document.body.appendChild(popEl); + poll(); + var pollTimer = setInterval(poll, POLL_MS); + + window.__SSPILLS_TEARDOWN__ = function () { + dead = true; // 先置死:所有异步回调(延迟布局/rAF/定时器)从此早退,死实例不再碰 DOM + try { mo.disconnect(); } catch (e) {} + try { clearInterval(pollTimer); } catch (e) {} + try { clearInterval(roTimer); } catch (e) {} + try { clearTimeout(layoutTimer); } catch (e) {} + try { ro && ro.disconnect(); } catch (e) {} + try { css.remove(); } catch (e) {} + try { restoreDockHost(); } catch (e) {} + try { rowEl && rowEl.remove(); } catch (e) {} + try { popEl && popEl.remove(); } catch (e) {} + rowEl = null; popEl = null; data = null; + }; +})(); diff --git a/external_plugins/session-stats/server/service_standalone.py b/external_plugins/session-stats/server/service_standalone.py new file mode 100644 index 00000000..b7432699 --- /dev/null +++ b/external_plugins/session-stats/server/service_standalone.py @@ -0,0 +1,26 @@ +"""session-stats 独立常驻数据服务。 + +由补丁器部署到 %LOCALAPPDATA%\\session-stats\\ 并写入登录自启, +与 MCP 服务器内置的 HTTP 服务共用同一套单实例仲裁(bind 失败 + 健康探测)—— +谁先占用端口谁服务,后到者静默退出。统计条因此不再随会话生灭。 +""" + +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import stats_server as ss # noqa: E402 + +# 扁平部署布局(本文件与 pills.js 同目录)下,把本目录的 pills.js 插到下发候选首位 +ss.PILLS_CANDIDATES.insert(0, str(Path(__file__).resolve().parent / "pills.js")) + +if __name__ == "__main__": + ss.start_http_service() + print("session-stats 数据服务运行中: " + f"http://{ss.HTTP_HOST}:{ss.HTTP_PORT}(Ctrl+C 退出)", flush=True) + try: + while True: + time.sleep(3600) # 保活 + except KeyboardInterrupt: + pass diff --git a/external_plugins/session-stats/server/stats_server.py b/external_plugins/session-stats/server/stats_server.py new file mode 100644 index 00000000..3dbc01df --- /dev/null +++ b/external_plugins/session-stats/server/stats_server.py @@ -0,0 +1,593 @@ +"""ZCode session-stats MCP 服务器(stdio,Python 标准库,零第三方依赖)。 + +协议:JSON-RPC 2.0 按行读写(MCP stdio 传输),日志走 stderr。 +数据源:~/.zcode/cli/db/db.sqlite(只读 URI 打开,WAL 下并发安全)。 + +指标口径(2026-10-31 对本机 db 实测定案): +- 时间戳为 epoch 毫秒;TPS = Σoutput / Σ((completed_at - first_token_at)/1000) +- input_tokens 已包含 cache_read_input_tokens(GLM 供应商实测), + 缓存命中 = cache_read / input;total = input + output +- 子代理会话通过 session.parent_id 挂在主会话下,current 口径聚合主+子 +- 错误/取消的请求行同样计步计耗时 +""" + +import json +import os +import sqlite3 +import sys +import threading +import time +import urllib.parse +import urllib.request +from datetime import datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +SERVER_NAME = "session-stats" +SERVER_VERSION = "0.3.0" +UI_URI = "ui://session-stats/panel.html" +PLUGIN_ROOT = Path(__file__).resolve().parent.parent +PANEL_FILE = PLUGIN_ROOT / "ui" / "panel.html" + +TOOLS = [ + { + "name": "get_stats", + "description": ( + "获取 ZCode 会话统计(轮次/步数、模型用时、工具用时、TTFT 均值、" + "TPS、token 用量、缓存命中率)。scope=current 返回当前会话(含子代理)," + "scope=today 返回今日全部会话汇总。" + ), + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "可选。指定主会话 id(sess_ 开头);缺省自动解析当前会话。", + }, + "scope": { + "type": "string", + "enum": ["current", "today"], + "description": "统计口径,默认 current。", + }, + }, + }, + "_meta": {"ui": {"visibility": ["model", "app"]}}, + }, + { + "name": "open_stats_panel", + "description": "打开/聚焦会话统计侧栏面板(无参数)。", + "inputSchema": {"type": "object", "properties": {}}, + "_meta": {"ui": {"visibility": ["model", "app"]}}, + }, +] + +RESOURCES = [ + {"uri": UI_URI, "name": "panel", "title": "会话统计", "mimeType": "text/html"} +] + +# ---------------------------------------------------------------- sqlite ---- + +_local = threading.local() +_db_cols: dict[str, set[str]] = {} + + +def log(msg: str) -> None: + print(f"[session-stats] {msg}", file=sys.stderr, flush=True) + + +def get_conn() -> sqlite3.Connection: + conn = getattr(_local, "conn", None) + if conn is None: + env_db = os.environ.get("ZCODE_SESSION_DB_PATH", "") + db = Path(env_db) if env_db else Path.home() / ".zcode" / "cli" / "db" / "db.sqlite" + if not db.exists(): + raise RuntimeError(f"会话数据库不存在: {db}") + conn = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=5) + conn.execute("PRAGMA busy_timeout=5000") + conn.row_factory = sqlite3.Row + _local.conn = conn + return conn + + +def close_local_conn() -> None: + conn = getattr(_local, "conn", None) + if conn is not None: + try: + conn.close() + except sqlite3.Error: + pass + _local.conn = None + + +def cols(table: str) -> set[str]: + if table not in _db_cols: + rows = get_conn().execute(f"PRAGMA table_info({table})").fetchall() + _db_cols[table] = {r[1] for r in rows} + return _db_cols[table] + + +# ------------------------------------------------------------- 会话解析 ---- + + +def read_hook_session() -> str | None: + """SessionStart hook 把 session_id 写进插件数据目录的 current_session 文件。""" + data_dir = os.environ.get("ZCODE_PLUGIN_DATA", "") + if not data_dir: + return None + try: + return (Path(data_dir) / "current_session").read_text(encoding="utf-8").strip() or None + except OSError: + return None + + +def fallback_session() -> str | None: + """兜底:最近一个有 model_usage 的非子代理会话。""" + row = get_conn().execute( + "SELECT session_id FROM model_usage WHERE session_id NOT LIKE 'sess_subagent%'" + " ORDER BY started_at DESC LIMIT 1" + ).fetchone() + return row[0] if row else None + + +def resolve_session_id(explicit: str | None) -> tuple[str | None, str]: + if explicit: + return explicit, "param" + env_sid = os.environ.get("ZCODE_SESSION_ID") + if env_sid: + return env_sid, "env" + hooked = read_hook_session() + if hooked: + return hooked, "hook" + fb = fallback_session() + return fb, "fallback" + + +def session_family(main_id: str) -> list[str]: + """主会话 + 其子代理会话(session.parent_id 关联)。""" + try: + kids = [ + r[0] + for r in get_conn().execute( + "SELECT id FROM session WHERE parent_id = ?", (main_id,) + ).fetchall() + ] + except sqlite3.Error: + kids = [] + return [main_id] + kids + + +# ------------------------------------------------------------- 聚合统计 ---- + +# 请求行的过滤条件由调用方给(current: session_id IN (...),today: started_at >= ?) + +# decode 窗口下限:部分供应商(如 minimax)的 first_token_at 不可靠——要么 NULL, +# 要么与 completed_at 几乎相等(实测 26ms 出 627 token → 2.4 万 tok/s)。 +# 窗口 <500ms 的行不参与任何 TPS 计算,宁可少算也不让分母趋零爆表。 +MIN_DECODE_MS = 500 + +_DECODE_GUARD = ( + "output_tokens > 0 AND first_token_at IS NOT NULL " + "AND completed_at IS NOT NULL AND completed_at > first_token_at " + f"AND completed_at - first_token_at >= {MIN_DECODE_MS}" +) + + +def agg_model(cur, cond: str, params: list) -> dict: + r = cur.execute( + f""" + SELECT + COUNT(DISTINCT logical_request_id) AS steps, + COALESCE(SUM(duration_ms), 0) AS model_ms, + AVG(time_to_first_token_ms) AS ttft_avg, + COALESCE(SUM(CASE WHEN time_to_first_token_ms IS NOT NULL + THEN 1 ELSE 0 END), 0) AS ttft_steps, + COALESCE(SUM(input_tokens), 0) AS t_in, + COALESCE(SUM(output_tokens), 0) AS t_out, + COALESCE(SUM(reasoning_tokens), 0) AS t_reason, + COALESCE(SUM(cache_read_input_tokens), 0) AS t_cread, + COALESCE(SUM(cache_creation_input_tokens), 0) AS t_cwrite, + COALESCE(SUM(CASE WHEN {_DECODE_GUARD} + THEN output_tokens END), 0) AS decode_tokens, + COALESCE(SUM(CASE WHEN {_DECODE_GUARD} + THEN completed_at - first_token_at END), 0) AS decode_ms + FROM model_usage WHERE {cond} + """, + params, + ).fetchone() + return dict(r) + + +def agg_tools(cur, cond: str, params: list) -> dict: + r = cur.execute( + "SELECT COALESCE(COUNT(*), 0) AS calls, COALESCE(SUM(duration_ms), 0) AS tool_ms" + f" FROM tool_usage WHERE {cond}", + params, + ).fetchone() + return dict(r) + + +def models_breakdown(cur, cond: str, params: list) -> list[dict]: + rows = cur.execute( + f""" + SELECT model_id, + COUNT(DISTINCT logical_request_id) AS steps, + COALESCE(SUM(input_tokens), 0) AS t_in, + COALESCE(SUM(output_tokens), 0) AS t_out, + COALESCE(SUM(cache_read_input_tokens), 0) AS t_cread, + COALESCE(SUM(cache_creation_input_tokens), 0) AS t_cwrite, + COALESCE(SUM(CASE WHEN {_DECODE_GUARD} + THEN output_tokens END), 0) AS decode_tokens, + COALESCE(SUM(CASE WHEN {_DECODE_GUARD} + THEN completed_at - first_token_at END), 0) AS decode_ms + FROM model_usage WHERE {cond} + GROUP BY model_id ORDER BY t_in + t_out DESC + """, + params, + ).fetchall() + out = [] + for r in rows: + d = dict(r) + d["tps"] = round(d["decode_tokens"] * 1000.0 / d["decode_ms"], 1) if d["decode_ms"] > 0 else None + out.append(d) + return out + + +def recent_requests(cur, cond: str, params: list, limit: int = 6) -> list[dict]: + """最近几条请求的原始计时(供 pill 算实时 TPS)。started_at 倒序。""" + rows = cur.execute( + f""" + SELECT logical_request_id, output_tokens, first_token_at, completed_at, + started_at, model_id, input_tokens, cache_read_input_tokens + FROM model_usage WHERE {cond} + ORDER BY started_at DESC LIMIT {int(limit)} + """, + params, + ).fetchall() + return [ + { + "rid": r[0], "out": r[1], "fta": r[2], "done_at": r[3], + "started": r[4], "model": r[5], + "in": r[6], "cread": r[7], + } + for r in rows + ] + + +def build_stats(scope: str, session_id: str | None, session_source: str) -> dict: + cur = get_conn().cursor() + payload = { + "scope": scope, + "session_id": session_id, + "session_source": session_source, + "generated_at": int(time.time() * 1000), + "turns": None, + "steps": 0, + "model_ms": 0, + "tool_ms": 0, + "tool_calls": 0, + "ttft_ms_avg": None, + "ttft_steps": 0, + "tps": None, + "decode_tokens": 0, + "decode_ms": 0, + "tokens": { + "input": 0, "output": 0, "reasoning": 0, + "cache_read": 0, "cache_write": 0, "total": 0, + }, + "cache_hit_pct": None, + "models": [], + "subagent_count": 0, + } + + if scope == "today": + # 本地零点(epoch ms) + midnight = int(datetime.now().replace(hour=0, minute=0, second=0, microsecond=0).timestamp() * 1000) + cond = "started_at >= ?" + params: list = [midnight] + n = cur.execute( + "SELECT COUNT(DISTINCT CASE WHEN session_id NOT LIKE 'sess_subagent%'" + " THEN session_id END) FROM model_usage WHERE started_at >= ?", + params, + ).fetchone() + payload["sessions"] = n[0] + else: + if not session_id: + payload["error"] = "无法解析当前会话 id(hook/env/db 兜底均未命中)" + return payload + ids = session_family(session_id) + payload["subagent_count"] = len(ids) - 1 + cond = "session_id IN (" + ",".join("?" * len(ids)) + ")" + params = list(ids) + payload["turns"] = cur.execute( + "SELECT COUNT(DISTINCT turn_id) FROM model_usage WHERE session_id = ?", + (session_id,), + ).fetchone()[0] + + m = agg_model(cur, cond, params) + t = agg_tools(cur, cond, params) + payload["steps"] = m["steps"] + payload["model_ms"] = m["model_ms"] + payload["ttft_ms_avg"] = round(m["ttft_avg"]) if m["ttft_avg"] is not None else None + payload["ttft_steps"] = m["ttft_steps"] + payload["decode_tokens"] = m["decode_tokens"] + payload["decode_ms"] = m["decode_ms"] + payload["tps"] = round(m["decode_tokens"] * 1000.0 / m["decode_ms"], 1) if m["decode_ms"] > 0 else None + payload["tool_calls"] = t["calls"] + payload["tool_ms"] = t["tool_ms"] + payload["tokens"] = { + "input": m["t_in"], + "output": m["t_out"], + "reasoning": m["t_reason"], + "cache_read": m["t_cread"], + "cache_write": m["t_cwrite"], + "total": m["t_in"] + m["t_out"], + } + # input 已含 cache_read(spike 实测);其他供应商若口径不同会 >100,钳制并让 UI 展示明细 + hit = m["t_cread"] * 100.0 / m["t_in"] if m["t_in"] > 0 else None + payload["cache_hit_pct"] = round(min(hit, 100.0), 1) if hit is not None else None + payload["models"] = models_breakdown(cur, cond, params) + payload["recent"] = recent_requests(cur, cond, params) + # 最近一次完成请求的 TPS(面板/弹窗「实时 TPS」行)。 + # 复用 _DECODE_GUARD 的窗口下限:脏 fta 行直接跳过,取最近一条窗口可信的请求。 + live = None + for r in payload["recent"]: + if ( + r["done_at"] is not None and r["fta"] is not None and (r["out"] or 0) > 0 + and r["done_at"] - r["fta"] >= MIN_DECODE_MS + ): + live = round(r["out"] * 1000.0 / (r["done_at"] - r["fta"]), 1) + break + payload["live_tps"] = live + return payload + + +# ------------------------------------------------------------- MCP 协议 ---- + + +def tool_get_stats(args: dict) -> dict: + scope = args.get("scope") or "current" + if scope not in ("current", "today"): + scope = "current" + if scope == "today": + sid, source = None, "n/a" + else: + sid, source = resolve_session_id(args.get("session_id")) + payload = build_stats(scope, sid, source) + text = json.dumps(payload, ensure_ascii=False, indent=1) + return {"content": [{"type": "text", "text": text}], "structuredContent": payload} + + +def tool_open_panel(_args: dict) -> dict: + return { + "content": [{"type": "text", "text": "会话统计面板已打开(stats-panel)。"}], + "_meta": {"ui": {"surface": "stats-panel"}}, + } + + +def read_panel_html() -> str: + try: + return PANEL_FILE.read_text(encoding="utf-8") + except OSError as e: + log(f"panel.html 读取失败: {e}") + return ( + "" + f"panel.html 缺失:{PANEL_FILE}" + ) + + +def reply(rid, result: dict) -> None: + send({"jsonrpc": "2.0", "id": rid, "result": result}) + + +def send_err(rid, code: int, message: str) -> None: + send({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}) + + +def send(msg: dict) -> None: + sys.stdout.write(json.dumps(msg, ensure_ascii=False) + "\n") + sys.stdout.flush() + + +def handle(req: dict) -> None: + method = req.get("method") + rid = req.get("id") + + if method == "initialize": + client_pv = (req.get("params") or {}).get("protocolVersion") or "2025-06-18" + reply(rid, { + "protocolVersion": client_pv, + "capabilities": {"tools": {}, "resources": {}}, + "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION}, + "instructions": "session-stats: 只读查询 ZCode 本地会话统计。", + }) + return + + if rid is None: + return # notification(notifications/initialized 等)一律不回 + + if method == "ping": + reply(rid, {}) + elif method == "tools/list": + reply(rid, {"tools": TOOLS}) + elif method == "tools/call": + params = req.get("params") or {} + name = params.get("name", "") + args = params.get("arguments") or {} + try: + if name == "get_stats": + result = tool_get_stats(args) + elif name == "open_stats_panel": + result = tool_open_panel(args) + else: + send_err(rid, -32602, f"unknown tool: {name}") + return + except Exception as e: # noqa: BLE001 + log(f"tools/call {name} 失败: {e!r}") + reply(rid, {"content": [{"type": "text", "text": f"统计失败: {e}"}], "isError": True}) + return + reply(rid, result) + elif method == "resources/list": + reply(rid, {"resources": RESOURCES}) + elif method == "resources/read": + uri = (req.get("params") or {}).get("uri") + if uri != UI_URI: + send_err(rid, -32602, f"unknown resource: {uri}") + return + reply(rid, {"contents": [{"uri": UI_URI, "mimeType": "text/html", "text": read_panel_html()}]}) + else: + send_err(rid, -32601, f"method not found: {method}") + + +# ------------------------------------------------------- 本地数据服务 ---- +# 渲染层注入 pill 的数据源:http://127.0.0.1:31710。 +# 生命周期跟随 MCP 服务器进程(会话存活期间);多实例靠端口单例 + 健康探测, +# 谁先绑定谁服务,后到的发现健康实例就静默退出。仅响应 file:// 页面 +# (Origin: null/缺失),其余 Origin 一律 403。 + +HTTP_HOST = "127.0.0.1" +HTTP_PORT = int(os.environ.get("ZCODE_STATS_HTTP_PORT", "31710")) +# pill 代码下发路径解析顺序:环境变量(开发者热更新用)→ 插件内置副本(共享默认)→ 开发目录 +PILLS_CANDIDATES = [ + os.environ.get("ZCODE_STATS_PILLS_PATH", ""), + str(PLUGIN_ROOT / "pills.js"), + str(PLUGIN_ROOT / "server" / "pills.js"), + str(PLUGIN_ROOT.parents[1] / "patches" / "pills.js"), +] + + +def read_pills_js() -> str | None: + for p in PILLS_CANDIDATES: + if p: + try: + return Path(p).read_text(encoding="utf-8") + except OSError: + continue + return None + + +def _health_check() -> bool: + try: + with urllib.request.urlopen( + f"http://{HTTP_HOST}:{HTTP_PORT}/health", timeout=1 + ) as r: + return r.status == 200 + except Exception: # noqa: BLE001 + return False + + +class _StatsHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + allowed = False + try: + origin = self.headers.get("Origin") + allowed = origin in (None, "", "null") + parsed = urllib.parse.urlparse(self.path) + if not allowed: + self._send(403, {"error": "origin rejected"}, cors=False) + elif parsed.path == "/health": + self._send(200, {"ok": True, "version": SERVER_VERSION}) + elif parsed.path == "/pills.js": + code = read_pills_js() + if code is None: + self._send(404, {"error": "pills.js not configured"}) + else: + body = code.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/javascript; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "null") + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + elif parsed.path == "/stats": + qs = urllib.parse.parse_qs(parsed.query) + scope = qs.get("scope", ["current"])[0] + if scope not in ("current", "today"): + scope = "current" + if scope == "today": + payload = build_stats("today", None, "n/a") + else: + sid_param = (qs.get("session_id") or [None])[0] + if sid_param is not None and not str(sid_param).startswith(("sess_", "session_")): + sid_param = None # 格式守卫,防宿主 DOM 传来 draft 等占位值 + sid, source = resolve_session_id(sid_param) + payload = build_stats("current", sid, source) + self._send(200, payload) + else: + self._send(404, {"error": "not found"}) + except Exception as e: # noqa: BLE001 + log(f"HTTP 处理异常: {e!r}") + try: + self._send(500, {"error": str(e)}, cors=allowed) + except Exception: # noqa: BLE001 + pass + finally: + close_local_conn() + + def _send(self, code: int, obj: dict, cors: bool = True) -> None: + body = json.dumps(obj, ensure_ascii=False).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json; charset=utf-8") + if cors: + self.send_header("Access-Control-Allow-Origin", "null") + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt, *args) -> None: # noqa: N802 + pass # 静默访问日志 + + +def start_http_service() -> bool: + """在本进程内启动 HTTP 数据服务。 + + 返回 True=本进程已开始服务;False=端口已有健康实例(或启动失败)。 + 常驻服务进程据此退出,避免自动重打补丁时反复拉起空转实例。 + """ + class _Server(ThreadingHTTPServer): + # Windows 上 SO_REUSEADDR 会允许重复绑定活跃端口,关闭以靠异常探测单例 + allow_reuse_address = False + daemon_threads = True + + for _ in range(3): + try: + srv = _Server((HTTP_HOST, HTTP_PORT), _StatsHandler) + except OSError: + if _health_check(): + log(f"端口 {HTTP_PORT} 已有健康实例,跳过启动") + return False + time.sleep(0.5) + continue + threading.Thread(target=srv.serve_forever, daemon=True).start() + log(f"数据服务已启动 http://{HTTP_HOST}:{HTTP_PORT}") + return True + log(f"数据服务启动失败(端口 {HTTP_PORT} 被占用且无健康实例)") + return False + + +def main() -> None: + try: + sys.stdout.reconfigure(newline="\n", encoding="utf-8") + sys.stdin.reconfigure(encoding="utf-8") + except Exception: # noqa: BLE001 + pass + log(f"started; plugin_root={PLUGIN_ROOT}") + start_http_service() + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + req = json.loads(line) + except json.JSONDecodeError as e: + log(f"非法 JSON 行: {e}; line={line[:120]!r}") + continue + try: + handle(req) + except Exception as e: # noqa: BLE001 + log(f"handle 异常: {e!r}") + if req.get("id") is not None: + send_err(req["id"], -32603, f"internal error: {e}") + + +if __name__ == "__main__": + main() diff --git a/external_plugins/session-stats/ui/panel.html b/external_plugins/session-stats/ui/panel.html new file mode 100644 index 00000000..df07dd26 --- /dev/null +++ b/external_plugins/session-stats/ui/panel.html @@ -0,0 +1,232 @@ + + + + + +会话统计 + + + +
正在启动…
+ + + diff --git a/marketplace.json b/marketplace.json index c7f09a06..4a5d469f 100644 --- a/marketplace.json +++ b/marketplace.json @@ -13,6 +13,41 @@ { "name": "openpencil", "source": "./external_plugins/openpencil" } ], "plugins": [ + { + "name": "session-stats", + "componentTypes": [ + "ui" + ], + "displayName": "Session Stats", + "displayName_i18n": { + "en": "Session Stats", + "zh-CN": "会话统计" + }, + "source": "./external_plugins/session-stats", + "description": "Session statistics for ZCode: turns/steps, model vs tool time, TTFT, TPS (average + latest request), token usage and cache hit rate. Zero third-party dependencies.", + "description_i18n": { + "en": "Session statistics for ZCode: turns/steps, model vs tool time, TTFT, TPS (average + latest request), token usage and cache hit rate. Zero third-party dependencies.", + "zh-CN": "会话统计:轮次/步数、模型/工具耗时、TTFT、TPS(会话平均+最近请求)、token 用量与缓存命中率。零第三方依赖。" + }, + "version": "0.3.0", + "author": { + "name": "gary.liu", + "url": "https://github.com/huantest2024" + }, + "homepage": "https://github.com/huantest2024/zcode-session-stats", + "repository": "https://github.com/huantest2024/zcode-session-stats", + "license": "MIT", + "keywords": [ + "stats", + "usage", + "tokens", + "cache", + "ttft", + "tps", + "panel" + ], + "category": "developer-tools" + }, { "name": "cloudbase-skills", "source": "./plugins/cloudbase-skills",