Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions desktop/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 测试运行时
Expand Down
1 change: 1 addition & 0 deletions desktop/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ fn main() {
"session_delete",
"session_patch",
"models_list",
"usage_stats",
"session_open",
"session_history",
"session_outline",
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/driver/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,12 @@ pub async fn models_list(host: State<'_, DriverHost>) -> Result<Value, String> {
host.get()?.models_list().await
}

/// 本地会话 token 用量统计(按天/会话/模型聚合,usage 事件记账)。
#[tauri::command]
pub async fn usage_stats(host: State<'_, DriverHost>) -> Result<Value, String> {
Ok(host.get()?.usage_stats())
}

/// 打开会话:返回尾部回放窗口 `{frames, cursor, has_more}`。历史走返回值
/// 而非 `frames:{sid}` 事件——返回值天生有序,不必依赖"监听先于命令"。
#[tauri::command]
Expand Down
31 changes: 31 additions & 0 deletions desktop/src/driver/normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 字的对话摘要
// (随对话演进改写,后一轮覆盖前一轮),只给顶层会话生成。
Expand Down Expand Up @@ -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 起所有新入口统一为扁平
Expand Down
7 changes: 7 additions & 0 deletions desktop/src/driver/ohmy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<WslCtx>,
}
Expand Down
44 changes: 44 additions & 0 deletions desktop/src/driver/ohmy_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,7 @@ fn bare_inner_events(tag: &str) -> (Arc<Inner>, 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)
Expand Down Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions desktop/src/driver/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ impl OhmyDriver {
engine_dir,
chat_workspaces_dir,
perm_persist_path,
stats: crate::stats::UsageStats::new(&cfg_dir),
wsl: wsl_ctx,
});

Expand Down
2 changes: 2 additions & 0 deletions desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ mod driver;
#[cfg(target_os = "windows")]
mod native_pet;
mod repo;
mod stats;
mod telemetry;
mod uploads;
mod util;
Expand Down Expand Up @@ -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,
Expand Down
Loading