Compare commits
5 Commits
d802534abe
...
95cf152ab9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95cf152ab9 | ||
|
|
3be9c1e646 | ||
|
|
4efc8b51e7 | ||
|
|
6a496ce212 | ||
|
|
606fcbcd29 |
@ -1416,7 +1416,14 @@ impl AgentLoop {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match tool
|
match tool
|
||||||
.execute_with_context(&self.tool_context, normalized_arguments.clone())
|
.execute_with_context(
|
||||||
|
&{
|
||||||
|
let mut ctx = self.tool_context.clone();
|
||||||
|
ctx.tool_call_id = Some(tool_call.id.clone());
|
||||||
|
ctx
|
||||||
|
},
|
||||||
|
normalized_arguments.clone(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
|
|||||||
@ -408,13 +408,28 @@ Be concise, aim for {} characters or less.
|
|||||||
.summarize_segment(&summary_source, provider_config)
|
.summarize_segment(&summary_source, provider_config)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Sanitize preserved messages: the boundary between the summarized
|
||||||
|
// and preserved sections can split an assistant tool_calls message
|
||||||
|
// from its tool result messages, creating orphaned sequences that
|
||||||
|
// would cause API 400 errors.
|
||||||
|
let mut preserved_messages = history[preserved_turn_start..].to_vec();
|
||||||
|
let removed =
|
||||||
|
crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut preserved_messages);
|
||||||
|
if removed > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
removed_count = removed,
|
||||||
|
preserved_turn_start,
|
||||||
|
"Compaction plan: removed incomplete tool call sequences from preserved messages"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Some(HistoryCompactionPlan {
|
Ok(Some(HistoryCompactionPlan {
|
||||||
preserved_system_messages,
|
preserved_system_messages,
|
||||||
summary_message: ChatMessage::system_with_context(
|
summary_message: ChatMessage::system_with_context(
|
||||||
format!("[Compressed History]\n\n{}", summary),
|
format!("[Compressed History]\n\n{}", summary),
|
||||||
Some(SYSTEM_CONTEXT_HISTORY_COMPACTION.to_string()),
|
Some(SYSTEM_CONTEXT_HISTORY_COMPACTION.to_string()),
|
||||||
),
|
),
|
||||||
preserved_messages: history[preserved_turn_start..].to_vec(),
|
preserved_messages,
|
||||||
compressed_turns: turn_ranges.len() - self.config.retain_last_user_turns,
|
compressed_turns: turn_ranges.len() - self.config.retain_last_user_turns,
|
||||||
preserved_turns: self.config.retain_last_user_turns,
|
preserved_turns: self.config.retain_last_user_turns,
|
||||||
}))
|
}))
|
||||||
@ -445,7 +460,7 @@ Be concise, aim for {} characters or less.
|
|||||||
"Starting context compression"
|
"Starting context compression"
|
||||||
);
|
);
|
||||||
|
|
||||||
let current_history = match self
|
let mut current_history = match self
|
||||||
.build_compaction_plan(&history, provider_config)
|
.build_compaction_plan(&history, provider_config)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
@ -461,6 +476,21 @@ Be concise, aim for {} characters or less.
|
|||||||
None => history,
|
None => history,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Post-compression sanitization: compression can split an assistant
|
||||||
|
// tool_calls message from its tool result messages at the boundary
|
||||||
|
// between the summarized and preserved sections. This pass removes
|
||||||
|
// any orphaned tool_calls or tool results to ensure the message
|
||||||
|
// sequence is always valid for the LLM API.
|
||||||
|
let removed =
|
||||||
|
crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut current_history);
|
||||||
|
if removed > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
removed_count = removed,
|
||||||
|
remaining_messages = current_history.len(),
|
||||||
|
"Post-compression sanitization removed incomplete tool call sequences"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
final_tokens = estimate_tokens(¤t_history),
|
final_tokens = estimate_tokens(¤t_history),
|
||||||
final_msg_count = current_history.len(),
|
final_msg_count = current_history.len(),
|
||||||
|
|||||||
@ -76,6 +76,7 @@ impl CommandHandler for ListTodosCommandHandler {
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
content: r.content,
|
content: r.content,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
|
created_by_message_id: r.created_by_message_id,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@ -82,6 +82,7 @@ impl AgentFactory {
|
|||||||
nesting_depth: 0,
|
nesting_depth: 0,
|
||||||
task_id: None,
|
task_id: None,
|
||||||
parent_task_id: None,
|
parent_task_id: None,
|
||||||
|
tool_call_id: None,
|
||||||
});
|
});
|
||||||
// 如果有取消信号接收端,注入 Agent
|
// 如果有取消信号接收端,注入 Agent
|
||||||
if let Some(token) = request.cancel_token {
|
if let Some(token) = request.cancel_token {
|
||||||
|
|||||||
@ -200,6 +200,7 @@ impl BusToolCallEmitter {
|
|||||||
priority: "medium".to_string(),
|
priority: "medium".to_string(),
|
||||||
created_at: now + idx as i64,
|
created_at: now + idx as i64,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
|
created_by_message_id: Some(message.id.clone()),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@ -492,7 +492,7 @@ async fn handle_inbound(
|
|||||||
if let Some(task_session_id) = response.metadata.get("task_session_id") {
|
if let Some(task_session_id) = response.metadata.get("task_session_id") {
|
||||||
// 提前提取 task_id,用于给历史消息打标记
|
// 提前提取 task_id,用于给历史消息打标记
|
||||||
let task_id = response.metadata.get("task_id").cloned().unwrap_or_default();
|
let task_id = response.metadata.get("task_id").cloned().unwrap_or_default();
|
||||||
if let Err(e) = send_task_messages(&store, task_session_id, sender, Some(task_id.clone())).await {
|
if let Err(e) = send_task_messages(&store, task_session_id, sender, Some(task_id.clone()), Some(&state.task_repository)).await {
|
||||||
tracing::warn!(error = %e, task_session_id = %task_session_id, "Failed to send task messages");
|
tracing::warn!(error = %e, task_session_id = %task_session_id, "Failed to send task messages");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -570,7 +570,7 @@ async fn handle_inbound(
|
|||||||
&load_chat_channel,
|
&load_chat_channel,
|
||||||
load_chat_id,
|
load_chat_id,
|
||||||
);
|
);
|
||||||
if let Err(e) = send_task_messages(&store, &session_id, sender, None).await {
|
if let Err(e) = send_task_messages(&store, &session_id, sender, None, None).await {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error = %e,
|
error = %e,
|
||||||
channel = %load_chat_channel,
|
channel = %load_chat_channel,
|
||||||
@ -669,9 +669,13 @@ async fn send_topic_history(
|
|||||||
|
|
||||||
for task in running_tasks {
|
for task in running_tasks {
|
||||||
if task.state == TaskSessionState::Running {
|
if task.state == TaskSessionState::Running {
|
||||||
|
// 判断是否为孙智能体:parent_session_id 以 "sub:" 开头表示父会话是子智能体
|
||||||
|
let parent_task_id = extract_parent_task_id(&task);
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
task_id = %task.id,
|
task_id = %task.id,
|
||||||
description = %task.description,
|
description = %task.description,
|
||||||
|
parent_task_id = ?parent_task_id,
|
||||||
"Re-sending TaskStarted for running task after topic history load"
|
"Re-sending TaskStarted for running task after topic history load"
|
||||||
);
|
);
|
||||||
let _ = sender
|
let _ = sender
|
||||||
@ -680,7 +684,8 @@ async fn send_topic_history(
|
|||||||
description: task.description.clone(),
|
description: task.description.clone(),
|
||||||
subagent_type: task.subagent_type.clone(),
|
subagent_type: task.subagent_type.clone(),
|
||||||
topic_id: Some(topic_id.to_string()),
|
topic_id: Some(topic_id.to_string()),
|
||||||
parent_task_id: None,
|
parent_task_id,
|
||||||
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@ -695,6 +700,7 @@ async fn send_task_messages(
|
|||||||
session_id: &str,
|
session_id: &str,
|
||||||
sender: &mpsc::Sender<WsOutbound>,
|
sender: &mpsc::Sender<WsOutbound>,
|
||||||
subagent_task_id: Option<String>,
|
subagent_task_id: Option<String>,
|
||||||
|
task_repository: Option<&Arc<dyn TaskRepository>>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let messages = store.load_messages(session_id)?;
|
let messages = store.load_messages(session_id)?;
|
||||||
|
|
||||||
@ -712,6 +718,37 @@ async fn send_task_messages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 补发子任务(孙智能体)的 TaskStarted 事件
|
||||||
|
// 解决重新进入子智能体视图后 navigateToTaskId 丢失的问题
|
||||||
|
if let (Some(repo), Some(parent_task_id)) = (task_repository, &subagent_task_id) {
|
||||||
|
match repo.list_tasks_for_session(session_id).await {
|
||||||
|
Ok(child_tasks) => {
|
||||||
|
for child in child_tasks {
|
||||||
|
if child.state == TaskSessionState::Running {
|
||||||
|
tracing::info!(
|
||||||
|
child_task_id = %child.id,
|
||||||
|
parent_task_id = %parent_task_id,
|
||||||
|
"Re-sending TaskStarted for child task after sub-agent view re-enter"
|
||||||
|
);
|
||||||
|
let _ = sender
|
||||||
|
.send(WsOutbound::TaskStarted {
|
||||||
|
task_id: child.id.clone(),
|
||||||
|
description: child.description.clone(),
|
||||||
|
subagent_type: child.subagent_type.clone(),
|
||||||
|
topic_id: child.parent_topic_id.clone(),
|
||||||
|
parent_task_id: Some(parent_task_id.clone()),
|
||||||
|
tool_call_id: None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, session_id = %session_id, "Failed to list child tasks for resend");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -736,6 +773,20 @@ fn set_subagent_task_id(outbound: &mut WsOutbound, task_id: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从 TaskSession 中提取父任务 ID(仅孙智能体有值)。
|
||||||
|
/// 孙智能体的 parent_session_id 格式为 "sub:{grandparent_session}:task:{parent_task_uuid}",
|
||||||
|
/// 从中提取 "task:{parent_task_uuid}" 作为 parent_task_id。
|
||||||
|
fn extract_parent_task_id(task: &crate::tools::task::types::TaskSession) -> Option<String> {
|
||||||
|
let parent = &task.parent_session_id;
|
||||||
|
// 仅当父会话是子智能体会话时才提取(格式: "sub:...:task:{uuid}")
|
||||||
|
if parent.starts_with("sub:") {
|
||||||
|
if let Some(pos) = parent.find(":task:") {
|
||||||
|
return Some(parent[pos + 1..].to_string()); // "task:{uuid}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// 将 ChatMessage 转换为 WsOutbound 列表
|
/// 将 ChatMessage 转换为 WsOutbound 列表
|
||||||
fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound> {
|
fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound> {
|
||||||
use crate::bus::message::ToolMessageState;
|
use crate::bus::message::ToolMessageState;
|
||||||
|
|||||||
@ -88,6 +88,7 @@ pub struct TodoItemSummary {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
pub created_by_message_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@ -214,6 +215,8 @@ pub enum WsOutbound {
|
|||||||
topic_id: Option<String>,
|
topic_id: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
parent_task_id: Option<String>,
|
parent_task_id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
tool_call_id: Option<String>,
|
||||||
},
|
},
|
||||||
#[serde(rename = "session_established")]
|
#[serde(rename = "session_established")]
|
||||||
SessionEstablished { session_id: String },
|
SessionEstablished { session_id: String },
|
||||||
|
|||||||
@ -174,6 +174,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
|
|||||||
subagent_type: message.metadata.get("task_subagent_type").cloned().unwrap_or_default(),
|
subagent_type: message.metadata.get("task_subagent_type").cloned().unwrap_or_default(),
|
||||||
topic_id: message.metadata.get("topic_id").cloned(),
|
topic_id: message.metadata.get("topic_id").cloned(),
|
||||||
parent_task_id: message.metadata.get("parent_task_id").cloned(),
|
parent_task_id: message.metadata.get("parent_task_id").cloned(),
|
||||||
|
tool_call_id: message.metadata.get("tool_call_id").cloned(),
|
||||||
}],
|
}],
|
||||||
OutboundEventKind::StreamDelta => vec![WsOutbound::StreamDelta {
|
OutboundEventKind::StreamDelta => vec![WsOutbound::StreamDelta {
|
||||||
id: message.tool_call_id.clone().unwrap_or_default(),
|
id: message.tool_call_id.clone().unwrap_or_default(),
|
||||||
|
|||||||
@ -632,41 +632,68 @@ impl OpenAIProvider {
|
|||||||
fn build_request_body(&self, request: &ChatCompletionRequest) -> Value {
|
fn build_request_body(&self, request: &ChatCompletionRequest) -> Value {
|
||||||
let supports_images = self.supports_images();
|
let supports_images = self.supports_images();
|
||||||
|
|
||||||
// --- Final defense: validate tool_call / tool result pairing ---
|
// --- Final defense: position-aware tool_call / tool result validation ---
|
||||||
// Collect all tool_call_ids that have a corresponding tool result message.
|
//
|
||||||
// Any assistant tool_call NOT in this set is orphaned and must be stripped
|
// Scan right-to-left (matching sanitize_incomplete_tool_call_sequences)
|
||||||
// to avoid API 400 errors ("insufficient tool messages following tool_calls").
|
// so that only tool results appearing AFTER an assistant message count
|
||||||
|
// as "resolved". A simple global scan would incorrectly accept a tool
|
||||||
|
// result that precedes its parent assistant (e.g. after compaction
|
||||||
|
// boundary splits), leading to API 400 errors:
|
||||||
|
// "insufficient tool messages following tool_calls message".
|
||||||
let mut resolved_tool_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
let mut resolved_tool_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||||
for m in &request.messages {
|
let mut with_parent: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||||
|
let mut skip_assistant_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for (i, m) in request.messages.iter().enumerate().rev() {
|
||||||
if m.role == "tool" {
|
if m.role == "tool" {
|
||||||
if let Some(ref tc_id) = m.tool_call_id {
|
if let Some(ref tc_id) = m.tool_call_id {
|
||||||
resolved_tool_ids.insert(tc_id.as_str());
|
resolved_tool_ids.insert(tc_id.as_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Build the set of assistant tool_call_ids that are fully valid
|
|
||||||
// (ALL tool_calls in the message have corresponding results).
|
|
||||||
// If an assistant has partial or no valid tool_calls, we strip the
|
|
||||||
// tool_calls field and serialize it as a plain assistant message.
|
|
||||||
let mut valid_tool_call_parent_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
|
||||||
for m in &request.messages {
|
|
||||||
if m.role == "assistant" {
|
if m.role == "assistant" {
|
||||||
if let Some(ref calls) = m.tool_calls {
|
if let Some(ref calls) = m.tool_calls {
|
||||||
if !calls.is_empty()
|
if !calls.is_empty() {
|
||||||
&& calls.iter().all(|tc| resolved_tool_ids.contains(tc.id.as_str()))
|
let all_resolved =
|
||||||
{
|
calls.iter().all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
|
||||||
|
if all_resolved {
|
||||||
for tc in calls {
|
for tc in calls {
|
||||||
valid_tool_call_parent_ids.insert(tc.id.as_str());
|
with_parent.insert(tc.id.as_str());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skip_assistant_indices.insert(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// valid_tool_call_parent_ids = with_parent (assistant tool_call_ids
|
||||||
|
// whose parent assistant has ALL results after it)
|
||||||
|
let valid_tool_call_parent_ids = &with_parent;
|
||||||
|
|
||||||
let mut body = json!({
|
let mut body = json!({
|
||||||
"model": self.model_id,
|
"model": self.model_id,
|
||||||
"messages": request.messages.iter().enumerate().filter_map(|(i, m)| {
|
"messages": request.messages.iter().enumerate().filter_map(|(i, m)| {
|
||||||
|
// Skip assistant messages identified as having incomplete tool_calls
|
||||||
|
// (position-aware scan found missing tool results after this assistant).
|
||||||
|
if skip_assistant_indices.contains(&i) {
|
||||||
|
tracing::warn!(
|
||||||
|
message_index = i,
|
||||||
|
"build_request_body: skipping assistant with incomplete tool call sequence \
|
||||||
|
(tool results missing after this position)"
|
||||||
|
);
|
||||||
|
// Serialize as plain assistant message (strip tool_calls)
|
||||||
|
let mut message = json!({
|
||||||
|
"role": m.role,
|
||||||
|
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i)
|
||||||
|
});
|
||||||
|
if let Some(reasoning_content) = &m.reasoning_content {
|
||||||
|
message["reasoning_content"] = Value::String(reasoning_content.clone());
|
||||||
|
}
|
||||||
|
return Some(message);
|
||||||
|
}
|
||||||
|
|
||||||
if m.role == "tool" {
|
if m.role == "tool" {
|
||||||
// Skip orphaned tool results (no matching assistant tool_call)
|
// Skip orphaned tool results (no matching assistant tool_call)
|
||||||
let is_orphaned = match &m.tool_call_id {
|
let is_orphaned = match &m.tool_call_id {
|
||||||
|
|||||||
@ -1517,7 +1517,7 @@ impl SessionStore {
|
|||||||
pub fn list_todos(&self, scope_key: &str) -> Result<Vec<TodoRecord>, StorageError> {
|
pub fn list_todos(&self, scope_key: &str) -> Result<Vec<TodoRecord>, StorageError> {
|
||||||
let conn = self.pool.get()?;
|
let conn = self.pool.get()?;
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at
|
"SELECT id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at, created_by_message_id
|
||||||
FROM todos
|
FROM todos
|
||||||
WHERE scope_key = ?1
|
WHERE scope_key = ?1
|
||||||
ORDER BY created_at ASC",
|
ORDER BY created_at ASC",
|
||||||
@ -1534,6 +1534,7 @@ impl SessionStore {
|
|||||||
priority: row.get(6)?,
|
priority: row.get(6)?,
|
||||||
created_at: row.get(7)?,
|
created_at: row.get(7)?,
|
||||||
updated_at: row.get(8)?,
|
updated_at: row.get(8)?,
|
||||||
|
created_by_message_id: row.get(9)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@ -1913,6 +1914,7 @@ fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> {
|
|||||||
priority TEXT NOT NULL DEFAULT 'medium',
|
priority TEXT NOT NULL DEFAULT 'medium',
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL,
|
updated_at INTEGER NOT NULL,
|
||||||
|
created_by_message_id TEXT,
|
||||||
PRIMARY KEY (id, scope_key)
|
PRIMARY KEY (id, scope_key)
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1953,6 +1955,7 @@ fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> {
|
|||||||
priority TEXT NOT NULL DEFAULT 'medium',
|
priority TEXT NOT NULL DEFAULT 'medium',
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL,
|
updated_at INTEGER NOT NULL,
|
||||||
|
created_by_message_id TEXT,
|
||||||
PRIMARY KEY (id, scope_key)
|
PRIMARY KEY (id, scope_key)
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1974,6 +1977,17 @@ fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> {
|
|||||||
tracing::info!("Todos table migration complete");
|
tracing::info!("Todos table migration complete");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Column migration: add created_by_message_id if it doesn't exist
|
||||||
|
let has_column = has_column(&conn, "todos", "created_by_message_id")?;
|
||||||
|
if !has_column {
|
||||||
|
tracing::info!("Adding created_by_message_id column to todos table");
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE todos ADD COLUMN created_by_message_id TEXT",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tracing::info!("Todos table column migration complete");
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -46,6 +46,7 @@ pub struct TodoRecord {
|
|||||||
pub priority: String,
|
pub priority: String,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
|
pub created_by_message_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@ -73,6 +73,20 @@ impl ToolRegistry {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 创建一个排除指定工具的新 registry 副本
|
||||||
|
pub fn without(&self, exclude: &[&str]) -> Self {
|
||||||
|
let exclude_set: std::collections::HashSet<&str> = exclude.iter().copied().collect();
|
||||||
|
let tools = self.tools.read().expect("ToolRegistry lock poisoned");
|
||||||
|
let filtered: HashMap<String, Arc<dyn ToolTrait>> = tools
|
||||||
|
.iter()
|
||||||
|
.filter(|(name, _)| !exclude_set.contains(name.as_str()))
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect();
|
||||||
|
let new_registry = ToolRegistry::new();
|
||||||
|
*new_registry.tools.write().expect("ToolRegistry lock poisoned") = filtered;
|
||||||
|
new_registry
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ToolRegistry {
|
impl Default for ToolRegistry {
|
||||||
|
|||||||
@ -19,6 +19,7 @@ use crate::tools::{ToolContext, ToolRegistry};
|
|||||||
use super::error::TaskError;
|
use super::error::TaskError;
|
||||||
use super::prompt::{extract_summary, SubagentPromptBuilder};
|
use super::prompt::{extract_summary, SubagentPromptBuilder};
|
||||||
use super::repository::TaskRepository;
|
use super::repository::TaskRepository;
|
||||||
|
use super::tool::TaskTool;
|
||||||
use super::types::{SubagentDef, SubagentSource, TaskDefinition, TaskSession, TaskToolResult};
|
use super::types::{SubagentDef, SubagentSource, TaskDefinition, TaskSession, TaskToolResult};
|
||||||
|
|
||||||
/// 子代理运行时配置
|
/// 子代理运行时配置
|
||||||
@ -234,6 +235,7 @@ impl SubAgentEmitter {
|
|||||||
priority: "medium".to_string(),
|
priority: "medium".to_string(),
|
||||||
created_at: now + idx as i64,
|
created_at: now + idx as i64,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
|
created_by_message_id: Some(message.id.clone()),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@ -332,9 +334,17 @@ impl DefaultSubAgentRuntime {
|
|||||||
) -> Result<AgentLoop, TaskError> {
|
) -> Result<AgentLoop, TaskError> {
|
||||||
let prompt_provider = Arc::new(StaticSystemPromptProvider::new(system_prompt));
|
let prompt_provider = Arc::new(StaticSystemPromptProvider::new(system_prompt));
|
||||||
|
|
||||||
|
// 孙智能体(depth >= 2)不注册 task 工具,防止无限嵌套
|
||||||
|
let child_depth = parent_nesting_depth + 1;
|
||||||
|
let tools = if child_depth >= 2 {
|
||||||
|
Arc::new(self.subagent_tools.without(&[TaskTool::TOOL_NAME]))
|
||||||
|
} else {
|
||||||
|
self.subagent_tools.clone()
|
||||||
|
};
|
||||||
|
|
||||||
AgentLoop::with_tools_and_system_prompt_provider(
|
AgentLoop::with_tools_and_system_prompt_provider(
|
||||||
AgentRuntimeConfig::from(self.provider_config.clone()),
|
AgentRuntimeConfig::from(self.provider_config.clone()),
|
||||||
self.subagent_tools.clone(),
|
tools,
|
||||||
prompt_provider,
|
prompt_provider,
|
||||||
None, // 子代理不需要 skill provider
|
None, // 子代理不需要 skill provider
|
||||||
)
|
)
|
||||||
@ -351,6 +361,7 @@ impl DefaultSubAgentRuntime {
|
|||||||
nesting_depth: parent_nesting_depth + 1,
|
nesting_depth: parent_nesting_depth + 1,
|
||||||
task_id: Some(session.id.clone()),
|
task_id: Some(session.id.clone()),
|
||||||
parent_task_id,
|
parent_task_id,
|
||||||
|
tool_call_id: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 如果有 MessageBus,附加实时广播 emitter
|
// 如果有 MessageBus,附加实时广播 emitter
|
||||||
@ -543,6 +554,11 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
metadata.insert("parent_task_id".to_string(), ptid.clone());
|
metadata.insert("parent_task_id".to_string(), ptid.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 传递 tool_call_id,前端据此精确匹配创建此任务的 tool_call
|
||||||
|
if let Some(ref tcid) = parent_context.tool_call_id {
|
||||||
|
metadata.insert("tool_call_id".to_string(), tcid.clone());
|
||||||
|
}
|
||||||
|
|
||||||
let event = OutboundMessage {
|
let event = OutboundMessage {
|
||||||
channel: session.parent_channel_name.clone(),
|
channel: session.parent_channel_name.clone(),
|
||||||
chat_id: session.parent_chat_id.clone(),
|
chat_id: session.parent_chat_id.clone(),
|
||||||
|
|||||||
@ -15,6 +15,8 @@ pub struct TaskTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TaskTool {
|
impl TaskTool {
|
||||||
|
pub const TOOL_NAME: &'static str = "task";
|
||||||
|
|
||||||
/// 创建 TaskTool
|
/// 创建 TaskTool
|
||||||
/// - `max_nesting_depth = None`:无深度限制(主 agent)
|
/// - `max_nesting_depth = None`:无深度限制(主 agent)
|
||||||
/// - `max_nesting_depth = Some(N)`:允许最多 N 层嵌套(子 agent)
|
/// - `max_nesting_depth = Some(N)`:允许最多 N 层嵌套(子 agent)
|
||||||
@ -29,7 +31,7 @@ impl TaskTool {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for TaskTool {
|
impl Tool for TaskTool {
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"task"
|
Self::TOOL_NAME
|
||||||
}
|
}
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
|
|||||||
@ -118,6 +118,7 @@ impl Tool for TodoReadTool {
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
content: r.content,
|
content: r.content,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
|
created_by_message_id: r.created_by_message_id,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@ -185,6 +186,7 @@ mod tests {
|
|||||||
nesting_depth: 0,
|
nesting_depth: 0,
|
||||||
task_id: None,
|
task_id: None,
|
||||||
parent_task_id: None,
|
parent_task_id: None,
|
||||||
|
tool_call_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,6 +226,7 @@ mod tests {
|
|||||||
priority: "medium".to_string(),
|
priority: "medium".to_string(),
|
||||||
created_at: 1000,
|
created_at: 1000,
|
||||||
updated_at: 1000,
|
updated_at: 1000,
|
||||||
|
created_by_message_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,6 +241,7 @@ mod tests {
|
|||||||
id: "a1".to_string(),
|
id: "a1".to_string(),
|
||||||
content: "任务A".to_string(),
|
content: "任务A".to_string(),
|
||||||
status: "pending".to_string(),
|
status: "pending".to_string(),
|
||||||
|
created_by_message_id: None,
|
||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -313,6 +317,7 @@ mod tests {
|
|||||||
id: "m1".to_string(),
|
id: "m1".to_string(),
|
||||||
content: "主会话任务".to_string(),
|
content: "主会话任务".to_string(),
|
||||||
status: "pending".to_string(),
|
status: "pending".to_string(),
|
||||||
|
created_by_message_id: None,
|
||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,6 +45,7 @@ pub(crate) struct TodoItem {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
pub created_by_message_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 工具完整返回
|
/// 工具完整返回
|
||||||
@ -143,7 +144,10 @@ impl Tool for TodoWriteTool {
|
|||||||
None => return Ok(error_result("todo_write requires session_id or topic_id in tool context")),
|
None => return Ok(error_result("todo_write requires session_id or topic_id in tool context")),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. 解析入参
|
// 2. 提取当前消息 ID(用于记录待办的创建来源)
|
||||||
|
let message_id = context.message_id.clone();
|
||||||
|
|
||||||
|
// 3. 解析入参
|
||||||
let todos_array = match args.get("todos").and_then(|v| v.as_array()) {
|
let todos_array = match args.get("todos").and_then(|v| v.as_array()) {
|
||||||
Some(arr) => arr,
|
Some(arr) => arr,
|
||||||
None => return Ok(error_result("Missing required parameter: todos (must be an array)")),
|
None => return Ok(error_result("Missing required parameter: todos (must be an array)")),
|
||||||
@ -219,6 +223,7 @@ impl Tool for TodoWriteTool {
|
|||||||
id,
|
id,
|
||||||
content,
|
content,
|
||||||
status: new_status.as_str().to_string(),
|
status: new_status.as_str().to_string(),
|
||||||
|
created_by_message_id: message_id.clone(),
|
||||||
});
|
});
|
||||||
} else if merge_mode {
|
} else if merge_mode {
|
||||||
// merge 模式:id 不匹配,尝试 content fallback
|
// merge 模式:id 不匹配,尝试 content fallback
|
||||||
@ -238,6 +243,7 @@ impl Tool for TodoWriteTool {
|
|||||||
id: old_item.id.clone(),
|
id: old_item.id.clone(),
|
||||||
content,
|
content,
|
||||||
status: new_status.as_str().to_string(),
|
status: new_status.as_str().to_string(),
|
||||||
|
created_by_message_id: message_id.clone(),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 全新项
|
// 全新项
|
||||||
@ -245,6 +251,7 @@ impl Tool for TodoWriteTool {
|
|||||||
id,
|
id,
|
||||||
content,
|
content,
|
||||||
status: new_status.as_str().to_string(),
|
status: new_status.as_str().to_string(),
|
||||||
|
created_by_message_id: message_id.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -253,6 +260,7 @@ impl Tool for TodoWriteTool {
|
|||||||
id,
|
id,
|
||||||
content,
|
content,
|
||||||
status: new_status.as_str().to_string(),
|
status: new_status.as_str().to_string(),
|
||||||
|
created_by_message_id: message_id.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -415,6 +423,7 @@ mod tests {
|
|||||||
nesting_depth: 0,
|
nesting_depth: 0,
|
||||||
task_id: None,
|
task_id: None,
|
||||||
parent_task_id: None,
|
parent_task_id: None,
|
||||||
|
tool_call_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,8 @@ pub struct ToolContext {
|
|||||||
pub task_id: Option<String>,
|
pub task_id: Option<String>,
|
||||||
/// 父任务 ID(仅子/孙智能体有值,用于构建任务层级)
|
/// 父任务 ID(仅子/孙智能体有值,用于构建任务层级)
|
||||||
pub parent_task_id: Option<String>,
|
pub parent_task_id: Option<String>,
|
||||||
|
/// 当前工具调用的 ID(由 agent_loop 在执行前注入,用于精确关联 TaskStarted 事件)
|
||||||
|
pub tool_call_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { ChannelSelector } from './components/Header/ChannelSelector'
|
|||||||
import { SessionSelector } from './components/Header/SessionSelector'
|
import { SessionSelector } from './components/Header/SessionSelector'
|
||||||
import { useWebSocket } from './hooks/useWebSocket'
|
import { useWebSocket } from './hooks/useWebSocket'
|
||||||
import { useChat } from './hooks/useChat'
|
import { useChat } from './hooks/useChat'
|
||||||
import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup } from './types/protocol'
|
import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup, TodoItemSummary } from './types/protocol'
|
||||||
|
|
||||||
function getInitialSettings(): GatewaySettings {
|
function getInitialSettings(): GatewaySettings {
|
||||||
return getGatewaySettings()
|
return getGatewaySettings()
|
||||||
@ -54,6 +54,9 @@ function App() {
|
|||||||
setTodos,
|
setTodos,
|
||||||
requestTodoList,
|
requestTodoList,
|
||||||
requestSubAgentTodoList,
|
requestSubAgentTodoList,
|
||||||
|
// 高亮消息
|
||||||
|
highlightedMessageId,
|
||||||
|
setHighlightedMessageId,
|
||||||
// 定时任务
|
// 定时任务
|
||||||
schedulerJobs,
|
schedulerJobs,
|
||||||
sidebarTab,
|
sidebarTab,
|
||||||
@ -396,6 +399,17 @@ function App() {
|
|||||||
: requestTodoList()
|
: requestTodoList()
|
||||||
}, [subAgentView, requestTodoList, requestSubAgentTodoList])
|
}, [subAgentView, requestTodoList, requestSubAgentTodoList])
|
||||||
|
|
||||||
|
// 点击待办项后滚动到对应消息
|
||||||
|
const handleTodoClick = useCallback((todo: TodoItemSummary) => {
|
||||||
|
// 直接使用后端返回的 created_by_message_id
|
||||||
|
if (todo.created_by_message_id) {
|
||||||
|
setHighlightedMessageId(todo.created_by_message_id)
|
||||||
|
} else {
|
||||||
|
// 如果消息 ID 不存在(旧数据),给出友好提示
|
||||||
|
alert('该待办的完成记录无法定位,可能是历史数据')
|
||||||
|
}
|
||||||
|
}, [setHighlightedMessageId])
|
||||||
|
|
||||||
const handleRefreshSchedulerJobs = useCallback(() => {
|
const handleRefreshSchedulerJobs = useCallback(() => {
|
||||||
const cmd = requestSchedulerJobList()
|
const cmd = requestSchedulerJobList()
|
||||||
handleCommand(cmd)
|
handleCommand(cmd)
|
||||||
@ -694,11 +708,13 @@ function App() {
|
|||||||
onStop={handleStopExecution}
|
onStop={handleStopExecution}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
viewKey={viewKey}
|
viewKey={viewKey}
|
||||||
|
highlightedMessageId={highlightedMessageId}
|
||||||
todoPanel={
|
todoPanel={
|
||||||
<TodoPanel
|
<TodoPanel
|
||||||
todos={todos}
|
todos={todos}
|
||||||
requestTodoList={refreshTodoList}
|
requestTodoList={refreshTodoList}
|
||||||
sendCommand={sendMemoryCommand}
|
sendCommand={sendMemoryCommand}
|
||||||
|
onTodoClick={handleTodoClick}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -15,6 +15,8 @@ interface ChatContainerProps {
|
|||||||
todoPanel?: React.ReactNode
|
todoPanel?: React.ReactNode
|
||||||
/** 视图标识,用于保存/恢复滚动位置 */
|
/** 视图标识,用于保存/恢复滚动位置 */
|
||||||
viewKey?: string
|
viewKey?: string
|
||||||
|
/** 高亮的消息 ID */
|
||||||
|
highlightedMessageId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatContainer({
|
export function ChatContainer({
|
||||||
@ -28,11 +30,12 @@ export function ChatContainer({
|
|||||||
showThinking = true,
|
showThinking = true,
|
||||||
todoPanel,
|
todoPanel,
|
||||||
viewKey,
|
viewKey,
|
||||||
|
highlightedMessageId,
|
||||||
}: ChatContainerProps) {
|
}: ChatContainerProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col relative">
|
<div className="flex h-full flex-col relative">
|
||||||
<div className="flex-1 overflow-hidden relative">
|
<div className="flex-1 overflow-hidden relative">
|
||||||
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} />
|
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} />
|
||||||
{todoPanel}
|
{todoPanel}
|
||||||
</div>
|
</div>
|
||||||
<MessageInput
|
<MessageInput
|
||||||
|
|||||||
@ -647,7 +647,7 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in group`}>
|
<div data-message-id={message.id} className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in group`}>
|
||||||
<div
|
<div
|
||||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${getAvatarStyles()} shadow-lg`}
|
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${getAvatarStyles()} shadow-lg`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -9,9 +9,11 @@ interface MessageListProps {
|
|||||||
showThinking?: boolean
|
showThinking?: boolean
|
||||||
/** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */
|
/** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */
|
||||||
viewKey?: string
|
viewKey?: string
|
||||||
|
/** 高亮的消息 ID,点击待办项后滚动并高亮显示 */
|
||||||
|
highlightedMessageId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey }: MessageListProps) {
|
export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey, highlightedMessageId }: MessageListProps) {
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const isAtBottomRef = useRef(true)
|
const isAtBottomRef = useRef(true)
|
||||||
@ -117,6 +119,28 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ---- highlight and scroll to todo message ----
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightedMessageId) return
|
||||||
|
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
// 查找目标消息元素
|
||||||
|
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`)
|
||||||
|
if (!targetElement) return
|
||||||
|
|
||||||
|
// 滚动到目标位置
|
||||||
|
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
|
||||||
|
// 添加高亮样式
|
||||||
|
targetElement.classList.add('todo-highlight')
|
||||||
|
setTimeout(() => {
|
||||||
|
targetElement.classList.remove('todo-highlight')
|
||||||
|
}, 2000)
|
||||||
|
}, [highlightedMessageId])
|
||||||
|
|
||||||
// ---- empty state ----
|
// ---- empty state ----
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ interface TodoPanelProps {
|
|||||||
todos: TodoItemSummary[]
|
todos: TodoItemSummary[]
|
||||||
requestTodoList: () => Command
|
requestTodoList: () => Command
|
||||||
sendCommand: (cmd: Command) => void
|
sendCommand: (cmd: Command) => void
|
||||||
|
onTodoClick?: (todo: TodoItemSummary) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── status config ────────────────────────────────────── */
|
/* ── status config ────────────────────────────────────── */
|
||||||
@ -64,7 +65,7 @@ function savePos(pos: { x: number; y: number }) {
|
|||||||
|
|
||||||
/* ── TodoPanel ────────────────────────────────────────── */
|
/* ── TodoPanel ────────────────────────────────────────── */
|
||||||
|
|
||||||
export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProps) {
|
export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }: TodoPanelProps) {
|
||||||
const [expanded, setExpanded] = useState(() => {
|
const [expanded, setExpanded] = useState(() => {
|
||||||
try { return localStorage.getItem('picobot-todo-expanded') === 'true' } catch { return false }
|
try { return localStorage.getItem('picobot-todo-expanded') === 'true' } catch { return false }
|
||||||
})
|
})
|
||||||
@ -249,12 +250,16 @@ export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProp
|
|||||||
<div className={`todo-group-body ${isCollapsed ? 'todo-group-body-closed' : 'todo-group-body-open'}`}>
|
<div className={`todo-group-body ${isCollapsed ? 'todo-group-body-closed' : 'todo-group-body-open'}`}>
|
||||||
<div className="ml-[7px] border-l-2 border-[var(--border-color)]/60 pl-3 mt-1.5 space-y-0.5">
|
<div className="ml-[7px] border-l-2 border-[var(--border-color)]/60 pl-3 mt-1.5 space-y-0.5">
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<div key={item.id} className="group/item py-1.5 px-2 -mx-2 rounded-md transition-colors duration-150 hover:bg-[var(--overlay-hover)] flex items-start gap-1.5">
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onTodoClick?.(item)}
|
||||||
|
className="group/item w-full text-left py-1.5 px-2 -mx-2 rounded-md transition-colors duration-150 hover:bg-[var(--overlay-hover)] flex items-start gap-1.5 cursor-pointer"
|
||||||
|
>
|
||||||
<span className={`h-2 w-2 rounded-full ${cfg.dot} shrink-0 mt-1.5`} />
|
<span className={`h-2 w-2 rounded-full ${cfg.dot} shrink-0 mt-1.5`} />
|
||||||
<span className="text-[13px] leading-relaxed text-[var(--text-primary)]/85 group-hover/item:text-[var(--text-primary)] transition-colors break-words">
|
<span className="text-[13px] leading-relaxed text-[var(--text-primary)]/85 group-hover/item:text-[var(--text-primary)] transition-colors break-words">
|
||||||
{item.content}
|
{item.content}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -106,6 +106,10 @@ interface UseChatReturn {
|
|||||||
requestTodoList: () => Command
|
requestTodoList: () => Command
|
||||||
requestSubAgentTodoList: (subTaskId: string) => Command
|
requestSubAgentTodoList: (subTaskId: string) => Command
|
||||||
|
|
||||||
|
// 高亮消息 ID(点击待办后滚动到对应消息)
|
||||||
|
highlightedMessageId: string | null
|
||||||
|
setHighlightedMessageId: Dispatch<SetStateAction<string | null>>
|
||||||
|
|
||||||
// 定时任务状态
|
// 定时任务状态
|
||||||
schedulerJobs: SchedulerJobSummary[]
|
schedulerJobs: SchedulerJobSummary[]
|
||||||
sidebarTab: 'topics' | 'scheduler'
|
sidebarTab: 'topics' | 'scheduler'
|
||||||
@ -156,6 +160,7 @@ export function useChat(): UseChatReturn {
|
|||||||
const [memories, setMemories] = useState<MemorySummary[]>([])
|
const [memories, setMemories] = useState<MemorySummary[]>([])
|
||||||
const [skills, setSkills] = useState<SkillSummary[]>([])
|
const [skills, setSkills] = useState<SkillSummary[]>([])
|
||||||
const [todos, setTodos] = useState<TodoItemSummary[]>([])
|
const [todos, setTodos] = useState<TodoItemSummary[]>([])
|
||||||
|
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null)
|
||||||
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
|
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
|
||||||
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
|
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
|
||||||
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
|
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
|
||||||
@ -176,6 +181,10 @@ export function useChat(): UseChatReturn {
|
|||||||
const selectedTopicRef = useRef<string | null>(null)
|
const selectedTopicRef = useRef<string | null>(null)
|
||||||
const pendingNewTopicRef = useRef(false)
|
const pendingNewTopicRef = useRef(false)
|
||||||
|
|
||||||
|
// Pending task navigations: tool_call_id -> task_id
|
||||||
|
// Used when task_started arrives before the tool_call is in the sub-agent view
|
||||||
|
const pendingTaskNavsRef = useRef<Map<string, string>>(new Map())
|
||||||
|
|
||||||
// Ref to send commands from within handleServerMessage (set by App.tsx)
|
// Ref to send commands from within handleServerMessage (set by App.tsx)
|
||||||
const sendMessageRef = useRef<((msg: WsInbound) => boolean) | null>(null)
|
const sendMessageRef = useRef<((msg: WsInbound) => boolean) | null>(null)
|
||||||
const setSendMessage = useCallback((fn: (msg: WsInbound) => boolean) => {
|
const setSendMessage = useCallback((fn: (msg: WsInbound) => boolean) => {
|
||||||
@ -391,14 +400,30 @@ export function useChat(): UseChatReturn {
|
|||||||
if (message.type === 'task_started') {
|
if (message.type === 'task_started') {
|
||||||
const msg = message as TaskStarted
|
const msg = message as TaskStarted
|
||||||
if (msg.parent_task_id === currentSubAgentView.taskId) {
|
if (msg.parent_task_id === currentSubAgentView.taskId) {
|
||||||
|
let matched = false
|
||||||
setSubAgentStack((prev) => {
|
setSubAgentStack((prev) => {
|
||||||
if (prev.length === 0) return prev
|
if (prev.length === 0) return prev
|
||||||
const top = prev[prev.length - 1]
|
const top = prev[prev.length - 1]
|
||||||
const updatedMessages = [...top.messages]
|
const updatedMessages = [...top.messages]
|
||||||
|
|
||||||
|
// 优先:按 tool_call_id 精确匹配
|
||||||
|
if (msg.tool_call_id) {
|
||||||
|
const idx = updatedMessages.findIndex(m =>
|
||||||
|
m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task')
|
||||||
|
if (idx >= 0 && !updatedMessages[idx].navigateToTaskId) {
|
||||||
|
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: msg.task_id }
|
||||||
|
matched = true
|
||||||
|
const newStack = [...prev]
|
||||||
|
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
|
||||||
|
return newStack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 回退:backward-search (兼容无 tool_call_id 的旧版本)
|
||||||
for (let i = updatedMessages.length - 1; i >= 0; i--) {
|
for (let i = updatedMessages.length - 1; i >= 0; i--) {
|
||||||
const m = updatedMessages[i]
|
const m = updatedMessages[i]
|
||||||
if (m.type === 'tool_call' && m.toolName === 'task' && !m.navigateToTaskId) {
|
if (m.type === 'tool_call' && m.toolName === 'task' && !m.navigateToTaskId) {
|
||||||
updatedMessages[i] = { ...m, navigateToTaskId: msg.task_id }
|
updatedMessages[i] = { ...m, navigateToTaskId: msg.task_id }
|
||||||
|
matched = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -406,6 +431,11 @@ export function useChat(): UseChatReturn {
|
|||||||
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
|
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
|
||||||
return newStack
|
return newStack
|
||||||
})
|
})
|
||||||
|
if (!matched) {
|
||||||
|
// tool_call 尚未到达,存储 pending navigation 等后续 tool_call 到达时回填
|
||||||
|
const key = msg.tool_call_id || `fallback:${msg.task_id}`
|
||||||
|
pendingTaskNavsRef.current.set(key, msg.task_id)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -416,6 +446,33 @@ export function useChat(): UseChatReturn {
|
|||||||
const msgSubagentTaskId = getSubagentTaskId(message)
|
const msgSubagentTaskId = getSubagentTaskId(message)
|
||||||
if (msgSubagentTaskId && msgSubagentTaskId === currentSubAgentView.taskId) {
|
if (msgSubagentTaskId && msgSubagentTaskId === currentSubAgentView.taskId) {
|
||||||
appendToSubAgentViewMessage(message)
|
appendToSubAgentViewMessage(message)
|
||||||
|
|
||||||
|
// 检查 pending navigation:当 task tool_call 到达时,回填之前未匹配的 navigateToTaskId
|
||||||
|
if (message.type === 'tool_call') {
|
||||||
|
const tc = message as ToolCall
|
||||||
|
if (tc.tool_name === 'task' && tc.tool_call_id) {
|
||||||
|
const key = tc.tool_call_id
|
||||||
|
const pendingTaskId = pendingTaskNavsRef.current.get(key)
|
||||||
|
if (pendingTaskId) {
|
||||||
|
pendingTaskNavsRef.current.delete(key)
|
||||||
|
setSubAgentStack((prev) => {
|
||||||
|
if (prev.length === 0) return prev
|
||||||
|
const top = prev[prev.length - 1]
|
||||||
|
const updatedMessages = [...top.messages]
|
||||||
|
const idx = updatedMessages.findIndex(m =>
|
||||||
|
m.toolCallId === tc.tool_call_id && m.type === 'tool_call')
|
||||||
|
if (idx >= 0) {
|
||||||
|
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: pendingTaskId }
|
||||||
|
const newStack = [...prev]
|
||||||
|
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
|
||||||
|
return newStack
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 子代理 todo_write 完成后自动刷新待办列表
|
// 子代理 todo_write 完成后自动刷新待办列表
|
||||||
if (message.type === 'tool_result' && (message as ToolResult).tool_name === 'todo_write') {
|
if (message.type === 'tool_result' && (message as ToolResult).tool_name === 'todo_write') {
|
||||||
const refreshCmd = requestSubAgentTodoList(currentSubAgentView.taskId)
|
const refreshCmd = requestSubAgentTodoList(currentSubAgentView.taskId)
|
||||||
@ -459,10 +516,22 @@ export function useChat(): UseChatReturn {
|
|||||||
|
|
||||||
// 设置 navigateToTaskId,让用户可以点击查看实时进度
|
// 设置 navigateToTaskId,让用户可以点击查看实时进度
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
console.log('[useChat] task_started searching messages for task tool_call, total messages:', prev.length)
|
console.log('[useChat] task_started searching messages for task tool_call, total messages:', prev.length, 'tool_call_id:', msg.tool_call_id)
|
||||||
|
// 优先:按 tool_call_id 精确匹配
|
||||||
|
if (msg.tool_call_id) {
|
||||||
|
const idx = prev.findIndex(m =>
|
||||||
|
m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task')
|
||||||
|
if (idx >= 0 && !prev[idx].navigateToTaskId) {
|
||||||
|
console.log('[useChat] task_started EXACT MATCH at index', idx, 'task_id:', msg.task_id)
|
||||||
|
const updated = [...prev]
|
||||||
|
updated[idx] = { ...updated[idx], navigateToTaskId: msg.task_id }
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 回退:backward-search (兼容无 tool_call_id 的旧版本)
|
||||||
for (let i = prev.length - 1; i >= 0; i--) {
|
for (let i = prev.length - 1; i >= 0; i--) {
|
||||||
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].navigateToTaskId) {
|
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].navigateToTaskId) {
|
||||||
console.log('[useChat] task_started SET navigateToTaskId at index', i, 'task_id:', msg.task_id)
|
console.log('[useChat] task_started BACKWARD MATCH at index', i, 'task_id:', msg.task_id)
|
||||||
const updated = [...prev]
|
const updated = [...prev]
|
||||||
updated[i] = { ...updated[i], navigateToTaskId: msg.task_id }
|
updated[i] = { ...updated[i], navigateToTaskId: msg.task_id }
|
||||||
return updated
|
return updated
|
||||||
@ -1037,6 +1106,8 @@ export function useChat(): UseChatReturn {
|
|||||||
setTodos,
|
setTodos,
|
||||||
requestTodoList,
|
requestTodoList,
|
||||||
requestSubAgentTodoList,
|
requestSubAgentTodoList,
|
||||||
|
highlightedMessageId,
|
||||||
|
setHighlightedMessageId,
|
||||||
schedulerJobs,
|
schedulerJobs,
|
||||||
sidebarTab,
|
sidebarTab,
|
||||||
setSidebarTab,
|
setSidebarTab,
|
||||||
|
|||||||
@ -294,10 +294,25 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes todo-highlight-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-color: rgba(0, 240, 255, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.animate-todo-card-in { animation: todo-card-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
.animate-todo-card-in { animation: todo-card-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
||||||
.animate-todo-item-in { animation: todo-item-in 0.2s ease-out forwards; }
|
.animate-todo-item-in { animation: todo-item-in 0.2s ease-out forwards; }
|
||||||
.animate-todo-ring-pulse { animation: todo-ring-pulse 2s ease-in-out infinite; }
|
.animate-todo-ring-pulse { animation: todo-ring-pulse 2s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* 待办点击高亮效果 */
|
||||||
|
.todo-highlight {
|
||||||
|
animation: todo-highlight-pulse 1s ease-in-out 2;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 分组折叠内容展开/收起 */
|
/* 分组折叠内容展开/收起 */
|
||||||
.todo-group-body {
|
.todo-group-body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@ -102,6 +102,7 @@ export interface TaskStarted {
|
|||||||
subagent_type: string
|
subagent_type: string
|
||||||
topic_id?: string
|
topic_id?: string
|
||||||
parent_task_id?: string
|
parent_task_id?: string
|
||||||
|
tool_call_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionEstablished {
|
export interface SessionEstablished {
|
||||||
@ -209,6 +210,7 @@ export interface TodoItemSummary {
|
|||||||
priority: string
|
priority: string
|
||||||
created_at: number
|
created_at: number
|
||||||
updated_at: number
|
updated_at: number
|
||||||
|
created_by_message_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TodoList {
|
export interface TodoList {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user