fix: include remaining v0.90.0 updates

This commit is contained in:
coso
2026-03-18 11:22:02 +08:00
parent 712dba45e7
commit ad8427be93
6 changed files with 939 additions and 286 deletions
+223 -1
View File
@@ -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());
}
}
+31
View File
@@ -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>
);
};