From 92f535f4e60fcedb3df81fa230eb12f33a0ec9ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=99=E9=9A=86=E6=B1=9F?= <1209026461@qq.com> Date: Fri, 14 Aug 2026 10:06:58 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(stats):=20token=20=E7=94=A8=E9=87=8F?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF=20+=20=E7=83=AD=E5=8A=9B=E5=9B=BE=20+=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D/=E6=B6=88=E6=81=AF/=E5=A4=A7=E7=BA=B2?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/Cargo.lock | 11 + desktop/Cargo.toml | 2 +- desktop/build.rs | 1 + desktop/src/driver/fold.rs | 22 +- desktop/src/driver/mod.rs | 6 + desktop/src/driver/normalize.rs | 43 ++ desktop/src/driver/ohmy.rs | 7 + desktop/src/driver/ohmy_tests.rs | 44 ++ desktop/src/driver/session.rs | 11 + desktop/src/driver/transport.rs | 1 + desktop/src/main.rs | 2 + desktop/src/stats.rs | 242 +++++++++ .../ui-next/src/features/chat/ChatView.tsx | 30 ++ desktop/ui-next/src/features/chat/LogList.tsx | 16 +- .../ui-next/src/features/chat/OutlineNav.tsx | 34 ++ .../src/features/chat/useSessionFeed.test.tsx | 4 +- .../src/features/chat/useSessionFeed.ts | 12 +- .../ui-next/src/features/sidebar/Sidebar.tsx | 43 +- .../ui-next/src/features/sidebar/listKit.tsx | 102 ++++ .../features/stats/UsageStatsView.test.tsx | 64 +++ .../src/features/stats/UsageStatsView.tsx | 459 ++++++++++++++++++ desktop/ui-next/src/lib/i18n/en.ts | 29 ++ desktop/ui-next/src/lib/i18n/zh.ts | 29 ++ desktop/ui-next/src/lib/ipc/sessions.ts | 3 + .../ui-next/src/lib/ipc/usageStats.test.ts | 44 ++ desktop/ui-next/src/lib/ipc/usageStats.ts | 122 +++++ .../ui-next/src/lib/layoutContract.test.ts | 2 +- desktop/ui-next/src/lib/protocol/reduce.ts | 31 +- desktop/ui-next/src/lib/protocol/types.ts | 4 + 29 files changed, 1407 insertions(+), 13 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.test.ts create mode 100644 desktop/ui-next/src/lib/ipc/usageStats.ts diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 64d98b9a5..cde9821ad 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2255,6 +2255,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2" version = "0.6.4" @@ -4306,7 +4315,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index ca34be237..854c8f6f8 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 32a815cea..90bb86792 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/fold.rs b/desktop/src/driver/fold.rs index a260483e7..5769336cb 100644 --- a/desktop/src/driver/fold.rs +++ b/desktop/src/driver/fold.rs @@ -201,8 +201,7 @@ pub(super) struct TurnFold { } impl TurnFold { - pub(super) fn push(&mut self, f: &Value) { - let seq = frame_seq(f); + pub(super) fn push(&mut self, f: &Value) { let seq = frame_seq(f); if self.out.is_empty() { self.from = seq; } @@ -254,6 +253,25 @@ impl TurnFold { self.tail = None; } + /// usage 事件(input/output tokens)挂到本轮最后一条 agent_message 帧上, + /// 供 UI 在每条助手消息旁展示其 token 用量。provider 在同一次模型调用 + /// 的头尾可能各发一次 usage,后到者覆盖前值(取该调用的最终计数)。 + pub(super) fn attach_usage(&mut self, input: u64, output: u64) { + for f in self.out.iter_mut().rev() { + let Some(data) = f.get("data") else { continue }; + if session_update(data) != Some("agent_message_chunk") { + continue; + } + if let Some(update) = f.get_mut("data").and_then(|d| d.get_mut("update")).and_then(|u| u.as_object_mut()) { + update.insert( + "usage".into(), + json!({ "input_tokens": input, "output_tokens": output }), + ); + } + break; + } + } + pub(super) fn is_empty(&self) -> bool { self.out.is_empty() } 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..747a48d03 100644 --- a/desktop/src/driver/normalize.rs +++ b/desktop/src/driver/normalize.rs @@ -415,6 +415,19 @@ impl Inner { if let Some((used, window)) = context_usage_fields(&data) { self.push_usage(&sid, used, window); } + self.record_usage(&sid, &data); + // 挂到本轮最后一条 agent_message 帧:每条助手消息显示其 token + // 用量。usage 事件是每次模型调用的全量,同帧后到覆盖前值。 + 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 { + if let Some(sess) = self.sess.sessions.lock_ok().get_mut(&sid) { + sess.fold.attach_usage(input, output); + } + // 实时路径:usage 晚于流式帧,单独补发 session-usage 事件, + // UI 据此把用量补到最后一条助手消息与大纲条目上。 + self.emit_session_usage(&sid, input, output); + } } // 会话摘要:引擎每轮用户消息后异步生成一句 ≤60 字的对话摘要 // (随对话演进改写,后一轮覆盖前一轮),只给顶层会话生成。 @@ -469,6 +482,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 217d18059..f3111150f 100644 --- a/desktop/src/driver/ohmy.rs +++ b/desktop/src/driver/ohmy.rs @@ -104,6 +104,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 @@ -145,6 +150,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, /// 技能库来源(skills.rs):内置(bundle 资源,可缺)与用户目录, diff --git a/desktop/src/driver/ohmy_tests.rs b/desktop/src/driver/ohmy_tests.rs index fb54e477f..3afbfeb34 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, skills_builtin_dir: None, skills_user_dir: home.join("skills"), @@ -1503,6 +1504,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/session.rs b/desktop/src/driver/session.rs index 42792352d..681823f78 100644 --- a/desktop/src/driver/session.rs +++ b/desktop/src/driver/session.rs @@ -2314,6 +2314,17 @@ impl Inner { ); } + /// 该会话最近一次模型调用的 token 用量(session-event 事件,不落帧): + /// UI 实时把用量补到当前回合的助手消息与大纲条目上(帧管线里 usage 事件 + /// 晚于流式帧,实时路径走这里;回放路径靠 attach_usage 挂在帧上)。 + pub(super) fn emit_session_usage(&self, sid: &str, input: u64, output: u64) { + let title = self.sess.sessions.lock_ok().get(sid).map(|s| s.title.clone()).unwrap_or_default(); + self.app.emit_json( + "session-event", + json!({ "type": "session-usage", "id": sid, "title": title, "input": input, "output": output }), + ); + } + pub(super) fn emit_session_ask(&self, sid: &str, open: bool) { let title = self.sess.sessions.lock_ok().get(sid).map(|s| s.title.clone()).unwrap_or_default(); self.app.emit_json( diff --git a/desktop/src/driver/transport.rs b/desktop/src/driver/transport.rs index e586b1069..7c0f698c9 100644 --- a/desktop/src/driver/transport.rs +++ b/desktop/src/driver/transport.rs @@ -349,6 +349,7 @@ impl OhmyDriver { engine_dir, chat_workspaces_dir, perm_persist_path, + stats: crate::stats::UsageStats::new(&cfg_dir), wsl: wsl_ctx, skills_builtin_dir: app_builtin_skills, skills_user_dir: crate::skills::user_dir(&cfg_dir), diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 0f72e5b4c..2e3acfbc4 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -20,6 +20,7 @@ mod driver; mod native_pet; mod repo; mod skills; +mod stats; mod telemetry; mod todos; mod uploads; @@ -1425,6 +1426,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/features/chat/ChatView.tsx b/desktop/ui-next/src/features/chat/ChatView.tsx index 28c1e7084..74e1adbf5 100644 --- a/desktop/ui-next/src/features/chat/ChatView.tsx +++ b/desktop/ui-next/src/features/chat/ChatView.tsx @@ -24,10 +24,13 @@ import { } from "react"; import { useApprovalHotkeys } from "@/app/shortcuts"; + +import { fmtCompact, showTokenPopover } from "@/features/sidebar/listKit"; import { useI18n } from "@/lib/i18n"; import { sessionOutline, type OutlineItem } from "@/lib/ipc/controls"; import { repoChanges, repoReveal } from "@/lib/ipc/repo"; import { sessionFrame, sessionPatch, type SessionMeta } from "@/lib/ipc/sessions"; +import { buildSessionUsageMap, usageStats, type TokenUsage } from "@/lib/ipc/usageStats"; import { onNativeFileDrop, uploadFileURL } from "@/lib/ipc/uploads"; import { workspaceRelativePath } from "@/lib/util/markdownPaths"; import { @@ -86,6 +89,20 @@ export function ChatView({ const { state, conn, historyLoaded, openError, hasMore, loadingEarlier, earlierError, loadEarlier, ensureLoaded } = useSessionFeed(meta.id, epoch); useApprovalHotkeys(state, meta.id); + // 头部 token 用量:会话切换时抓一次(usage 事件由壳记账;子代理已归并进父任务) + const [usage, setUsage] = useState(null); + useEffect(() => { + let alive = true; + void usageStats() + .then((data) => { + if (!alive) return; + setUsage(buildSessionUsageMap(data.sessions).get(meta.id) ?? null); + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, [meta.id]); // lastSeq 也喂给 composer:帧到达才是"上行已被壳接收"的可信信号 // (useComposer 的 ComposerFeed 头注写了三个信号各自兜住的故障) const composer = useComposer(meta.id, { running: state.running, historyLoaded, lastSeq: state.lastSeq }); @@ -617,6 +634,7 @@ export function ChatView({ alive = false; }; }, [changesToken, meta.id]); + const dragDepth = useRef(0); const onDragEnter = (e: DragEvent) => { if (![...(e.dataTransfer?.items ?? [])].some((i) => i.kind === "file")) return; @@ -743,6 +761,18 @@ export function ChatView({ )} + {/* 任务 token 用量:标题右侧,点击弹明细(输入/输出/调用 + 按模型) */} + {usage && usage.input + usage.output > 0 && ( + + )} {/* §7:indicator 壳与徽标是头部非交互子节点,必须各自带拖拽属性 */}
0 ? "indicator" : undefined}> {changesCount > 0 && ( diff --git a/desktop/ui-next/src/features/chat/LogList.tsx b/desktop/ui-next/src/features/chat/LogList.tsx index 1f03f95ef..f56eaf2ef 100644 --- a/desktop/ui-next/src/features/chat/LogList.tsx +++ b/desktop/ui-next/src/features/chat/LogList.tsx @@ -20,6 +20,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Markdown, MarkdownInline } from "@/components/markdown/Markdown"; import { downloadUpload, Lightbox, UploadImg } from "@/components/media/UploadImg"; import { useI18n } from "@/lib/i18n"; +import { t } from "@/lib/i18n"; import type { FrameSender } from "@/lib/ipc/approvals"; import { openExternal } from "@/lib/ipc/host"; import { isImagePath } from "@/lib/ipc/uploads"; @@ -29,6 +30,7 @@ import { markProgrammaticScroll } from "@/lib/util/scrollAnchor"; import type { ChatItem, ChatState, Frame, PermItem } from "@/lib/protocol/types"; import { presentToolCall } from "@/lib/tools/toolLabels"; import { thoughtMarkdown, thoughtSummary } from "@/lib/util/thoughtMarkdown"; +import { fmtCompact } from "@/features/sidebar/listKit"; import { AskCard } from "./cards/AskCard"; import { PermCard } from "./cards/PermCard"; import { statusDot } from "./cards/statusDot"; @@ -197,14 +199,26 @@ function renderItem(item: ChatItem, o: RenderOpts) { switch (item.kind) { case "user": return ; - case "agent": + case "agent": { + const u = item.usage; + const hasUsage = !!u && (u.input_tokens ?? 0) + (u.output_tokens ?? 0) > 0; // 时间绝对定位在块顶空隙(悬停显影,不占流式高度) return (
+ {/* 本条消息的 token 用量(壳侧 usage 事件挂帧;回放/重启后可见) */} + {hasUsage && ( +
+ ↑{fmtCompact(u!.input_tokens ?? 0)} ↓{fmtCompact(u!.output_tokens ?? 0)} +
+ )}
); + } case "thought": // 与助手块同构:时间线在块顶空隙 return ( diff --git a/desktop/ui-next/src/features/chat/OutlineNav.tsx b/desktop/ui-next/src/features/chat/OutlineNav.tsx index f1cc9bce8..09aa8c192 100644 --- a/desktop/ui-next/src/features/chat/OutlineNav.tsx +++ b/desktop/ui-next/src/features/chat/OutlineNav.tsx @@ -8,6 +8,7 @@ // 旧「浮窗跟随指针高度」不做——dropdown 锚定已确定面板落点。 import { memo, useEffect, useRef, useState } from "react"; +import { fmtCompact } from "@/features/sidebar/listKit"; import { useI18n } from "@/lib/i18n"; import type { OutlineItem } from "@/lib/ipc/controls"; import { ATT_LINE } from "@/lib/protocol/attLine"; @@ -29,6 +30,8 @@ export interface OutlineEntry { attCount: number; /** 当天 HH:MM,跨天带日期(fmtClock);无可靠时间为空。 */ time: string; + /** 该轮助手回复的 token 用量(壳侧 usage 挂帧;已加载进流的轮次才有) */ + usage?: { input_tokens?: number; output_tokens?: number }; } /** 目录 + 流内实时用户消息 → 合并去重的大纲条目。 @@ -37,6 +40,27 @@ export interface OutlineEntry { * 跳转锚,两条同 seq 只能定位到同一气泡。目录条目带真实翻页 offset, * 流内补的没有(undefined)。 */ export function outlineEntriesOf(outline: OutlineItem[], items: readonly ChatItem[]): OutlineEntry[] { + // 每条用户提问 → 其回合内各助手回复的 token 用量合计(usage 事件挂帧, + // 仅已加载进流的轮次有数据)。agent 项不带所属用户 seq,按「用户项之后到 + // 下一条用户项之前的所有 agent 项」聚合。 + const usageBySeq = new Map(); + for (let i = 0; i < items.length; i++) { + const it = items[i]; + if (!it || it.kind !== "user") continue; + if (it.seq === undefined) continue; + let input = 0; + let output = 0; + for (let j = i + 1; j < items.length; j++) { + const nj = items[j]; + if (!nj || nj.kind === "user") break; + if (nj.kind === "agent" && nj.usage) { + input += nj.usage.input_tokens ?? 0; + output += nj.usage.output_tokens ?? 0; + } + } + if (input + output > 0) usageBySeq.set(it.seq, { input_tokens: input, output_tokens: output }); + } + const merged: Array<{ seq: number; text: string; timestamp?: number; offset?: number }> = [...outline]; const seen = new Set(outline.map((o) => o.seq)); for (const it of items) { @@ -57,12 +81,14 @@ export function outlineEntriesOf(outline: OutlineItem[], items: readonly ChatIte } const text = body.join(" ").replace(/\s+/g, " ").trim(); const label = text.length > MAX_LABEL ? `${text.slice(0, MAX_LABEL)}…` : text; + const usage = usageBySeq.get(it.seq); out.push({ seq: it.seq, label, attCount, time: fmtClock(it.timestamp), ...(it.offset !== undefined ? { offset: it.offset } : {}), + ...(usage ? { usage } : {}), }); } return out; @@ -159,6 +185,14 @@ export const OutlineNav = memo(function OutlineNav({ }} > {labelOf(e)} + {e.usage && (e.usage.input_tokens ?? 0) + (e.usage.output_tokens ?? 0) > 0 && ( + + ↑{fmtCompact(e.usage.input_tokens ?? 0)} ↓{fmtCompact(e.usage.output_tokens ?? 0)} + + )} {e.time && {e.time}} diff --git a/desktop/ui-next/src/features/chat/useSessionFeed.test.tsx b/desktop/ui-next/src/features/chat/useSessionFeed.test.tsx index d2f4f7431..9612e5c3c 100644 --- a/desktop/ui-next/src/features/chat/useSessionFeed.test.tsx +++ b/desktop/ui-next/src/features/chat/useSessionFeed.test.tsx @@ -254,7 +254,9 @@ describe("useSessionFeed:窗口与实时帧的先后、监听生命周期、打 const { unmount } = renderHook(() => useSessionFeed("s1")); unmount(); // 一次 IPC 往返之内切走/卸载:注册还在途中 release(); - await waitFor(() => expect([...offs].sort()).toEqual(["conn-status:s1", "frames:s1"])); + await waitFor(() => + expect([...offs].sort()).toEqual(["conn-status:s1", "frames:s1", "session-event"]), + ); }); it("session_open 失败:openError 外显(壳只在成功路径 emit conn-status,不显就是空会话)", async () => { diff --git a/desktop/ui-next/src/features/chat/useSessionFeed.ts b/desktop/ui-next/src/features/chat/useSessionFeed.ts index 5499cb5e8..cda632417 100644 --- a/desktop/ui-next/src/features/chat/useSessionFeed.ts +++ b/desktop/ui-next/src/features/chat/useSessionFeed.ts @@ -7,11 +7,12 @@ import { startTransition, useCallback, useEffect, useRef, useState } from "react import type { Frame } from "@/lib/protocol/types"; import { afterEngineReady } from "@/lib/ipc/engine"; -import { createChatState, prependHistory, reduceBatch } from "@/lib/protocol/reduce"; +import { createChatState, patchLastAgentUsage, prependHistory, reduceBatch } from "@/lib/protocol/reduce"; import type { ChatState } from "@/lib/protocol/types"; import { onConnStatus, onFrames, + onSessionEvent, sessionClose, sessionHistory, sessionOpen, @@ -126,6 +127,14 @@ export function useSessionFeed(id: string | null, epoch = 0): SessionFeed { const connP = onConnStatus(id, (s) => { if (alive) setConn(s); }); + // session-usage:usage 事件晚于流式帧,壳单独补发;这里把用量实时挂到 + // 最后一条助手消息上(大纲与消息徽标随之出现,无需重开会话)。 + const usageOff = onSessionEvent((e) => { + if (!alive || e.type !== "session-usage" || e.id !== id) return; + const input = e.input ?? 0; + const output = e.output ?? 0; + if (input > 0 || output > 0) setState((s) => patchLastAgentUsage(s, input, output)); + }); void (async () => { try { await Promise.all([framesP, connP]); @@ -182,6 +191,7 @@ export function useSessionFeed(id: string | null, epoch = 0): SessionFeed { alive = false; void framesP.then((f) => f()).catch(() => {}); void connP.then((f) => f()).catch(() => {}); + usageOff(); void sessionClose(id); }; }, [id, epoch]); diff --git a/desktop/ui-next/src/features/sidebar/Sidebar.tsx b/desktop/ui-next/src/features/sidebar/Sidebar.tsx index a77d6e47d..9d4a0637d 100644 --- a/desktop/ui-next/src/features/sidebar/Sidebar.tsx +++ b/desktop/ui-next/src/features/sidebar/Sidebar.tsx @@ -11,12 +11,13 @@ // 行交互:右键 = 行菜单(重命名/归档/删除二段确认)。 // 行/组头/小节折叠的呈现件收口在 listKit(三列表统一,不做两套)。 import { IconArchive, IconFolder, IconFolderOpen, IconInbox, IconMessages, IconPlus, IconRefresh } from "@tabler/icons-react"; -import { useState, type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from "react"; +import { useEffect, useState, type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from "react"; import { CloudTaskList, useCloudProjects, useCloudTasks, type CloudTasksFeed } from "@/features/cloud/CloudTaskList"; -import { GroupLabel, levelPad, ListRow, NEST_NO_GUIDE, SectionFold } from "@/features/sidebar/listKit"; +import { GroupLabel, fmtCompact, levelPad, ListRow, NEST_NO_GUIDE, SectionFold, showTokenPopover } from "@/features/sidebar/listKit"; import { rowStatusLabel, rowTrailing } from "@/features/sidebar/sessionStatus"; import { TODO_GROUP_KEY, TodoSection, type TodoWiring } from "@/features/todo/TodoSection"; +import { buildSessionUsageMap, sumUsage, usageStats, type TokenUsage } from "@/lib/ipc/usageStats"; import { Brand } from "@/features/titlebar/TitleBar"; import { useUpdate } from "@/features/update/useUpdate"; import { openMenu, type MenuItem } from "@/lib/contextMenu"; @@ -62,6 +63,8 @@ interface RowPlumbing { renamingId: string | null; onRenameStart: (id: string) => void; onRenameEnd: () => void; + /** 会话 id → 该会话(含归并的子代理)的 token 用量 */ + usage: ReadonlyMap; } function SessionRow({ meta, p, level }: { meta: SessionMeta; p: RowPlumbing; level?: number }) { @@ -139,6 +142,7 @@ function SessionRow({ meta, p, level }: { meta: SessionMeta; p: RowPlumbing; lev s.waiting_ask).length; + // 文件夹级 token 合计:本组全部任务(含归档)的用量合并 + const groupUsage = sumUsage( + [...group.sessions.map((s) => s.id), ...group.archivedSessions.map((s) => s.id)], + p.usage, + ); const menuItems: MenuItem[] = [ ...(archivedProject ? [] : [{ label: t("sidebar.project.newTaskIn"), run: () => p.actions.onNewTaskIn(group.key) }]), { @@ -247,6 +256,21 @@ function ProjectDetails({ {dropTarget && } {waiting > 0 && {waiting}} + {/* 文件夹 token 合计:点击弹明细(输入/输出/调用 + 按模型) */} + {groupUsage && groupUsage.input + groupUsage.output > 0 && ( + + )} {/* 快捷钮常驻占位、hover 只切可见性:插入式显隐会挤动项目名,鼠标一进一出就抖 */} {!archivedProject && ( + )} {trailing && } diff --git a/desktop/ui-next/src/features/stats/UsageStatsView.test.tsx b/desktop/ui-next/src/features/stats/UsageStatsView.test.tsx new file mode 100644 index 000000000..9e23fb49b --- /dev/null +++ b/desktop/ui-next/src/features/stats/UsageStatsView.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { UsageStatsView } from "./UsageStatsView"; + +afterEach(() => { + localStorage.clear(); + delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; +}); + +function stubShell(data: unknown) { + (window as unknown as { __TAURI__?: unknown }).__TAURI__ = { + core: { invoke: (cmd: string) => (cmd === "usage_stats" ? Promise.resolve(data) : Promise.resolve(null)) }, + }; +} + +/** 距今天 offset 天的 usage 行,日期钉为本机时区 */ +function day(offset: number, input: number, output: number, calls: number) { + const d = new Date(); + d.setDate(d.getDate() - offset); + const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + return { date, input_tokens: input, output_tokens: output, calls }; +} + +const sample = (days: ReturnType[]) => ({ + totals: days.reduce( + (acc, d) => ({ input_tokens: acc.input_tokens + d.input_tokens, output_tokens: acc.output_tokens + d.output_tokens, calls: acc.calls + d.calls }), + { input_tokens: 0, output_tokens: 0, calls: 0 }, + ), + days, + models: [{ model: "gpt-5", input_tokens: 100, output_tokens: 50, calls: 3 }], + sessions: [], +}); + +describe("UsageStatsView 按天热力图", () => { + it("渲染 GitHub 风格热力图:标题/图例/单元格网格", async () => { + stubShell(sample([day(0, 100, 50, 5), day(2, 300, 200, 12), day(5, 40, 20, 2)])); + render(); + + expect(await screen.findByText("按天活跃热力图")).toBeDefined(); + expect(screen.getByText("少")).toBeDefined(); + expect(screen.getByText("多")).toBeDefined(); + + // 单元格带 title(以年份开头),数量 = 7 行 × 周数,落在一年窗口内(52~54 周) + const cells = document.querySelectorAll('span[title^="202"]'); + expect(cells.length).toBeGreaterThanOrEqual(7 * 52); + expect(cells.length).toBeLessThanOrEqual(7 * 54); + expect(cells.length % 7).toBe(0); + + // 有 usage 的天映射到带色阶的格子(最活跃的天应为最深档 bg-success) + const hottest = [...cells].find((c) => c.title.includes("调用次数 12")); + expect(hottest).toBeDefined(); + expect(hottest!.className).toContain("bg-success"); + expect(hottest!.className).not.toContain("/"); + }); + + it("无数据的格子为灰底(空态不渲染热力图)", async () => { + stubShell({ totals: { input_tokens: 0, output_tokens: 0, calls: 0 }, days: [], models: [], sessions: [] }); + render(); + // 空态文案,而非热力图 + expect(await screen.findByText(/还没有用量数据/)).toBeDefined(); + expect(screen.queryByText("按天活跃热力图")).toBeNull(); + }); +}); diff --git a/desktop/ui-next/src/features/stats/UsageStatsView.tsx b/desktop/ui-next/src/features/stats/UsageStatsView.tsx new file mode 100644 index 000000000..42f520ad2 --- /dev/null +++ b/desktop/ui-next/src/features/stats/UsageStatsView.tsx @@ -0,0 +1,459 @@ +// 本地会话 token 用量统计面板(侧栏「用量统计」空间主视图)。 +// +// 数据来自壳侧 usage 事件记账(按天/会话/模型聚合),挂载时取一次,之后 +// 手动刷新——统计面板不常驻轮询,与设置页的账号权益面板同理念。 +// +// 布局:汇总卡(今日/近7天/累计)→ 每日趋势(最近 14 天,输入/输出堆叠条) +// → 按模型表 → 按任务/会话表(顶层任务行可展开看该任务的按模型/按天明细, +// 子代理会话以「子代理」标记挂在父任务行下)。 +import { IconChevronDown, IconRefresh } from "@tabler/icons-react"; +import { useCallback, useEffect, useState } from "react"; + +import { useI18n } from "@/lib/i18n"; +import { usageStats, type Bucket, type DayRow, type SessionRow, type UsageStats } from "@/lib/ipc/usageStats"; + +const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +const total = (b: Bucket): number => b.input_tokens + b.output_tokens; + +const fmt = (n: number): string => n.toLocaleString("en-US"); + +/** 浏览器本地时区的今天 `YYYY-MM-DD`,与壳侧 `stats::today()` 同口径 */ +const todayKey = (): string => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +}; + +const zeroBucket: Bucket = { input_tokens: 0, output_tokens: 0, calls: 0 }; + +/** 汇总卡:大号总计 + 「输入 X · 输出 Y · N 次调用」副行 */ +function SumCard({ label, bucket }: { label: string; bucket: Bucket }) { + return ( +
+ {label} + {fmt(total(bucket))} + + ↑{fmt(bucket.input_tokens)} · ↓{fmt(bucket.output_tokens)} · {fmt(bucket.calls)} 次 + +
+ ); +} + +/** 每日趋势:最近 14 天,每天一行,输入(主色)+输出(次色)横向堆叠条 */ +function DailyChart({ days }: { days: UsageStats["days"] }) { + const { t } = useI18n(); + const recent = days.slice(0, 14).reverse(); + if (recent.length === 0) return

{t("stats.noData")}

; + const max = Math.max(...recent.map((d) => total(d)), 1); + return ( +
+ {recent.map((d) => { + const inW = Math.round((d.input_tokens / max) * 100); + const outW = Math.round((d.output_tokens / max) * 100); + return ( +
+ {d.date.slice(5)} +
+
+
+
+ {fmt(total(d))} +
+ ); + })} +
+ + + {t("stats.input")} + {t("stats.output")} + +
+
+ ); +} + +/** GitHub 提交图风格按天热力图:列=周(周日为首),行=周日~周六,色阶=当天调用次数。 + * 有 usage 事件的天按相对强度着色,其余灰底;悬停看当天 calls + input/output tokens。 */ +function UsageHeatmap({ days }: { days: UsageStats["days"] }) { + const { t } = useI18n(); + const byDate = new Map(); + for (const d of days) byDate.set(d.date, d); + + const today = new Date(); + const start = new Date(today); + start.setDate(start.getDate() - 52 * 7); // 一年窗口(GitHub 提交图风格) + start.setDate(start.getDate() - start.getDay()); // 对齐到周日 + + const weeks: { date: Date; bucket: DayRow | null }[][] = []; + const cur = new Date(start); + let maxCalls = 0; + while (cur <= today) { + const week: { date: Date; bucket: DayRow | null }[] = []; + for (let i = 0; i < 7; i++) { + const d = new Date(cur); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const b = byDate.get(key) ?? null; + if (b && b.calls > maxCalls) maxCalls = b.calls; + week.push({ date: d, bucket: b }); + cur.setDate(cur.getDate() + 1); + } + weeks.push(week); + } + const weeksShown = weeks.length; + + const level = (calls: number): number => { + if (calls <= 0 || maxCalls <= 0) return 0; + if (maxCalls === 1) return 4; + const rel = calls / maxCalls; + if (rel > 0.75) return 4; + if (rel > 0.5) return 3; + if (rel > 0.25) return 2; + return 1; + }; + const cellCls = ["bg-base-200", "bg-success/30", "bg-success/50", "bg-success/75", "bg-success"]; + const dowLabel = ["", "Mon", "", "Wed", "", "Fri", ""]; // 0=周日 + + const monthOf = (d: Date) => d.toLocaleDateString("en-US", { month: "short" }); + const monthCells = weeks.map((w) => monthOf(w[0]!.date)); + + return ( +
+

+ {t("stats.heatmap.title")} + {t("stats.heatmap.period", { weeks: weeksShown })} +

+
+
+
+ {dowLabel.map((l, r) => ( + + {l} + + ))} +
+
+
+ {monthCells.map((m, i) => { + const label = m ?? ""; + const prev = monthCells[i - 1] ?? ""; + return ( + 0 && label === prev ? "invisible" : ""}`} + > + {label} + + ); + })} +
+ {[0, 1, 2, 3, 4, 5, 6].map((row) => ( +
+ {weeks.map((w, wi) => { + const cell = w[row]!; + const b = cell.bucket; + const dateLabel = cell.date.toLocaleDateString("zh-CN", { year: "numeric", month: "short", day: "numeric" }); + return ( + + ); + })} +
+ ))} +
+ {t("stats.heatmap.less")} + {cellCls.map((c, i) => ( + + ))} + {t("stats.heatmap.more")} +
+
+
+
+
+ ); +} + +/** 按模型表 */ +function ModelTable({ models }: { models: UsageStats["models"] }) { + const { t } = useI18n(); + if (models.length === 0) return

{t("stats.noData")}

; + return ( +
+ + + + + + + + + + + + {models.map((m) => ( + + + + + + + + ))} + +
{t("stats.model")}{t("stats.input")}{t("stats.output")}{t("stats.card.total")}{t("stats.calls")}
{m.model}{fmt(m.input_tokens)}{fmt(m.output_tokens)}{fmt(total(m))}{fmt(m.calls)}
+
+ ); +} + +/** 单个会话的展开明细:按模型 + 按天 */ +function SessionDetail({ session }: { session: SessionRow }) { + const { t } = useI18n(); + return ( +
+
+
{t("stats.byModel")}
+ + + {session.models.map((m) => ( + + + + + + + ))} + +
{m.model}{fmt(m.input_tokens)}{fmt(m.output_tokens)}{fmt(total(m))}
+
+
+
{t("stats.daily")}
+ + + {session.days.map((d) => ( + + + + + + + ))} + +
{d.date}{fmt(d.input_tokens)}{fmt(d.output_tokens)}{fmt(total(d))}
+
+
+ ); +} + +/** 按任务/会话表:顶层任务行 + 挂在父行下的子代理行,均可展开看明细 */ +function SessionTable({ sessions }: { sessions: UsageStats["sessions"] }) { + const { t } = useI18n(); + const [open, setOpen] = useState>({}); + if (sessions.length === 0) return

{t("stats.noData")}

; + + const childrenByParent = new Map(); + for (const s of sessions) { + if (!s.parent) continue; + const list = childrenByParent.get(s.parent) ?? []; + list.push(s); + childrenByParent.set(s.parent, list); + } + const topLevel = sessions.filter((s) => !s.parent); + const orphaned = sessions.filter((s) => s.parent && !childrenByParent.has(s.parent) && !topLevel.some((t2) => t2.session_id === s.parent)); + + const Row = ({ s, child }: { s: SessionRow; child?: boolean }) => { + const expanded = !!open[s.session_id]; + const kids = child ? [] : (childrenByParent.get(s.session_id) ?? []); + return ( + <> + setOpen((m) => ({ ...m, [s.session_id]: !expanded }))} + > + + + {s.title || s.session_id} + {child && ( + {t("stats.parentTask")} + )} + + {fmt(s.input_tokens)} + {fmt(s.output_tokens)} + {fmt(total(s))} + {fmt(s.calls)} + + {expanded && ( + + + + + + )} + {!child && + kids.map((k) => ( + + ))} + + ); + }; + + return ( +
+ + + + + + + + + + + + {topLevel.map((s) => ( + + ))} + {orphaned.map((s) => ( + + ))} + +
{t("stats.session")}{t("stats.input")}{t("stats.output")}{t("stats.card.total")}{t("stats.calls")}
+
+ ); +} + +export function UsageStatsView() { + const { t } = useI18n(); + const [stats, setStats] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + const load = useCallback(async (silent = false) => { + if (!silent) setBusy(true); + setError(""); + try { + setStats(await usageStats()); + } catch (e) { + setError(errMsg(e)); + } finally { + if (!silent) setBusy(false); + } + }, []); + + // 挂载时取一次;此后每 60s 静默轮询 + 窗口回到前台时刷新,保证跨天/后台 + // 任务消耗能被面板看到,不必手动点刷新 + useEffect(() => { + void load(); + const timer = setInterval(() => void load(true), 60_000); + const onVisible = () => { + if (document.visibilityState === "visible") void load(true); + }; + document.addEventListener("visibilitychange", onVisible); + return () => { + clearInterval(timer); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [load]); + + const byDate = new Map(); + for (const d of stats?.days ?? []) byDate.set(d.date, d); + + // 「今日」按真实日历日取;今天还没有用量时显示 0,而不是回退到最近有数据的那天 + const todayBucket = byDate.get(todayKey()) ?? zeroBucket; + + // 「近 7 天」按最近 7 个自然日累加(含今天),中间空档也算 0 + const last7: Bucket = { ...zeroBucket }; + for (let i = 0; i < 7; i++) { + const d = new Date(); + d.setDate(d.getDate() - i); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const b = byDate.get(key); + if (b) { + last7.input_tokens += b.input_tokens; + last7.output_tokens += b.output_tokens; + last7.calls += b.calls; + } + } + + return ( +
+
+
+
+

{t("stats.title")}

+

{t("stats.subtitle")}

+
+ +
+ + {error ? ( +
+ {t("stats.loadFailed", { reason: error })} +
+ ) : stats === null ? ( +
+ + {t("stats.loading")} +
+ ) : stats.totals.calls === 0 ? ( +
+

{t("stats.empty.title")}

+

{t("stats.empty.detail")}

+
+ ) : ( + <> +
+ + + +
+ +
+

+ {t("stats.daily")} + {t("stats.daily.last14")} +

+ +
+ + + +
+

{t("stats.byModel")}

+
+ +
+
+ +
+

{t("stats.bySession")}

+
+ +
+
+ + )} +
+
+ ); +} diff --git a/desktop/ui-next/src/lib/i18n/en.ts b/desktop/ui-next/src/lib/i18n/en.ts index 6a26ea597..e71540207 100644 --- a/desktop/ui-next/src/lib/i18n/en.ts +++ b/desktop/ui-next/src/lib/i18n/en.ts @@ -21,8 +21,10 @@ export const en: Record = { "rail.local": "Local tasks", "rail.cloud": "Cloud tasks", "rail.chat": "Local chats", + "rail.stats": "Usage", "rail.todo": "Todos", "rail.settings": "Settings", + "sidebar.row.tokens": "Token usage", "sidebar.label": "Sessions", // 搜索行按用户指令撤下(LAYOUT §6),回归时原样复用这几条,故保留 @@ -850,4 +852,31 @@ export const en: Record = { "ctx.paste": "Paste", "ctx.selectAll": "Select all", + "stats.title": "Usage stats", + "stats.subtitle": "Token consumption of local desktop sessions only (recorded from engine usage events); cloud tasks are not included.", + "stats.refresh": "Refresh", + "stats.loading": "Loading…", + "stats.loadFailed": "Failed to load usage stats: {reason}", + "stats.empty.title": "No usage data yet", + "stats.empty.detail": "Once local sessions call models, input/output tokens will accumulate here by day.", + "stats.card.today": "Today", + "stats.card.last7d": "Last 7 days", + "stats.card.total": "Total", + "stats.tokens": "tokens", + "stats.input": "Input", + "stats.output": "Output", + "stats.calls": "Calls", + "stats.daily": "Daily trend", + "stats.daily.last14": "Last 14 days", + "stats.heatmap.title": "Daily activity heatmap", + "stats.heatmap.period": "Last {weeks} weeks", + "stats.heatmap.less": "Less", + "stats.heatmap.more": "More", + "stats.byModel": "By model", + "stats.bySession": "By task/session", + "stats.model": "Model", + "stats.session": "Task/Session", + "stats.parentTask": "Sub-agent (rolled into parent task)", + "stats.noData": "No data", + }; diff --git a/desktop/ui-next/src/lib/i18n/zh.ts b/desktop/ui-next/src/lib/i18n/zh.ts index df7b625bb..02b3f200c 100644 --- a/desktop/ui-next/src/lib/i18n/zh.ts +++ b/desktop/ui-next/src/lib/i18n/zh.ts @@ -25,8 +25,10 @@ export const zh = { "rail.local": "本地任务", "rail.cloud": "云端任务", "rail.chat": "本地会话", + "rail.stats": "用量统计", "rail.todo": "待办", "rail.settings": "设置", + "sidebar.row.tokens": "Token 用量", "sidebar.label": "会话列表", // 搜索行按用户指令撤下(LAYOUT §6),回归时原样复用这几条,故保留 @@ -869,6 +871,33 @@ export const zh = { "ctx.paste": "粘贴", "ctx.selectAll": "全选", + "stats.title": "用量统计", + "stats.subtitle": "仅统计本机桌面会话的 token 消耗(引擎 usage 事件记账);云端任务不在此列。", + "stats.refresh": "刷新", + "stats.loading": "加载中…", + "stats.loadFailed": "用量统计加载失败:{reason}", + "stats.empty.title": "还没有用量数据", + "stats.empty.detail": "本地会话发生模型调用后,这里会按天累计 input/output tokens。", + "stats.card.today": "今日", + "stats.card.last7d": "近 7 天", + "stats.card.total": "累计", + "stats.tokens": "tokens", + "stats.input": "输入", + "stats.output": "输出", + "stats.calls": "调用次数", + "stats.daily": "每日趋势", + "stats.daily.last14": "最近 14 天", + "stats.heatmap.title": "按天活跃热力图", + "stats.heatmap.period": "最近 {weeks} 周", + "stats.heatmap.less": "少", + "stats.heatmap.more": "多", + "stats.byModel": "按模型", + "stats.bySession": "按任务/会话", + "stats.model": "模型", + "stats.session": "任务/会话", + "stats.parentTask": "子代理(归入父任务)", + "stats.noData": "暂无数据", + } as const; export type MessageKey = keyof typeof zh; diff --git a/desktop/ui-next/src/lib/ipc/sessions.ts b/desktop/ui-next/src/lib/ipc/sessions.ts index 86525df1f..4c322071f 100644 --- a/desktop/ui-next/src/lib/ipc/sessions.ts +++ b/desktop/ui-next/src/lib/ipc/sessions.ts @@ -58,6 +58,9 @@ export interface SessionEvent { status?: string; open?: boolean; summary?: string; + /** session-usage:该会话最近一次模型调用的 token 用量 */ + input?: number; + output?: number; } /** ⚠️ 壳内失败要**抛**给调用方。 diff --git a/desktop/ui-next/src/lib/ipc/usageStats.test.ts b/desktop/ui-next/src/lib/ipc/usageStats.test.ts new file mode 100644 index 000000000..9ea002519 --- /dev/null +++ b/desktop/ui-next/src/lib/ipc/usageStats.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { buildSessionUsageMap, sumUsage, type TokenUsage } from "./usageStats"; + +const sess = (session_id: string, parent: string | null, input: number, output: number, calls: number, model: string) => ({ + session_id, + parent, + title: session_id, + input_tokens: input, + output_tokens: output, + calls, + days: [], + models: [{ model, input_tokens: input, output_tokens: output, calls }], +}); + +describe("buildSessionUsageMap", () => { + it("子代理归并进父任务:父任务总量含子会话", () => { + const map = buildSessionUsageMap([ + sess("parent", null, 100, 50, 2, "gpt-5"), + sess("child", "parent", 300, 200, 5, "gpt-5"), + ]); + const parent = map.get("parent")!; + expect(parent.input).toBe(400); // 100 + 300 + expect(parent.output).toBe(250); + expect(parent.calls).toBe(7); + expect(parent.models[0]?.input_tokens).toBe(400); + // 子会话自身也有条目 + expect(map.get("child")!.input).toBe(300); + }); +}); + +describe("sumUsage", () => { + it("多会话合并成合计;无用量返回 null", () => { + const map = new Map([ + ["a", { input: 100, output: 50, calls: 2, models: [] }], + ["b", { input: 300, output: 200, calls: 5, models: [] }], + ]); + const total = sumUsage(["a", "b"], map)!; + expect(total.input).toBe(400); + expect(total.output).toBe(250); + expect(total.calls).toBe(7); + expect(sumUsage(["nope"], map)).toBeNull(); + }); +}); diff --git a/desktop/ui-next/src/lib/ipc/usageStats.ts b/desktop/ui-next/src/lib/ipc/usageStats.ts new file mode 100644 index 000000000..de44ca82e --- /dev/null +++ b/desktop/ui-next/src/lib/ipc/usageStats.ts @@ -0,0 +1,122 @@ +// 本地会话 token 用量统计(壳侧 usage 事件记账,按天/会话/模型聚合)。 +// 浏览器模式无此能力:列表类返回空聚合(静态事实)。 +import { inDesktopShell, invoke } from "./ipc"; + +export interface UsageStats { + totals: Bucket; + /** 按天汇总,倒序 */ + days: (DayRow & Bucket)[]; + /** 按模型汇总,用量倒序 */ + models: ModelRow[]; + /** 按会话汇总,用量倒序;子代理会话带 parent 可归并到父任务 */ + sessions: SessionRow[]; +} + +export interface Bucket { + input_tokens: number; + output_tokens: number; + calls: number; +} + +export interface DayRow extends Bucket { + date: string; +} + +export interface ModelRow extends Bucket { + model: string; +} + +export interface SessionRow extends Bucket { + session_id: string; + title: string; + /** 子代理会话的父会话 id;顶层任务为 null */ + parent: string | null; + days: (DayRow & Bucket)[]; + models: ModelRow[]; +} + +/** 壳内失败会抛(引擎重启时降级成空聚合会把面板洗成"全零")。 */ +export async function usageStats(): Promise { + if (!inDesktopShell()) { + return { totals: { input_tokens: 0, output_tokens: 0, calls: 0 }, days: [], models: [], sessions: [] }; + } + return invoke("usage_stats"); +} + +/** 行尾/头部展示用的 token 用量(子代理已归并进父任务)。 */ +export interface TokenUsage { + input: number; + output: number; + calls: number; + models: { model: string; input_tokens: number; output_tokens: number; calls: number }[]; +} + +interface UsageAgg { + input: number; + output: number; + calls: number; + models: Map; +} + +function addAgg(m: Map, sid: string, s: SessionRow) { + let agg = m.get(sid); + if (!agg) { + agg = { input: 0, output: 0, calls: 0, models: new Map() }; + m.set(sid, agg); + } + agg.input += s.input_tokens; + agg.output += s.output_tokens; + agg.calls += s.calls; + for (const md of s.models) { + const cur = agg.models.get(md.model); + agg.models.set(md.model, { + input: (cur?.input ?? 0) + md.input_tokens, + output: (cur?.output ?? 0) + md.output_tokens, + calls: (cur?.calls ?? 0) + md.calls, + }); + } +} + +const aggToUsage = (a: UsageAgg): TokenUsage => ({ + input: a.input, + output: a.output, + calls: a.calls, + models: a.models.size + ? [...a.models.entries()].map(([model, v]) => ({ model, input_tokens: v.input, output_tokens: v.output, calls: v.calls })) + : [], +}); + +/** 把 usage_stats 快照聚合为「会话 id → 用量」;子代理(parent 非空)归并进父任务。 */ +export function buildSessionUsageMap(sessions: UsageStats["sessions"]): Map { + const m = new Map(); + for (const s of sessions) { + addAgg(m, s.session_id, s); + if (s.parent) addAgg(m, s.parent, s); + } + const out = new Map(); + for (const [sid, agg] of m) out.set(sid, aggToUsage(agg)); + return out; +} + +/** 把一组会话 id 的用量合并成一个合计(文件夹级展示用)。 */ +export function sumUsage(ids: Iterable, map: ReadonlyMap): TokenUsage | null { + const agg: UsageAgg = { input: 0, output: 0, calls: 0, models: new Map() }; + let any = false; + for (const id of ids) { + const u = map.get(id); + if (!u) continue; + any = true; + agg.input += u.input; + agg.output += u.output; + agg.calls += u.calls; + for (const md of u.models) { + const cur = agg.models.get(md.model); + agg.models.set(md.model, { + input: (cur?.input ?? 0) + md.input_tokens, + output: (cur?.output ?? 0) + md.output_tokens, + calls: (cur?.calls ?? 0) + md.calls, + }); + } + } + return any ? aggToUsage(agg) : null; +} diff --git a/desktop/ui-next/src/lib/layoutContract.test.ts b/desktop/ui-next/src/lib/layoutContract.test.ts index 887aeec47..f5747363c 100644 --- a/desktop/ui-next/src/lib/layoutContract.test.ts +++ b/desktop/ui-next/src/lib/layoutContract.test.ts @@ -19,7 +19,7 @@ function sources(dir: string = SRC): string[] { return out; } -const rel = (p: string) => p.slice(SRC.length + 1); +const rel = (p: string) => p.slice(SRC.length + 1).replace(/\\/g, "/"); describe("LAYOUT §6.2 menu 截断铁律", () => { // daisyUI 5 的 `.menu` **和** `.menu :where(li)` 都是 `flex-flow: column wrap` diff --git a/desktop/ui-next/src/lib/protocol/reduce.ts b/desktop/ui-next/src/lib/protocol/reduce.ts index be439bed1..bce56f7f0 100644 --- a/desktop/ui-next/src/lib/protocol/reduce.ts +++ b/desktop/ui-next/src/lib/protocol/reduce.ts @@ -105,17 +105,40 @@ function lastToolIndex(items: readonly ChatItem[], tcId: string): number { /** 追加流式文本:streamKind 未断且末项同类则并入,否则新开一项。 * timestamp 只记在首个分片上(agent/thought 都要块级时间显影)。 */ -function appendStream(s: ChatState, kind: "agent" | "thought", text: string, timestamp?: number): ChatState { +function appendStream( + s: ChatState, + kind: "agent" | "thought", + text: string, + timestamp?: number, + usage?: { input_tokens?: number; output_tokens?: number }, +): ChatState { const items = s.items.slice(); const last = items.at(-1); if (s.streamKind === kind && last && last.kind === kind) { - items[items.length - 1] = { ...last, text: last.text + text }; + // 合并流式分片;usage 只在该消息的收尾分片上出现,并入末项 + items[items.length - 1] = { ...last, text: last.text + text, ...(usage ? { usage } : {}) }; } else { - items.push({ kind, text, ...(timestamp !== undefined ? { timestamp } : {}) }); + items.push({ + kind, + text, + ...(timestamp !== undefined ? { timestamp } : {}), + ...(usage ? { usage } : {}), + }); } return { ...s, items, streamKind: kind }; } +/** session-usage 实时补丁:把用量挂到最后一条助手消息上(壳在 usage 事件 + * 晚于流式帧时单独发 session-usage;回放路径则靠帧内 usage 字段走 appendStream)。 */ +export function patchLastAgentUsage(s: ChatState, input: number, output: number): ChatState { + const items = s.items.slice(); + const last = items.at(-1); + if (last?.kind === "agent") { + items[items.length - 1] = { ...last, usage: { input_tokens: input, output_tokens: output } }; + } + return { ...s, items }; +} + /** 追加非流式项并断流(下一个文本分片必须新开气泡,不得并进旧项)。 */ function pushItem(s: ChatState, item: ChatItem): ChatState { return { ...s, items: [...s.items, item], streamKind: "" }; @@ -378,7 +401,7 @@ function stripSourceSuffix(name: string): string { function reduceAcp(s: ChatState, u: AcpUpdate, timestamp?: number): ChatState { switch (u.sessionUpdate) { case "agent_message_chunk": - return appendStream(s, "agent", toolContentText(u.content), timestamp); + return appendStream(s, "agent", toolContentText(u.content), timestamp, u.usage); case "agent_thought_chunk": return appendStream(s, "thought", toolContentText(u.content), timestamp); case "tool_call": { diff --git a/desktop/ui-next/src/lib/protocol/types.ts b/desktop/ui-next/src/lib/protocol/types.ts index 2f3d77bb4..b84e7d910 100644 --- a/desktop/ui-next/src/lib/protocol/types.ts +++ b/desktop/ui-next/src/lib/protocol/types.ts @@ -25,6 +25,8 @@ export interface AcpUpdate { toolCallId?: string; title?: string; kind?: string; + /** 该条消息的 token 用量(壳侧 usage 事件挂到 agent_message 帧上) */ + usage?: { input_tokens?: number; output_tokens?: number }; status?: string; rawInput?: unknown; rawOutput?: unknown; @@ -133,6 +135,8 @@ export interface AgentItem { text: string; /** 首个流式分片时间(Unix ms;旧记录可缺省) */ timestamp?: number; + /** 本条消息的 token 用量(壳侧 usage 事件挂帧,回放/重启后可见) */ + usage?: { input_tokens?: number; output_tokens?: number }; } /** 思考块(流式聚合成一项)。 */ From 77b6b5edfc40c6cb24bbf6736768a493d3025ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=99=E9=9A=86=E6=B1=9F?= <1209026461@qq.com> Date: Fri, 14 Aug 2026 10:37:27 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(stats):=20token=20=E7=94=A8=E9=87=8F?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF=20+=20=E7=83=AD=E5=8A=9B=E5=9B=BE=20+=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D/=E6=B6=88=E6=81=AF/=E5=A4=A7=E7=BA=B2?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/ui-next/src/app/App.tsx | 20 +++++++++++++------- desktop/ui-next/src/app/shellChrome.test.ts | 8 +++++--- desktop/ui-next/src/app/shellChrome.ts | 5 +++-- desktop/ui-next/src/lib/util/prefs.ts | 2 +- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/desktop/ui-next/src/app/App.tsx b/desktop/ui-next/src/app/App.tsx index ec87d0d65..7e18a0fde 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 { useTodos } from "@/features/todo/useTodos"; import { ResizeEdges } from "@/features/titlebar/ResizeEdges"; import { MacWindowControls, TitleBar } from "@/features/titlebar/TitleBar"; @@ -55,6 +56,7 @@ const SPACE_ICONS: Record = { local: IconFolderCode, cloud: IconCloud, chat: IconMessages, + stats: IconChartBar, }; const NOTICE_TONE: Record = { @@ -129,7 +131,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 (
- {(["local", "cloud", "chat"] as const).map((s) => { + {(["local", "cloud", "chat", "stats"] as const).map((s) => { // 徽标不再只挂本地任务:本地会话同样会停在等待确认上(用户报障 // 2026-08-10「本地会话的等待审批没有计数提示」),两个空间一个口径 const count = waiting[s]; @@ -592,9 +594,9 @@ export function App() { // 任务时窗口切换器里仍挂着上一个本地会话的标题) useEffect(() => { const label = windowContextLabel( - { settingsOpen, creating: !!creating, cloudSpace: space === "cloud" }, + { settingsOpen, creating: !!creating, cloudSpace: space === "cloud", statsSpace: space === "stats" }, cloudTask, - space === "cloud" ? null : current, + space === "cloud" || space === "stats" ? null : current, t, ); setWindowTitle(`${label} — ${t("app.name")}`); @@ -630,6 +632,7 @@ export function App() { local: sessions.filter((m) => m.kind !== "chat" && m.waiting_ask).length, cloud: 0, chat: sessions.filter((m) => m.kind === "chat" && m.waiting_ask).length, + stats: 0, }; // 新建弹窗的最近目录:非 chat、未归档(会话与项目两级),按最近活跃排,项目 key 去重 @@ -735,8 +738,9 @@ export function App() { initialCloudProject={creating.cloudProject} initialText={creating.text} initialFiles={creating.files} - // 侧栏 + 属于当前空间:rail 停在哪个空间,新建就默认开哪个页签 - initialKind={space} + // 侧栏 + 属于当前空间:rail 停在哪个空间,新建就默认开哪个页签。 + // stats 空间没有对应的新建页签,回退默认(本地) + initialKind={space === "stats" ? undefined : space} recentDirs={recentDirs} // 云端页签未连接时的出口:与侧栏云端空态同一个动作(关掉新建、 // 开设置页——设置页初始分区就是「账号」,直达连接入口) @@ -761,6 +765,8 @@ export function App() { setCloudReload((n) => n + 1); }} /> + ) : space === "stats" ? ( + ) : space === "cloud" && cloudTask ? ( { "rail.cloud": "云端任务", "rail.chat": "本地会话", "rail.local": "本地任务", + "rail.stats": "用量统计", "main.welcome.title": "开始一个任务", }) as Record )[k] ?? k) as Parameters[3]; @@ -49,17 +50,18 @@ describe("windowContextLabel(原生窗口标题的上下文)", () => { settingsOpen: false, creating: false, cloudSpace: false, + statsSpace: false, ...over, }); // 优先级 = 主区分支的渲染优先级;此前标题只认 current,开着本地任务切到 - // 设置/新建/云端任务时,窗口切换器里仍挂着上一个本地会话的标题。 - // (待办已出链:2026-08-12 定案清单本体进侧栏,主区没有待办视图。) - it("按主区渲染优先级取:设置 > 新建 > 云端 > 本地会话 > 欢迎页", () => { + // 设置/新建/云端任务时,窗口切换器里仍挂着上一个本地会话的标题 + it("按主区渲染优先级取:设置 > 新建 > 云端 > 用量统计 > 本地会话 > 欢迎页", () => { const cur = { title: "重构登录页", kind: "local" }; expect(windowContextLabel(view({ settingsOpen: true }), null, cur, t)).toBe("设置"); expect(windowContextLabel(view({ creating: true }), null, cur, t)).toBe("新建任务"); expect(windowContextLabel(view({ cloudSpace: true }), { title: "修 CI" }, cur, t)).toBe("修 CI"); + expect(windowContextLabel(view({ statsSpace: true }), null, cur, t)).toBe("用量统计"); expect(windowContextLabel(view(), null, cur, t)).toBe("重构登录页"); expect(windowContextLabel(view(), null, null, t)).toBe("开始一个任务"); }); diff --git a/desktop/ui-next/src/app/shellChrome.ts b/desktop/ui-next/src/app/shellChrome.ts index f0932394f..cb542b72d 100644 --- a/desktop/ui-next/src/app/shellChrome.ts +++ b/desktop/ui-next/src/app/shellChrome.ts @@ -28,10 +28,10 @@ export function isDevtoolsHotkey(e: Pick 本地会话 > 欢迎页。(待办不再入链:2026-08-12 定案清单本体进 * 侧栏,主区没有待办视图了。) */ export function windowContextLabel( - view: { settingsOpen: boolean; creating: boolean; cloudSpace: boolean }, + view: { settingsOpen: boolean; creating: boolean; cloudSpace: boolean; statsSpace: boolean }, cloudTask: { title?: string; summary?: string; content?: string } | null, current: { title?: string; kind?: string } | null, - t: (k: "settings.title" | "create.title" | "rail.cloud" | "rail.chat" | "rail.local" | "main.welcome.title") => string, + t: (k: "settings.title" | "create.title" | "rail.cloud" | "rail.chat" | "rail.local" | "rail.stats" | "main.welcome.title") => string, ): string { if (view.settingsOpen) return t("settings.title"); if (view.creating) return t("create.title"); @@ -39,6 +39,7 @@ export function windowContextLabel( if (!cloudTask) return t("main.welcome.title"); return cloudTask.title || cloudTask.summary || cloudTask.content || t("rail.cloud"); } + if (view.statsSpace) return t("rail.stats"); if (current) return current.title || t(current.kind === "chat" ? "rail.chat" : "rail.local"); return t("main.welcome.title"); } diff --git a/desktop/ui-next/src/lib/util/prefs.ts b/desktop/ui-next/src/lib/util/prefs.ts index 60c0c74e7..67286bb2a 100644 --- a/desktop/ui-next/src/lib/util/prefs.ts +++ b/desktop/ui-next/src/lib/util/prefs.ts @@ -1,7 +1,7 @@ // 本机 UI 偏好(mc.* 命名空间,键名与取值格式 = 旧 UI 契约)。 // 模块顶层不碰 localStorage,只用 getItem/setItem。 -export type Space = "local" | "cloud" | "chat"; +export type Space = "local" | "cloud" | "chat" | "stats"; /** 启动落点恒为本地任务(用户定案 2026-08-09:「应用打开后默认选本地项目, * 不要选云端项目」)。所以**没有 readSpace** ——上次停在哪儿不再决定这次开在