From a55c80e497405fcd65ebad729c2cb65af6a51f73 Mon Sep 17 00:00:00 2001 From: GaryLiuTesting <160207920+huantest2024@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:34:58 +0800 Subject: [PATCH 01/15] feat(session-stats): add session statistics plugin (v0.3.0) --- .../session-stats/.zcode-plugin/plugin.json | 49 ++ external_plugins/session-stats/README.md | 66 ++ external_plugins/session-stats/README_CN.md | 51 ++ .../session-stats/commands/stats.md | 26 + .../session-stats/hooks/hooks.json | 18 + .../session-stats/hooks/write_session.py | 32 + .../session-stats/server/pills.js | 414 +++++++++++++ .../server/service_standalone.py | 26 + .../session-stats/server/stats_server.py | 577 ++++++++++++++++++ external_plugins/session-stats/ui/panel.html | 232 +++++++ marketplace.json | 35 ++ 11 files changed, 1526 insertions(+) create mode 100644 external_plugins/session-stats/.zcode-plugin/plugin.json create mode 100644 external_plugins/session-stats/README.md create mode 100644 external_plugins/session-stats/README_CN.md create mode 100644 external_plugins/session-stats/commands/stats.md create mode 100644 external_plugins/session-stats/hooks/hooks.json create mode 100644 external_plugins/session-stats/hooks/write_session.py create mode 100644 external_plugins/session-stats/server/pills.js create mode 100644 external_plugins/session-stats/server/service_standalone.py create mode 100644 external_plugins/session-stats/server/stats_server.py create mode 100644 external_plugins/session-stats/ui/panel.html 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..40543437 --- /dev/null +++ b/external_plugins/session-stats/server/pills.js @@ -0,0 +1,414 @@ +/* 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"; + var rowEl = null, popEl = null, css = null, lastHtml = ""; + + 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; + } + function stripWs(s) { + return String(s).replace(/\s+/g, ""); + } + + + css = document.createElement("style"); + css.textContent = + ".__sspills-row{position:absolute;bottom:10px;transform:translateX(-50%);" + + "display:inline-flex;align-items:center;gap:6px;z-index:30;}" + + ".__sspills-pill{display:inline-flex;align-items:center;gap:5px;height:30px;padding:0 12px;" + + "border-radius:8px;background:rgba(255,255,255,.045);border:1px solid rgba(255,255,255,.09);" + + "color:rgba(232,235,241,.88);font:500 13px/1 'Segoe UI','Microsoft YaHei',system-ui,sans-serif;" + + "cursor:pointer;white-space:nowrap;transition:border-color .15s,background .15s;}" + + ".__sspills-pill:hover{border-color:rgba(122,162,247,.55);background:rgba(122,162,247,.08);}" + + ".__sspills-pill .ico{opacity:.6;}" + + ".__sspills-pill b{font-weight:600;}" + + ".__sspills-pill .sep{opacity:.35;}" + + ".__sspills-pill .dv{opacity:.3;margin:0 4px;border-left:1px solid currentColor;height:12px;}" + + ".__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{background:rgba(0,0,0,.045);border-color:rgba(0,0,0,.12);color:rgba(28,30,36,.88);}" + + ".__sspills-pill:hover{border-color:rgba(59,91,219,.5);background:rgba(59,91,219,.06);}" + + ".__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)], + ["缓存命中率", 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(["最近请求 缓存命中", 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 + "
"; + } + + function renderPopup() { + 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; + if (!popEl) return; + popEl.style.display = open ? "block" : "none"; + 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; + 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(); + layout(); + if (popupOpen) renderPopup(); + }) + .catch(function () { + fails++; + if (fails >= 3 && rowEl) rowEl.style.display = "none"; + }); + } + + /* 实时 TPS:优先取进行中的请求(token/(now-首token)),否则取最近一次完成的请求 */ + 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) { + 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) { + return { v: r.out * 1000 / (r.done_at - r.fta), inflight: false }; + } + } + return null; + } + + function pillEl(ico, body) { + return '' + + '' + ico + "" + body + ""; + } + function pillHtml(mode) { + if (!data) return pillEl("⏱", "加载中…"); + if (data.draft) return pillEl("⏱", "新会话 · 暂无统计"); + if (data.error) return pillEl("⏱", "统计不可用"); + var t = data.tokens || {}; + 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 ? '●' : "") + : "—"; + if (mode === "mini") { + // 迷你版:实时 TPS + token 消耗量 + return pillEl("⏱", shownHtml + '·' + fmtTok(t.total) + " tok"); + } + if (mode === "mid") { + // 中档:步数 + 速度 + 消耗量 + 缓存命中(去"轮"字与分隔竖线,紧凑单行) + return pillEl("⏱", + "" + (data.steps != null ? data.steps : 0) + " 步" + + '·' + shownHtml + + '·' + fmtTok(t.total) + "" + + '·命中 ' + (data.cache_hit_pct != null ? data.cache_hit_pct : "—") + "%"); + } + return pillEl("⏱", + "" + (data.turns != null ? data.turns : "—") + " 轮 " + (data.steps != null ? data.steps : 0) + " 步" + + '·' + shownHtml + + '' + + "" + fmtTok(t.total) + " tok" + + '·缓存命中 ' + (data.cache_hit_pct != null ? data.cache_hit_pct : "—") + "%"); + } + function render() { + if (!rowEl) return; + var stale = Date.now() - lastOk > 9e5; + rowEl.classList.toggle("stale", stale); + var html = pillHtml(pillMode); + if (html !== lastHtml) { rowEl.innerHTML = html; lastHtml = html; } + } + + function makeRow() { + var el = document.createElement("div"); + el.className = "__sspills-row"; + el.addEventListener("click", function (ev) { + var act = ev.target && ev.target.closest && ev.target.closest("[data-act='detail']"); + if (act) setPopup(!popupOpen); + ev.stopPropagation(); + }); + return el; + } + + function stripWs(s) { + return String(s).replace(/\s+/g, ""); + } + function findChipByText(root, txt) { + var btns = root.querySelectorAll("button,[role='button']"); + for (var i = 0; i < btns.length; i++) { + if (stripWs(btns[i].textContent) === txt) return btns[i]; + } + return null; + } + + /* 只取「可见」的输入条容器——多标签页时隐藏标签页的 dock 也在 DOM 里, + 拿第一个会量到错误几何(重叠的根因)。 */ + function visibleDock() { + var docks = document.querySelectorAll("[data-v4-composer-dock-content]"); + for (var i = 0; i < docks.length; i++) { + if (docks[i].offsetParent !== null) return docks[i]; + } + return docks[0] || null; + } + + /* 布局:pill 放进「左侧 chip 组 ↔ 模型选择器」之间的空隙并居中; + 放不下完整版 → 自动缩窄为迷你版(实时 TPS + token 消耗量);再放不下才隐藏。 + 垂直对齐模型选择器中心线。 */ + function layout() { + var dock = visibleDock(); + if (!dock) { + if (rowEl) { rowEl.remove(); } + return; + } + if (!rowEl || rowEl.parentElement !== dock || !rowEl.isConnected) { + if (!rowEl) rowEl = makeRow(); + dock.appendChild(rowEl); + } + var trig = dock.querySelector('[data-testid="chat-model-select-trigger"]'); + if (!trig) { rowEl.style.display = "none"; return; } + var leftChip = findChipByText(dock, "电脑操作") || findChipByText(dock, "完全访问"); + var leftEdge, rightEdge; + if (leftChip) { + leftEdge = leftChip.getBoundingClientRect().right; + rightEdge = trig.getBoundingClientRect().left; + } else { + var dr0 = dock.getBoundingClientRect(); + leftEdge = dr0.left + 60; + rightEdge = trig.getBoundingClientRect().left; + } + var gap = rightEdge - leftEdge; + var dockW = dock.getBoundingClientRect().width; + /* 档位随输入框宽度:宽 → 完整版;中 → 中档;窄 → 迷你版。 + 落位再按空隙校验,放不下自动降一档,保证不重叠。 */ + var order; + if (dockW >= 700) order = ["full", "mid", "mini"]; + else if (dockW >= 460) order = ["mid", "mini"]; + else order = ["mini"]; + var chosen = null; + for (var mi = 0; mi < order.length; mi++) { + var mode = order[mi]; + var html = pillHtml(mode); + if (html !== lastHtml) { rowEl.innerHTML = html; lastHtml = html; } + rowEl.style.display = ""; + if (gap >= rowEl.offsetWidth + 24) { chosen = mode; break; } + } + if (!chosen) { rowEl.style.display = "none"; return; } + pillMode = chosen; + var dockRect = dock.getBoundingClientRect(); + var centerX = (leftEdge + rightEdge) / 2; + rowEl.style.left = Math.round(centerX - dockRect.left) + "px"; + var tr = trig.getBoundingClientRect(); + var pillH = rowEl.offsetHeight || 30; + rowEl.style.bottom = Math.max(0, Math.round((dockRect.bottom - (tr.top + tr.height / 2)) - pillH / 2)) + "px"; + rowEl.style.display = ""; + } + + var scheduled = false; + function schedule() { + if (scheduled) return; + scheduled = true; + requestAnimationFrame(function () { + scheduled = false; + layout(); + var sid = currentSessionId(); + if (sid !== lastSid) poll(); + }); + } + + document.addEventListener("click", function (ev) { + if (popupOpen && rowEl && !rowEl.contains(ev.target) && popEl && !popEl.contains(ev.target)) { + setPopup(false); + } + }, true); + window.addEventListener("resize", function () { layout(); if (popupOpen) renderPopup(); }); + document.addEventListener("scroll", function () { if (popupOpen) renderPopup(); }, true); + + var mo = new MutationObserver(schedule); + mo.observe(document.documentElement, { + childList: true, subtree: true, attributes: true, attributeFilter: ["data-session-id"], + }); + layout(); + popEl = document.createElement("div"); + popEl.className = "__sspills-pop"; + document.body.appendChild(popEl); + poll(); + var pollTimer = setInterval(poll, POLL_MS); + + window.__SSPILLS_TEARDOWN__ = function () { + try { mo.disconnect(); } catch (e) {} + try { clearInterval(pollTimer); } catch (e) {} + try { css.remove(); } 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..529cb708 --- /dev/null +++ b/external_plugins/session-stats/server/stats_server.py @@ -0,0 +1,577 @@ +"""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_GUARD = ( + "output_tokens > 0 AND first_token_at IS NOT NULL " + "AND completed_at IS NOT NULL AND completed_at > first_token_at" +) + + +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」行) + 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"]: + 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() -> None: + 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 + time.sleep(0.5) + continue + threading.Thread(target=srv.serve_forever, daemon=True).start() + log(f"数据服务已启动 http://{HTTP_HOST}:{HTTP_PORT}") + return + log(f"数据服务启动失败(端口 {HTTP_PORT} 被占用且无健康实例)") + + +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", From 930ecbfea40741c35518234186c334ff4b12400a Mon Sep 17 00:00:00 2001 From: GaryLiuTesting <160207920+huantest2024@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:48:55 +0800 Subject: [PATCH 02/15] fix(session-stats): sync latest pill layout fixes (ghost purge, persistent modes, popup self-heal) --- .../session-stats/server/pills.js | 219 +++++++++++++----- 1 file changed, 167 insertions(+), 52 deletions(-) diff --git a/external_plugins/session-stats/server/pills.js b/external_plugins/session-stats/server/pills.js index 40543437..5f85298c 100644 --- a/external_plugins/session-stats/server/pills.js +++ b/external_plugins/session-stats/server/pills.js @@ -6,8 +6,8 @@ var ENDPOINT = "http://127.0.0.1:31710"; var POLL_MS = 5000; - var data = null, lastOk = 0, fails = 0, popupOpen = false, pillMode = "full"; - var rowEl = null, popEl = null, css = null, lastHtml = ""; + var data = null, lastOk = 0, fails = 0, popupOpen = false, pillMode = "full", openedAt = 0; + var rowEl = null, popEl = null, css = null; function fmtDur(ms) { if (ms == null) return "—"; @@ -133,7 +133,17 @@ '
' + 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) { @@ -165,8 +175,10 @@ function setPopup(open) { popupOpen = open; + ensurePopEl(); if (!popEl) return; popEl.style.display = open ? "block" : "none"; + openedAt = open ? Date.now() : 0; if (open) renderPopup(); } @@ -203,6 +215,7 @@ function poll() { var sid = currentSessionId(); lastSid = sid; + requestLayout(); // 每次轮询统一走调度器(宽度比对交给 ResizeObserver) if (sid === "draft") { // 新会话(尚未产生请求):显示空态,不去借别的会话的数据 data = { draft: true, session_source: "draft" }; @@ -218,7 +231,7 @@ .then(function (j) { data = j; lastOk = Date.now(); fails = 0; render(); - layout(); + requestLayout(); if (popupOpen) renderPopup(); }) .catch(function () { @@ -247,14 +260,11 @@ return null; } - function pillEl(ico, body) { - return '' + - '' + ico + "" + body + ""; - } - function pillHtml(mode) { - if (!data) return pillEl("⏱", "加载中…"); - if (data.draft) return pillEl("⏱", "新会话 · 暂无统计"); - if (data.error) return pillEl("⏱", "统计不可用"); + function pillBody(mode) { + // 三档内容的内层 HTML(外层 pill 壳由 makeRow 常驻创建,切换只改 display,零重建) + if (!data) return "加载中…"; + if (data.draft) return "新会话 · 暂无统计"; + if (data.error) return "统计不可用"; var t = data.tokens || {}; var lt = liveTps(data); var shown = lt ? Math.round(lt.v * 10) / 10 : data.tps; @@ -262,40 +272,82 @@ ? shown + ' tok/s' + (lt && lt.inflight ? '●' : "") : "—"; if (mode === "mini") { - // 迷你版:实时 TPS + token 消耗量 - return pillEl("⏱", shownHtml + '·' + fmtTok(t.total) + " tok"); + return shownHtml + '·' + fmtTok(t.total) + " tok"; } if (mode === "mid") { - // 中档:步数 + 速度 + 消耗量 + 缓存命中(去"轮"字与分隔竖线,紧凑单行) - return pillEl("⏱", - "" + (data.steps != null ? data.steps : 0) + " 步" + + return "" + (data.steps != null ? data.steps : 0) + " 步" + '·' + shownHtml + '·' + fmtTok(t.total) + "" + - '·命中 ' + (data.cache_hit_pct != null ? data.cache_hit_pct : "—") + "%"); + '·命中 ' + (data.cache_hit_pct != null ? data.cache_hit_pct : "—") + "%"; } - return pillEl("⏱", - "" + (data.turns != null ? data.turns : "—") + " 轮 " + (data.steps != null ? data.steps : 0) + " 步" + + return "" + (data.turns != null ? data.turns : "—") + " 轮 " + (data.steps != null ? data.steps : 0) + " 步" + '·' + shownHtml + '' + "" + fmtTok(t.total) + " tok" + - '·缓存命中 ' + (data.cache_hit_pct != null ? data.cache_hit_pct : "—") + "%"); + '·缓存命中 ' + (data.cache_hit_pct != null ? data.cache_hit_pct : "—") + "%"; + } + + /* 三档内容常驻为三个子 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"]; + 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); - var html = pillHtml(pillMode); - if (html !== lastHtml) { rowEl.innerHTML = html; lastHtml = html; } + ensureModeSpans(); + if (!modeSpans) return; + var modes = ["full", "mid", "mini"]; + 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"]; + 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) { - var act = ev.target && ev.target.closest && ev.target.closest("[data-act='detail']"); - if (act) setPopup(!popupOpen); + // 点击 pill 任意位置都开合弹窗(不要求命中特定子元素,防重建竞态) + 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"; + ico.textContent = "⏱"; + pill.appendChild(ico); + el.appendChild(pill); return el; } @@ -310,20 +362,51 @@ return null; } - /* 只取「可见」的输入条容器——多标签页时隐藏标签页的 dock 也在 DOM 里, - 拿第一个会量到错误几何(重叠的根因)。 */ + /* 只取「可见」的输入条容器——多标签页时隐藏标签页的 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++) { - if (docks[i].offsetParent !== null) return docks[i]; + var r = docks[i].getBoundingClientRect(); + if (r.width > 0 && r.height > 0) return docks[i]; } return docks[0] || null; } - /* 布局:pill 放进「左侧 chip 组 ↔ 模型选择器」之间的空隙并居中; - 放不下完整版 → 自动缩窄为迷你版(实时 TPS + token 消耗量);再放不下才隐藏。 - 垂直对齐模型选择器中心线。 */ + /* 布局调度器:所有触发点只置脏标记,每帧最多执行一次完整布局。 + - 单次布局内一次性量取全部几何(同一 dock、同一帧),杜绝跨 dock/过期 rect + - 档位判定带真滞回(±24px),临界宽度不再反复跳档 + - 空隙不足时先降档;连续 4 次(≈2 秒)都放不下才隐藏,恢复条件一到立即回来 */ + var layoutDirty = true; + var layoutScheduled = false; + var shrinkStreak = 0; + + function requestLayout() { + layoutDirty = true; + if (layoutScheduled) return; + layoutScheduled = true; + requestAnimationFrame(function () { + layoutScheduled = false; + if (layoutDirty) { layoutDirty = false; layout(); } + }); + } + + /* 清扫幽灵实例: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]); + } + } + function layout() { + purgeForeign(); var dock = visibleDock(); if (!dock) { if (rowEl) { rowEl.remove(); } @@ -333,42 +416,64 @@ if (!rowEl) rowEl = makeRow(); dock.appendChild(rowEl); } + var dockRect = dock.getBoundingClientRect(); var trig = dock.querySelector('[data-testid="chat-model-select-trigger"]'); if (!trig) { rowEl.style.display = "none"; return; } var leftChip = findChipByText(dock, "电脑操作") || findChipByText(dock, "完全访问"); - var leftEdge, rightEdge; + var leftEdge; if (leftChip) { leftEdge = leftChip.getBoundingClientRect().right; - rightEdge = trig.getBoundingClientRect().left; } else { - var dr0 = dock.getBoundingClientRect(); - leftEdge = dr0.left + 60; - rightEdge = trig.getBoundingClientRect().left; + leftEdge = dockRect.left + 60; + } + /* 右边界 = 左 chip 右侧所有按钮(模型选择器、上下文查看图标等)中最近者的左边缘—— + 空隙里有什么原生控件都自动避开,绝不覆盖 */ + var trigRect = trig.getBoundingClientRect(); + var rightEdge = trigRect.left; + var btns = dock.querySelectorAll("button,[role='button']"); + for (var bi = 0; bi < btns.length; bi++) { + var br = btns[bi].getBoundingClientRect(); + if (br.width > 0 && br.left > leftEdge + 4 && br.left < rightEdge) rightEdge = br.left; } var gap = rightEdge - leftEdge; - var dockW = dock.getBoundingClientRect().width; - /* 档位随输入框宽度:宽 → 完整版;中 → 中档;窄 → 迷你版。 - 落位再按空隙校验,放不下自动降一档,保证不重叠。 */ - var order; - if (dockW >= 700) order = ["full", "mid", "mini"]; - else if (dockW >= 460) order = ["mid", "mini"]; - else order = ["mini"]; + var dockW = dockRect.width; + + /* 档位判定:按 dock 宽度 + 真滞回(升降门槛错开 48px),临界宽度稳定不跳 */ + var HY = 24; + var want; + if (pillMode === "full") want = dockW >= 700 - HY ? "full" : (dockW >= 460 - HY ? "mid" : "mini"); + else if (pillMode === "mid") want = dockW >= 700 + HY ? "full" : (dockW >= 460 - HY ? "mid" : "mini"); + else want = dockW >= 700 + HY ? "full" : (dockW >= 460 + HY ? "mid" : "mini"); + + var order = [want]; + var all = ["full", "mid", "mini"]; + for (var ai = 0; ai < all.length; ai++) { + if (all[ai] !== want) order.push(all[ai]); + } var chosen = null; + ensureModeSpans(); for (var mi = 0; mi < order.length; mi++) { var mode = order[mi]; - var html = pillHtml(mode); - if (html !== lastHtml) { rowEl.innerHTML = html; lastHtml = html; } + setModeSpans(mode); // 纯 display 切换,零重建 rowEl.style.display = ""; if (gap >= rowEl.offsetWidth + 24) { chosen = mode; break; } } - if (!chosen) { rowEl.style.display = "none"; return; } + if (!chosen) { + /* 连续放不下才隐藏;一旦隐藏,由 5 秒轮询/宽度变化自动重试恢复 */ + shrinkStreak++; + if (shrinkStreak >= 4 || rowEl.style.display === "none") { + rowEl.style.display = "none"; + return; + } + return; // 给动画中的侧栏一点缓冲,下一帧再试 + } + shrinkStreak = 0; pillMode = chosen; - var dockRect = dock.getBoundingClientRect(); + setModeSpans(chosen); var centerX = (leftEdge + rightEdge) / 2; rowEl.style.left = Math.round(centerX - dockRect.left) + "px"; - var tr = trig.getBoundingClientRect(); var pillH = rowEl.offsetHeight || 30; - rowEl.style.bottom = Math.max(0, Math.round((dockRect.bottom - (tr.top + tr.height / 2)) - pillH / 2)) + "px"; + rowEl.style.bottom = Math.max(0, Math.round((dockRect.bottom - (trigRect.top + trigRect.height / 2)) - pillH / 2)) + "px"; rowEl.style.display = ""; } @@ -378,25 +483,35 @@ scheduled = true; requestAnimationFrame(function () { scheduled = false; - layout(); + requestLayout(); var sid = currentSessionId(); if (sid !== lastSid) poll(); }); } document.addEventListener("click", function (ev) { - if (popupOpen && rowEl && !rowEl.contains(ev.target) && popEl && !popEl.contains(ev.target)) { + if (!popupOpen) return; + if (Date.now() - openedAt < 400) return; // 刚开启:忽略宿主合成的迟到点击,防秒关 + if (rowEl && !rowEl.contains(ev.target) && popEl && !popEl.contains(ev.target)) { setPopup(false); } }, true); - window.addEventListener("resize", function () { layout(); if (popupOpen) renderPopup(); }); + window.addEventListener("resize", function () { requestLayout(); if (popupOpen) renderPopup(); }); document.addEventListener("scroll", function () { if (popupOpen) renderPopup(); }, true); var mo = new MutationObserver(schedule); mo.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-session-id"], }); - layout(); + /* 输入框容器尺寸变化(侧栏开合/拖分隔条不触发 window resize):ResizeObserver 直接触发重布局 */ + if (typeof ResizeObserver !== "undefined") { + var ro = new ResizeObserver(function () { requestLayout(); }); + var roTimer = setInterval(function () { + var d = visibleDock(); + if (d) { ro.observe(d); clearInterval(roTimer); } + }, 2000); + } + requestLayout(); popEl = document.createElement("div"); popEl.className = "__sspills-pop"; document.body.appendChild(popEl); From 1fd85eed68bd6d575444ab77f518a474d9443ae6 Mon Sep 17 00:00:00 2001 From: GaryLiuTesting <160207920+huantest2024@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:16:01 +0800 Subject: [PATCH 03/15] fix(session-stats): debounce layout and stable width --- .../session-stats/server/pills.js | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/external_plugins/session-stats/server/pills.js b/external_plugins/session-stats/server/pills.js index 5f85298c..052ea59d 100644 --- a/external_plugins/session-stats/server/pills.js +++ b/external_plugins/session-stats/server/pills.js @@ -374,22 +374,34 @@ return docks[0] || null; } - /* 布局调度器:所有触发点只置脏标记,每帧最多执行一次完整布局。 - - 单次布局内一次性量取全部几何(同一 dock、同一帧),杜绝跨 dock/过期 rect - - 档位判定带真滞回(±24px),临界宽度不再反复跳档 - - 空隙不足时先降档;连续 4 次(≈2 秒)都放不下才隐藏,恢复条件一到立即回来 */ - var layoutDirty = true; - var layoutScheduled = false; + /* 布局调度器:所有触发点只置脏标记,界面稳定后才执行一次完整布局。 + - 尾随防抖 180ms:滚轮滑动/侧栏动画等过渡帧全部跳过,只在静止后布局一次 + - 档位判定用「稳定宽度」(宽度持续 300ms 才认),过渡帧不换档 + - 档位带真滞回(±24px),临界宽度不反复跳 + - 空隙不足先降档;连续 4 次放不下才隐藏,条件恢复立即回来 */ + var layoutTimer = 0; var shrinkStreak = 0; + var basisW = 0; // 稳定宽度(档位判定基准) + var pendingW = 0; // 待确认的新宽度 + var pendingSince = 0; function requestLayout() { - layoutDirty = true; - if (layoutScheduled) return; - layoutScheduled = true; - requestAnimationFrame(function () { - layoutScheduled = false; - if (layoutDirty) { layoutDirty = false; layout(); } - }); + if (layoutTimer) clearTimeout(layoutTimer); + layoutTimer = setTimeout(function () { + layoutTimer = 0; + layout(); + }, 180); + } + + function stableWidth(w) { + if (!basisW || Math.abs(w - basisW) <= 2) { + if (!basisW) basisW = w; + pendingW = 0; + return basisW; + } + if (Math.abs(w - pendingW) > 2) { pendingW = w; pendingSince = Date.now(); } + else if (Date.now() - pendingSince > 300) { basisW = pendingW; pendingW = 0; } + return basisW; } /* 清扫幽灵实例:React 重建 dock 会泄漏旧 rowEl(代码只追新不删旧), @@ -436,9 +448,9 @@ if (br.width > 0 && br.left > leftEdge + 4 && br.left < rightEdge) rightEdge = br.left; } var gap = rightEdge - leftEdge; - var dockW = dockRect.width; + var dockW = stableWidth(dockRect.width); // 档位判定用稳定宽度,过渡帧不换档 - /* 档位判定:按 dock 宽度 + 真滞回(升降门槛错开 48px),临界宽度稳定不跳 */ + /* 档位判定:按稳定宽度 + 真滞回(升降门槛错开 48px),临界宽度稳定不跳 */ var HY = 24; var want; if (pillMode === "full") want = dockW >= 700 - HY ? "full" : (dockW >= 460 - HY ? "mid" : "mini"); @@ -475,6 +487,8 @@ var pillH = rowEl.offsetHeight || 30; rowEl.style.bottom = Math.max(0, Math.round((dockRect.bottom - (trigRect.top + trigRect.height / 2)) - pillH / 2)) + "px"; rowEl.style.display = ""; + /* 宽度已变但尚未稳定:再排一次布局,让稳定宽度有机会生效 */ + if (pendingW) setTimeout(requestLayout, 340); } var scheduled = false; From b62a2b2dba690184579171c49bc1c111381be505 Mon Sep 17 00:00:00 2001 From: GaryLiuTesting <160207920+huantest2024@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:18:12 +0800 Subject: [PATCH 04/15] fix(session-stats): scroll exemption --- external_plugins/session-stats/server/pills.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/external_plugins/session-stats/server/pills.js b/external_plugins/session-stats/server/pills.js index 052ea59d..3dea90a1 100644 --- a/external_plugins/session-stats/server/pills.js +++ b/external_plugins/session-stats/server/pills.js @@ -384,11 +384,18 @@ var basisW = 0; // 稳定宽度(档位判定基准) var pendingW = 0; // 待确认的新宽度 var pendingSince = 0; + var lastScrollAt = 0; // 滚动豁免:滚动后 700ms 内不布局(滚动中会话顶栏/输入条有过渡动画) function requestLayout() { if (layoutTimer) clearTimeout(layoutTimer); layoutTimer = setTimeout(function () { layoutTimer = 0; + var quiet = Date.now() - lastScrollAt; + if (quiet < 700) { + // 滚动豁免期内:推迟到豁免期结束再布局 + layoutTimer = setTimeout(function () { layoutTimer = 0; layout(); }, 700 - quiet + 20); + return; + } layout(); }, 180); } @@ -511,7 +518,11 @@ } }, true); window.addEventListener("resize", function () { requestLayout(); if (popupOpen) renderPopup(); }); - document.addEventListener("scroll", function () { if (popupOpen) renderPopup(); }, true); + document.addEventListener("scroll", function () { + lastScrollAt = Date.now(); + if (popupOpen) renderPopup(); + requestLayout(); // 记录豁免;滚动停稳后会补执行一次 + }, true); var mo = new MutationObserver(schedule); mo.observe(document.documentElement, { From 6e7c1542e3d866d406de1c24549bb0fa6975ccfa Mon Sep 17 00:00:00 2001 From: GaryLiuTesting <160207920+huantest2024@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:21:36 +0800 Subject: [PATCH 05/15] fix(session-stats): horizontal band filter for right edge --- external_plugins/session-stats/server/pills.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/external_plugins/session-stats/server/pills.js b/external_plugins/session-stats/server/pills.js index 3dea90a1..4c75c5f4 100644 --- a/external_plugins/session-stats/server/pills.js +++ b/external_plugins/session-stats/server/pills.js @@ -445,14 +445,22 @@ } else { leftEdge = dockRect.left + 60; } - /* 右边界 = 左 chip 右侧所有按钮(模型选择器、上下文查看图标等)中最近者的左边缘—— - 空隙里有什么原生控件都自动避开,绝不覆盖 */ + /* 右边界 = 左 chip 右侧、与 chip 同一水平带的按钮(模型选择器、上下文图标等)中 + 最近者的左边缘。垂直带过滤(±22px)排除悬浮在输入条上方的「回到底部」按钮、 + 提示条等——它们不该影响 pill 的空隙计算。 */ var trigRect = trig.getBoundingClientRect(); + var leftChipRect = leftChip ? leftChip.getBoundingClientRect() : null; + var bandCy = leftChipRect + ? (leftChipRect.top + leftChipRect.bottom) / 2 + : (trigRect.top + trigRect.bottom) / 2; var rightEdge = trigRect.left; var btns = dock.querySelectorAll("button,[role='button']"); for (var bi = 0; bi < btns.length; bi++) { var br = btns[bi].getBoundingClientRect(); - if (br.width > 0 && br.left > leftEdge + 4 && br.left < rightEdge) rightEdge = br.left; + if (br.width > 0 && br.left > leftEdge + 4 && br.left < rightEdge && + Math.abs((br.top + br.bottom) / 2 - bandCy) < 22) { + rightEdge = br.left; + } } var gap = rightEdge - leftEdge; var dockW = stableWidth(dockRect.width); // 档位判定用稳定宽度,过渡帧不换档 From 2685574d5de3809a488fa4ecd7f055b0c57ee13a Mon Sep 17 00:00:00 2001 From: GaryLiuTesting <160207920+huantest2024@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:24:01 +0800 Subject: [PATCH 06/15] fix(session-stats): prevent popup close flash --- external_plugins/session-stats/server/pills.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/external_plugins/session-stats/server/pills.js b/external_plugins/session-stats/server/pills.js index 4c75c5f4..8a533e37 100644 --- a/external_plugins/session-stats/server/pills.js +++ b/external_plugins/session-stats/server/pills.js @@ -6,7 +6,7 @@ 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; + 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) { @@ -335,7 +335,9 @@ el.className = "__sspills-row"; el.style.cursor = "pointer"; el.addEventListener("click", function (ev) { - // 点击 pill 任意位置都开合弹窗(不要求命中特定子元素,防重建竞态) + // 点击 pill 任意位置都开合弹窗;250ms 去抖防宿主合成重复点击(关了又开的闪烁) + if (Date.now() - lastToggleAt < 250) { ev.stopPropagation(); return; } + lastToggleAt = Date.now(); setPopup(!popupOpen); ev.stopPropagation(); }); @@ -523,6 +525,8 @@ 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 () { requestLayout(); if (popupOpen) renderPopup(); }); From 42d640f498813d083c320911eb33a9c166daec8e Mon Sep 17 00:00:00 2001 From: GaryLiuTesting <160207920+huantest2024@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:58:40 +0800 Subject: [PATCH 07/15] fix(session-stats): silent dead instances and position debounce --- .../session-stats/server/pills.js | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/external_plugins/session-stats/server/pills.js b/external_plugins/session-stats/server/pills.js index 8a533e37..64541252 100644 --- a/external_plugins/session-stats/server/pills.js +++ b/external_plugins/session-stats/server/pills.js @@ -381,6 +381,8 @@ - 档位判定用「稳定宽度」(宽度持续 300ms 才认),过渡帧不换档 - 档位带真滞回(±24px),临界宽度不反复跳 - 空隙不足先降档;连续 4 次放不下才隐藏,条件恢复立即回来 */ + var dead = false; // teardown 后置 true:一切异步回调早退,死实例不碰 DOM + var lastLeft = -9999, lastBottom = -9999; var layoutTimer = 0; var shrinkStreak = 0; var basisW = 0; // 稳定宽度(档位判定基准) @@ -389,13 +391,19 @@ 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; layout(); }, 700 - quiet + 20); + layoutTimer = setTimeout(function () { + layoutTimer = 0; + if (dead) return; + layout(); + }, 700 - quiet + 20); return; } layout(); @@ -427,6 +435,7 @@ } function layout() { + if (dead) return; purgeForeign(); var dock = visibleDock(); if (!dock) { @@ -500,9 +509,12 @@ pillMode = chosen; setModeSpans(chosen); var centerX = (leftEdge + rightEdge) / 2; - rowEl.style.left = Math.round(centerX - dockRect.left) + "px"; + var newLeft = Math.round(centerX - dockRect.left); var pillH = rowEl.offsetHeight || 30; - rowEl.style.bottom = Math.max(0, Math.round((dockRect.bottom - (trigRect.top + trigRect.height / 2)) - pillH / 2)) + "px"; + var newBottom = Math.max(0, Math.round((dockRect.bottom - (trigRect.top + trigRect.height / 2)) - pillH / 2)); + /* 位置亚像素防抖:变化 ≤1px 不应用(悬浮态量出的亚像素抖动直接吞掉) */ + if (Math.abs(newLeft - lastLeft) > 1) { rowEl.style.left = newLeft + "px"; lastLeft = newLeft; } + if (Math.abs(newBottom - lastBottom) > 1) { rowEl.style.bottom = newBottom + "px"; lastBottom = newBottom; } rowEl.style.display = ""; /* 宽度已变但尚未稳定:再排一次布局,让稳定宽度有机会生效 */ if (pendingW) setTimeout(requestLayout, 340); @@ -541,11 +553,14 @@ childList: true, subtree: true, attributes: true, attributeFilter: ["data-session-id"], }); /* 输入框容器尺寸变化(侧栏开合/拖分隔条不触发 window resize):ResizeObserver 直接触发重布局 */ + var ro = null; + var roTimer = 0; if (typeof ResizeObserver !== "undefined") { - var ro = new ResizeObserver(function () { requestLayout(); }); - var roTimer = setInterval(function () { + ro = new ResizeObserver(function () { requestLayout(); }); + roTimer = setInterval(function () { + if (dead) { clearInterval(roTimer); roTimer = 0; return; } var d = visibleDock(); - if (d) { ro.observe(d); clearInterval(roTimer); } + if (d) { ro.observe(d); clearInterval(roTimer); roTimer = 0; } }, 2000); } requestLayout(); @@ -556,8 +571,12 @@ 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 { rowEl && rowEl.remove(); } catch (e) {} try { popEl && popEl.remove(); } catch (e) {} From 5f4fb17c0e9bb9c632b8436b18b632f9d14aead7 Mon Sep 17 00:00:00 2001 From: GaryLiuTesting <160207920+huantest2024@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:44:01 +0800 Subject: [PATCH 08/15] fix(session-stats): immediate clamp to avoid residue --- .../session-stats/server/pills.js | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/external_plugins/session-stats/server/pills.js b/external_plugins/session-stats/server/pills.js index 64541252..41133e63 100644 --- a/external_plugins/session-stats/server/pills.js +++ b/external_plugins/session-stats/server/pills.js @@ -42,7 +42,7 @@ css = document.createElement("style"); css.textContent = ".__sspills-row{position:absolute;bottom:10px;transform:translateX(-50%);" + - "display:inline-flex;align-items:center;gap:6px;z-index:30;}" + + "display:inline-flex;align-items:center;gap:6px;z-index:30;transition:left .12s ease;}" + ".__sspills-pill{display:inline-flex;align-items:center;gap:5px;height:30px;padding:0 12px;" + "border-radius:8px;background:rgba(255,255,255,.045);border:1px solid rgba(255,255,255,.09);" + "color:rgba(232,235,241,.88);font:500 13px/1 'Segoe UI','Microsoft YaHei',system-ui,sans-serif;" + @@ -552,11 +552,49 @@ mo.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-session-id"], }); - /* 输入框容器尺寸变化(侧栏开合/拖分隔条不触发 window resize):ResizeObserver 直接触发重布局 */ + /* 立即钳制:输入框尺寸突变(侧栏开合)时先保证 pill 不压原生控件, + 防抖布局随后再正式归位。避免防抖窗口期内 pill 悬在旧位置形成「留痕」。 */ + function clampNow() { + if (dead || !rowEl || rowEl.style.display === "none") return; + var dock = visibleDock(); + if (!dock) return; + var trig = dock.querySelector('[data-testid="chat-model-select-trigger"]'); + if (!trig) return; + var trigRect = trig.getBoundingClientRect(); + var dockRect = dock.getBoundingClientRect(); + var leftChip = findChipByText(dock, "电脑操作") || findChipByText(dock, "完全访问"); + var leftEdge = leftChip ? leftChip.getBoundingClientRect().right : dockRect.left + 60; + var bandCy = leftChip + ? (leftChip.getBoundingClientRect().top + leftChip.getBoundingClientRect().bottom) / 2 + : (trigRect.top + trigRect.bottom) / 2; + var rightEdge = trigRect.left; + var btns = dock.querySelectorAll("button,[role='button']"); + for (var bi = 0; bi < btns.length; bi++) { + var br = btns[bi].getBoundingClientRect(); + if (br.width > 0 && br.left > leftEdge + 4 && br.left < rightEdge && + Math.abs((br.top + br.bottom) / 2 - bandCy) < 22) rightEdge = br.left; + } + var pw = rowEl.offsetWidth || 0; + if (!pw) return; + if (rightEdge - leftEdge < pw + 24) { + rowEl.style.display = "none"; // 空隙已不够:先隐,等防抖布局选到合适档位再显 + return; + } + var centerX = (leftEdge + rightEdge) / 2; + var newLeft = Math.round(centerX - dockRect.left); + var curLeft = parseFloat(rowEl.style.left || "0") || 0; + if (Math.abs(newLeft - curLeft) > 1) { + rowEl.style.left = newLeft + "px"; + lastLeft = newLeft; + } + } + + /* 输入框容器尺寸变化(侧栏开合/拖分隔条不触发 window resize): + 立即钳制防留痕 + 防抖触发完整布局 */ var ro = null; var roTimer = 0; if (typeof ResizeObserver !== "undefined") { - ro = new ResizeObserver(function () { requestLayout(); }); + ro = new ResizeObserver(function () { clampNow(); requestLayout(); }); roTimer = setInterval(function () { if (dead) { clearInterval(roTimer); roTimer = 0; return; } var d = visibleDock(); From 787ba60a348edbd9564c604a1b69720a93fcc48d Mon Sep 17 00:00:00 2001 From: GaryLiuTesting <160207920+huantest2024@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:05:03 +0800 Subject: [PATCH 09/15] feat(session-stats): pill shows last-request tokens --- .../session-stats/server/pills.js | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/external_plugins/session-stats/server/pills.js b/external_plugins/session-stats/server/pills.js index 41133e63..5959ceff 100644 --- a/external_plugins/session-stats/server/pills.js +++ b/external_plugins/session-stats/server/pills.js @@ -260,31 +260,50 @@ 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 t = data.tokens || {}; 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 === "mini") { - return shownHtml + '·' + fmtTok(t.total) + " tok"; + return shownHtml + '·' + reqTokHtml; } if (mode === "mid") { return "" + (data.steps != null ? data.steps : 0) + " 步" + '·' + shownHtml + - '·' + fmtTok(t.total) + "" + - '·命中 ' + (data.cache_hit_pct != null ? data.cache_hit_pct : "—") + "%"; + '·' + reqTokHtml + + '·命中 ' + (hit != null ? hit : "—") + "%"; } return "" + (data.turns != null ? data.turns : "—") + " 轮 " + (data.steps != null ? data.steps : 0) + " 步" + '·' + shownHtml + '' + - "" + fmtTok(t.total) + " tok" + - '·缓存命中 ' + (data.cache_hit_pct != null ? data.cache_hit_pct : "—") + "%"; + reqTokHtml + + '·命中 ' + (hit != null ? hit : "—") + "%"; } /* 三档内容常驻为三个子 span,档位切换只改 display(无 innerHTML 重建 → 无闪现土壤) */ From 0bdbeb6cb5a44e01414f281a818957497b638aaa Mon Sep 17 00:00:00 2001 From: GaryLiuTesting <160207920+huantest2024@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:11:43 +0800 Subject: [PATCH 10/15] style(session-stats): separators to spaces --- external_plugins/session-stats/server/pills.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/external_plugins/session-stats/server/pills.js b/external_plugins/session-stats/server/pills.js index 5959ceff..698ceac8 100644 --- a/external_plugins/session-stats/server/pills.js +++ b/external_plugins/session-stats/server/pills.js @@ -291,19 +291,19 @@ ? "入 " + fmtTok(lr["in"]) + " · 出 " + fmtTok(lr.out) : "累计 " + fmtTok((data.tokens || {}).total) + " tok"; if (mode === "mini") { - return shownHtml + '·' + reqTokHtml; + return shownHtml + " " + reqTokHtml; } if (mode === "mid") { return "" + (data.steps != null ? data.steps : 0) + " 步" + - '·' + shownHtml + - '·' + reqTokHtml + - '·命中 ' + (hit != null ? hit : "—") + "%"; + " " + shownHtml + + " " + reqTokHtml + + " 命中 " + (hit != null ? hit : "—") + "%"; } return "" + (data.turns != null ? data.turns : "—") + " 轮 " + (data.steps != null ? data.steps : 0) + " 步" + - '·' + shownHtml + + " " + shownHtml + '' + reqTokHtml + - '·命中 ' + (hit != null ? hit : "—") + "%"; + " 命中 " + (hit != null ? hit : "—") + "%"; } /* 三档内容常驻为三个子 span,档位切换只改 display(无 innerHTML 重建 → 无闪现土壤) */ From d64f1e03c1bf8df23c7996ee6d159b60b6ed08af Mon Sep 17 00:00:00 2001 From: GaryLiuTesting <160207920+huantest2024@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:22:13 +0800 Subject: [PATCH 11/15] feat(session-stats): gauge icon + separators + cache-miss rows --- .../session-stats/server/pills.js | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/external_plugins/session-stats/server/pills.js b/external_plugins/session-stats/server/pills.js index 698ceac8..d70680ff 100644 --- a/external_plugins/session-stats/server/pills.js +++ b/external_plugins/session-stats/server/pills.js @@ -48,9 +48,10 @@ "color:rgba(232,235,241,.88);font:500 13px/1 'Segoe UI','Microsoft YaHei',system-ui,sans-serif;" + "cursor:pointer;white-space:nowrap;transition:border-color .15s,background .15s;}" + ".__sspills-pill:hover{border-color:rgba(122,162,247,.55);background:rgba(122,162,247,.08);}" + - ".__sspills-pill .ico{opacity:.6;}" + + ".__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;}" + - ".__sspills-pill .sep{opacity:.35;}" + + ".__sspills-pill .sep{opacity:.5;margin:0 6px;}" + ".__sspills-pill .dv{opacity:.3;margin:0 4px;border-left:1px solid currentColor;height:12px;}" + ".__sspills-pill .live{color:#9ece6a;font-size:8px;margin-left:4px;}" + ".__sspills-row.stale .__sspills-pill{opacity:.4;}" + @@ -102,6 +103,7 @@ ["输入(含缓存)", 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 + "%" : "—"], ]; // 最近一次完成的请求:输入/输出/缓存命中 @@ -112,6 +114,7 @@ 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) { @@ -295,15 +298,17 @@ } if (mode === "mid") { return "" + (data.steps != null ? data.steps : 0) + " 步" + - " " + shownHtml + + '·' + shownHtml + " " + reqTokHtml + - " 命中 " + (hit != null ? hit : "—") + "%"; + '·' + + "缓存命中 " + (hit != null ? hit : "—") + "%"; } return "" + (data.turns != null ? data.turns : "—") + " 轮 " + (data.steps != null ? data.steps : 0) + " 步" + - " " + shownHtml + + '·' + shownHtml + '' + reqTokHtml + - " 命中 " + (hit != null ? hit : "—") + "%"; + '·' + + "缓存命中 " + (hit != null ? hit : "—") + "%"; } /* 三档内容常驻为三个子 span,档位切换只改 display(无 innerHTML 重建 → 无闪现土壤) */ @@ -366,7 +371,13 @@ pill.setAttribute("data-act", "detail"); var ico = document.createElement("span"); ico.className = "ico"; - ico.textContent = "⏱"; + // 仪表盘图标(gauge-outline,16 viewBox / 1px 描边),与应用内统计图标同源 + ico.innerHTML = + '' + + '' + + '' + + '' + + ""; pill.appendChild(ico); el.appendChild(pill); return el; From 86f22cfcb104a5315b35ae5d1111dcc523b59a8f Mon Sep 17 00:00:00 2001 From: GaryLiuTesting <160207920+huantest2024@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:42:52 +0800 Subject: [PATCH 12/15] fix(session-stats): no-overlap placement + micro tier --- .../session-stats/server/pills.js | 1327 +++++++++-------- 1 file changed, 674 insertions(+), 653 deletions(-) diff --git a/external_plugins/session-stats/server/pills.js b/external_plugins/session-stats/server/pills.js index d70680ff..25fd9d3c 100644 --- a/external_plugins/session-stats/server/pills.js +++ b/external_plugins/session-stats/server/pills.js @@ -1,653 +1,674 @@ -/* 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; - } - function stripWs(s) { - return String(s).replace(/\s+/g, ""); - } - - - css = document.createElement("style"); - css.textContent = - ".__sspills-row{position:absolute;bottom:10px;transform:translateX(-50%);" + - "display:inline-flex;align-items:center;gap:6px;z-index:30;transition:left .12s ease;}" + - ".__sspills-pill{display:inline-flex;align-items:center;gap:5px;height:30px;padding:0 12px;" + - "border-radius:8px;background:rgba(255,255,255,.045);border:1px solid rgba(255,255,255,.09);" + - "color:rgba(232,235,241,.88);font:500 13px/1 'Segoe UI','Microsoft YaHei',system-ui,sans-serif;" + - "cursor:pointer;white-space:nowrap;transition:border-color .15s,background .15s;}" + - ".__sspills-pill:hover{border-color:rgba(122,162,247,.55);background:rgba(122,162,247,.08);}" + - ".__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;}" + - ".__sspills-pill .sep{opacity:.5;margin:0 6px;}" + - ".__sspills-pill .dv{opacity:.3;margin:0 4px;border-left:1px solid currentColor;height:12px;}" + - ".__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{background:rgba(0,0,0,.045);border-color:rgba(0,0,0,.12);color:rgba(28,30,36,.88);}" + - ".__sspills-pill:hover{border-color:rgba(59,91,219,.5);background:rgba(59,91,219,.06);}" + - ".__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)),否则取最近一次完成的请求 */ - 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) { - 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) { - 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 === "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"]; - 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"]; - 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"]; - 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; - } - - function stripWs(s) { - return String(s).replace(/\s+/g, ""); - } - function findChipByText(root, txt) { - var btns = root.querySelectorAll("button,[role='button']"); - for (var i = 0; i < btns.length; i++) { - if (stripWs(btns[i].textContent) === txt) return btns[i]; - } - return null; - } - - /* 只取「可见」的输入条容器——多标签页时隐藏标签页的 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:滚轮滑动/侧栏动画等过渡帧全部跳过,只在静止后布局一次 - - 档位判定用「稳定宽度」(宽度持续 300ms 才认),过渡帧不换档 - - 档位带真滞回(±24px),临界宽度不反复跳 - - 空隙不足先降档;连续 4 次放不下才隐藏,条件恢复立即回来 */ - var dead = false; // teardown 后置 true:一切异步回调早退,死实例不碰 DOM - var lastLeft = -9999, lastBottom = -9999; - var layoutTimer = 0; - var shrinkStreak = 0; - var basisW = 0; // 稳定宽度(档位判定基准) - var pendingW = 0; // 待确认的新宽度 - var pendingSince = 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); - } - - function stableWidth(w) { - if (!basisW || Math.abs(w - basisW) <= 2) { - if (!basisW) basisW = w; - pendingW = 0; - return basisW; - } - if (Math.abs(w - pendingW) > 2) { pendingW = w; pendingSince = Date.now(); } - else if (Date.now() - pendingSince > 300) { basisW = pendingW; pendingW = 0; } - return basisW; - } - - /* 清扫幽灵实例: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]); - } - } - - function layout() { - if (dead) return; - purgeForeign(); - var dock = visibleDock(); - if (!dock) { - if (rowEl) { rowEl.remove(); } - return; - } - if (!rowEl || rowEl.parentElement !== dock || !rowEl.isConnected) { - if (!rowEl) rowEl = makeRow(); - dock.appendChild(rowEl); - } - var dockRect = dock.getBoundingClientRect(); - var trig = dock.querySelector('[data-testid="chat-model-select-trigger"]'); - if (!trig) { rowEl.style.display = "none"; return; } - var leftChip = findChipByText(dock, "电脑操作") || findChipByText(dock, "完全访问"); - var leftEdge; - if (leftChip) { - leftEdge = leftChip.getBoundingClientRect().right; - } else { - leftEdge = dockRect.left + 60; - } - /* 右边界 = 左 chip 右侧、与 chip 同一水平带的按钮(模型选择器、上下文图标等)中 - 最近者的左边缘。垂直带过滤(±22px)排除悬浮在输入条上方的「回到底部」按钮、 - 提示条等——它们不该影响 pill 的空隙计算。 */ - var trigRect = trig.getBoundingClientRect(); - var leftChipRect = leftChip ? leftChip.getBoundingClientRect() : null; - var bandCy = leftChipRect - ? (leftChipRect.top + leftChipRect.bottom) / 2 - : (trigRect.top + trigRect.bottom) / 2; - var rightEdge = trigRect.left; - var btns = dock.querySelectorAll("button,[role='button']"); - for (var bi = 0; bi < btns.length; bi++) { - var br = btns[bi].getBoundingClientRect(); - if (br.width > 0 && br.left > leftEdge + 4 && br.left < rightEdge && - Math.abs((br.top + br.bottom) / 2 - bandCy) < 22) { - rightEdge = br.left; - } - } - var gap = rightEdge - leftEdge; - var dockW = stableWidth(dockRect.width); // 档位判定用稳定宽度,过渡帧不换档 - - /* 档位判定:按稳定宽度 + 真滞回(升降门槛错开 48px),临界宽度稳定不跳 */ - var HY = 24; - var want; - if (pillMode === "full") want = dockW >= 700 - HY ? "full" : (dockW >= 460 - HY ? "mid" : "mini"); - else if (pillMode === "mid") want = dockW >= 700 + HY ? "full" : (dockW >= 460 - HY ? "mid" : "mini"); - else want = dockW >= 700 + HY ? "full" : (dockW >= 460 + HY ? "mid" : "mini"); - - var order = [want]; - var all = ["full", "mid", "mini"]; - for (var ai = 0; ai < all.length; ai++) { - if (all[ai] !== want) order.push(all[ai]); - } - var chosen = null; - ensureModeSpans(); - for (var mi = 0; mi < order.length; mi++) { - var mode = order[mi]; - setModeSpans(mode); // 纯 display 切换,零重建 - rowEl.style.display = ""; - if (gap >= rowEl.offsetWidth + 24) { chosen = mode; break; } - } - if (!chosen) { - /* 连续放不下才隐藏;一旦隐藏,由 5 秒轮询/宽度变化自动重试恢复 */ - shrinkStreak++; - if (shrinkStreak >= 4 || rowEl.style.display === "none") { - rowEl.style.display = "none"; - return; - } - return; // 给动画中的侧栏一点缓冲,下一帧再试 - } - shrinkStreak = 0; - pillMode = chosen; - setModeSpans(chosen); - var centerX = (leftEdge + rightEdge) / 2; - var newLeft = Math.round(centerX - dockRect.left); - var pillH = rowEl.offsetHeight || 30; - var newBottom = Math.max(0, Math.round((dockRect.bottom - (trigRect.top + trigRect.height / 2)) - pillH / 2)); - /* 位置亚像素防抖:变化 ≤1px 不应用(悬浮态量出的亚像素抖动直接吞掉) */ - if (Math.abs(newLeft - lastLeft) > 1) { rowEl.style.left = newLeft + "px"; lastLeft = newLeft; } - if (Math.abs(newBottom - lastBottom) > 1) { rowEl.style.bottom = newBottom + "px"; lastBottom = newBottom; } - rowEl.style.display = ""; - /* 宽度已变但尚未稳定:再排一次布局,让稳定宽度有机会生效 */ - if (pendingW) setTimeout(requestLayout, 340); - } - - 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 () { requestLayout(); if (popupOpen) renderPopup(); }); - document.addEventListener("scroll", function () { - lastScrollAt = Date.now(); - if (popupOpen) renderPopup(); - requestLayout(); // 记录豁免;滚动停稳后会补执行一次 - }, true); - - var mo = new MutationObserver(schedule); - mo.observe(document.documentElement, { - childList: true, subtree: true, attributes: true, attributeFilter: ["data-session-id"], - }); - /* 立即钳制:输入框尺寸突变(侧栏开合)时先保证 pill 不压原生控件, - 防抖布局随后再正式归位。避免防抖窗口期内 pill 悬在旧位置形成「留痕」。 */ - function clampNow() { - if (dead || !rowEl || rowEl.style.display === "none") return; - var dock = visibleDock(); - if (!dock) return; - var trig = dock.querySelector('[data-testid="chat-model-select-trigger"]'); - if (!trig) return; - var trigRect = trig.getBoundingClientRect(); - var dockRect = dock.getBoundingClientRect(); - var leftChip = findChipByText(dock, "电脑操作") || findChipByText(dock, "完全访问"); - var leftEdge = leftChip ? leftChip.getBoundingClientRect().right : dockRect.left + 60; - var bandCy = leftChip - ? (leftChip.getBoundingClientRect().top + leftChip.getBoundingClientRect().bottom) / 2 - : (trigRect.top + trigRect.bottom) / 2; - var rightEdge = trigRect.left; - var btns = dock.querySelectorAll("button,[role='button']"); - for (var bi = 0; bi < btns.length; bi++) { - var br = btns[bi].getBoundingClientRect(); - if (br.width > 0 && br.left > leftEdge + 4 && br.left < rightEdge && - Math.abs((br.top + br.bottom) / 2 - bandCy) < 22) rightEdge = br.left; - } - var pw = rowEl.offsetWidth || 0; - if (!pw) return; - if (rightEdge - leftEdge < pw + 24) { - rowEl.style.display = "none"; // 空隙已不够:先隐,等防抖布局选到合适档位再显 - return; - } - var centerX = (leftEdge + rightEdge) / 2; - var newLeft = Math.round(centerX - dockRect.left); - var curLeft = parseFloat(rowEl.style.left || "0") || 0; - if (Math.abs(newLeft - curLeft) > 1) { - rowEl.style.left = newLeft + "px"; - lastLeft = newLeft; - } - } - - /* 输入框容器尺寸变化(侧栏开合/拖分隔条不触发 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 { rowEl && rowEl.remove(); } catch (e) {} - try { popEl && popEl.remove(); } catch (e) {} - rowEl = null; popEl = null; data = null; - }; -})(); +/* 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; + } + function stripWs(s) { + return String(s).replace(/\s+/g, ""); + } + + + css = document.createElement("style"); + css.textContent = + ".__sspills-row{position:absolute;bottom:10px;transform:translateX(-50%);" + + "display:inline-flex;align-items:center;gap:6px;z-index:30;transition:left .12s ease;}" + + ".__sspills-pill{display:inline-flex;align-items:center;gap:5px;height:30px;padding:0 12px;" + + "border-radius:8px;background:rgba(255,255,255,.045);border:1px solid rgba(255,255,255,.09);" + + "color:rgba(232,235,241,.88);font:500 13px/1 'Segoe UI','Microsoft YaHei',system-ui,sans-serif;" + + "cursor:pointer;white-space:nowrap;transition:border-color .15s,background .15s;}" + + ".__sspills-pill:hover{border-color:rgba(122,162,247,.55);background:rgba(122,162,247,.08);}" + + ".__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;}" + + ".__sspills-pill .sep{opacity:.5;margin:0 6px;}" + + ".__sspills-pill .dv{opacity:.3;margin:0 4px;border-left:1px solid currentColor;height:12px;}" + + ".__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{background:rgba(0,0,0,.045);border-color:rgba(0,0,0,.12);color:rgba(28,30,36,.88);}" + + ".__sspills-pill:hover{border-color:rgba(59,91,219,.5);background:rgba(59,91,219,.06);}" + + ".__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)),否则取最近一次完成的请求 */ + 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) { + 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) { + 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; + } + + function stripWs(s) { + return String(s).replace(/\s+/g, ""); + } + function findChipByText(root, txt) { + var btns = root.querySelectorAll("button,[role='button']"); + for (var i = 0; i < btns.length; i++) { + if (stripWs(btns[i].textContent) === txt) return btns[i]; + } + return null; + } + + /* 只取「可见」的输入条容器——多标签页时隐藏标签页的 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:滚轮滑动/侧栏动画等过渡帧全部跳过,只在静止后布局一次 + - 档位判定用「稳定宽度」(宽度持续 300ms 才认),过渡帧不换档 + - 档位带真滞回(±24px),临界宽度不反复跳 + - 空隙不足先降档;连续 4 次放不下才隐藏,条件恢复立即回来 */ + var dead = false; // teardown 后置 true:一切异步回调早退,死实例不碰 DOM + var lastLeft = -9999, lastBottom = -9999; + var layoutTimer = 0; + var shrinkStreak = 0; + var basisW = 0; // 稳定宽度(档位判定基准) + var pendingW = 0; // 待确认的新宽度 + var pendingSince = 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); + } + + function stableWidth(w) { + if (!basisW || Math.abs(w - basisW) <= 2) { + if (!basisW) basisW = w; + pendingW = 0; + return basisW; + } + if (Math.abs(w - pendingW) > 2) { pendingW = w; pendingSince = Date.now(); } + else if (Date.now() - pendingSince > 300) { basisW = pendingW; pendingW = 0; } + return basisW; + } + + /* 清扫幽灵实例: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]); + } + } + + function layout() { + if (dead) return; + purgeForeign(); + var dock = visibleDock(); + if (!dock) { + if (rowEl) { rowEl.remove(); } + return; + } + if (!rowEl || rowEl.parentElement !== dock || !rowEl.isConnected) { + if (!rowEl) rowEl = makeRow(); + dock.appendChild(rowEl); + } + var dockRect = dock.getBoundingClientRect(); + var trig = dock.querySelector('[data-testid="chat-model-select-trigger"]'); + if (!trig) { rowEl.style.display = "none"; return; } + var leftChip = findChipByText(dock, "电脑操作") || findChipByText(dock, "完全访问"); + var leftEdge; + if (leftChip) { + leftEdge = leftChip.getBoundingClientRect().right; + } else { + leftEdge = dockRect.left + 60; + } + /* 右边界 = 左 chip 右侧、与 chip 同一水平带的按钮(模型选择器、上下文图标等)中 + 最近者的左边缘。垂直带过滤(±22px)排除悬浮在输入条上方的「回到底部」按钮、 + 提示条等——它们不该影响 pill 的空隙计算。 */ + var trigRect = trig.getBoundingClientRect(); + var leftChipRect = leftChip ? leftChip.getBoundingClientRect() : null; + var bandCy = leftChipRect + ? (leftChipRect.top + leftChipRect.bottom) / 2 + : (trigRect.top + trigRect.bottom) / 2; + var rightEdge = trigRect.left; + var btns = dock.querySelectorAll("button,[role='button']"); + for (var bi = 0; bi < btns.length; bi++) { + var br = btns[bi].getBoundingClientRect(); + if (br.width > 0 && br.left > leftEdge + 4 && br.left < rightEdge && + Math.abs((br.top + br.bottom) / 2 - bandCy) < 22) { + rightEdge = br.left; + } + } + var gap = rightEdge - leftEdge; + var dockW = stableWidth(dockRect.width); // 档位判定用稳定宽度,过渡帧不换档 + + /* 档位判定:按稳定宽度 + 真滞回(升降门槛错开 48px),临界宽度稳定不跳 */ + var HY = 24; + var want; + if (pillMode === "full") want = dockW >= 700 - HY ? "full" : (dockW >= 460 - HY ? "mid" : "mini"); + else if (pillMode === "mid") want = dockW >= 700 + HY ? "full" : (dockW >= 460 - HY ? "mid" : "mini"); + else want = dockW >= 700 + HY ? "full" : (dockW >= 460 + HY ? "mid" : "mini"); + + var order = [want]; + var all = ["full", "mid", "mini", "micro"]; + for (var ai = 0; ai < all.length; ai++) { + if (all[ai] !== want) order.push(all[ai]); + } + var chosen = null; + ensureModeSpans(); + for (var mi = 0; mi < order.length; mi++) { + var mode = order[mi]; + setModeSpans(mode); // 纯 display 切换,零重建 + rowEl.style.display = ""; + if (gap >= rowEl.offsetWidth + 24) { chosen = mode; break; } + } + if (!chosen) { + /* 放不下就立即隐藏——宁可暂时不显示,也绝不停在旧位置压住模型选择器/发送按钮 */ + shrinkStreak = 0; + rowEl.style.display = "none"; + return; + } + shrinkStreak = 0; + pillMode = chosen; + setModeSpans(chosen); + var centerX = (leftEdge + rightEdge) / 2; + var newLeft = Math.round(centerX - dockRect.left); + var pillH = rowEl.offsetHeight || 30; + var newBottom = Math.max(0, Math.round((dockRect.bottom - (trigRect.top + trigRect.height / 2)) - pillH / 2)); + /* 位置亚像素防抖:变化 ≤1px 不应用(悬浮态量出的亚像素抖动直接吞掉) */ + if (Math.abs(newLeft - lastLeft) > 1) { rowEl.style.left = newLeft + "px"; lastLeft = newLeft; } + if (Math.abs(newBottom - lastBottom) > 1) { rowEl.style.bottom = newBottom + "px"; lastBottom = newBottom; } + rowEl.style.display = ""; + /* 落位自校正:绝对定位的包含块可能被宿主 transform 改写(侧栏动画等), + 量出实际中心与目标中心的偏差回写一次;校正后仍越界就隐藏,绝不压住原生控件。 + 校正期间关掉 left 过渡,否则量到的是动画中间值,会把自己越修越偏。 */ + rowEl.style.transition = "none"; + for (var pass = 0; pass < 2; pass++) { + var rr = rowEl.getBoundingClientRect(); + if (!rr.width) break; + var delta = centerX - (rr.left + rr.width / 2); + if (Math.abs(delta) < 1.5) break; + var curLeft = parseFloat(rowEl.style.left || "0") || 0; + var fixedLeft = Math.round(curLeft + delta); + rowEl.style.left = fixedLeft + "px"; + lastLeft = fixedLeft; + } + var fr = rowEl.getBoundingClientRect(); + rowEl.style.transition = ""; + if (fr.width > 0 && (fr.left < leftEdge - 2 || fr.right > rightEdge + 2)) { + rowEl.style.display = "none"; // 实测仍压到原生控件:本帧不显示 + return; + } + /* 宽度已变但尚未稳定:再排一次布局,让稳定宽度有机会生效 */ + if (pendingW) setTimeout(requestLayout, 340); + } + + 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 () { requestLayout(); if (popupOpen) renderPopup(); }); + document.addEventListener("scroll", function () { + lastScrollAt = Date.now(); + if (popupOpen) renderPopup(); + requestLayout(); // 记录豁免;滚动停稳后会补执行一次 + }, true); + + var mo = new MutationObserver(schedule); + mo.observe(document.documentElement, { + childList: true, subtree: true, attributes: true, attributeFilter: ["data-session-id"], + }); + /* 立即钳制:输入框尺寸突变(侧栏开合)时先保证 pill 不压原生控件, + 防抖布局随后再正式归位。避免防抖窗口期内 pill 悬在旧位置形成「留痕」。 */ + function clampNow() { + if (dead || !rowEl || rowEl.style.display === "none") return; + var dock = visibleDock(); + if (!dock) return; + var trig = dock.querySelector('[data-testid="chat-model-select-trigger"]'); + if (!trig) return; + var trigRect = trig.getBoundingClientRect(); + var dockRect = dock.getBoundingClientRect(); + var leftChip = findChipByText(dock, "电脑操作") || findChipByText(dock, "完全访问"); + var leftEdge = leftChip ? leftChip.getBoundingClientRect().right : dockRect.left + 60; + var bandCy = leftChip + ? (leftChip.getBoundingClientRect().top + leftChip.getBoundingClientRect().bottom) / 2 + : (trigRect.top + trigRect.bottom) / 2; + var rightEdge = trigRect.left; + var btns = dock.querySelectorAll("button,[role='button']"); + for (var bi = 0; bi < btns.length; bi++) { + var br = btns[bi].getBoundingClientRect(); + if (br.width > 0 && br.left > leftEdge + 4 && br.left < rightEdge && + Math.abs((br.top + br.bottom) / 2 - bandCy) < 22) rightEdge = br.left; + } + var pw = rowEl.offsetWidth || 0; + if (!pw) return; + if (rightEdge - leftEdge < pw + 24) { + rowEl.style.display = "none"; // 空隙已不够:先隐,等防抖布局选到合适档位再显 + return; + } + var centerX = (leftEdge + rightEdge) / 2; + var newLeft = Math.round(centerX - dockRect.left); + var curLeft = parseFloat(rowEl.style.left || "0") || 0; + if (Math.abs(newLeft - curLeft) > 1) { + rowEl.style.left = newLeft + "px"; + lastLeft = newLeft; + } + } + + /* 输入框容器尺寸变化(侧栏开合/拖分隔条不触发 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 { rowEl && rowEl.remove(); } catch (e) {} + try { popEl && popEl.remove(); } catch (e) {} + rowEl = null; popEl = null; data = null; + }; +})(); From 0ae1ac21f131285f6950a2666c9796b2b538d56d Mon Sep 17 00:00:00 2001 From: GaryLiuTesting <160207920+huantest2024@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:51:03 +0800 Subject: [PATCH 13/15] feat(session-stats): stats row below composer --- .../session-stats/server/pills.js | 205 +++++------------- 1 file changed, 51 insertions(+), 154 deletions(-) diff --git a/external_plugins/session-stats/server/pills.js b/external_plugins/session-stats/server/pills.js index 25fd9d3c..9367f592 100644 --- a/external_plugins/session-stats/server/pills.js +++ b/external_plugins/session-stats/server/pills.js @@ -34,25 +34,20 @@ if (!id) return "—"; return id.length > 26 ? id.slice(0, 23) + "…" : id; } - function stripWs(s) { - return String(s).replace(/\s+/g, ""); - } - css = document.createElement("style"); css.textContent = - ".__sspills-row{position:absolute;bottom:10px;transform:translateX(-50%);" + - "display:inline-flex;align-items:center;gap:6px;z-index:30;transition:left .12s ease;}" + - ".__sspills-pill{display:inline-flex;align-items:center;gap:5px;height:30px;padding:0 12px;" + - "border-radius:8px;background:rgba(255,255,255,.045);border:1px solid rgba(255,255,255,.09);" + - "color:rgba(232,235,241,.88);font:500 13px/1 'Segoe UI','Microsoft YaHei',system-ui,sans-serif;" + - "cursor:pointer;white-space:nowrap;transition:border-color .15s,background .15s;}" + - ".__sspills-pill:hover{border-color:rgba(122,162,247,.55);background:rgba(122,162,247,.08);}" + + ".__sspills-row{position:fixed;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;}" + - ".__sspills-pill .sep{opacity:.5;margin:0 6px;}" + - ".__sspills-pill .dv{opacity:.3;margin:0 4px;border-left:1px solid currentColor;height:12px;}" + + ".__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;" + @@ -70,8 +65,9 @@ ".__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{background:rgba(0,0,0,.045);border-color:rgba(0,0,0,.12);color:rgba(28,30,36,.88);}" + - ".__sspills-pill:hover{border-color:rgba(59,91,219,.5);background:rgba(59,91,219,.06);}" + + ".__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);}" + @@ -387,17 +383,6 @@ return el; } - function stripWs(s) { - return String(s).replace(/\s+/g, ""); - } - function findChipByText(root, txt) { - var btns = root.querySelectorAll("button,[role='button']"); - for (var i = 0; i < btns.length; i++) { - if (stripWs(btns[i].textContent) === txt) return btns[i]; - } - return null; - } - /* 只取「可见」的输入条容器——多标签页时隐藏标签页的 dock 也在 DOM 里。 不能用 offsetParent 判断(fixed 定位/特殊容器下恒为 null,可见也会误判为隐藏), 改用实测宽度 > 0。 */ @@ -412,16 +397,10 @@ /* 布局调度器:所有触发点只置脏标记,界面稳定后才执行一次完整布局。 - 尾随防抖 180ms:滚轮滑动/侧栏动画等过渡帧全部跳过,只在静止后布局一次 - - 档位判定用「稳定宽度」(宽度持续 300ms 才认),过渡帧不换档 - - 档位带真滞回(±24px),临界宽度不反复跳 - - 空隙不足先降档;连续 4 次放不下才隐藏,条件恢复立即回来 */ + - 统计行挂在输入条下方(fixed),宽度只取决于输入条宽度:按「宽→窄」挑第一个放得下的档位 */ var dead = false; // teardown 后置 true:一切异步回调早退,死实例不碰 DOM - var lastLeft = -9999, lastBottom = -9999; + var lastLeft = -9999, lastTop = -9999; var layoutTimer = 0; - var shrinkStreak = 0; - var basisW = 0; // 稳定宽度(档位判定基准) - var pendingW = 0; // 待确认的新宽度 - var pendingSince = 0; var lastScrollAt = 0; // 滚动豁免:滚动后 700ms 内不布局(滚动中会话顶栏/输入条有过渡动画) function requestLayout() { @@ -444,17 +423,6 @@ }, 180); } - function stableWidth(w) { - if (!basisW || Math.abs(w - basisW) <= 2) { - if (!basisW) basisW = w; - pendingW = 0; - return basisW; - } - if (Math.abs(w - pendingW) > 2) { pendingW = w; pendingSince = Date.now(); } - else if (Date.now() - pendingSince > 300) { basisW = pendingW; pendingW = 0; } - return basisW; - } - /* 清扫幽灵实例:React 重建 dock 会泄漏旧 rowEl(代码只追新不删旧), 泄漏元素停留旧档位与新 pill 叠加闪现。每次布局前清掉一切非当前实例。 */ function purgeForeign() { @@ -476,99 +444,45 @@ if (rowEl) { rowEl.remove(); } return; } - if (!rowEl || rowEl.parentElement !== dock || !rowEl.isConnected) { - if (!rowEl) rowEl = makeRow(); - dock.appendChild(rowEl); - } var dockRect = dock.getBoundingClientRect(); - var trig = dock.querySelector('[data-testid="chat-model-select-trigger"]'); - if (!trig) { rowEl.style.display = "none"; return; } - var leftChip = findChipByText(dock, "电脑操作") || findChipByText(dock, "完全访问"); - var leftEdge; - if (leftChip) { - leftEdge = leftChip.getBoundingClientRect().right; - } else { - leftEdge = dockRect.left + 60; + if (!(dockRect.width > 0 && dockRect.height > 0)) { + if (rowEl) rowEl.style.display = "none"; + return; } - /* 右边界 = 左 chip 右侧、与 chip 同一水平带的按钮(模型选择器、上下文图标等)中 - 最近者的左边缘。垂直带过滤(±22px)排除悬浮在输入条上方的「回到底部」按钮、 - 提示条等——它们不该影响 pill 的空隙计算。 */ - var trigRect = trig.getBoundingClientRect(); - var leftChipRect = leftChip ? leftChip.getBoundingClientRect() : null; - var bandCy = leftChipRect - ? (leftChipRect.top + leftChipRect.bottom) / 2 - : (trigRect.top + trigRect.bottom) / 2; - var rightEdge = trigRect.left; - var btns = dock.querySelectorAll("button,[role='button']"); - for (var bi = 0; bi < btns.length; bi++) { - var br = btns[bi].getBoundingClientRect(); - if (br.width > 0 && br.left > leftEdge + 4 && br.left < rightEdge && - Math.abs((br.top + br.bottom) / 2 - bandCy) < 22) { - rightEdge = br.left; - } + /* 统计行挂在输入条下方(fixed 挂 body),不再和 chip/模型选择器抢同一行空间 */ + if (!rowEl || !rowEl.isConnected) { + if (!rowEl) rowEl = makeRow(); + document.body.appendChild(rowEl); } - var gap = rightEdge - leftEdge; - var dockW = stableWidth(dockRect.width); // 档位判定用稳定宽度,过渡帧不换档 - - /* 档位判定:按稳定宽度 + 真滞回(升降门槛错开 48px),临界宽度稳定不跳 */ - var HY = 24; - var want; - if (pillMode === "full") want = dockW >= 700 - HY ? "full" : (dockW >= 460 - HY ? "mid" : "mini"); - else if (pillMode === "mid") want = dockW >= 700 + HY ? "full" : (dockW >= 460 - HY ? "mid" : "mini"); - else want = dockW >= 700 + HY ? "full" : (dockW >= 460 + HY ? "mid" : "mini"); - - var order = [want]; + ensureModeSpans(); + var avail = Math.round(dockRect.width - 24); // 左右各留 12px,与输入条左缘对齐 var all = ["full", "mid", "mini", "micro"]; - for (var ai = 0; ai < all.length; ai++) { - if (all[ai] !== want) order.push(all[ai]); - } var chosen = null; - ensureModeSpans(); - for (var mi = 0; mi < order.length; mi++) { - var mode = order[mi]; + for (var ai = 0; ai < all.length; ai++) { + var mode = all[ai]; setModeSpans(mode); // 纯 display 切换,零重建 rowEl.style.display = ""; - if (gap >= rowEl.offsetWidth + 24) { chosen = mode; break; } + // 换档时多留 8px 余量,防止内容在临界宽度上反复升降档 + var need = rowEl.offsetWidth + (mode === pillMode ? 0 : 8); + if (avail >= need) { chosen = mode; break; } } if (!chosen) { - /* 放不下就立即隐藏——宁可暂时不显示,也绝不停在旧位置压住模型选择器/发送按钮 */ - shrinkStreak = 0; - rowEl.style.display = "none"; + rowEl.style.display = "none"; // 连 micro 都放不下:宁可隐藏也不压住界面 return; } - shrinkStreak = 0; pillMode = chosen; setModeSpans(chosen); - var centerX = (leftEdge + rightEdge) / 2; - var newLeft = Math.round(centerX - dockRect.left); - var pillH = rowEl.offsetHeight || 30; - var newBottom = Math.max(0, Math.round((dockRect.bottom - (trigRect.top + trigRect.height / 2)) - pillH / 2)); - /* 位置亚像素防抖:变化 ≤1px 不应用(悬浮态量出的亚像素抖动直接吞掉) */ - if (Math.abs(newLeft - lastLeft) > 1) { rowEl.style.left = newLeft + "px"; lastLeft = newLeft; } - if (Math.abs(newBottom - lastBottom) > 1) { rowEl.style.bottom = newBottom + "px"; lastBottom = newBottom; } - rowEl.style.display = ""; - /* 落位自校正:绝对定位的包含块可能被宿主 transform 改写(侧栏动画等), - 量出实际中心与目标中心的偏差回写一次;校正后仍越界就隐藏,绝不压住原生控件。 - 校正期间关掉 left 过渡,否则量到的是动画中间值,会把自己越修越偏。 */ - rowEl.style.transition = "none"; - for (var pass = 0; pass < 2; pass++) { - var rr = rowEl.getBoundingClientRect(); - if (!rr.width) break; - var delta = centerX - (rr.left + rr.width / 2); - if (Math.abs(delta) < 1.5) break; - var curLeft = parseFloat(rowEl.style.left || "0") || 0; - var fixedLeft = Math.round(curLeft + delta); - rowEl.style.left = fixedLeft + "px"; - lastLeft = fixedLeft; - } - var fr = rowEl.getBoundingClientRect(); - rowEl.style.transition = ""; - if (fr.width > 0 && (fr.left < leftEdge - 2 || fr.right > rightEdge + 2)) { - rowEl.style.display = "none"; // 实测仍压到原生控件:本帧不显示 + var pillH = rowEl.offsetHeight || 20; + var top = Math.round(dockRect.bottom + 4); + /* 输入条下方没有一行空间(小窗/贴底布局)→ 不显示,绝不上移压住输入条 */ + if (top + pillH > window.innerHeight - 2) { + rowEl.style.display = "none"; return; } - /* 宽度已变但尚未稳定:再排一次布局,让稳定宽度有机会生效 */ - if (pendingW) setTimeout(requestLayout, 340); + var left = Math.round(dockRect.left + 12); + if (Math.abs(left - lastLeft) > 1) { rowEl.style.left = left + "px"; lastLeft = left; } + if (Math.abs(top - lastTop) > 1) { rowEl.style.top = top + "px"; lastTop = top; } + rowEl.style.display = ""; } var scheduled = false; @@ -592,10 +506,11 @@ lastScrollAt = Date.now(); } }, true); - window.addEventListener("resize", function () { requestLayout(); if (popupOpen) renderPopup(); }); + window.addEventListener("resize", function () { clampNow(); requestLayout(); if (popupOpen) renderPopup(); }); document.addEventListener("scroll", function () { lastScrollAt = Date.now(); if (popupOpen) renderPopup(); + clampNow(); // 统计行跟随输入条移动(fixed 坐标,滚轮时不能等防抖) requestLayout(); // 记录豁免;滚动停稳后会补执行一次 }, true); @@ -603,41 +518,23 @@ mo.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-session-id"], }); - /* 立即钳制:输入框尺寸突变(侧栏开合)时先保证 pill 不压原生控件, - 防抖布局随后再正式归位。避免防抖窗口期内 pill 悬在旧位置形成「留痕」。 */ + /* 立即贴位:输入框尺寸/位置突变(侧栏开合、滚动)时先把统计行跟到位, + 防抖布局随后再决定档位。坐标是视口坐标,fixed 定位直接用,没有包含块换算问题。 */ function clampNow() { - if (dead || !rowEl || rowEl.style.display === "none") return; + if (dead || !rowEl) return; var dock = visibleDock(); if (!dock) return; - var trig = dock.querySelector('[data-testid="chat-model-select-trigger"]'); - if (!trig) return; - var trigRect = trig.getBoundingClientRect(); var dockRect = dock.getBoundingClientRect(); - var leftChip = findChipByText(dock, "电脑操作") || findChipByText(dock, "完全访问"); - var leftEdge = leftChip ? leftChip.getBoundingClientRect().right : dockRect.left + 60; - var bandCy = leftChip - ? (leftChip.getBoundingClientRect().top + leftChip.getBoundingClientRect().bottom) / 2 - : (trigRect.top + trigRect.bottom) / 2; - var rightEdge = trigRect.left; - var btns = dock.querySelectorAll("button,[role='button']"); - for (var bi = 0; bi < btns.length; bi++) { - var br = btns[bi].getBoundingClientRect(); - if (br.width > 0 && br.left > leftEdge + 4 && br.left < rightEdge && - Math.abs((br.top + br.bottom) / 2 - bandCy) < 22) rightEdge = br.left; - } - var pw = rowEl.offsetWidth || 0; - if (!pw) return; - if (rightEdge - leftEdge < pw + 24) { - rowEl.style.display = "none"; // 空隙已不够:先隐,等防抖布局选到合适档位再显 + if (!(dockRect.width > 0)) return; + var pillH = rowEl.offsetHeight || 20; + var top = Math.round(dockRect.bottom + 4); + if (rowEl.style.display !== "none" && (top + pillH > window.innerHeight - 2 || dockRect.width - 24 < rowEl.offsetWidth)) { + rowEl.style.display = "none"; // 空间不够先隐,等布局选档 return; } - var centerX = (leftEdge + rightEdge) / 2; - var newLeft = Math.round(centerX - dockRect.left); - var curLeft = parseFloat(rowEl.style.left || "0") || 0; - if (Math.abs(newLeft - curLeft) > 1) { - rowEl.style.left = newLeft + "px"; - lastLeft = newLeft; - } + var left = Math.round(dockRect.left + 12); + if (Math.abs(left - lastLeft) > 1) { rowEl.style.left = left + "px"; lastLeft = left; } + if (Math.abs(top - lastTop) > 1) { rowEl.style.top = top + "px"; lastTop = top; } } /* 输入框容器尺寸变化(侧栏开合/拖分隔条不触发 window resize): From 5588625bb31f4da7c88ba758de50ee6fa9c9602b Mon Sep 17 00:00:00 2001 From: GaryLiuTesting <160207920+huantest2024@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:38:17 +0800 Subject: [PATCH 14/15] feat(session-stats): stats row inside composer card, centered --- .../session-stats/server/pills.js | 68 ++++++++++++------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/external_plugins/session-stats/server/pills.js b/external_plugins/session-stats/server/pills.js index 9367f592..36c8c9ed 100644 --- a/external_plugins/session-stats/server/pills.js +++ b/external_plugins/session-stats/server/pills.js @@ -37,7 +37,8 @@ css = document.createElement("style"); css.textContent = - ".__sspills-row{position:fixed;display:inline-flex;align-items:center;gap:6px;z-index:30;}" + + ".__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;" + @@ -287,7 +288,7 @@ var hit = reqHitPct(lr); // 最近请求 token 段:入 X / 出 Y(无数据时退回会话累计) var reqTokHtml = lr - ? "入 " + fmtTok(lr["in"]) + " · 出 " + fmtTok(lr.out) + ? "输入 " + fmtTok(lr["in"]) + " · 输出 " + fmtTok(lr.out) : "累计 " + fmtTok((data.tokens || {}).total) + " tok"; if (mode === "micro") { // 最窄档:只剩实时速度(参照同款统计条的 compact 形态),保证窄输入框也放得下 @@ -399,7 +400,6 @@ - 尾随防抖 180ms:滚轮滑动/侧栏动画等过渡帧全部跳过,只在静止后布局一次 - 统计行挂在输入条下方(fixed),宽度只取决于输入条宽度:按「宽→窄」挑第一个放得下的档位 */ var dead = false; // teardown 后置 true:一切异步回调早退,死实例不碰 DOM - var lastLeft = -9999, lastTop = -9999; var layoutTimer = 0; var lastScrollAt = 0; // 滚动豁免:滚动后 700ms 内不布局(滚动中会话顶栏/输入条有过渡动画) @@ -436,24 +436,49 @@ } } + /* 输入条卡片底部原本只剩 ~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; } - /* 统计行挂在输入条下方(fixed 挂 body),不再和 chip/模型选择器抢同一行空间 */ if (!rowEl || !rowEl.isConnected) { if (!rowEl) rowEl = makeRow(); - document.body.appendChild(rowEl); } + if (rowEl.parentElement !== dock) dock.appendChild(rowEl); // 挂回 dock:位置/居中全交给 CSS ensureModeSpans(); var avail = Math.round(dockRect.width - 24); // 左右各留 12px,与输入条左缘对齐 var all = ["full", "mid", "mini", "micro"]; @@ -472,16 +497,6 @@ } pillMode = chosen; setModeSpans(chosen); - var pillH = rowEl.offsetHeight || 20; - var top = Math.round(dockRect.bottom + 4); - /* 输入条下方没有一行空间(小窗/贴底布局)→ 不显示,绝不上移压住输入条 */ - if (top + pillH > window.innerHeight - 2) { - rowEl.style.display = "none"; - return; - } - var left = Math.round(dockRect.left + 12); - if (Math.abs(left - lastLeft) > 1) { rowEl.style.left = left + "px"; lastLeft = left; } - if (Math.abs(top - lastTop) > 1) { rowEl.style.top = top + "px"; lastTop = top; } rowEl.style.display = ""; } @@ -524,17 +539,23 @@ 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 pillH = rowEl.offsetHeight || 20; - var top = Math.round(dockRect.bottom + 4); - if (rowEl.style.display !== "none" && (top + pillH > window.innerHeight - 2 || dockRect.width - 24 < rowEl.offsetWidth)) { - rowEl.style.display = "none"; // 空间不够先隐,等布局选档 - 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; } } - var left = Math.round(dockRect.left + 12); - if (Math.abs(left - lastLeft) > 1) { rowEl.style.left = left + "px"; lastLeft = left; } - if (Math.abs(top - lastTop) > 1) { rowEl.style.top = top + "px"; lastTop = top; } + if (!found) { rowEl.style.display = "none"; return; } + pillMode = found; + setModeSpans(found); } /* 输入框容器尺寸变化(侧栏开合/拖分隔条不触发 window resize): @@ -564,6 +585,7 @@ 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; From 18d5f41a542a9711fa49606785dcadd81200e893 Mon Sep 17 00:00:00 2001 From: GaryLiuTesting <160207920+huantest2024@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:35:20 +0800 Subject: [PATCH 15/15] fix(session-stats): centered stats row + full labels + no-flicker; TPS decode-window guard --- .../session-stats/server/pills.js | 5 ++-- .../session-stats/server/stats_server.py | 28 +++++++++++++++---- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/external_plugins/session-stats/server/pills.js b/external_plugins/session-stats/server/pills.js index 36c8c9ed..6bcea527 100644 --- a/external_plugins/session-stats/server/pills.js +++ b/external_plugins/session-stats/server/pills.js @@ -241,19 +241,20 @@ } /* 实时 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) { + 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) { + 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 }; } } diff --git a/external_plugins/session-stats/server/stats_server.py b/external_plugins/session-stats/server/stats_server.py index 529cb708..3dbc01df 100644 --- a/external_plugins/session-stats/server/stats_server.py +++ b/external_plugins/session-stats/server/stats_server.py @@ -160,9 +160,15 @@ def session_family(main_id: str) -> list[str]: # 请求行的过滤条件由调用方给(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" + "AND completed_at IS NOT NULL AND completed_at > first_token_at " + f"AND completed_at - first_token_at >= {MIN_DECODE_MS}" ) @@ -321,10 +327,14 @@ def build_stats(scope: str, session_id: str | None, session_source: str) -> dict 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」行) + # 最近一次完成请求的 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"]: + 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 @@ -527,7 +537,12 @@ def log_message(self, fmt, *args) -> None: # noqa: N802 pass # 静默访问日志 -def start_http_service() -> None: +def start_http_service() -> bool: + """在本进程内启动 HTTP 数据服务。 + + 返回 True=本进程已开始服务;False=端口已有健康实例(或启动失败)。 + 常驻服务进程据此退出,避免自动重打补丁时反复拉起空转实例。 + """ class _Server(ThreadingHTTPServer): # Windows 上 SO_REUSEADDR 会允许重复绑定活跃端口,关闭以靠异常探测单例 allow_reuse_address = False @@ -539,13 +554,14 @@ class _Server(ThreadingHTTPServer): except OSError: if _health_check(): log(f"端口 {HTTP_PORT} 已有健康实例,跳过启动") - return + return False time.sleep(0.5) continue threading.Thread(target=srv.serve_forever, daemon=True).start() log(f"数据服务已启动 http://{HTTP_HOST}:{HTTP_PORT}") - return + return True log(f"数据服务启动失败(端口 {HTTP_PORT} 被占用且无健康实例)") + return False def main() -> None: