From ad8427be93fd4ad178daddc7008bba30e0b47b7a Mon Sep 17 00:00:00 2001 From: coso Date: Wed, 18 Mar 2026 11:22:02 +0800 Subject: [PATCH] fix: include remaining v0.90.0 updates --- .../crates/core/src/database/dao/agent.rs | 224 ++++++- .../core/src/database/dao/orchestrator.rs | 23 +- src-tauri/crates/core/src/database/mod.rs | 31 + .../conversation_statistics_service.rs | 299 ++++------ .../components/AgentThreadTimeline.test.tsx | 101 +++- .../chat/components/AgentThreadTimeline.tsx | 547 +++++++++++++++--- 6 files changed, 939 insertions(+), 286 deletions(-) diff --git a/src-tauri/crates/core/src/database/dao/agent.rs b/src-tauri/crates/core/src/database/dao/agent.rs index 0f5e17117..7b14b07e4 100644 --- a/src-tauri/crates/core/src/database/dao/agent.rs +++ b/src-tauri/crates/core/src/database/dao/agent.rs @@ -5,6 +5,7 @@ use crate::agent::types::{ AgentMessage, AgentSession, ContentPart, FunctionCall, MessageContent, ToolCall, }; +use crate::database::ConversationWindowSummary; use chrono::{Local, TimeZone}; use rusqlite::{params, Connection}; @@ -621,6 +622,54 @@ impl AgentDao { ) } + pub fn summarize_by_model_pattern( + conn: &Connection, + model_pattern: &str, + match_mode: AgentModelPatternMatch, + from_datetime: Option<&str>, + to_datetime: Option<&str>, + ) -> Result { + let sql = format!( + "SELECT + ( + SELECT COUNT(*) + FROM agent_sessions s + WHERE s.model {0} ?1 + AND (?2 IS NULL OR datetime(s.created_at) >= datetime(?2)) + AND (?3 IS NULL OR datetime(s.created_at) < datetime(?3)) + ) AS session_count, + ( + SELECT COUNT(*) + FROM agent_messages m + JOIN agent_sessions s ON s.id = m.session_id + WHERE s.model {0} ?1 + AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2)) + AND (?3 IS NULL OR datetime(m.timestamp) < datetime(?3)) + ) AS message_count, + ( + SELECT COALESCE(SUM(LENGTH(m.content_json)), 0) + FROM agent_messages m + JOIN agent_sessions s ON s.id = m.session_id + WHERE s.model {0} ?1 + AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2)) + AND (?3 IS NULL OR datetime(m.timestamp) < datetime(?3)) + ) AS content_chars", + match_mode.sql_operator() + ); + + conn.query_row( + &sql, + params![model_pattern, from_datetime, to_datetime], + |row| { + Ok(ConversationWindowSummary { + session_count: row.get(0)?, + message_count: row.get(1)?, + content_chars: row.get(2)?, + }) + }, + ) + } + pub fn count_messages_by_model_pattern( conn: &Connection, model_pattern: &str, @@ -956,8 +1005,41 @@ impl AgentDao { #[cfg(test)] mod tests { use crate::agent::types::MessageContent; + use rusqlite::{params, Connection}; - use super::{parse_message_content, parse_tool_calls, JSON_RECURSION_LIMIT}; + use super::{ + parse_message_content, parse_tool_calls, AgentDao, AgentModelPatternMatch, + JSON_RECURSION_LIMIT, + }; + + fn setup_pattern_test_db() -> Connection { + let conn = Connection::open_in_memory().expect("打开内存数据库"); + conn.execute_batch( + " + CREATE TABLE agent_sessions ( + id TEXT PRIMARY KEY, + model TEXT NOT NULL, + system_prompt TEXT, + title TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + working_dir TEXT, + execution_strategy TEXT + ); + CREATE TABLE agent_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content_json TEXT NOT NULL, + timestamp TEXT NOT NULL, + tool_calls_json TEXT, + tool_call_id TEXT + ); + ", + ) + .expect("创建测试 schema"); + conn + } #[test] fn parse_tool_calls_should_compat_with_legacy_missing_type() { @@ -1055,4 +1137,144 @@ mod tests { let parsed = parse_message_content(&payload.to_string()); assert_eq!(parsed.as_text(), ""); } + + #[test] + fn model_pattern_queries_should_filter_general_and_agent_records() { + let conn = setup_pattern_test_db(); + + conn.execute( + "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at) VALUES (?1, ?2, NULL, ?3, ?4, ?5)", + params!["general-1", "general:default", "通用 1", "2026-03-12T10:00:00+08:00", "2026-03-12T10:00:00+08:00"], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at) VALUES (?1, ?2, NULL, ?3, ?4, ?5)", + params!["general-2", "general:helper", "通用 2", "2026-03-14T09:00:00+08:00", "2026-03-14T09:00:00+08:00"], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at) VALUES (?1, ?2, NULL, ?3, ?4, ?5)", + params!["agent-1", "claude-sonnet-4", "Agent 1", "2026-03-12T11:00:00+08:00", "2026-03-12T11:00:00+08:00"], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at) VALUES (?1, ?2, NULL, ?3, ?4, ?5)", + params!["agent-2", "gpt-4.1", "Agent 2", "2026-03-15T09:00:00+08:00", "2026-03-15T09:00:00+08:00"], + ) + .unwrap(); + + let general_json_1 = r#"[{"type":"text","text":"第一条 general 消息"}]"#; + let general_json_2 = r#"[{"type":"text","text":"第二条 general 消息"}]"#; + let agent_json_1 = r#"[{"type":"text","text":"第一条 agent 消息"}]"#; + let agent_json_2 = r#"[{"type":"text","text":"第二条 agent 消息,比第一条更长一些"}]"#; + let agent_json_1_chars = agent_json_1.chars().count() as i64; + let agent_json_2_chars = agent_json_2.chars().count() as i64; + + conn.execute( + "INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)", + params!["general-1", "user", general_json_1, "2026-03-12T10:01:00+08:00"], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)", + params!["general-2", "assistant", general_json_2, "2026-03-14T09:01:00+08:00"], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)", + params!["agent-1", "assistant", agent_json_1, "2026-03-12T11:01:00+08:00"], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)", + params!["agent-2", "assistant", agent_json_2, "2026-03-15T09:01:00+08:00"], + ) + .unwrap(); + + let general_sessions = AgentDao::count_sessions_by_model_pattern( + &conn, + "general:%", + AgentModelPatternMatch::Like, + None, + None, + ) + .unwrap(); + assert_eq!(general_sessions, 2); + + let general_summary = AgentDao::summarize_by_model_pattern( + &conn, + "general:%", + AgentModelPatternMatch::Like, + None, + Some("2026-03-15 00:00:00"), + ) + .unwrap(); + assert_eq!(general_summary.session_count, 2); + assert_eq!(general_summary.message_count, 2); + assert_eq!( + general_summary.content_chars, + (general_json_1.chars().count() + general_json_2.chars().count()) as i64 + ); + + let recent_agent_sessions = AgentDao::count_sessions_by_model_pattern( + &conn, + "general:%", + AgentModelPatternMatch::NotLike, + Some("2026-03-13 00:00:00"), + None, + ) + .unwrap(); + assert_eq!(recent_agent_sessions, 1); + + let general_messages = AgentDao::count_messages_by_model_pattern( + &conn, + "general:%", + AgentModelPatternMatch::Like, + None, + Some("2026-03-15 00:00:00"), + ) + .unwrap(); + assert_eq!(general_messages, 2); + + let agent_chars = AgentDao::sum_message_chars_by_model_pattern( + &conn, + "general:%", + AgentModelPatternMatch::NotLike, + None, + None, + ) + .unwrap(); + assert_eq!(agent_chars, agent_json_1_chars + agent_json_2_chars); + + let agent_usage = AgentDao::list_model_usage_by_model_pattern( + &conn, + "general:%", + AgentModelPatternMatch::NotLike, + None, + 10, + ) + .unwrap(); + assert_eq!(agent_usage.len(), 2); + assert_eq!(agent_usage[0].model, "gpt-4.1"); + assert_eq!(agent_usage[0].conversations, 1); + assert_eq!(agent_usage[0].content_chars, agent_json_2_chars as u64); + assert_eq!(agent_usage[1].model, "claude-sonnet-4"); + assert_eq!(agent_usage[1].conversations, 1); + assert_eq!(agent_usage[1].content_chars, agent_json_1_chars as u64); + + let general_rows = AgentDao::list_message_text_rows_by_model_pattern( + &conn, + "general:%", + AgentModelPatternMatch::Like, + Some("2026-03-13 00:00:00"), + None, + 10, + ) + .unwrap(); + assert_eq!(general_rows.len(), 1); + assert_eq!(general_rows[0].session_id, "general-2"); + assert_eq!(general_rows[0].role, "assistant"); + assert_eq!(general_rows[0].content, "第二条 general 消息"); + assert!(general_rows[0].timestamp_ms > 0); + } } diff --git a/src-tauri/crates/core/src/database/dao/orchestrator.rs b/src-tauri/crates/core/src/database/dao/orchestrator.rs index 24c3ce55c..d92ee1739 100644 --- a/src-tauri/crates/core/src/database/dao/orchestrator.rs +++ b/src-tauri/crates/core/src/database/dao/orchestrator.rs @@ -63,6 +63,12 @@ pub struct ModelUsageAggregate { /// Orchestrator DAO pub struct OrchestratorDao; +fn is_missing_model_usage_stats_table(error: &rusqlite::Error) -> bool { + error + .to_string() + .contains("no such table: model_usage_stats") +} + impl OrchestratorDao { // ======================================================================== // 模型元数据操作 @@ -546,11 +552,14 @@ impl OrchestratorDao { } pub fn has_model_usage_stats(conn: &Connection) -> Result { - let row_count: i64 = conn - .query_row("SELECT COUNT(*) FROM model_usage_stats", [], |row| { + let row_count: i64 = + match conn.query_row("SELECT COUNT(*) FROM model_usage_stats", [], |row| { row.get(0) - }) - .map_err(|e| e.to_string())?; + }) { + Ok(count) => count, + Err(error) if is_missing_model_usage_stats_table(&error) => return Ok(false), + Err(error) => return Err(error.to_string()), + }; Ok(row_count > 0) } @@ -868,4 +877,10 @@ mod tests { assert_eq!(filtered[0].request_count, 1); assert_eq!(filtered[0].total_tokens, 1200); } + + #[test] + fn test_has_model_usage_stats_returns_false_when_table_missing() { + let conn = Connection::open_in_memory().unwrap(); + assert!(!OrchestratorDao::has_model_usage_stats(&conn).unwrap()); + } } diff --git a/src-tauri/crates/core/src/database/mod.rs b/src-tauri/crates/core/src/database/mod.rs index 0fea6f0a7..15a269f13 100644 --- a/src-tauri/crates/core/src/database/mod.rs +++ b/src-tauri/crates/core/src/database/mod.rs @@ -23,6 +23,23 @@ use std::sync::{Arc, Mutex}; /// - 如果调用方已经拿到了 `&Connection`,优先沿用该连接向下传递。 pub type DbConnection = Arc>; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ConversationWindowSummary { + pub session_count: i64, + pub message_count: i64, + pub content_chars: i64, +} + +impl ConversationWindowSummary { + pub fn merge(self, other: Self) -> Self { + Self { + session_count: self.session_count + other.session_count, + message_count: self.message_count + other.message_count, + content_chars: self.content_chars + other.content_chars, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PendingGeneralMessage { pub id: String, @@ -128,6 +145,18 @@ pub fn sum_pending_general_message_chars( }) } +pub fn summarize_pending_general( + conn: &Connection, + from_timestamp_ms: Option, + to_timestamp_ms: Option, +) -> Result { + Ok(ConversationWindowSummary { + session_count: count_pending_general_sessions(conn, from_timestamp_ms, to_timestamp_ms)?, + message_count: count_pending_general_messages(conn, from_timestamp_ms, to_timestamp_ms)?, + content_chars: sum_pending_general_message_chars(conn, from_timestamp_ms, to_timestamp_ms)?, + }) +} + /// 获取数据库连接锁(自动处理 poisoned lock) pub fn lock_db(db: &DbConnection) -> Result, String> { match db.lock() { @@ -201,11 +230,13 @@ mod tests { let session_count = count_pending_general_sessions(&conn, None, None).unwrap(); let message_count = count_pending_general_messages(&conn, None, None).unwrap(); let char_count = sum_pending_general_message_chars(&conn, None, None).unwrap(); + let summary = summarize_pending_general(&conn, None, None).unwrap(); assert!(messages.is_empty()); assert!(session_messages.is_empty()); assert_eq!(session_count, 0); assert_eq!(message_count, 0); assert_eq!(char_count, 0); + assert_eq!(summary, ConversationWindowSummary::default()); } } diff --git a/src-tauri/src/services/conversation_statistics_service.rs b/src-tauri/src/services/conversation_statistics_service.rs index 6ea072543..3256e0df9 100644 --- a/src-tauri/src/services/conversation_statistics_service.rs +++ b/src-tauri/src/services/conversation_statistics_service.rs @@ -4,10 +4,7 @@ use crate::database::dao::agent::{AgentDao, AgentModelPatternMatch}; use crate::database::dao::orchestrator::OrchestratorDao; -use crate::database::{ - count_pending_general_messages, count_pending_general_sessions, - sum_pending_general_message_chars, -}; +use crate::database::{summarize_pending_general, ConversationWindowSummary}; use chrono::{DateTime, Datelike, Duration, Local, TimeZone, Timelike}; use rusqlite::Connection; use serde::{Deserialize, Serialize}; @@ -73,6 +70,13 @@ struct ConversationStats { today_messages: u32, } +#[derive(Debug, Clone, Copy, Default)] +struct ConversationWindowTriplet { + total: ConversationWindowSummary, + monthly: ConversationWindowSummary, + today: ConversationWindowSummary, +} + #[derive(Debug, Clone, Copy, Default)] struct TokenStats { total_tokens: u64, @@ -98,11 +102,12 @@ pub fn get_usage_stats_from_db( let today_start = start_of_day(now); let month_start = start_of_month(now); - // 查询通用对话统计 - let general_stats = query_general_chat_stats(conn, &today_start, &month_start)?; - - // 查询 Agent 对话统计 - let agent_stats = query_agent_chat_stats(conn, &today_start, &month_start)?; + let general_windows = + build_window_triplet(conn, summarize_general_window, &today_start, &month_start)?; + let agent_windows = + build_window_triplet(conn, summarize_agent_window, &today_start, &month_start)?; + let general_stats = build_conversation_stats(general_windows); + let agent_stats = build_conversation_stats(agent_windows); // 合并统计 let total_conversations = general_stats.total_conversations + agent_stats.total_conversations; @@ -116,7 +121,13 @@ pub fn get_usage_stats_from_db( let monthly_messages = general_stats.monthly_messages + agent_stats.monthly_messages; // Token 优先使用真实统计表;无记录时回退到基于消息内容长度的估算 - let token_stats = query_token_stats(conn, &today_start, &month_start)?; + let token_stats = query_token_stats( + conn, + &today_start, + &month_start, + general_windows, + agent_windows, + )?; let total_tokens = token_stats.total_tokens; let monthly_tokens = token_stats.monthly_tokens; let today_tokens = token_stats.today_tokens; @@ -204,191 +215,122 @@ fn format_sqlite_datetime(timestamp_ms: i64) -> String { .unwrap_or_else(|| Local::now().format("%Y-%m-%d %H:%M:%S").to_string()) } -fn query_general_session_count( +fn summarize_unified_window( conn: &Connection, + match_mode: AgentModelPatternMatch, from_timestamp_ms: Option, to_timestamp_ms: Option, -) -> Result { +) -> Result { let from_text = from_timestamp_ms.map(format_sqlite_datetime); let to_text = to_timestamp_ms.map(format_sqlite_datetime); - let unified_count = AgentDao::count_sessions_by_model_pattern( + AgentDao::summarize_by_model_pattern( conn, GENERAL_MODE_PATTERN, + match_mode, + from_text.as_deref(), + to_text.as_deref(), + ) + .map_err(|e| match match_mode { + AgentModelPatternMatch::Like => format!("查询 unified general 摘要失败: {e}"), + AgentModelPatternMatch::NotLike => format!("查询非通用 unified 摘要失败: {e}"), + }) +} + +fn summarize_general_window( + conn: &Connection, + from_timestamp_ms: Option, + to_timestamp_ms: Option, +) -> Result { + let unified = summarize_unified_window( + conn, AgentModelPatternMatch::Like, - from_text.as_deref(), - to_text.as_deref(), - ) - .map_err(|e| format!("查询 unified general 会话数失败: {e}"))?; + from_timestamp_ms, + to_timestamp_ms, + )?; + let pending = summarize_pending_general(conn, from_timestamp_ms, to_timestamp_ms) + .map_err(|e| format!("查询待迁移 general 摘要失败: {e}"))?; - let pending_count = count_pending_general_sessions(conn, from_timestamp_ms, to_timestamp_ms) - .map_err(|e| format!("查询待迁移 general 会话数失败: {e}"))?; - - Ok(unified_count + pending_count) + Ok(unified.merge(pending)) } -fn query_general_message_count( +fn summarize_agent_window( conn: &Connection, from_timestamp_ms: Option, to_timestamp_ms: Option, -) -> Result { - let from_text = from_timestamp_ms.map(format_sqlite_datetime); - let to_text = to_timestamp_ms.map(format_sqlite_datetime); - - let unified_count = AgentDao::count_messages_by_model_pattern( +) -> Result { + summarize_unified_window( conn, - GENERAL_MODE_PATTERN, - AgentModelPatternMatch::Like, - from_text.as_deref(), - to_text.as_deref(), - ) - .map_err(|e| format!("查询 unified general 消息数失败: {e}"))?; - - let pending_count = count_pending_general_messages(conn, from_timestamp_ms, to_timestamp_ms) - .map_err(|e| format!("查询待迁移 general 消息数失败: {e}"))?; - - Ok(unified_count + pending_count) -} - -fn sum_general_message_chars( - conn: &Connection, - from_timestamp_ms: Option, - to_timestamp_ms: Option, -) -> Result { - let from_text = from_timestamp_ms.map(format_sqlite_datetime); - let to_text = to_timestamp_ms.map(format_sqlite_datetime); - - let unified_chars = AgentDao::sum_message_chars_by_model_pattern( - conn, - GENERAL_MODE_PATTERN, - AgentModelPatternMatch::Like, - from_text.as_deref(), - to_text.as_deref(), - ) - .map_err(|e| format!("估算 unified general Token 失败: {e}"))?; - - let pending_chars = sum_pending_general_message_chars(conn, from_timestamp_ms, to_timestamp_ms) - .map_err(|e| format!("估算待迁移 general Token 失败: {e}"))?; - - Ok(unified_chars + pending_chars) -} - -fn query_non_general_session_count( - conn: &Connection, - from_timestamp_ms: Option, - to_timestamp_ms: Option, -) -> Result { - let from_text = from_timestamp_ms.map(format_sqlite_datetime); - let to_text = to_timestamp_ms.map(format_sqlite_datetime); - - AgentDao::count_sessions_by_model_pattern( - conn, - GENERAL_MODE_PATTERN, AgentModelPatternMatch::NotLike, - from_text.as_deref(), - to_text.as_deref(), + from_timestamp_ms, + to_timestamp_ms, ) - .map_err(|e| format!("查询非通用 unified 会话数失败: {e}")) } -fn query_non_general_message_count( +fn build_window_triplet( conn: &Connection, - from_timestamp_ms: Option, - to_timestamp_ms: Option, -) -> Result { - let from_text = from_timestamp_ms.map(format_sqlite_datetime); - let to_text = to_timestamp_ms.map(format_sqlite_datetime); + summarize_window: impl Fn( + &Connection, + Option, + Option, + ) -> Result, + today_start: &DateTime, + month_start: &DateTime, +) -> Result { + let today_ts = today_start.timestamp_millis(); + let month_ts = month_start.timestamp_millis(); - AgentDao::count_messages_by_model_pattern( - conn, - GENERAL_MODE_PATTERN, - AgentModelPatternMatch::NotLike, - from_text.as_deref(), - to_text.as_deref(), - ) - .map_err(|e| format!("查询非通用 unified 消息数失败: {e}")) + Ok(ConversationWindowTriplet { + total: summarize_window(conn, None, None)?, + monthly: summarize_window(conn, Some(month_ts), None)?, + today: summarize_window(conn, Some(today_ts), None)?, + }) } -fn sum_non_general_message_chars( - conn: &Connection, - from_timestamp_ms: Option, - to_timestamp_ms: Option, -) -> Result { - let from_text = from_timestamp_ms.map(format_sqlite_datetime); - let to_text = to_timestamp_ms.map(format_sqlite_datetime); - - AgentDao::sum_message_chars_by_model_pattern( - conn, - GENERAL_MODE_PATTERN, - AgentModelPatternMatch::NotLike, - from_text.as_deref(), - to_text.as_deref(), - ) - .map_err(|e| format!("估算非通用 unified Token 失败: {e}")) +fn build_conversation_stats(windows: ConversationWindowTriplet) -> ConversationStats { + ConversationStats { + total_conversations: clamp_i64_to_u32(windows.total.session_count), + total_messages: clamp_i64_to_u32(windows.total.message_count), + monthly_conversations: clamp_i64_to_u32(windows.monthly.session_count), + monthly_messages: clamp_i64_to_u32(windows.monthly.message_count), + today_conversations: clamp_i64_to_u32(windows.today.session_count), + today_messages: clamp_i64_to_u32(windows.today.message_count), + } } -/// 查询通用对话统计 fn query_general_chat_stats( conn: &Connection, today_start: &DateTime, month_start: &DateTime, ) -> Result { - let today_ts = today_start.timestamp_millis(); - let month_ts = month_start.timestamp_millis(); - - let today_conversations = query_general_session_count(conn, Some(today_ts), None)?; - let today_messages = query_general_message_count(conn, Some(today_ts), None)?; - let monthly_conversations = query_general_session_count(conn, Some(month_ts), None)?; - let monthly_messages = query_general_message_count(conn, Some(month_ts), None)?; - let total_conversations = query_general_session_count(conn, None, None)?; - let total_messages = query_general_message_count(conn, None, None)?; - - Ok(ConversationStats { - total_conversations: clamp_i64_to_u32(total_conversations), - total_messages: clamp_i64_to_u32(total_messages), - monthly_conversations: clamp_i64_to_u32(monthly_conversations), - monthly_messages: clamp_i64_to_u32(monthly_messages), - today_conversations: clamp_i64_to_u32(today_conversations), - today_messages: clamp_i64_to_u32(today_messages), - }) + build_window_triplet(conn, summarize_general_window, today_start, month_start) + .map(build_conversation_stats) } -/// 查询 Agent 对话统计 fn query_agent_chat_stats( conn: &Connection, today_start: &DateTime, month_start: &DateTime, ) -> Result { - let today_ts = today_start.timestamp_millis(); - let month_ts = month_start.timestamp_millis(); - - let today_conversations = query_non_general_session_count(conn, Some(today_ts), None)?; - let today_messages = query_non_general_message_count(conn, Some(today_ts), None)?; - let monthly_conversations = query_non_general_session_count(conn, Some(month_ts), None)?; - let monthly_messages = query_non_general_message_count(conn, Some(month_ts), None)?; - let total_conversations = query_non_general_session_count(conn, None, None)?; - let total_messages = query_non_general_message_count(conn, None, None)?; - - Ok(ConversationStats { - total_conversations: clamp_i64_to_u32(total_conversations), - total_messages: clamp_i64_to_u32(total_messages), - monthly_conversations: clamp_i64_to_u32(monthly_conversations), - monthly_messages: clamp_i64_to_u32(monthly_messages), - today_conversations: clamp_i64_to_u32(today_conversations), - today_messages: clamp_i64_to_u32(today_messages), - }) + build_window_triplet(conn, summarize_agent_window, today_start, month_start) + .map(build_conversation_stats) } fn query_token_stats( conn: &Connection, today_start: &DateTime, month_start: &DateTime, + general_windows: ConversationWindowTriplet, + agent_windows: ConversationWindowTriplet, ) -> Result { if let Some(actual_tokens) = query_model_usage_table_tokens(conn, today_start, month_start)? { return Ok(actual_tokens); } - query_estimated_tokens_from_messages(conn, today_start, month_start) + Ok(query_estimated_tokens_from_windows( + general_windows, + agent_windows, + )) } fn query_model_usage_table_tokens( @@ -421,27 +363,21 @@ fn query_model_usage_table_tokens( })) } -fn query_estimated_tokens_from_messages( - conn: &Connection, - today_start: &DateTime, - month_start: &DateTime, -) -> Result { - let today_ts = today_start.timestamp_millis(); - let month_ts = month_start.timestamp_millis(); - - let general_total_chars = sum_general_message_chars(conn, None, None)?; - let general_monthly_chars = sum_general_message_chars(conn, Some(month_ts), None)?; - let general_today_chars = sum_general_message_chars(conn, Some(today_ts), None)?; - - let agent_total_chars = sum_non_general_message_chars(conn, None, None)?; - let agent_monthly_chars = sum_non_general_message_chars(conn, Some(month_ts), None)?; - let agent_today_chars = sum_non_general_message_chars(conn, Some(today_ts), None)?; - - Ok(TokenStats { - total_tokens: chars_to_estimated_tokens(general_total_chars + agent_total_chars), - monthly_tokens: chars_to_estimated_tokens(general_monthly_chars + agent_monthly_chars), - today_tokens: chars_to_estimated_tokens(general_today_chars + agent_today_chars), - }) +fn query_estimated_tokens_from_windows( + general_windows: ConversationWindowTriplet, + agent_windows: ConversationWindowTriplet, +) -> TokenStats { + TokenStats { + total_tokens: chars_to_estimated_tokens( + general_windows.total.content_chars + agent_windows.total.content_chars, + ), + monthly_tokens: chars_to_estimated_tokens( + general_windows.monthly.content_chars + agent_windows.monthly.content_chars, + ), + today_tokens: chars_to_estimated_tokens( + general_windows.today.content_chars + agent_windows.today.content_chars, + ), + } } /// 获取模型使用排行 @@ -463,6 +399,12 @@ fn query_model_usage_from_stats_table( conn: &Connection, range_start: Option>, ) -> Result, String> { + if !OrchestratorDao::has_model_usage_stats(conn) + .map_err(|e| format!("检查模型统计表失败: {e}"))? + { + return Ok(Vec::new()); + } + let start_key = range_start.map(|start| start.format("%Y-%m-%d").to_string()); let rows = OrchestratorDao::list_model_usage_aggregates(conn, start_key.as_deref(), 20) .map_err(|e| format!("执行模型统计查询失败: {e}"))?; @@ -559,15 +501,12 @@ pub fn get_daily_usage_trends_from_db( let day_start_ts = day_start.timestamp_millis(); let day_end_ts = day_end.timestamp_millis(); let day_key = day_start.format("%Y-%m-%d").to_string(); + let general_window = summarize_general_window(conn, Some(day_start_ts), Some(day_end_ts)) + .map_err(|e| format!("查询通用日摘要失败: {e}"))?; + let agent_window = summarize_agent_window(conn, Some(day_start_ts), Some(day_end_ts)) + .map_err(|e| format!("查询 Agent 日摘要失败: {e}"))?; - let conversations = query_general_session_count(conn, Some(day_start_ts), Some(day_end_ts)) - .map_err(|e| format!("查询通用会话日统计失败: {e}"))?; - - let agent_conversations = - query_non_general_session_count(conn, Some(day_start_ts), Some(day_end_ts)) - .map_err(|e| format!("查询 Agent 会话日统计失败: {e}"))?; - - let total_conversations = conversations + agent_conversations; + let total_conversations = general_window.session_count + agent_window.session_count; let tokens = if use_actual_tokens { let day_tokens = OrchestratorDao::get_model_usage_tokens_on(conn, &day_key) @@ -575,15 +514,7 @@ pub fn get_daily_usage_trends_from_db( clamp_i64_to_u64(day_tokens) } else { - let general_chars = - sum_general_message_chars(conn, Some(day_start_ts), Some(day_end_ts)) - .map_err(|e| format!("估算通用消息日 Token 失败: {e}"))?; - - let agent_chars = - sum_non_general_message_chars(conn, Some(day_start_ts), Some(day_end_ts)) - .map_err(|e| format!("估算 Agent 消息日 Token 失败: {e}"))?; - - chars_to_estimated_tokens(general_chars + agent_chars) + chars_to_estimated_tokens(general_window.content_chars + agent_window.content_chars) }; daily_usage.push(DailyUsage { diff --git a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx index 3740cc670..83b4d25b9 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx @@ -152,6 +152,19 @@ function renderTimeline( return container; } +function clickTimelineToggle(container: HTMLElement) { + const button = container.querySelector( + '[data-testid="agent-thread-details-toggle"]', + ); + if (!button) { + throw new Error("未找到执行细节切换按钮"); + } + + act(() => { + button.click(); + }); +} + describe("AgentThreadTimeline", () => { it("应渲染本回合概览与按时序组织的分组块", () => { const items: AgentThreadItem[] = [ @@ -194,14 +207,24 @@ describe("AgentThreadTimeline", () => { const container = renderTimeline(items, { isCurrentTurn: true }); + expect( + container.querySelector('[data-testid="agent-thread-details-inline-text"]') + ?.textContent, + ).toContain("已完成页面检查"); + expect( + container.querySelector('[data-testid="agent-thread-flow"]'), + ).toBeNull(); + + clickTimelineToggle(container); + expect( container.querySelector('[data-testid="agent-thread-summary"]'), ).not.toBeNull(); + expect(container.textContent).toContain("本回合摘要"); expect( container.querySelector('[data-testid="agent-thread-summary-shell"]'), ).not.toBeNull(); - expect(container.textContent).toContain("本回合摘要"); - expect(container.textContent).toContain("4 段流程"); + expect( container.querySelector('[data-testid="agent-thread-goal"]')?.textContent, ).toContain("请检查并发布文章"); @@ -236,6 +259,13 @@ describe("AgentThreadTimeline", () => { ]; const container = renderTimeline(items); + + expect( + container.querySelector('[data-testid="agent-thread-flow"]'), + ).toBeNull(); + + clickTimelineToggle(container); + const approvalGroup = container.querySelector( '[data-testid="agent-thread-block:1:approval"]', ); @@ -283,6 +313,7 @@ describe("AgentThreadTimeline", () => { ]; const container = renderTimeline(items); + clickTimelineToggle(container); const blockIds = Array.from( container.querySelectorAll( "details[data-testid^='agent-thread-block:']", @@ -298,6 +329,44 @@ describe("AgentThreadTimeline", () => { ]); }); + it("完成后折叠条仍应保留最近的思考过程", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("browser-1", 1), + type: "tool_call", + tool_name: "browser_navigate", + arguments: { url: "https://example.com" }, + }, + { + ...createBaseItem("plan-1", 2), + type: "plan", + text: "先梳理问题背景,再给出三套方案。", + }, + { + ...createBaseItem("browser-2", 3), + type: "tool_call", + tool_name: "browser_click", + arguments: { selector: "#submit" }, + }, + ]; + + const container = renderTimeline(items, { + isCurrentTurn: true, + turn: { + status: "completed", + }, + }); + + expect( + container.querySelector('[data-testid="agent-thread-details-inline-text"]') + ?.textContent, + ).toContain("阶段 02"); + expect( + container.querySelector('[data-testid="agent-thread-details-inline-text"]') + ?.textContent, + ).toContain("先梳理问题背景"); + }); + it("运行中的块应被高亮,已完成块应降噪", () => { const items: AgentThreadItem[] = [ { @@ -323,6 +392,17 @@ describe("AgentThreadTimeline", () => { ]; const container = renderTimeline(items, { isCurrentTurn: true }); + + expect( + container.querySelector('[data-testid="agent-thread-details-inline-icon"]') + ?.getAttribute("data-state"), + ).toBe("running"); + expect( + container.querySelector('[data-testid="agent-thread-details-inline-text"]') + ?.textContent, + ).toContain("Mac mini 最新价格"); + + clickTimelineToggle(container); const browserBlock = container.querySelector( '[data-testid="agent-thread-block:1:browser"]', ); @@ -372,6 +452,9 @@ describe("AgentThreadTimeline", () => { expect(container.textContent).toContain("待继续"); expect(container.textContent).toContain("完成登录"); expect(container.textContent).not.toContain("已中断"); + + clickTimelineToggle(container); + expect( container .querySelector('[data-testid="agent-thread-block:1:browser"]') @@ -414,6 +497,12 @@ describe("AgentThreadTimeline", () => { }, }); + expect( + container.querySelector('[data-testid="agent-thread-flow"]'), + ).toBeNull(); + + clickTimelineToggle(container); + expect( container .querySelector('[data-testid="agent-thread-block:1:thinking"]') @@ -461,6 +550,12 @@ describe("AgentThreadTimeline", () => { }, }); + expect( + container.querySelector('[data-testid="agent-thread-flow"]'), + ).toBeNull(); + + clickTimelineToggle(container); + expect( container.querySelector('[data-testid="timeline-a2ui-card"]'), ).not.toBeNull(); @@ -492,6 +587,8 @@ describe("AgentThreadTimeline", () => { }, }); + clickTimelineToggle(container); + expect( container.querySelector('[data-testid="timeline-a2ui-card"]'), ).not.toBeNull(); diff --git a/src/components/agent/chat/components/AgentThreadTimeline.tsx b/src/components/agent/chat/components/AgentThreadTimeline.tsx index 7e8a2e5ab..22d22aa8a 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.tsx @@ -1,7 +1,8 @@ -import React, { useMemo } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, Bot, + CheckCircle2, ChevronDown, Clock3, FileText, @@ -15,6 +16,11 @@ import { } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import type { ToolCallState } from "@/lib/api/agentStream"; import type { ActionRequired, @@ -31,6 +37,7 @@ import { isActionRequestA2UICompatible } from "../utils/actionRequestA2UI"; import { parseAIResponse } from "@/components/content-creator/a2ui/parser"; import type { A2UIResponse } from "@/components/content-creator/a2ui/types"; import { TIMELINE_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets"; +import { cn } from "@/lib/utils"; import { MarkdownRenderer } from "./MarkdownRenderer"; import { ActionRequestA2UIPreviewCard } from "./ActionRequestA2UIPreviewCard"; import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard"; @@ -54,6 +61,13 @@ interface TurnStatusMeta { overviewText: string; } +type TimelineCompactTone = + | "running" + | "waiting" + | "failed" + | "paused" + | "done"; + function shortenInlineText( value: string | undefined | null, maxLength = 72, @@ -375,6 +389,7 @@ function resolveTurnStatusMeta(params: { actionableCount, } = params; const pendingAction = findLatestPendingAction(actionRequests); + const hasInProgressItem = items.some((item) => item.status === "in_progress"); if (pendingAction?.uiKind === "browser_preflight") { const phase = pendingAction.browserPrepState || "idle"; @@ -462,6 +477,14 @@ function resolveTurnStatusMeta(params: { }; case "completed": default: + if (hasInProgressItem) { + return { + label: "执行中", + badgeVariant: "secondary", + overviewText: resolveOverviewText(turn, summaryText, actionableCount), + }; + } + return { label: "已完成", badgeVariant: "outline", @@ -890,6 +913,233 @@ function resolveExpandedBlockIndexes(params: { return expanded; } +function resolveTimelineDetailsDefaultExpanded(params: { + turn: AgentThreadTurn; + items: AgentThreadItem[]; + actionRequests?: ActionRequired[]; + isCurrentTurn: boolean; +}): boolean { + void params; + return false; +} + +function resolveCompactTone(params: { + turn: AgentThreadTurn; + turnStatusMeta: TurnStatusMeta; +}): TimelineCompactTone { + const { turn, turnStatusMeta } = params; + + if (turn.status === "failed") { + return "failed"; + } + + if (turn.status === "running" || turnStatusMeta.label === "执行中") { + return "running"; + } + + if ( + turnStatusMeta.label === "待处理" || + turnStatusMeta.label === "待继续" || + turnStatusMeta.label === "连接浏览器" || + turnStatusMeta.label === "浏览器未就绪" + ) { + return "waiting"; + } + + if (turn.status === "aborted") { + return "paused"; + } + + return "done"; +} + +function resolveFocusInlineText( + block: AgentThreadOrderedBlock | null, +): string | null { + if (!block) { + return null; + } + + if (isCompactTechnicalBlock(block)) { + return resolveCompactTechnicalSummary(block); + } + + const preview = block.previewLines.find((line) => line.trim().length > 0); + if (preview) { + return preview; + } + + return block.title.trim() || null; +} + +function resolveLatestThinkingPreview( + blocks: AgentThreadOrderedBlock[], +): { + text: string | null; + stageLabel: string | null; +} { + for (let index = blocks.length - 1; index >= 0; index -= 1) { + const block = blocks[index]; + if (block?.kind !== "thinking") { + continue; + } + + const latestPreview = [...block.previewLines] + .reverse() + .find((line) => line.trim().length > 0); + + return { + text: latestPreview || resolveFocusInlineText(block), + stageLabel: `阶段 ${String(index + 1).padStart(2, "0")}`, + }; + } + + return { + text: null, + stageLabel: null, + }; +} + +function resolveCollapsedProcessText(params: { + compactTone: TimelineCompactTone; + displayModelSummaryText: string | null; + flowBlockCount: number; + focusBlock: AgentThreadOrderedBlock | null; + focusBlockStageLabel: string | null; + orderedBlocks: AgentThreadOrderedBlock[]; + promptPreview: string | null; + turnStatusMeta: TurnStatusMeta; +}): string { + const { + compactTone, + displayModelSummaryText, + flowBlockCount, + focusBlock, + focusBlockStageLabel, + orderedBlocks, + promptPreview, + turnStatusMeta, + } = params; + const focusInlineText = resolveFocusInlineText(focusBlock); + const latestThinkingPreview = resolveLatestThinkingPreview(orderedBlocks); + + const detail = + compactTone === "running" + ? focusInlineText || + turnStatusMeta.overviewText || + displayModelSummaryText || + promptPreview + : compactTone === "waiting" || + compactTone === "failed" || + compactTone === "paused" + ? turnStatusMeta.overviewText || + focusInlineText || + displayModelSummaryText || + promptPreview + : latestThinkingPreview.text || + focusInlineText || + displayModelSummaryText || + turnStatusMeta.overviewText || + promptPreview; + + const stageLabel = + compactTone === "running" + ? flowBlockCount > 1 + ? focusBlockStageLabel + : null + : compactTone === "done" && latestThinkingPreview.text && flowBlockCount > 1 + ? latestThinkingPreview.stageLabel + : null; + + const segments = [turnStatusMeta.label]; + if (stageLabel) { + segments.push(stageLabel); + } + + const shortDetail = shortenInlineText( + detail || "执行轨迹已收起,点击查看完整过程。", + compactTone === "running" ? 88 : 78, + ); + if (shortDetail && shortDetail !== turnStatusMeta.label) { + segments.push(shortDetail); + } + + return segments.join(" · "); +} + +function TimelineCompactStatusIcon({ + tone, +}: { + tone: TimelineCompactTone; +}) { + if (tone === "running") { + return ( + + + + + + + ); + } + + if (tone === "waiting") { + return ( + + + + ); + } + + if (tone === "failed") { + return ( + + + + ); + } + + if (tone === "paused") { + return ( + + + + ); + } + + return ( + + + + ); +} + function TimelineBlockCard({ block, index, @@ -1095,123 +1345,230 @@ export const AgentThreadTimeline: React.FC = ({ summaryText: displayModel.summaryText, actionableCount, }); + const defaultDetailsExpanded = useMemo( + () => + resolveTimelineDetailsDefaultExpanded({ + turn, + items: visibleItems, + actionRequests, + isCurrentTurn, + }), + [actionRequests, isCurrentTurn, turn, visibleItems], + ); + const [detailsExpanded, setDetailsExpanded] = useState(defaultDetailsExpanded); + const lastTurnIdRef = useRef(turn.id); + + useEffect(() => { + if (lastTurnIdRef.current !== turn.id) { + lastTurnIdRef.current = turn.id; + setDetailsExpanded(defaultDetailsExpanded); + return; + } + + if (defaultDetailsExpanded) { + setDetailsExpanded(true); + } + }, [defaultDetailsExpanded, turn.id]); if (visibleItems.length === 0) { return null; } + const toggleActionLabel = detailsExpanded + ? "收起执行细节" + : isCurrentTurn + ? "查看当前回合执行细节" + : "展开回合执行细节"; + const compactTone = resolveCompactTone({ turn, turnStatusMeta }); + const collapsedProcessText = resolveCollapsedProcessText({ + compactTone, + displayModelSummaryText: displayModel.summaryText, + flowBlockCount, + focusBlock, + focusBlockStageLabel, + orderedBlocks: displayModel.orderedBlocks, + promptPreview, + turnStatusMeta, + }); + const showRunningAccent = compactTone === "running"; + return ( -
-
-
- -
-
+ + + + + +
+
+
+ {turnStatusMeta.overviewText}
- {flowBlockCount} 段流程 - {isCurrentTurn ? 当前回合 : null} - - {turnStatusMeta.label} - -
+
{formatTimestamp(turn.started_at) || "刚刚"}
-
- {turnStatusMeta.overviewText} -
- - {promptPreview || focusBlock ? ( -
- {promptPreview ? ( -
-
- 用户目标 -
-
- {promptPreview} -
+
+
+ +
+
+
+
+ 本回合摘要
- ) : null} - - {focusBlock ? ( -
{flowBlockCount} 段流程 + {isCurrentTurn ? 当前回合 : null} + -
- 当前聚焦 -
-
- {focusBlockStageLabel ? ( - {focusBlockStageLabel} - ) : null} - - {focusBlock.title} - -
- {focusBlock.previewLines[0] ? ( -
- {focusBlock.previewLines[0]} + {turnStatusMeta.label} + +
+ + {formatTimestamp(turn.started_at) || "刚刚"} +
+
+ +
+ {turnStatusMeta.overviewText} +
+ + {promptPreview || focusBlock ? ( +
+ {promptPreview ? ( +
+
+ 用户目标 +
+
+ {promptPreview} +
+
+ ) : null} + + {focusBlock ? ( +
+
+ 当前聚焦 +
+
+ {focusBlockStageLabel ? ( + {focusBlockStageLabel} + ) : null} + + {focusBlock.title} + +
+ {focusBlock.previewLines[0] ? ( +
+ {focusBlock.previewLines[0]} +
+ ) : null}
) : null}
) : null} -
- ) : null} - {displayModel.summaryChips.length > 0 ? ( -
- {displayModel.summaryChips.map((chip) => ( - - ))} -
- ) : null} + {displayModel.summaryChips.length > 0 ? ( +
+ {displayModel.summaryChips.map((chip) => ( + + ))} +
+ ) : null} - {turn.error_message && - turnStatusMeta.badgeVariant === "destructive" ? ( -
- {turn.error_message} + {turn.error_message && + turnStatusMeta.badgeVariant === "destructive" ? ( +
+ {turn.error_message} +
+ ) : null}
- ) : null} +
+ +
+ {displayModel.orderedBlocks.map((block, index) => ( + + ))} +
-
- -
- {displayModel.orderedBlocks.map((block, index) => ( - - ))} -
-
+ + ); };