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
11 changes: 11 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
22 changes: 20 additions & 2 deletions desktop/src/driver/fold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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()
}
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
43 changes: 43 additions & 0 deletions desktop/src/driver/normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 字的对话摘要
// (随对话演进改写,后一轮覆盖前一轮),只给顶层会话生成。
Expand Down Expand Up @@ -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 起所有新入口统一为扁平
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 @@ -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
Expand Down Expand Up @@ -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<WslCtx>,
/// 技能库来源(skills.rs):内置(bundle 资源,可缺)与用户目录,
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,
skills_builtin_dir: None,
skills_user_dir: home.join("skills"),
Expand Down Expand Up @@ -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]
Expand Down
11 changes: 11 additions & 0 deletions desktop/src/driver/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
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 @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod driver;
mod native_pet;
mod repo;
mod skills;
mod stats;
mod telemetry;
mod todos;
mod uploads;
Expand Down Expand Up @@ -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,
Expand Down
Loading