From 184f687087ad1763bb1d934bae1992e99e8f4c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=99=E9=9A=86=E6=B1=9F?= <1209026461@qq.com> Date: Wed, 12 Aug 2026 17:35:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(stats):=20token=20=E7=94=A8=E9=87=8F?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF=20+=20GitHub=20=E9=A3=8E=E6=A0=BC=E6=8C=89?= =?UTF-8?q?=E5=A4=A9=E7=83=AD=E5=8A=9B=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rust 侧新增 crate::stats::UsageStats:按天/会话/模型聚合引擎 usage 事件(input/output tokens + 调用次数),持久化 usage-stats.json - 新增 usage_stats Tauri 命令返回 totals/days/models/sessions 快照 - 前端新增用量统计空间:汇总卡、近 14 天趋势、按模型/会话明细表 - 新增 GitHub 提交图风格的一年按天活跃热力图(相对色阶 + 悬停明细) - 修复 stats 相关编译问题(json! 语句块、Space/initialKind 类型) --- desktop/Cargo.lock | 7 + desktop/Cargo.toml | 2 +- desktop/build.rs | 1 + desktop/src/driver/mod.rs | 6 + desktop/src/driver/normalize.rs | 31 ++ desktop/src/driver/ohmy.rs | 7 + desktop/src/driver/ohmy_tests.rs | 44 ++ desktop/src/driver/transport.rs | 1 + desktop/src/main.rs | 2 + desktop/src/stats.rs | 242 ++++++++++ desktop/ui-next/src/app/App.tsx | 20 +- desktop/ui-next/src/app/shellChrome.test.ts | 5 +- desktop/ui-next/src/app/shellChrome.ts | 5 +- .../ui-next/src/features/sidebar/Sidebar.tsx | 16 +- .../features/stats/UsageStatsView.test.tsx | 64 +++ .../src/features/stats/UsageStatsView.tsx | 427 ++++++++++++++++++ desktop/ui-next/src/lib/i18n/en.ts | 28 ++ desktop/ui-next/src/lib/i18n/zh.ts | 28 ++ desktop/ui-next/src/lib/ipc/usageStats.ts | 44 ++ desktop/ui-next/src/lib/util/prefs.ts | 2 +- 20 files changed, 967 insertions(+), 15 deletions(-) create mode 100644 desktop/src/stats.rs create mode 100644 desktop/ui-next/src/features/stats/UsageStatsView.test.tsx create mode 100644 desktop/ui-next/src/features/stats/UsageStatsView.tsx create mode 100644 desktop/ui-next/src/lib/ipc/usageStats.ts diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 4d602fa7f..ed5ce0ae5 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2224,6 +2224,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" + [[package]] name = "num-traits" version = "0.2.19" @@ -4307,6 +4313,7 @@ checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 08e5c213a..a05f87042 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -39,7 +39,7 @@ cookie = "0.18" regex = "1" # cookie 罐过期时间的 RFC3339 解析。time 本就在依赖树里(cookie 的 Expires # 解析),显式声明 + parsing feature,替代手写历算(严格校验月/日范围) -time = { version = "0.3", features = ["parsing"] } +time = { version = "0.3", features = ["parsing", "local-offset"] } [dev-dependencies] # baizhi 集成测试:假服务端 + async 测试运行时 diff --git a/desktop/build.rs b/desktop/build.rs index b02a69b85..80a531dc3 100644 --- a/desktop/build.rs +++ b/desktop/build.rs @@ -53,6 +53,7 @@ fn main() { "session_delete", "session_patch", "models_list", + "usage_stats", "session_open", "session_history", "session_outline", diff --git a/desktop/src/driver/mod.rs b/desktop/src/driver/mod.rs index dbb32cb39..d20e488f6 100644 --- a/desktop/src/driver/mod.rs +++ b/desktop/src/driver/mod.rs @@ -396,6 +396,12 @@ pub async fn models_list(host: State<'_, DriverHost>) -> Result { host.get()?.models_list().await } +/// 本地会话 token 用量统计(按天/会话/模型聚合,usage 事件记账)。 +#[tauri::command] +pub async fn usage_stats(host: State<'_, DriverHost>) -> Result { + Ok(host.get()?.usage_stats()) +} + /// 打开会话:返回尾部回放窗口 `{frames, cursor, has_more}`。历史走返回值 /// 而非 `frames:{sid}` 事件——返回值天生有序,不必依赖"监听先于命令"。 #[tauri::command] diff --git a/desktop/src/driver/normalize.rs b/desktop/src/driver/normalize.rs index 350577198..2ed1b412a 100644 --- a/desktop/src/driver/normalize.rs +++ b/desktop/src/driver/normalize.rs @@ -415,6 +415,7 @@ impl Inner { if let Some((used, window)) = context_usage_fields(&data) { self.push_usage(&sid, used, window); } + self.record_usage(&sid, &data); } // 会话摘要:引擎每轮用户消息后异步生成一句 ≤60 字的对话摘要 // (随对话演进改写,后一轮覆盖前一轮),只给顶层会话生成。 @@ -469,6 +470,36 @@ impl Inner { _ => {} } } + + /// usage 事件里的 input/output tokens → 用量统计(按天/会话/模型)。 + /// 引擎每次模型调用发一个 usage 事件,input/output 为该调用全量,直接 + /// 累加进对应桶。模型取会话当前 model_name——运行中不可切模型,归属 + /// 可靠。只记 input/output 非 0 的事件(纯 context 快照不记账)。 + pub(super) fn record_usage(&self, sid: &str, data: &Value) { + let input = data.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0); + let output = data.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0); + if input == 0 && output == 0 { + return; + } + // 跨组嵌套仅 subagents → sessions 一条(见 ohmy.rs::Inner),先取子代理 + let parent = self.sub.subagents.lock_ok().get(sid).map(|r| r.parent_sid.clone()); + let (model, title) = { + let sessions = self.sess.sessions.lock_ok(); + match sessions.get(sid) { + Some(s) => (s.model_name.clone(), s.title.clone()), + None => (String::new(), String::new()), + } + }; + self.stats.record( + &crate::stats::today(), + sid, + &title, + &model, + parent.as_deref(), + input, + output, + ); + } } /// 上下文占用字段防腐层。13c8adc 起所有新入口统一为扁平 diff --git a/desktop/src/driver/ohmy.rs b/desktop/src/driver/ohmy.rs index b1ad5bb2e..9f13a0384 100644 --- a/desktop/src/driver/ohmy.rs +++ b/desktop/src/driver/ohmy.rs @@ -97,6 +97,11 @@ impl OhmyDriver { crate::wsl::host_fs_view(&w.distro, &w.guest_home).to_string_lossy().into_owned() }) } + + /// 本地会话 token 用量统计快照(按天/会话/模型聚合)。 + pub fn usage_stats(&self) -> Value { + self.0.stats.snapshot() + } } /// WSL 运行环境上下文(kernel_env=wsl:* 时随引擎启动填入;一次 prepare @@ -138,6 +143,8 @@ pub(super) struct Inner { pub(super) chat_workspaces_dir: PathBuf, /// 壳侧审批记忆持久化路径(兼容尾巴,配对 SessionsState::perm_remember) pub(super) perm_persist_path: PathBuf, + /// 本地会话 token 用量统计(按天/会话/模型;usage 事件记账,落盘 usage-stats.json) + pub(super) stats: crate::stats::UsageStats, /// WSL 运行环境上下文(本机模式 None;见 WslCtx) pub(super) wsl: Option, } diff --git a/desktop/src/driver/ohmy_tests.rs b/desktop/src/driver/ohmy_tests.rs index 52da6f94f..bc19e7100 100644 --- a/desktop/src/driver/ohmy_tests.rs +++ b/desktop/src/driver/ohmy_tests.rs @@ -971,6 +971,7 @@ fn bare_inner_events(tag: &str) -> (Arc, EmittedEvents) { engine_dir: home.join("ohmyagent"), chat_workspaces_dir: home.join("local-data/chat-workspaces"), perm_persist_path: home.join("perm.json"), + stats: crate::stats::UsageStats::new(&home), wsl: None, }); (inner, events) @@ -1499,6 +1500,49 @@ fn streaming_usage_updates_emitting_agent_without_parent_leak() { assert_eq!(usage_of("child"), vec![(45_678, 64_000)]); } +/// usage 事件里的 input/output tokens 会记入本地用量统计(按天/会话/模型), +/// 纯 context 快照(无 input/output)不重复记账;模型归属取会话当前 model_name。 +#[test] +fn usage_events_accumulate_into_daily_stats() { + let inner = bare_inner("usage-stats"); + { + let mut sessions = inner.sess.sessions.lock().unwrap(); + let mut main = bare_session("main"); + main.model_name = "glm-4.5".into(); + main.title = "重构登录".into(); + sessions.insert("main".into(), main); + } + + inner.handle_event(json!({ "type": "usage", "session_id": "main", "seq": 1, + "data": { "input_tokens": 900, "output_tokens": 20, + "context_used": 1_234, "context_window": 200_000 } })); + // 同一调用的 message_start/message_delta 第二个事件没有 input/output → 不记账 + inner.handle_event(json!({ "type": "usage", "session_id": "main", "seq": 2, + "data": { "context_used": 1_234, "context_window": 200_000 } })); + inner.handle_event(json!({ "type": "usage", "session_id": "main", "seq": 3, + "data": { "input_tokens": 40_000, "output_tokens": 5_000, + "context_used": 45_678, "context_window": 64_000 } })); + + let snap = inner.stats.snapshot(); + assert_eq!(snap["totals"]["input_tokens"], 40_900, "{snap}"); + assert_eq!(snap["totals"]["output_tokens"], 5_020, "{snap}"); + assert_eq!(snap["totals"]["calls"], 2, "纯 context 快照不应计调用次数: {snap}"); + + let today = crate::stats::today(); + assert_eq!(snap["days"][0]["date"], today, "{snap}"); + assert_eq!(snap["days"][0]["input_tokens"], 40_900, "{snap}"); + + assert_eq!(snap["models"][0]["model"], "glm-4.5", "{snap}"); + assert_eq!(snap["models"][0]["input_tokens"], 40_900, "{snap}"); + + let sess = &snap["sessions"][0]; + assert_eq!(sess["session_id"], "main", "{snap}"); + assert_eq!(sess["title"], "重构登录", "{snap}"); + assert!(sess["parent"].is_null(), "{snap}"); + assert_eq!(sess["input_tokens"], 40_900, "{snap}"); + assert_eq!(sess["days"][0]["output_tokens"], 5_020, "{snap}"); +} + /// 最新 Agent 将 turn/stopped 与 usage 统一成扁平字段;旧版嵌套形状仍 /// 接受,保证桌面壳与已分发 sidecar 的滚动升级兼容。 #[test] diff --git a/desktop/src/driver/transport.rs b/desktop/src/driver/transport.rs index 05a938880..03b094a54 100644 --- a/desktop/src/driver/transport.rs +++ b/desktop/src/driver/transport.rs @@ -346,6 +346,7 @@ impl OhmyDriver { engine_dir, chat_workspaces_dir, perm_persist_path, + stats: crate::stats::UsageStats::new(&cfg_dir), wsl: wsl_ctx, }); diff --git a/desktop/src/main.rs b/desktop/src/main.rs index de8cec09c..5391a7d98 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -19,6 +19,7 @@ mod driver; #[cfg(target_os = "windows")] mod native_pet; mod repo; +mod stats; mod telemetry; mod uploads; mod util; @@ -1289,6 +1290,7 @@ fn main() { driver::session_delete, driver::session_patch, driver::models_list, + driver::usage_stats, driver::session_open, driver::session_history, driver::session_outline, diff --git a/desktop/src/stats.rs b/desktop/src/stats.rs new file mode 100644 index 000000000..810b690eb --- /dev/null +++ b/desktop/src/stats.rs @@ -0,0 +1,242 @@ +//! 本地会话 token 用量统计(按天 → 会话 → 模型聚合)。 +//! +//! 数据源:引擎每收到一次模型调用的 provider usage 就推一个 `usage` 事件, +//! 壳在 normalize.rs 的 usage 分支里把 `input_tokens`/`output_tokens` 记到这里。 +//! 仅统计本机桌面会话,不涉及云端任务。 +//! +//! 落盘:`/usage-stats.json`,结构: +//! `{ "": { "": { "": Record } } }`。 +//! 每次 record 全量原子写(文件小,usage 事件按模型调用频次,量级可接受)。 + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex as StdMutex; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::config::atomic_write_private; +use crate::util::LockExt; + +/// 保留最近多少天的记录,避免落盘文件无限膨胀。 +const KEEP_DAYS: i64 = 366; + +/// 某天某会话某模型的累计消耗(合并幂等:同名桶只累加 token 与调用次数)。 +#[derive(Default, Clone, Debug, Serialize, Deserialize)] +pub(super) struct Record { + pub(super) title: String, + /// 子代理会话的父会话 id(顶层任务为 None,UI 可据此归并) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) parent: Option, + pub(super) input_tokens: u64, + pub(super) output_tokens: u64, + /// 模型调用次数(即收到的 usage 事件数) + pub(super) calls: u64, +} + +type ModelBuckets = HashMap; +type SessionBuckets = HashMap; +type Days = HashMap; + +pub(super) struct UsageStats { + path: PathBuf, + days: StdMutex, +} + +impl UsageStats { + pub(super) fn new(config_dir: &Path) -> Self { + let path = config_dir.join("usage-stats.json"); + let days = std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .unwrap_or_default(); + let store = Self { path, days: StdMutex::new(days) }; + store.prune(); + store + } + + /// 记一条模型调用的 token 消耗(usage 事件里的 input/output 全量累加)。 + pub(super) fn record( + &self, + date: &str, + sid: &str, + title: &str, + model: &str, + parent: Option<&str>, + input_tokens: u64, + output_tokens: u64, + ) { + if input_tokens == 0 && output_tokens == 0 { + return; + } + let model = if model.is_empty() { "" } else { model }; + let mut days = self.days.lock_ok(); + let rec = days + .entry(date.to_string()) + .or_default() + .entry(sid.to_string()) + .or_default() + .entry(model.to_string()) + .or_default(); + if !title.is_empty() { + rec.title = title.to_string(); + } + if parent.is_some() { + rec.parent = parent.map(|p| p.to_string()); + } + rec.input_tokens += input_tokens; + rec.output_tokens += output_tokens; + rec.calls += 1; + drop(days); + self.prune(); + self.persist(); + } + + /// 丢掉超过 KEEP_DAYS 的旧天。 + fn prune(&self) { + let cutoff = today_inner_checked_sub(KEEP_DAYS); + let mut days = self.days.lock_ok(); + days.retain(|date, _| date.as_str() >= cutoff.as_str()); + } + + /// 全量原子落盘。 + fn persist(&self) { + let days = self.days.lock_ok(); + let _ = serde_json::to_vec_pretty(&*days) + .map(|data| atomic_write_private(&self.path, &data)); + } + + /// 聚合快照,供 `usage_stats` IPC 命令返回给 UI。 + /// - totals:全部记录汇总 + /// - days:按天汇总(倒序) + /// - models:按模型汇总(用量倒序) + /// - sessions:按会话汇总(用量倒序),每条含 parent 与按天/按模型子拆分 + pub(super) fn snapshot(&self) -> Value { + let days = self.days.lock_ok(); + let mut totals = (0u64, 0u64, 0u64); + let mut by_day: BTreeMap = BTreeMap::new(); + let mut by_model: BTreeMap = BTreeMap::new(); + // sid → (title, parent, total, by_day, by_model) + let mut by_session: BTreeMap = BTreeMap::new(); + + for (date, sessions) in days.iter() { + for (sid, models) in sessions.iter() { + for (model, rec) in models.iter() { + totals.0 += rec.input_tokens; + totals.1 += rec.output_tokens; + totals.2 += rec.calls; + + let d = by_day.entry(date.clone()).or_default(); + d.0 += rec.input_tokens; + d.1 += rec.output_tokens; + d.2 += rec.calls; + + let m = by_model.entry(model.clone()).or_default(); + m.0 += rec.input_tokens; + m.1 += rec.output_tokens; + m.2 += rec.calls; + + let s = by_session.entry(sid.clone()).or_insert_with(|| SessionAgg { + title: rec.title.clone(), + parent: rec.parent.clone(), + ..Default::default() + }); + if !rec.title.is_empty() { + s.title = rec.title.clone(); + } + if rec.parent.is_some() { + s.parent = rec.parent.clone(); + } + s.total.0 += rec.input_tokens; + s.total.1 += rec.output_tokens; + s.total.2 += rec.calls; + let d2 = s.by_day.entry(date.clone()).or_default(); + d2.0 += rec.input_tokens; + d2.1 += rec.output_tokens; + d2.2 += rec.calls; + let m2 = s.by_model.entry(model.clone()).or_default(); + m2.0 += rec.input_tokens; + m2.1 += rec.output_tokens; + m2.2 += rec.calls; + } + } + } + drop(days); + + let sessions_json = { + let mut rows: Vec<(&String, &SessionAgg)> = by_session.iter().collect(); + rows.sort_by(|a, b| { + let ta = a.1.total.0 + a.1.total.1; + let tb = b.1.total.0 + b.1.total.1; + tb.cmp(&ta).then(a.0.cmp(b.0)) + }); + rows.into_iter().map(|(sid, s)| json!({ + "session_id": sid, + "title": s.title, + "parent": s.parent, + "input_tokens": s.total.0, + "output_tokens": s.total.1, + "calls": s.total.2, + "days": s.by_day.iter().rev().map(|(date, d)| json!({ + "date": date, + "input_tokens": d.0, + "output_tokens": d.1, + "calls": d.2, + })).collect::>(), + "models": sorted_bucket_json(s.by_model.clone()), + })).collect::>() + }; + + json!({ + "totals": bucket_json(totals), + "days": by_day.iter().rev().map(|(date, d)| json!({ + "date": date, + "input_tokens": d.0, + "output_tokens": d.1, + "calls": d.2, + })).collect::>(), + "models": sorted_bucket_json(by_model), + "sessions": sessions_json, + }) + } +} + +#[derive(Default)] +struct SessionAgg { + title: String, + parent: Option, + total: (u64, u64, u64), + by_day: BTreeMap, + by_model: BTreeMap, +} + +fn bucket_json(b: (u64, u64, u64)) -> Value { + json!({ "input_tokens": b.0, "output_tokens": b.1, "calls": b.2 }) +} + +fn sorted_bucket_json(map: BTreeMap) -> Vec { + let mut rows: Vec<(&String, &(u64, u64, u64))> = map.iter().collect(); + rows.sort_by(|a, b| (b.1).0.cmp(&(a.1).0).then(b.0.cmp(a.0))); + rows.into_iter() + .map(|(k, v)| json!({ + "model": k, + "input_tokens": v.0, + "output_tokens": v.1, + "calls": v.2, + })) + .collect() +} + +/// 本地时区的今天,`YYYY-MM-DD`。取不到本地时区时退回 UTC。 +pub(super) fn today() -> String { + let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + let d = now.date(); + format!("{:04}-{:02}-{:02}", d.year(), d.month() as u8, d.day()) +} + +/// today() 往前推 n 天(仅用于剪枝,不解析历史日期)。 +fn today_inner_checked_sub(days: i64) -> String { + let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + let d = now.date().checked_sub(time::Duration::days(days)).unwrap_or(now.date()); + format!("{:04}-{:02}-{:02}", d.year(), d.month() as u8, d.day()) +} diff --git a/desktop/ui-next/src/app/App.tsx b/desktop/ui-next/src/app/App.tsx index 40f3709ce..4950989a7 100644 --- a/desktop/ui-next/src/app/App.tsx +++ b/desktop/ui-next/src/app/App.tsx @@ -7,7 +7,7 @@ // 侧栏 attention 高亮; // - D8 增量自愈:session-event/意图指向未知 id → 重拉全表再选中; // - H9 意图消费:open-* 事件送达即 takeUiIntent 消费壳侧副本,防刷新重放。 -import { IconAlertCircle, IconCircleCheck, IconCloud, IconFolderCode, IconHelpCircle, IconMessages, IconPlayerStop, IconSend, IconSettings, IconWorld, IconX } from "@tabler/icons-react"; +import { IconAlertCircle, IconChartBar, IconCircleCheck, IconCloud, IconFolderCode, IconHelpCircle, IconMessages, IconPlayerStop, IconSend, IconSettings, IconWorld, IconX } from "@tabler/icons-react"; import { useEffect, useRef, useState } from "react"; import { ChatView } from "@/features/chat/ChatView"; @@ -17,6 +17,7 @@ import { EngineBanner } from "@/features/engine/EngineBanner"; import { NewTaskModal } from "@/features/newtask/NewTaskModal"; import { SettingsView } from "@/features/settings/SettingsView"; import { Sidebar } from "@/features/sidebar/Sidebar"; +import { UsageStatsView } from "@/features/stats/UsageStatsView"; import { ResizeEdges } from "@/features/titlebar/ResizeEdges"; import { MacWindowControls, TitleBar } from "@/features/titlebar/TitleBar"; import { windowContextLabel } from "@/app/shellChrome"; @@ -52,6 +53,7 @@ const SPACE_ICONS: Record = { local: IconFolderCode, cloud: IconCloud, chat: IconMessages, + stats: IconChartBar, }; const NOTICE_TONE: Record = { @@ -126,7 +128,7 @@ function SpaceRail({ onToggleSettings: () => void; }) { const { t } = useI18n(); - const labels: Record = { local: t("rail.local"), cloud: t("rail.cloud"), chat: t("rail.chat") }; + const labels: Record = { local: t("rail.local"), cloud: t("rail.cloud"), chat: t("rail.chat"), stats: t("rail.stats") }; return (