mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
fix: include remaining v0.90.0 updates
This commit is contained in:
@@ -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<ConversationWindowSummary, rusqlite::Error> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,23 @@ use std::sync::{Arc, Mutex};
|
||||
/// - 如果调用方已经拿到了 `&Connection`,优先沿用该连接向下传递。
|
||||
pub type DbConnection = Arc<Mutex<Connection>>;
|
||||
|
||||
#[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<i64>,
|
||||
to_timestamp_ms: Option<i64>,
|
||||
) -> Result<ConversationWindowSummary, rusqlite::Error> {
|
||||
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<std::sync::MutexGuard<'_, Connection>, 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<i64>,
|
||||
to_timestamp_ms: Option<i64>,
|
||||
) -> Result<i64, String> {
|
||||
) -> Result<ConversationWindowSummary, String> {
|
||||
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<i64>,
|
||||
to_timestamp_ms: Option<i64>,
|
||||
) -> Result<ConversationWindowSummary, String> {
|
||||
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<i64>,
|
||||
to_timestamp_ms: Option<i64>,
|
||||
) -> Result<i64, String> {
|
||||
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<ConversationWindowSummary, String> {
|
||||
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<i64>,
|
||||
to_timestamp_ms: Option<i64>,
|
||||
) -> Result<i64, String> {
|
||||
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<i64>,
|
||||
to_timestamp_ms: Option<i64>,
|
||||
) -> Result<i64, String> {
|
||||
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<i64>,
|
||||
to_timestamp_ms: Option<i64>,
|
||||
) -> Result<i64, String> {
|
||||
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<i64>,
|
||||
Option<i64>,
|
||||
) -> Result<ConversationWindowSummary, String>,
|
||||
today_start: &DateTime<Local>,
|
||||
month_start: &DateTime<Local>,
|
||||
) -> Result<ConversationWindowTriplet, String> {
|
||||
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<i64>,
|
||||
to_timestamp_ms: Option<i64>,
|
||||
) -> Result<i64, String> {
|
||||
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<Local>,
|
||||
month_start: &DateTime<Local>,
|
||||
) -> Result<ConversationStats, String> {
|
||||
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<Local>,
|
||||
month_start: &DateTime<Local>,
|
||||
) -> Result<ConversationStats, String> {
|
||||
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<Local>,
|
||||
month_start: &DateTime<Local>,
|
||||
general_windows: ConversationWindowTriplet,
|
||||
agent_windows: ConversationWindowTriplet,
|
||||
) -> Result<TokenStats, String> {
|
||||
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<Local>,
|
||||
month_start: &DateTime<Local>,
|
||||
) -> Result<TokenStats, String> {
|
||||
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<DateTime<Local>>,
|
||||
) -> Result<Vec<RawModelUsage>, 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 {
|
||||
|
||||
@@ -152,6 +152,19 @@ function renderTimeline(
|
||||
return container;
|
||||
}
|
||||
|
||||
function clickTimelineToggle(container: HTMLElement) {
|
||||
const button = container.querySelector<HTMLButtonElement>(
|
||||
'[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<HTMLElement>(
|
||||
'[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<HTMLElement>(
|
||||
"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<HTMLElement>(
|
||||
'[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<HTMLElement>('[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<HTMLElement>('[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();
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
className="relative flex h-5 w-5 shrink-0 items-center justify-center"
|
||||
data-state={tone}
|
||||
data-testid="agent-thread-details-inline-icon"
|
||||
>
|
||||
<span
|
||||
className="absolute inset-0 rounded-full bg-[conic-gradient(from_180deg_at_50%_50%,#38bdf8_0deg,#22c55e_140deg,#fbbf24_260deg,#38bdf8_360deg)] opacity-95 animate-spin"
|
||||
style={{ animationDuration: "2.8s" }}
|
||||
/>
|
||||
<span className="absolute inset-[1.5px] rounded-full bg-background/90" />
|
||||
<span className="absolute inset-[3px] rounded-full bg-[linear-gradient(135deg,rgba(56,189,248,0.22),rgba(34,197,94,0.18),rgba(251,191,36,0.22))] shadow-[0_0_14px_rgba(56,189,248,0.28)]" />
|
||||
<Loader2
|
||||
className="relative h-2.5 w-2.5 animate-spin text-sky-600"
|
||||
style={{ animationDuration: "1.15s" }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (tone === "waiting") {
|
||||
return (
|
||||
<span
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-amber-200/80 bg-amber-100 text-amber-700 shadow-sm shadow-amber-950/5"
|
||||
data-state={tone}
|
||||
data-testid="agent-thread-details-inline-icon"
|
||||
>
|
||||
<Clock3 className="h-3 w-3" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (tone === "failed") {
|
||||
return (
|
||||
<span
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-rose-200/80 bg-rose-100 text-rose-700 shadow-sm shadow-rose-950/5"
|
||||
data-state={tone}
|
||||
data-testid="agent-thread-details-inline-icon"
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (tone === "paused") {
|
||||
return (
|
||||
<span
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-slate-200/80 bg-slate-100 text-slate-600 shadow-sm shadow-slate-950/5"
|
||||
data-state={tone}
|
||||
data-testid="agent-thread-details-inline-icon"
|
||||
>
|
||||
<Clock3 className="h-3 w-3" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-emerald-200/80 bg-emerald-100 text-emerald-700 shadow-sm shadow-emerald-950/5"
|
||||
data-state={tone}
|
||||
data-testid="agent-thread-details-inline-icon"
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineBlockCard({
|
||||
block,
|
||||
index,
|
||||
@@ -1095,123 +1345,230 @@ export const AgentThreadTimeline: React.FC<AgentThreadTimelineProps> = ({
|
||||
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 (
|
||||
<div className="mt-3 space-y-3 rounded-2xl border border-border/60 bg-muted/20 p-3">
|
||||
<div className="relative pl-14" data-testid="agent-thread-summary-shell">
|
||||
<div className="absolute left-0 top-2.5 flex h-8 w-8 items-center justify-center rounded-full border border-primary/15 bg-primary/10 text-primary shadow-sm shadow-primary/10">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<div
|
||||
className="rounded-xl border border-border/50 bg-background/70 px-4 py-2.5"
|
||||
data-testid="agent-thread-summary"
|
||||
<Collapsible
|
||||
open={detailsExpanded}
|
||||
onOpenChange={setDetailsExpanded}
|
||||
className={detailsExpanded ? "mt-3" : "mt-2"}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={toggleActionLabel}
|
||||
title={toggleActionLabel}
|
||||
className={cn(
|
||||
"group relative inline-flex w-full max-w-[28rem] items-center gap-2 overflow-hidden rounded-full border px-2.5 py-1.5 text-left shadow-sm transition-all duration-200",
|
||||
detailsExpanded
|
||||
? "border-border/65 bg-background/72"
|
||||
: "bg-background/88 hover:bg-background",
|
||||
compactTone === "running" &&
|
||||
"border-sky-200/80 shadow-[0_10px_28px_-22px_rgba(56,189,248,0.75)] hover:border-sky-300/80",
|
||||
compactTone === "waiting" &&
|
||||
"border-amber-200/80 bg-amber-50/72 hover:border-amber-300/80",
|
||||
compactTone === "failed" &&
|
||||
"border-rose-200/80 bg-rose-50/72 hover:border-rose-300/80",
|
||||
compactTone === "paused" &&
|
||||
"border-slate-200/80 bg-slate-50/78 hover:border-slate-300/80",
|
||||
compactTone === "done" &&
|
||||
"border-border/60 hover:border-emerald-200/70",
|
||||
)}
|
||||
data-testid="agent-thread-details-toggle"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||
本回合摘要
|
||||
{showRunningAccent ? (
|
||||
<span className="pointer-events-none absolute inset-x-3 bottom-0.5 h-px rounded-full bg-gradient-to-r from-sky-400/0 via-sky-400/85 to-emerald-400/0 animate-pulse" />
|
||||
) : null}
|
||||
|
||||
<TimelineCompactStatusIcon tone={compactTone} />
|
||||
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-[12px] font-medium text-foreground"
|
||||
data-testid="agent-thread-details-inline-text"
|
||||
>
|
||||
{collapsedProcessText}
|
||||
</span>
|
||||
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200 ${
|
||||
detailsExpanded ? "rotate-180" : "rotate-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="mt-3 space-y-3" data-testid="agent-thread-details">
|
||||
<div className="flex items-center justify-between gap-3 px-1">
|
||||
<div className="min-w-0 text-xs text-muted-foreground">
|
||||
{turnStatusMeta.overviewText}
|
||||
</div>
|
||||
<Badge variant="outline">{flowBlockCount} 段流程</Badge>
|
||||
{isCurrentTurn ? <Badge variant="secondary">当前回合</Badge> : null}
|
||||
<Badge
|
||||
variant={turnStatusMeta.badgeVariant}
|
||||
className={turnStatusMeta.badgeClassName}
|
||||
>
|
||||
{turnStatusMeta.label}
|
||||
</Badge>
|
||||
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<div className="shrink-0 inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
<span>{formatTimestamp(turn.started_at) || "刚刚"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 text-sm leading-6 text-foreground">
|
||||
{turnStatusMeta.overviewText}
|
||||
</div>
|
||||
|
||||
{promptPreview || focusBlock ? (
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{promptPreview ? (
|
||||
<div
|
||||
className="rounded-xl border border-border/60 bg-background/80 px-3 py-2"
|
||||
data-testid="agent-thread-goal"
|
||||
>
|
||||
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
|
||||
用户目标
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-foreground">
|
||||
{promptPreview}
|
||||
</div>
|
||||
<div
|
||||
className="relative pl-14"
|
||||
data-testid="agent-thread-summary-shell"
|
||||
>
|
||||
<div className="absolute left-0 top-2.5 flex h-8 w-8 items-center justify-center rounded-full border border-primary/15 bg-primary/10 text-primary shadow-sm shadow-primary/10">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<div
|
||||
className="rounded-xl border border-border/50 bg-background/70 px-4 py-2.5"
|
||||
data-testid="agent-thread-summary"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||
本回合摘要
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{focusBlock ? (
|
||||
<div
|
||||
className="rounded-xl border border-border/60 bg-background/80 px-3 py-2"
|
||||
data-testid="agent-thread-focus"
|
||||
<Badge variant="outline">{flowBlockCount} 段流程</Badge>
|
||||
{isCurrentTurn ? <Badge variant="secondary">当前回合</Badge> : null}
|
||||
<Badge
|
||||
variant={turnStatusMeta.badgeVariant}
|
||||
className={turnStatusMeta.badgeClassName}
|
||||
>
|
||||
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
|
||||
当前聚焦
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
{focusBlockStageLabel ? (
|
||||
<Badge variant="outline">{focusBlockStageLabel}</Badge>
|
||||
) : null}
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{focusBlock.title}
|
||||
</span>
|
||||
</div>
|
||||
{focusBlock.previewLines[0] ? (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{focusBlock.previewLines[0]}
|
||||
{turnStatusMeta.label}
|
||||
</Badge>
|
||||
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
<span>{formatTimestamp(turn.started_at) || "刚刚"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 text-sm leading-6 text-foreground">
|
||||
{turnStatusMeta.overviewText}
|
||||
</div>
|
||||
|
||||
{promptPreview || focusBlock ? (
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{promptPreview ? (
|
||||
<div
|
||||
className="rounded-xl border border-border/60 bg-background/80 px-3 py-2"
|
||||
data-testid="agent-thread-goal"
|
||||
>
|
||||
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
|
||||
用户目标
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-foreground">
|
||||
{promptPreview}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{focusBlock ? (
|
||||
<div
|
||||
className="rounded-xl border border-border/60 bg-background/80 px-3 py-2"
|
||||
data-testid="agent-thread-focus"
|
||||
>
|
||||
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
|
||||
当前聚焦
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
{focusBlockStageLabel ? (
|
||||
<Badge variant="outline">{focusBlockStageLabel}</Badge>
|
||||
) : null}
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{focusBlock.title}
|
||||
</span>
|
||||
</div>
|
||||
{focusBlock.previewLines[0] ? (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{focusBlock.previewLines[0]}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{displayModel.summaryChips.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{displayModel.summaryChips.map((chip) => (
|
||||
<SummaryChip key={chip.kind} chip={chip} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{displayModel.summaryChips.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{displayModel.summaryChips.map((chip) => (
|
||||
<SummaryChip key={chip.kind} chip={chip} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{turn.error_message &&
|
||||
turnStatusMeta.badgeVariant === "destructive" ? (
|
||||
<div className="mt-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{turn.error_message}
|
||||
{turn.error_message &&
|
||||
turnStatusMeta.badgeVariant === "destructive" ? (
|
||||
<div className="mt-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{turn.error_message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3" data-testid="agent-thread-flow">
|
||||
{displayModel.orderedBlocks.map((block, index) => (
|
||||
<TimelineBlockCard
|
||||
key={block.id}
|
||||
block={block}
|
||||
index={index}
|
||||
isLast={index === displayModel.orderedBlocks.length - 1}
|
||||
emphasis={
|
||||
activeBlockIndex === index
|
||||
? "active"
|
||||
: block.status === "completed"
|
||||
? "quiet"
|
||||
: "default"
|
||||
}
|
||||
isExpanded={expandedBlockIndexes.has(index)}
|
||||
onFileClick={onFileClick}
|
||||
onPermissionResponse={onPermissionResponse}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3" data-testid="agent-thread-flow">
|
||||
{displayModel.orderedBlocks.map((block, index) => (
|
||||
<TimelineBlockCard
|
||||
key={block.id}
|
||||
block={block}
|
||||
index={index}
|
||||
isLast={index === displayModel.orderedBlocks.length - 1}
|
||||
emphasis={
|
||||
activeBlockIndex === index
|
||||
? "active"
|
||||
: block.status === "completed"
|
||||
? "quiet"
|
||||
: "default"
|
||||
}
|
||||
isExpanded={expandedBlockIndexes.has(index)}
|
||||
onFileClick={onFileClick}
|
||||
onPermissionResponse={onPermissionResponse}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user