Compare commits
8 Commits
5e5de7ce9f
...
06756a4816
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06756a4816 | ||
|
|
2bda66a042 | ||
|
|
d0051baa07 | ||
|
|
3b0b4c1f2e | ||
|
|
4cb26b5b67 | ||
|
|
5d3a583915 | ||
|
|
182bebdaef | ||
|
|
34011a6fa3 |
@ -2,6 +2,7 @@ use crate::agent::AgentRuntimeConfig;
|
|||||||
use crate::agent::{SystemPromptContext, SystemPromptProvider};
|
use crate::agent::{SystemPromptContext, SystemPromptProvider};
|
||||||
use crate::bus::ChatMessage;
|
use crate::bus::ChatMessage;
|
||||||
use crate::bus::message::ToolMessageState;
|
use crate::bus::message::ToolMessageState;
|
||||||
|
use crate::storage::ConversationRepository;
|
||||||
use crate::domain::messages::{ContentBlock, ToolCall};
|
use crate::domain::messages::{ContentBlock, ToolCall};
|
||||||
use crate::observability::{
|
use crate::observability::{
|
||||||
Observer, ObserverEvent, ToolExecutionOutcome, ToolExecutionState, truncate_args,
|
Observer, ObserverEvent, ToolExecutionOutcome, ToolExecutionState, truncate_args,
|
||||||
@ -657,6 +658,38 @@ pub trait EmittedMessageHandler: Send + Sync + 'static {
|
|||||||
async fn handle(&self, message: ChatMessage);
|
async fn handle(&self, message: ChatMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 装饰器:在内部 emitter 广播前,先将消息持久化到 DB
|
||||||
|
pub struct PersistingEmittedMessageHandler<H: EmittedMessageHandler> {
|
||||||
|
inner: H,
|
||||||
|
conversation_repository: Arc<dyn ConversationRepository>,
|
||||||
|
session_id: String,
|
||||||
|
topic_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<H: EmittedMessageHandler> PersistingEmittedMessageHandler<H> {
|
||||||
|
pub fn new(
|
||||||
|
inner: H,
|
||||||
|
conversation_repository: Arc<dyn ConversationRepository>,
|
||||||
|
session_id: impl Into<String>,
|
||||||
|
topic_id: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self { inner, conversation_repository, session_id: session_id.into(), topic_id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<H: EmittedMessageHandler> EmittedMessageHandler for PersistingEmittedMessageHandler<H> {
|
||||||
|
async fn handle(&self, message: ChatMessage) {
|
||||||
|
if let Err(e) = self.conversation_repository
|
||||||
|
.append_message_with_topic(&self.session_id, self.topic_id.as_deref(), &message)
|
||||||
|
{
|
||||||
|
tracing::error!(error = %e, session_id = %self.session_id,
|
||||||
|
"Failed to persist emitted message");
|
||||||
|
}
|
||||||
|
self.inner.handle(message).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub trait SkillProvider: Send + Sync + 'static {
|
pub trait SkillProvider: Send + Sync + 'static {
|
||||||
fn system_index_prompt(&self) -> Option<String>;
|
fn system_index_prompt(&self) -> Option<String>;
|
||||||
|
|
||||||
@ -884,6 +917,7 @@ impl AgentLoop {
|
|||||||
let assistant_message =
|
let assistant_message =
|
||||||
ChatMessage::assistant(recoverable_llm_message(&e.to_string()));
|
ChatMessage::assistant(recoverable_llm_message(&e.to_string()));
|
||||||
emitted_messages.push(assistant_message.clone());
|
emitted_messages.push(assistant_message.clone());
|
||||||
|
self.emit_live_tool_call_message(assistant_message.clone()).await;
|
||||||
return Ok(AgentProcessResult {
|
return Ok(AgentProcessResult {
|
||||||
final_response: assistant_message,
|
final_response: assistant_message,
|
||||||
emitted_messages,
|
emitted_messages,
|
||||||
@ -908,6 +942,7 @@ impl AgentLoop {
|
|||||||
ChatMessage::assistant(response.content)
|
ChatMessage::assistant(response.content)
|
||||||
};
|
};
|
||||||
emitted_messages.push(assistant_message.clone());
|
emitted_messages.push(assistant_message.clone());
|
||||||
|
self.emit_live_tool_call_message(assistant_message.clone()).await;
|
||||||
return Ok(AgentProcessResult {
|
return Ok(AgentProcessResult {
|
||||||
final_response: assistant_message,
|
final_response: assistant_message,
|
||||||
emitted_messages,
|
emitted_messages,
|
||||||
@ -1013,6 +1048,7 @@ impl AgentLoop {
|
|||||||
tool_call.name,
|
tool_call.name,
|
||||||
));
|
));
|
||||||
emitted_messages.push(assistant_message.clone());
|
emitted_messages.push(assistant_message.clone());
|
||||||
|
self.emit_live_tool_call_message(assistant_message.clone()).await;
|
||||||
return Ok(AgentProcessResult {
|
return Ok(AgentProcessResult {
|
||||||
final_response: assistant_message,
|
final_response: assistant_message,
|
||||||
emitted_messages,
|
emitted_messages,
|
||||||
@ -1089,6 +1125,7 @@ impl AgentLoop {
|
|||||||
ChatMessage::assistant(response.content)
|
ChatMessage::assistant(response.content)
|
||||||
};
|
};
|
||||||
emitted_messages.push(assistant_message.clone());
|
emitted_messages.push(assistant_message.clone());
|
||||||
|
self.emit_live_tool_call_message(assistant_message.clone()).await;
|
||||||
Ok(AgentProcessResult {
|
Ok(AgentProcessResult {
|
||||||
final_response: assistant_message,
|
final_response: assistant_message,
|
||||||
emitted_messages,
|
emitted_messages,
|
||||||
@ -1104,6 +1141,7 @@ impl AgentLoop {
|
|||||||
);
|
);
|
||||||
let final_message = ChatMessage::assistant(recoverable_llm_message(&e.to_string()));
|
let final_message = ChatMessage::assistant(recoverable_llm_message(&e.to_string()));
|
||||||
emitted_messages.push(final_message.clone());
|
emitted_messages.push(final_message.clone());
|
||||||
|
self.emit_live_tool_call_message(final_message.clone()).await;
|
||||||
Ok(AgentProcessResult {
|
Ok(AgentProcessResult {
|
||||||
final_response: final_message,
|
final_response: final_message,
|
||||||
emitted_messages,
|
emitted_messages,
|
||||||
|
|||||||
@ -4,7 +4,8 @@ pub mod runtime_config;
|
|||||||
pub mod system_prompt;
|
pub mod system_prompt;
|
||||||
|
|
||||||
pub use agent_loop::{
|
pub use agent_loop::{
|
||||||
AgentError, AgentLoop, AgentProcessResult, EmittedMessageHandler, SkillProvider,
|
AgentError, AgentLoop, AgentProcessResult, EmittedMessageHandler,
|
||||||
|
PersistingEmittedMessageHandler, SkillProvider,
|
||||||
};
|
};
|
||||||
pub use context_compressor::ContextCompressor;
|
pub use context_compressor::ContextCompressor;
|
||||||
pub use runtime_config::AgentRuntimeConfig;
|
pub use runtime_config::AgentRuntimeConfig;
|
||||||
|
|||||||
@ -11,6 +11,7 @@ use serde::Deserialize;
|
|||||||
use tokio::sync::{RwLock, broadcast};
|
use tokio::sync::{RwLock, broadcast};
|
||||||
|
|
||||||
use crate::bus::{MediaItem, MessageBus, OutboundMessage};
|
use crate::bus::{MediaItem, MessageBus, OutboundMessage};
|
||||||
|
use crate::bus::message::OutboundEventKind;
|
||||||
use crate::channels::base::{Channel, ChannelError};
|
use crate::channels::base::{Channel, ChannelError};
|
||||||
use crate::config::{FeishuChannelConfig, LLMProviderConfig};
|
use crate::config::{FeishuChannelConfig, LLMProviderConfig};
|
||||||
use crate::text::{char_count, truncate_with_ellipsis};
|
use crate::text::{char_count, truncate_with_ellipsis};
|
||||||
@ -2401,6 +2402,12 @@ impl Channel for FeishuChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
|
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
|
||||||
|
if matches!(msg.event_kind, OutboundEventKind::ToolResult | OutboundEventKind::ToolPending)
|
||||||
|
|| msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let receive_id = if msg.chat_id.starts_with("oc_") {
|
let receive_id = if msg.chat_id.starts_with("oc_") {
|
||||||
&msg.chat_id
|
&msg.chat_id
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ use tokio::task::JoinHandle;
|
|||||||
use wechatbot::{BotOptions, SendContent, WeChatBot};
|
use wechatbot::{BotOptions, SendContent, WeChatBot};
|
||||||
|
|
||||||
use crate::bus::{InboundMessage, MediaItem, MessageBus, OutboundMessage};
|
use crate::bus::{InboundMessage, MediaItem, MessageBus, OutboundMessage};
|
||||||
|
use crate::bus::message::OutboundEventKind;
|
||||||
use crate::channels::base::{Channel, ChannelError};
|
use crate::channels::base::{Channel, ChannelError};
|
||||||
use crate::config::{LLMProviderConfig, WechatChannelConfig};
|
use crate::config::{LLMProviderConfig, WechatChannelConfig};
|
||||||
|
|
||||||
@ -286,6 +287,12 @@ impl Channel for WechatChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
|
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
|
||||||
|
if matches!(msg.event_kind, OutboundEventKind::ToolResult | OutboundEventKind::ToolPending)
|
||||||
|
|| msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let text = msg.content.trim().to_string();
|
let text = msg.content.trim().to_string();
|
||||||
let mut text_sent = false;
|
let mut text_sent = false;
|
||||||
|
|
||||||
|
|||||||
@ -77,7 +77,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(), subagent_task_id: None,
|
||||||
},
|
},
|
||||||
MessageKind::Notification => {
|
MessageKind::Notification => {
|
||||||
// 根据元数据判断具体类型
|
// 根据元数据判断具体类型
|
||||||
@ -97,7 +97,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(), subagent_task_id: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else if let Some(session_id) = response.metadata.get("session_id") {
|
} else if let Some(session_id) = response.metadata.get("session_id") {
|
||||||
@ -136,7 +136,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(), subagent_task_id: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else if let Some(sessions_json) = response.metadata.get("sessions") {
|
} else if let Some(sessions_json) = response.metadata.get("sessions") {
|
||||||
@ -154,7 +154,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(), subagent_task_id: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else if let Some(topics_json) = response.metadata.get("topics") {
|
} else if let Some(topics_json) = response.metadata.get("topics") {
|
||||||
@ -173,7 +173,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(), subagent_task_id: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -182,7 +182,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(), subagent_task_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -194,7 +194,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(), subagent_task_id: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
outbounds.push(outbound);
|
outbounds.push(outbound);
|
||||||
|
|||||||
171
src/command/handlers/load_task_messages.rs
Normal file
171
src/command/handlers/load_task_messages.rs
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
use crate::command::context::CommandContext;
|
||||||
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
|
use crate::command::response::{CommandError, CommandResponse};
|
||||||
|
use crate::command::Command;
|
||||||
|
use crate::storage::SessionStore;
|
||||||
|
use crate::tools::task::repository::TaskRepository;
|
||||||
|
use crate::tools::task::types::{TaskSession, TaskSessionState};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct LoadTaskMessagesCommandHandler {
|
||||||
|
task_repository: Arc<dyn TaskRepository>,
|
||||||
|
store: Arc<SessionStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoadTaskMessagesCommandHandler {
|
||||||
|
pub fn new(
|
||||||
|
task_repository: Arc<dyn TaskRepository>,
|
||||||
|
store: Arc<SessionStore>,
|
||||||
|
) -> Self {
|
||||||
|
Self { task_repository, store }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl CommandHandler for LoadTaskMessagesCommandHandler {
|
||||||
|
fn can_handle(&self, cmd: &Command) -> bool {
|
||||||
|
matches!(cmd, Command::LoadTaskMessages { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata(&self) -> Option<CommandMetadata> {
|
||||||
|
Some(CommandMetadata {
|
||||||
|
name: "load_task_messages",
|
||||||
|
description: "加载子智能体任务的消息历史",
|
||||||
|
usage: "/load_task_messages <task_id>",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(
|
||||||
|
&self,
|
||||||
|
cmd: Command,
|
||||||
|
ctx: CommandContext,
|
||||||
|
) -> Result<CommandResponse, CommandError> {
|
||||||
|
match cmd {
|
||||||
|
Command::LoadTaskMessages { task_id } => {
|
||||||
|
handle_load_task_messages(self, task_id, ctx).await
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_load_task_messages(
|
||||||
|
handler: &LoadTaskMessagesCommandHandler,
|
||||||
|
task_id: String,
|
||||||
|
ctx: CommandContext,
|
||||||
|
) -> Result<CommandResponse, CommandError> {
|
||||||
|
tracing::info!(
|
||||||
|
task_id = %task_id,
|
||||||
|
request_id = %ctx.request_id,
|
||||||
|
"LoadTaskMessages: looking up task"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Try in-memory repository first
|
||||||
|
let task = match handler
|
||||||
|
.task_repository
|
||||||
|
.load_task_session(&task_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(task)) => {
|
||||||
|
tracing::info!(
|
||||||
|
task_id = %task.id,
|
||||||
|
session_id = %task.session_id,
|
||||||
|
state = ?task.state,
|
||||||
|
"LoadTaskMessages: task found in memory"
|
||||||
|
);
|
||||||
|
Some(task)
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
tracing::info!(
|
||||||
|
task_id = %task_id,
|
||||||
|
"LoadTaskMessages: task not in memory, searching database"
|
||||||
|
);
|
||||||
|
// 2. Fall back to database (survives restarts)
|
||||||
|
reconstruct_task_from_db(&handler.store, &task_id)?
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
task_id = %task_id,
|
||||||
|
error = %e,
|
||||||
|
"LoadTaskMessages: repository error during lookup"
|
||||||
|
);
|
||||||
|
return Err(CommandError::new("LOAD_TASK_ERROR", e.to_string()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let task = task.ok_or_else(|| {
|
||||||
|
tracing::warn!(
|
||||||
|
task_id = %task_id,
|
||||||
|
"LoadTaskMessages: task not found in repository or database"
|
||||||
|
);
|
||||||
|
CommandError::new("TASK_NOT_FOUND", format!("Task not found: {}", task_id))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = format!("{:?}", task.state).to_lowercase();
|
||||||
|
|
||||||
|
let mut response = CommandResponse::success(ctx.request_id)
|
||||||
|
.with_metadata("task_session_id", &task.session_id)
|
||||||
|
.with_metadata("task_id", &task.id)
|
||||||
|
.with_metadata("task_description", &task.description)
|
||||||
|
.with_metadata("task_subagent_type", &task.subagent_type)
|
||||||
|
.with_metadata("task_status", &status);
|
||||||
|
|
||||||
|
if let Some(ref summary) = task.summary {
|
||||||
|
response = response.with_metadata("task_summary", summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconstruct a TaskSession from the database when it's not in the in-memory repository.
|
||||||
|
/// Task sessions have id format: sub:{parent_session_id}:task:{uuid}
|
||||||
|
fn reconstruct_task_from_db(
|
||||||
|
store: &SessionStore,
|
||||||
|
task_id: &str,
|
||||||
|
) -> Result<Option<TaskSession>, CommandError> {
|
||||||
|
let sessions = store
|
||||||
|
.find_sessions_by_id_suffix(&format!(":{}", task_id))
|
||||||
|
.map_err(|e| CommandError::new("DB_ERROR", e.to_string()))?;
|
||||||
|
|
||||||
|
if sessions.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let record = &sessions[0];
|
||||||
|
let session_id = record.id.clone();
|
||||||
|
|
||||||
|
// Extract parent_session_id from session_id: "sub:{parent}:task:{uuid}"
|
||||||
|
let parent_session_id = session_id
|
||||||
|
.strip_prefix("sub:")
|
||||||
|
.and_then(|rest| {
|
||||||
|
let pos = rest.find(":task:");
|
||||||
|
pos.map(|p| rest[..p].to_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Extract description from title: "Subagent: {description}"
|
||||||
|
let description = record
|
||||||
|
.title
|
||||||
|
.strip_prefix("Subagent: ")
|
||||||
|
.unwrap_or(&record.title)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let now = record.updated_at;
|
||||||
|
|
||||||
|
Ok(Some(TaskSession {
|
||||||
|
id: task_id.to_string(),
|
||||||
|
session_id,
|
||||||
|
parent_session_id,
|
||||||
|
parent_topic_id: None,
|
||||||
|
parent_chat_id: record.chat_id.clone(),
|
||||||
|
parent_channel_name: record.channel_name.clone(),
|
||||||
|
description,
|
||||||
|
subagent_type: "general".to_string(),
|
||||||
|
state: TaskSessionState::Completed,
|
||||||
|
created_at: record.created_at,
|
||||||
|
updated_at: now,
|
||||||
|
summary: None,
|
||||||
|
error: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ pub mod list_channels;
|
|||||||
pub mod list_sessions;
|
pub mod list_sessions;
|
||||||
pub mod list_sessions_by_channel;
|
pub mod list_sessions_by_channel;
|
||||||
pub mod list_topics;
|
pub mod list_topics;
|
||||||
|
pub mod load_task_messages;
|
||||||
pub mod load_topic;
|
pub mod load_topic;
|
||||||
pub mod save_session;
|
pub mod save_session;
|
||||||
pub mod save_topic;
|
pub mod save_topic;
|
||||||
|
|||||||
@ -43,6 +43,8 @@ pub enum Command {
|
|||||||
},
|
},
|
||||||
/// 列出 Session 的所有 Topics
|
/// 列出 Session 的所有 Topics
|
||||||
ListTopics { session_id: String },
|
ListTopics { session_id: String },
|
||||||
|
/// 加载子智能体任务的消息历史
|
||||||
|
LoadTaskMessages { task_id: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Command {
|
impl Command {
|
||||||
@ -60,6 +62,7 @@ impl Command {
|
|||||||
Command::ListChannels => "list_channels",
|
Command::ListChannels => "list_channels",
|
||||||
Command::ListSessionsByChannel { .. } => "list_sessions_by_channel",
|
Command::ListSessionsByChannel { .. } => "list_sessions_by_channel",
|
||||||
Command::ListTopics { .. } => "list_topics",
|
Command::ListTopics { .. } => "list_topics",
|
||||||
|
Command::LoadTaskMessages { .. } => "load_task_messages",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -157,7 +157,8 @@ impl AgentExecutionService {
|
|||||||
.emitted_messages
|
.emitted_messages
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|message| {
|
.filter(|message| {
|
||||||
(!message.is_assistant_tool_call_message() || !request.suppress_live_tool_calls)
|
// 当存在 live_emitter 时,所有消息已在 loop 中实时广播,不需要 post-loop 发送
|
||||||
|
!request.suppress_live_tool_calls
|
||||||
&& should_display_message_to_user(self.show_tool_results, message)
|
&& should_display_message_to_user(self.show_tool_results, message)
|
||||||
})
|
})
|
||||||
.flat_map(|message| {
|
.flat_map(|message| {
|
||||||
|
|||||||
@ -91,6 +91,7 @@ impl GatewayState {
|
|||||||
config.memory_maintenance.clone(),
|
config.memory_maintenance.clone(),
|
||||||
session_ttl_hours,
|
session_ttl_hours,
|
||||||
mcp_config,
|
mcp_config,
|
||||||
|
Some(bus.clone()),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
use crate::agent::{AgentError, CompositeSystemPromptProvider};
|
use crate::agent::{AgentError, CompositeSystemPromptProvider, PersistingEmittedMessageHandler};
|
||||||
use crate::bus::{InboundMessage, MessageBus, OutboundMessage};
|
use crate::bus::{InboundMessage, MessageBus, OutboundMessage};
|
||||||
use crate::command::adapter::InputAdapter;
|
use crate::command::adapter::InputAdapter;
|
||||||
use crate::command::adapters::channel::ChannelInputAdapter;
|
use crate::command::adapters::channel::ChannelInputAdapter;
|
||||||
@ -218,11 +218,16 @@ impl InboundProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 普通消息进入 AgentLoop
|
// 普通消息进入 AgentLoop
|
||||||
let live_emitter = Arc::new(BusToolCallEmitter::new(
|
let live_emitter = Arc::new(PersistingEmittedMessageHandler::new(
|
||||||
|
BusToolCallEmitter::new(
|
||||||
self.bus.clone(),
|
self.bus.clone(),
|
||||||
inbound.channel.clone(),
|
inbound.channel.clone(),
|
||||||
inbound.chat_id.clone(),
|
inbound.chat_id.clone(),
|
||||||
inbound.forwarded_metadata.clone(),
|
inbound.forwarded_metadata.clone(),
|
||||||
|
),
|
||||||
|
self.session_manager.store(),
|
||||||
|
&session_id,
|
||||||
|
current_topic.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
match self
|
match self
|
||||||
|
|||||||
@ -4,6 +4,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::agent::AgentError;
|
use crate::agent::AgentError;
|
||||||
|
use crate::bus::MessageBus;
|
||||||
use crate::config::{LLMProviderConfig, MemoryMaintenanceConfig, SubagentsConfig, TaskConfig};
|
use crate::config::{LLMProviderConfig, MemoryMaintenanceConfig, SubagentsConfig, TaskConfig};
|
||||||
use crate::gateway::tool_registry_factory::ToolRegistryFactory;
|
use crate::gateway::tool_registry_factory::ToolRegistryFactory;
|
||||||
use crate::mcp::McpInitializer;
|
use crate::mcp::McpInitializer;
|
||||||
@ -44,6 +45,7 @@ pub(crate) fn build_session_manager(
|
|||||||
maintenance_config: MemoryMaintenanceConfig,
|
maintenance_config: MemoryMaintenanceConfig,
|
||||||
session_ttl_hours: Option<u64>,
|
session_ttl_hours: Option<u64>,
|
||||||
mcp_config: crate::mcp::McpConfig,
|
mcp_config: crate::mcp::McpConfig,
|
||||||
|
bus: Option<Arc<MessageBus>>,
|
||||||
) -> Result<(SessionManager, Arc<dyn TaskRepository>), AgentError> {
|
) -> Result<(SessionManager, Arc<dyn TaskRepository>), AgentError> {
|
||||||
build_session_manager_with_sender(
|
build_session_manager_with_sender(
|
||||||
agent_prompt_reinject_every,
|
agent_prompt_reinject_every,
|
||||||
@ -59,6 +61,7 @@ pub(crate) fn build_session_manager(
|
|||||||
maintenance_config,
|
maintenance_config,
|
||||||
session_ttl_hours,
|
session_ttl_hours,
|
||||||
mcp_config,
|
mcp_config,
|
||||||
|
bus,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,6 +80,7 @@ pub(crate) fn build_session_manager_with_sender(
|
|||||||
maintenance_config: MemoryMaintenanceConfig,
|
maintenance_config: MemoryMaintenanceConfig,
|
||||||
session_ttl_hours: Option<u64>,
|
session_ttl_hours: Option<u64>,
|
||||||
mcp_config: crate::mcp::McpConfig,
|
mcp_config: crate::mcp::McpConfig,
|
||||||
|
bus: Option<Arc<MessageBus>>,
|
||||||
) -> Result<(SessionManager, Arc<dyn TaskRepository>), AgentError> {
|
) -> Result<(SessionManager, Arc<dyn TaskRepository>), AgentError> {
|
||||||
let store = Arc::new(
|
let store = Arc::new(
|
||||||
SessionStore::new()
|
SessionStore::new()
|
||||||
@ -188,6 +192,7 @@ pub(crate) fn build_session_manager_with_sender(
|
|||||||
subagent_tools,
|
subagent_tools,
|
||||||
provider_config.clone(),
|
provider_config.clone(),
|
||||||
catalog,
|
catalog,
|
||||||
|
bus.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
(factory.with_subagent_runtime(subagent_runtime), task_repository)
|
(factory.with_subagent_runtime(subagent_runtime), task_repository)
|
||||||
|
|||||||
@ -505,6 +505,7 @@ impl SessionManager {
|
|||||||
maintenance_config,
|
maintenance_config,
|
||||||
session_ttl_hours,
|
session_ttl_hours,
|
||||||
mcp_config,
|
mcp_config,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.map(|(session_manager, _)| session_manager)
|
.map(|(session_manager, _)| session_manager)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -145,23 +145,6 @@ impl SessionHistory {
|
|||||||
.map_err(|err| AgentError::Other(format!("clear history persistence error: {}", err)))
|
.map_err(|err| AgentError::Other(format!("clear history persistence error: {}", err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn append_persisted_message(
|
|
||||||
&mut self,
|
|
||||||
chat_id: &str,
|
|
||||||
message: ChatMessage,
|
|
||||||
) -> Result<(), AgentError> {
|
|
||||||
let session_id = self.persistent_session_id(chat_id);
|
|
||||||
// 获取当前话题 ID,用于关联消息
|
|
||||||
let topic_id = self.chat_topic_ids.get(chat_id).map(|s| s.as_str());
|
|
||||||
self.conversations
|
|
||||||
.append_message_with_topic(&session_id, topic_id, &message)
|
|
||||||
.map_err(|err| {
|
|
||||||
AgentError::Other(format!("append message persistence error: {}", err))
|
|
||||||
})?;
|
|
||||||
self.add_message(chat_id, message);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn append_persisted_messages<I>(
|
pub(crate) fn append_persisted_messages<I>(
|
||||||
&mut self,
|
&mut self,
|
||||||
chat_id: &str,
|
chat_id: &str,
|
||||||
@ -170,30 +153,28 @@ impl SessionHistory {
|
|||||||
where
|
where
|
||||||
I: IntoIterator<Item = ChatMessage>,
|
I: IntoIterator<Item = ChatMessage>,
|
||||||
{
|
{
|
||||||
|
let messages: Vec<ChatMessage> = messages.into_iter().collect();
|
||||||
|
if messages.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
for message in messages {
|
for message in messages {
|
||||||
self.append_persisted_message(chat_id, message)?;
|
self.add_message(chat_id, message);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将消息保存到指定话题(直接写入数据库,不更新内存历史)
|
/// 将消息保存到指定话题
|
||||||
/// 用于异步执行结果保存到原始话题的场景
|
/// 每条消息已通过 PersistingEmittedMessageHandler 逐条持久化,此处仅保留接口兼容
|
||||||
pub(crate) fn append_to_topic(
|
pub(crate) fn append_to_topic(
|
||||||
&self,
|
&self,
|
||||||
chat_id: &str,
|
_chat_id: &str,
|
||||||
topic_id: &str,
|
_topic_id: &str,
|
||||||
messages: &[ChatMessage],
|
messages: &[ChatMessage],
|
||||||
) -> Result<(), AgentError> {
|
) -> Result<(), AgentError> {
|
||||||
let session_id = self.persistent_session_id(chat_id);
|
if messages.is_empty() {
|
||||||
|
return Ok(());
|
||||||
for message in messages {
|
|
||||||
self.conversations
|
|
||||||
.append_message_with_topic(&session_id, Some(topic_id), message)
|
|
||||||
.map_err(|err| {
|
|
||||||
AgentError::Other(format!("append message to topic error: {}", err))
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ use crate::command::handlers::list_channels::ListChannelsCommandHandler;
|
|||||||
use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
|
use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
|
||||||
use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler;
|
use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler;
|
||||||
use crate::command::handlers::list_topics::ListTopicsCommandHandler;
|
use crate::command::handlers::list_topics::ListTopicsCommandHandler;
|
||||||
|
use crate::command::handlers::load_task_messages::LoadTaskMessagesCommandHandler;
|
||||||
use crate::command::handlers::load_topic::LoadTopicCommandHandler;
|
use crate::command::handlers::load_topic::LoadTopicCommandHandler;
|
||||||
use crate::command::handlers::save_session::SaveSessionCommandHandler;
|
use crate::command::handlers::save_session::SaveSessionCommandHandler;
|
||||||
use crate::command::handlers::session::SessionCommandHandler;
|
use crate::command::handlers::session::SessionCommandHandler;
|
||||||
@ -299,6 +300,11 @@ async fn handle_inbound(
|
|||||||
router.register(Box::new(GetCurrentSessionCommandHandler::new(store.clone())));
|
router.register(Box::new(GetCurrentSessionCommandHandler::new(store.clone())));
|
||||||
// 注册 load_topic 处理器
|
// 注册 load_topic 处理器
|
||||||
router.register(Box::new(LoadTopicCommandHandler::new(store.clone())));
|
router.register(Box::new(LoadTopicCommandHandler::new(store.clone())));
|
||||||
|
// 注册 load_task_messages 处理器
|
||||||
|
router.register(Box::new(LoadTaskMessagesCommandHandler::new(
|
||||||
|
state.task_repository.clone(),
|
||||||
|
store.clone(),
|
||||||
|
)));
|
||||||
router.register(Box::new(SaveSessionCommandHandler::new(
|
router.register(Box::new(SaveSessionCommandHandler::new(
|
||||||
store.clone(),
|
store.clone(),
|
||||||
state.task_repository.clone(),
|
state.task_repository.clone(),
|
||||||
@ -359,6 +365,28 @@ async fn handle_inbound(
|
|||||||
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send topic history");
|
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send topic history");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 加载子智能体任务消息
|
||||||
|
if let Some(task_session_id) = response.metadata.get("task_session_id") {
|
||||||
|
if let Err(e) = send_task_messages(&store, task_session_id, sender).await {
|
||||||
|
tracing::warn!(error = %e, task_session_id = %task_session_id, "Failed to send task messages");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送 TaskMessagesLoaded 元数据
|
||||||
|
let task_id = response.metadata.get("task_id").cloned().unwrap_or_default();
|
||||||
|
let description = response.metadata.get("task_description").cloned().unwrap_or_default();
|
||||||
|
let subagent_type = response.metadata.get("task_subagent_type").cloned().unwrap_or_default();
|
||||||
|
let status = response.metadata.get("task_status").cloned().unwrap_or_default();
|
||||||
|
let summary = response.metadata.get("task_summary").cloned();
|
||||||
|
|
||||||
|
let _ = sender.send(WsOutbound::TaskMessagesLoaded {
|
||||||
|
task_id,
|
||||||
|
description,
|
||||||
|
subagent_type,
|
||||||
|
status,
|
||||||
|
summary,
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
|
||||||
if current_topic_id.is_none() {
|
if current_topic_id.is_none() {
|
||||||
if let Some(topics_json) = response.metadata.get("topics") {
|
if let Some(topics_json) = response.metadata.get("topics") {
|
||||||
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
|
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
|
||||||
@ -438,6 +466,26 @@ async fn send_topic_history(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 加载并发送子智能体任务的历史消息
|
||||||
|
async fn send_task_messages(
|
||||||
|
store: &Arc<crate::storage::SessionStore>,
|
||||||
|
session_id: &str,
|
||||||
|
sender: &mpsc::Sender<WsOutbound>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let messages = store.load_messages(session_id)?;
|
||||||
|
|
||||||
|
tracing::info!(session_id = %session_id, message_count = messages.len(), "Sending task messages");
|
||||||
|
|
||||||
|
for msg in messages {
|
||||||
|
let outbound = chat_message_to_ws_outbound(&msg);
|
||||||
|
if let Some(outbound) = outbound {
|
||||||
|
let _ = sender.send(outbound).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 将 ChatMessage 转换为 WsOutbound
|
/// 将 ChatMessage 转换为 WsOutbound
|
||||||
fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbound> {
|
fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbound> {
|
||||||
use crate::bus::message::ToolMessageState;
|
use crate::bus::message::ToolMessageState;
|
||||||
@ -454,6 +502,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
|
|||||||
arguments: tool_call.arguments.clone(),
|
arguments: tool_call.arguments.clone(),
|
||||||
content: format!("{}\nargs: {}", tool_call.name, tool_call.arguments),
|
content: format!("{}\nargs: {}", tool_call.name, tool_call.arguments),
|
||||||
role: msg.role.clone(),
|
role: msg.role.clone(),
|
||||||
|
subagent_task_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -463,6 +512,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
|
|||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: msg.role.clone(),
|
role: msg.role.clone(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
|
subagent_task_id: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
"tool" => {
|
"tool" => {
|
||||||
@ -474,6 +524,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
|
|||||||
tool_name: msg.tool_name.clone().unwrap_or_default(),
|
tool_name: msg.tool_name.clone().unwrap_or_default(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: msg.role.clone(),
|
role: msg.role.clone(),
|
||||||
|
subagent_task_id: None,
|
||||||
}),
|
}),
|
||||||
ToolMessageState::PendingUserAction => Some(WsOutbound::ToolPending {
|
ToolMessageState::PendingUserAction => Some(WsOutbound::ToolPending {
|
||||||
id: msg.id.clone(),
|
id: msg.id.clone(),
|
||||||
@ -482,6 +533,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
|
|||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: msg.role.clone(),
|
role: msg.role.clone(),
|
||||||
resume_hint: "完成外部操作后,直接发一条继续消息即可。".to_string(),
|
resume_hint: "完成外部操作后,直接发一条继续消息即可。".to_string(),
|
||||||
|
subagent_task_id: None,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -490,6 +542,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
|
|||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: msg.role.clone(),
|
role: msg.role.clone(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
|
subagent_task_id: None,
|
||||||
}),
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,6 +79,8 @@ pub enum WsOutbound {
|
|||||||
role: String,
|
role: String,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
attachments: Vec<MediaSummary>,
|
attachments: Vec<MediaSummary>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
subagent_task_id: Option<String>,
|
||||||
},
|
},
|
||||||
#[serde(rename = "tool_call")]
|
#[serde(rename = "tool_call")]
|
||||||
ToolCall {
|
ToolCall {
|
||||||
@ -88,6 +90,8 @@ pub enum WsOutbound {
|
|||||||
arguments: serde_json::Value,
|
arguments: serde_json::Value,
|
||||||
content: String,
|
content: String,
|
||||||
role: String,
|
role: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
subagent_task_id: Option<String>,
|
||||||
},
|
},
|
||||||
#[serde(rename = "tool_result")]
|
#[serde(rename = "tool_result")]
|
||||||
ToolResult {
|
ToolResult {
|
||||||
@ -96,6 +100,8 @@ pub enum WsOutbound {
|
|||||||
tool_name: String,
|
tool_name: String,
|
||||||
content: String,
|
content: String,
|
||||||
role: String,
|
role: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
subagent_task_id: Option<String>,
|
||||||
},
|
},
|
||||||
#[serde(rename = "tool_pending")]
|
#[serde(rename = "tool_pending")]
|
||||||
ToolPending {
|
ToolPending {
|
||||||
@ -105,6 +111,8 @@ pub enum WsOutbound {
|
|||||||
content: String,
|
content: String,
|
||||||
role: String,
|
role: String,
|
||||||
resume_hint: String,
|
resume_hint: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
subagent_task_id: Option<String>,
|
||||||
},
|
},
|
||||||
#[serde(rename = "error")]
|
#[serde(rename = "error")]
|
||||||
Error { code: String, message: String },
|
Error { code: String, message: String },
|
||||||
@ -137,6 +145,15 @@ pub enum WsOutbound {
|
|||||||
},
|
},
|
||||||
#[serde(rename = "session_saved")]
|
#[serde(rename = "session_saved")]
|
||||||
SessionSaved { session_id: String, filepath: String },
|
SessionSaved { session_id: String, filepath: String },
|
||||||
|
#[serde(rename = "task_messages_loaded")]
|
||||||
|
TaskMessagesLoaded {
|
||||||
|
task_id: String,
|
||||||
|
description: String,
|
||||||
|
subagent_type: String,
|
||||||
|
status: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
summary: Option<String>,
|
||||||
|
},
|
||||||
#[serde(rename = "pong")]
|
#[serde(rename = "pong")]
|
||||||
Pong,
|
Pong,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
|||||||
content: message.content.clone(),
|
content: message.content.clone(),
|
||||||
role: message.role.clone(),
|
role: message.role.clone(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
|
subagent_task_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,6 +32,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
|||||||
arguments: tool_call.arguments.clone(),
|
arguments: tool_call.arguments.clone(),
|
||||||
content: format_tool_call_content(&tool_call.name, &tool_call.arguments),
|
content: format_tool_call_content(&tool_call.name, &tool_call.arguments),
|
||||||
role: message.role.clone(),
|
role: message.role.clone(),
|
||||||
|
subagent_task_id: None,
|
||||||
}));
|
}));
|
||||||
outbound
|
outbound
|
||||||
} else {
|
} else {
|
||||||
@ -39,6 +41,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
|||||||
content: message.content.clone(),
|
content: message.content.clone(),
|
||||||
role: message.role.clone(),
|
role: message.role.clone(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
|
subagent_task_id: None,
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -53,6 +56,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
|||||||
tool_name: message.tool_name.clone().unwrap_or_default(),
|
tool_name: message.tool_name.clone().unwrap_or_default(),
|
||||||
content: message.content.clone(),
|
content: message.content.clone(),
|
||||||
role: message.role.clone(),
|
role: message.role.clone(),
|
||||||
|
subagent_task_id: None,
|
||||||
}],
|
}],
|
||||||
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
|
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
|
||||||
id: message.id.clone(),
|
id: message.id.clone(),
|
||||||
@ -61,6 +65,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
|||||||
content: message.content.clone(),
|
content: message.content.clone(),
|
||||||
role: message.role.clone(),
|
role: message.role.clone(),
|
||||||
resume_hint: TOOL_PENDING_RESUME_HINT.to_string(),
|
resume_hint: TOOL_PENDING_RESUME_HINT.to_string(),
|
||||||
|
subagent_task_id: None,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
@ -86,6 +91,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
|
|||||||
content: message.content.clone(),
|
content: message.content.clone(),
|
||||||
role: message.role.clone(),
|
role: message.role.clone(),
|
||||||
attachments,
|
attachments,
|
||||||
|
subagent_task_id: message.metadata.get("subagent_task_id").cloned(),
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
OutboundEventKind::ToolCall => vec![WsOutbound::ToolCall {
|
OutboundEventKind::ToolCall => vec![WsOutbound::ToolCall {
|
||||||
@ -101,6 +107,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
|
|||||||
.unwrap_or(serde_json::Value::Null),
|
.unwrap_or(serde_json::Value::Null),
|
||||||
content: message.content.clone(),
|
content: message.content.clone(),
|
||||||
role: message.role.clone(),
|
role: message.role.clone(),
|
||||||
|
subagent_task_id: message.metadata.get("subagent_task_id").cloned(),
|
||||||
}],
|
}],
|
||||||
OutboundEventKind::ToolResult => vec![WsOutbound::ToolResult {
|
OutboundEventKind::ToolResult => vec![WsOutbound::ToolResult {
|
||||||
id: message
|
id: message
|
||||||
@ -111,6 +118,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
|
|||||||
tool_name: message.tool_name.clone().unwrap_or_default(),
|
tool_name: message.tool_name.clone().unwrap_or_default(),
|
||||||
content: message.content.clone(),
|
content: message.content.clone(),
|
||||||
role: message.role.clone(),
|
role: message.role.clone(),
|
||||||
|
subagent_task_id: message.metadata.get("subagent_task_id").cloned(),
|
||||||
}],
|
}],
|
||||||
OutboundEventKind::ToolPending => vec![WsOutbound::ToolPending {
|
OutboundEventKind::ToolPending => vec![WsOutbound::ToolPending {
|
||||||
id: message
|
id: message
|
||||||
@ -122,6 +130,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
|
|||||||
content: message.content.clone(),
|
content: message.content.clone(),
|
||||||
role: message.role.clone(),
|
role: message.role.clone(),
|
||||||
resume_hint: TOOL_PENDING_RESUME_HINT.to_string(),
|
resume_hint: TOOL_PENDING_RESUME_HINT.to_string(),
|
||||||
|
subagent_task_id: message.metadata.get("subagent_task_id").cloned(),
|
||||||
}],
|
}],
|
||||||
OutboundEventKind::ErrorNotification => vec![WsOutbound::Error {
|
OutboundEventKind::ErrorNotification => vec![WsOutbound::Error {
|
||||||
code: "AGENT_ERROR".to_string(),
|
code: "AGENT_ERROR".to_string(),
|
||||||
|
|||||||
@ -319,6 +319,33 @@ impl SessionStore {
|
|||||||
.map_err(StorageError::from)
|
.map_err(StorageError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find sessions whose id ends with the given suffix (used for task session lookup)
|
||||||
|
pub fn find_sessions_by_id_suffix(
|
||||||
|
&self,
|
||||||
|
suffix: &str,
|
||||||
|
) -> Result<Vec<SessionRecord>, StorageError> {
|
||||||
|
let conn = self.conn.lock().expect("session db mutex poisoned");
|
||||||
|
let pattern = format!("%{}", suffix);
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"
|
||||||
|
SELECT id, title, channel_name, chat_id, summary,
|
||||||
|
created_at, updated_at, last_active_at,
|
||||||
|
archived_at, deleted_at, message_count,
|
||||||
|
user_turn_count, agent_prompt_reinjection_count
|
||||||
|
FROM sessions
|
||||||
|
WHERE id LIKE ?1 AND deleted_at IS NULL
|
||||||
|
ORDER BY last_active_at DESC
|
||||||
|
",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let rows = stmt.query_map(params![pattern], map_session_record)?;
|
||||||
|
let mut sessions = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
sessions.push(row?);
|
||||||
|
}
|
||||||
|
Ok(sessions)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_sessions(
|
pub fn list_sessions(
|
||||||
&self,
|
&self,
|
||||||
channel_name: &str,
|
channel_name: &str,
|
||||||
@ -590,6 +617,92 @@ impl SessionStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn append_messages_batch(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
topic_id: Option<&str>,
|
||||||
|
messages: &[ChatMessage],
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
if messages.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = self.conn.lock().expect("session db mutex poisoned");
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
|
||||||
|
let mut seq: i64 = tx.query_row(
|
||||||
|
"SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?1",
|
||||||
|
params![session_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
for message in messages {
|
||||||
|
let media_refs_json = serde_json::to_string(&message.media_refs)?;
|
||||||
|
let tool_calls_json = message
|
||||||
|
.tool_calls
|
||||||
|
.as_ref()
|
||||||
|
.map(serde_json::to_string)
|
||||||
|
.transpose()?;
|
||||||
|
tx.execute(
|
||||||
|
"
|
||||||
|
INSERT INTO messages (
|
||||||
|
id, session_id, topic_id, seq, role, content,
|
||||||
|
system_context, reasoning_content, media_refs_json,
|
||||||
|
tool_call_id, tool_name, tool_calls_json, created_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
|
||||||
|
",
|
||||||
|
params![
|
||||||
|
message.id,
|
||||||
|
session_id,
|
||||||
|
topic_id,
|
||||||
|
seq,
|
||||||
|
message.role,
|
||||||
|
message.content,
|
||||||
|
message.system_context,
|
||||||
|
message.reasoning_content,
|
||||||
|
media_refs_json,
|
||||||
|
message.tool_call_id,
|
||||||
|
message.tool_name,
|
||||||
|
tool_calls_json,
|
||||||
|
message.timestamp,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
seq += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = current_timestamp();
|
||||||
|
let user_msg_count: i64 = messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.role == "user")
|
||||||
|
.count()
|
||||||
|
.try_into()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let msg_count: i64 = messages.len() as i64;
|
||||||
|
|
||||||
|
tx.execute(
|
||||||
|
"
|
||||||
|
UPDATE sessions
|
||||||
|
SET message_count = message_count + ?2,
|
||||||
|
user_turn_count = user_turn_count + ?3,
|
||||||
|
updated_at = ?4,
|
||||||
|
last_active_at = ?4,
|
||||||
|
archived_at = NULL
|
||||||
|
WHERE id = ?1 AND deleted_at IS NULL
|
||||||
|
",
|
||||||
|
params![session_id, msg_count, user_msg_count, now],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if let Some(tid) = topic_id {
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE topics SET message_count = message_count + ?2, last_active_at = ?3 WHERE id = ?1",
|
||||||
|
params![tid, msg_count, now],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn compact_active_history(
|
pub fn compact_active_history(
|
||||||
&self,
|
&self,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
|
|||||||
@ -33,6 +33,13 @@ pub trait ConversationRepository: Send + Sync + 'static {
|
|||||||
message: &ChatMessage,
|
message: &ChatMessage,
|
||||||
) -> Result<(), StorageError>;
|
) -> Result<(), StorageError>;
|
||||||
|
|
||||||
|
fn append_messages_batch(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
topic_id: Option<&str>,
|
||||||
|
messages: &[ChatMessage],
|
||||||
|
) -> Result<(), StorageError>;
|
||||||
|
|
||||||
fn clear_messages(&self, session_id: &str) -> Result<(), StorageError>;
|
fn clear_messages(&self, session_id: &str) -> Result<(), StorageError>;
|
||||||
|
|
||||||
fn compact_active_history(
|
fn compact_active_history(
|
||||||
@ -178,6 +185,15 @@ impl ConversationRepository for super::SessionStore {
|
|||||||
super::SessionStore::append_message_with_topic(self, session_id, topic_id, message)
|
super::SessionStore::append_message_with_topic(self, session_id, topic_id, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn append_messages_batch(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
topic_id: Option<&str>,
|
||||||
|
messages: &[ChatMessage],
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
super::SessionStore::append_messages_batch(self, session_id, topic_id, messages)
|
||||||
|
}
|
||||||
|
|
||||||
fn clear_messages(&self, session_id: &str) -> Result<(), StorageError> {
|
fn clear_messages(&self, session_id: &str) -> Result<(), StorageError> {
|
||||||
super::SessionStore::clear_messages(self, session_id)
|
super::SessionStore::clear_messages(self, session_id)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ use std::path::Path;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::text::take_prefix_chars;
|
||||||
use crate::tools::traits::{Tool, ToolResult};
|
use crate::tools::traits::{Tool, ToolResult};
|
||||||
use crate::tools::extract_u64;
|
use crate::tools::extract_u64;
|
||||||
|
|
||||||
@ -187,8 +188,13 @@ impl Tool for FileReadTool {
|
|||||||
}
|
}
|
||||||
end_idx = i + 1;
|
end_idx = i + 1;
|
||||||
}
|
}
|
||||||
|
if end_idx == 0 && !lines.is_empty() {
|
||||||
|
// First line alone exceeds MAX_CHARS — take its prefix
|
||||||
|
result = take_prefix_chars(&lines[0], MAX_CHARS.saturating_sub(100));
|
||||||
|
} else {
|
||||||
result = lines[..end_idx].join("\n");
|
result = lines[..end_idx].join("\n");
|
||||||
let truncated_amount = original_len - result.len();
|
}
|
||||||
|
let truncated_amount = original_len.saturating_sub(result.len());
|
||||||
result.push_str(&format!(
|
result.push_str(&format!(
|
||||||
"\n\n... ({} chars truncated) ...",
|
"\n\n... ({} chars truncated) ...",
|
||||||
truncated_amount
|
truncated_amount
|
||||||
@ -312,4 +318,28 @@ mod tests {
|
|||||||
assert!(!result.success);
|
assert!(!result.success);
|
||||||
assert!(result.error.unwrap().contains("Not a file"));
|
assert!(result.error.unwrap().contains("Not a file"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_read_single_long_line() {
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
// Write a single line longer than MAX_CHARS
|
||||||
|
let long_line = "A".repeat(150_000);
|
||||||
|
file.write_all(long_line.as_bytes()).unwrap();
|
||||||
|
|
||||||
|
let tool = FileReadTool::new();
|
||||||
|
let result = tool
|
||||||
|
.execute(json!({ "path": file.path().to_str().unwrap() }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.success);
|
||||||
|
// Should contain the line number prefix and the beginning of the content
|
||||||
|
assert!(result.output.starts_with("1| AAAA"));
|
||||||
|
// Should contain truncation notice since content exceeds MAX_CHARS
|
||||||
|
assert!(result.output.contains("chars truncated"));
|
||||||
|
// Should contain end-of-file notice (1 line total)
|
||||||
|
assert!(result.output.contains("End of file — 1 lines total"));
|
||||||
|
// Should NOT be empty content — the fix ensures the prefix is preserved
|
||||||
|
assert!(result.output.len() > 100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,15 +57,35 @@ impl Default for InMemoryTaskRepository {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl TaskRepository for InMemoryTaskRepository {
|
impl TaskRepository for InMemoryTaskRepository {
|
||||||
async fn save_task_session(&self, session: &TaskSession) -> Result<(), StorageError> {
|
async fn save_task_session(&self, session: &TaskSession) -> Result<(), StorageError> {
|
||||||
|
tracing::warn!(
|
||||||
|
task_id = %session.id,
|
||||||
|
session_id = %session.session_id,
|
||||||
|
state = ?session.state,
|
||||||
|
"REPO_SAVE: Saving task session"
|
||||||
|
);
|
||||||
self.sessions
|
self.sessions
|
||||||
.write()
|
.write()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.insert(session.id.clone(), session.clone());
|
.insert(session.id.clone(), session.clone());
|
||||||
|
tracing::warn!(
|
||||||
|
task_id = %session.id,
|
||||||
|
total_tasks = self.sessions.read().unwrap().len(),
|
||||||
|
"REPO_SAVE: Task session saved, current repository size"
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_task_session(&self, task_id: &str) -> Result<Option<TaskSession>, StorageError> {
|
async fn load_task_session(&self, task_id: &str) -> Result<Option<TaskSession>, StorageError> {
|
||||||
Ok(self.sessions.read().unwrap().get(task_id).cloned())
|
let sessions = self.sessions.read().unwrap();
|
||||||
|
let total = sessions.len();
|
||||||
|
let keys: Vec<&str> = sessions.keys().map(|k| k.as_str()).collect();
|
||||||
|
tracing::warn!(
|
||||||
|
lookup_task_id = %task_id,
|
||||||
|
total_tasks = total,
|
||||||
|
all_keys = ?keys,
|
||||||
|
"REPO_LOOKUP: Looking up task session"
|
||||||
|
);
|
||||||
|
Ok(sessions.get(task_id).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_task_session(&self, task_id: &str) -> Result<bool, StorageError> {
|
async fn delete_task_session(&self, task_id: &str) -> Result<bool, StorageError> {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@ -7,8 +7,10 @@ use std::time::Duration;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::agent::{AgentLoop, AgentRuntimeConfig, SystemPrompt, SystemPromptContext, SystemPromptProvider};
|
use crate::agent::{AgentLoop, AgentRuntimeConfig, EmittedMessageHandler, PersistingEmittedMessageHandler, SystemPrompt, SystemPromptContext, SystemPromptProvider};
|
||||||
use crate::bus::ChatMessage;
|
use crate::bus::ChatMessage;
|
||||||
|
use crate::bus::message::OutboundMessage;
|
||||||
|
use crate::bus::MessageBus;
|
||||||
use crate::config::{LLMProviderConfig, SubagentsConfig};
|
use crate::config::{LLMProviderConfig, SubagentsConfig};
|
||||||
use crate::storage::ConversationRepository;
|
use crate::storage::ConversationRepository;
|
||||||
use crate::tools::{ToolContext, ToolRegistry};
|
use crate::tools::{ToolContext, ToolRegistry};
|
||||||
@ -97,6 +99,37 @@ impl StaticSystemPromptProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 子智能体工具调用实时广播器(不依赖 gateway 层)
|
||||||
|
struct SubAgentEmitter {
|
||||||
|
bus: Arc<MessageBus>,
|
||||||
|
channel_name: String,
|
||||||
|
chat_id: String,
|
||||||
|
metadata: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EmittedMessageHandler for SubAgentEmitter {
|
||||||
|
async fn handle(&self, message: ChatMessage) {
|
||||||
|
for outbound in OutboundMessage::from_chat_message(
|
||||||
|
&self.channel_name,
|
||||||
|
&self.chat_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&self.metadata,
|
||||||
|
&message,
|
||||||
|
) {
|
||||||
|
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
||||||
|
tracing::error!(
|
||||||
|
error = %error,
|
||||||
|
channel = %self.channel_name,
|
||||||
|
chat_id = %self.chat_id,
|
||||||
|
"Failed to publish live sub-agent tool call"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SystemPromptProvider for StaticSystemPromptProvider {
|
impl SystemPromptProvider for StaticSystemPromptProvider {
|
||||||
fn build(&self, _context: &SystemPromptContext) -> Option<SystemPrompt> {
|
fn build(&self, _context: &SystemPromptContext) -> Option<SystemPrompt> {
|
||||||
Some(SystemPrompt {
|
Some(SystemPrompt {
|
||||||
@ -115,6 +148,7 @@ pub struct DefaultSubAgentRuntime {
|
|||||||
provider_config: LLMProviderConfig,
|
provider_config: LLMProviderConfig,
|
||||||
/// 子代理定义目录(内置 + 自定义)
|
/// 子代理定义目录(内置 + 自定义)
|
||||||
catalog: Arc<SubagentCatalog>,
|
catalog: Arc<SubagentCatalog>,
|
||||||
|
bus: Option<Arc<MessageBus>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DefaultSubAgentRuntime {
|
impl DefaultSubAgentRuntime {
|
||||||
@ -125,6 +159,7 @@ impl DefaultSubAgentRuntime {
|
|||||||
subagent_tools: Arc<ToolRegistry>,
|
subagent_tools: Arc<ToolRegistry>,
|
||||||
provider_config: LLMProviderConfig,
|
provider_config: LLMProviderConfig,
|
||||||
catalog: Arc<SubagentCatalog>,
|
catalog: Arc<SubagentCatalog>,
|
||||||
|
bus: Option<Arc<MessageBus>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
@ -133,6 +168,7 @@ impl DefaultSubAgentRuntime {
|
|||||||
subagent_tools,
|
subagent_tools,
|
||||||
provider_config,
|
provider_config,
|
||||||
catalog,
|
catalog,
|
||||||
|
bus,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,16 +210,39 @@ impl DefaultSubAgentRuntime {
|
|||||||
None, // 子代理不需要 skill provider
|
None, // 子代理不需要 skill provider
|
||||||
)
|
)
|
||||||
.map(|agent| {
|
.map(|agent| {
|
||||||
agent.with_tool_context(ToolContext {
|
let agent = agent.with_tool_context(ToolContext {
|
||||||
channel_name: Some(session.parent_channel_name.clone()),
|
channel_name: Some(session.parent_channel_name.clone()),
|
||||||
sender_id: None,
|
sender_id: None,
|
||||||
chat_id: Some(session.parent_chat_id.clone()), // 使用父会话 chat_id
|
chat_id: Some(session.parent_chat_id.clone()),
|
||||||
session_id: Some(session.session_id.clone()), // 子代理自己的 session_id
|
session_id: Some(session.session_id.clone()),
|
||||||
topic_id: session.parent_topic_id.clone(), // 继承父话题 ID
|
topic_id: session.parent_topic_id.clone(),
|
||||||
message_id: None,
|
message_id: None,
|
||||||
message_seq: None,
|
message_seq: None,
|
||||||
subagent_description: Some(session.description.clone()),
|
subagent_description: Some(session.description.clone()),
|
||||||
})
|
});
|
||||||
|
|
||||||
|
// 如果有 MessageBus,附加实时广播 emitter
|
||||||
|
if let Some(bus) = &self.bus {
|
||||||
|
let mut metadata = HashMap::new();
|
||||||
|
metadata.insert("subagent_task_id".to_string(), session.id.clone());
|
||||||
|
metadata.insert("is_subagent_event".to_string(), "true".to_string());
|
||||||
|
|
||||||
|
let emitter = Arc::new(PersistingEmittedMessageHandler::new(
|
||||||
|
SubAgentEmitter {
|
||||||
|
bus: bus.clone(),
|
||||||
|
channel_name: session.parent_channel_name.clone(),
|
||||||
|
chat_id: session.parent_chat_id.clone(),
|
||||||
|
metadata,
|
||||||
|
},
|
||||||
|
self.conversation_repository.clone(),
|
||||||
|
session.session_id.clone(),
|
||||||
|
session.parent_topic_id.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return agent.with_emitted_message_handler(emitter);
|
||||||
|
}
|
||||||
|
|
||||||
|
agent
|
||||||
})
|
})
|
||||||
.map_err(|e| TaskError::AgentCreationFailed(e.to_string()))
|
.map_err(|e| TaskError::AgentCreationFailed(e.to_string()))
|
||||||
}
|
}
|
||||||
@ -220,13 +279,6 @@ impl DefaultSubAgentRuntime {
|
|||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(process_result)) => {
|
Ok(Ok(process_result)) => {
|
||||||
// 保存子智能体产生的所有消息到数据库
|
|
||||||
for message in &process_result.emitted_messages {
|
|
||||||
if let Err(e) = self.conversation_repository.append_message(&session.session_id, message) {
|
|
||||||
tracing::warn!(error = %e, session_id = %session.session_id, "Failed to append subagent message");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let final_message = process_result.final_response;
|
let final_message = process_result.final_response;
|
||||||
Ok(TaskToolResult {
|
Ok(TaskToolResult {
|
||||||
status: "success".to_string(),
|
status: "success".to_string(),
|
||||||
@ -272,13 +324,6 @@ impl DefaultSubAgentRuntime {
|
|||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(process_result)) => {
|
Ok(Ok(process_result)) => {
|
||||||
// 保存子智能体产生的所有消息到数据库
|
|
||||||
for message in &process_result.emitted_messages {
|
|
||||||
if let Err(e) = self.conversation_repository.append_message(&session.session_id, message) {
|
|
||||||
tracing::warn!(error = %e, session_id = %session.session_id, "Failed to append subagent message");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let final_message = process_result.final_response;
|
let final_message = process_result.final_response;
|
||||||
Ok(TaskToolResult {
|
Ok(TaskToolResult {
|
||||||
status: "success".to_string(),
|
status: "success".to_string(),
|
||||||
@ -340,6 +385,13 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. 保存任务会话
|
// 5. 保存任务会话
|
||||||
|
tracing::info!(
|
||||||
|
task_id = %session.id,
|
||||||
|
session_id = %session.session_id,
|
||||||
|
description = %session.description,
|
||||||
|
subagent_type = %session.subagent_type,
|
||||||
|
"Spawning sub-agent task"
|
||||||
|
);
|
||||||
self.task_repository.save_task_session(&session).await?;
|
self.task_repository.save_task_session(&session).await?;
|
||||||
|
|
||||||
// 6. 构建子代理系统提示词
|
// 6. 构建子代理系统提示词
|
||||||
@ -364,12 +416,24 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
Ok(tool_result) => {
|
Ok(tool_result) => {
|
||||||
let mut session = session;
|
let mut session = session;
|
||||||
session.mark_completed(tool_result.summary.clone());
|
session.mark_completed(tool_result.summary.clone());
|
||||||
|
tracing::info!(
|
||||||
|
task_id = %session.id,
|
||||||
|
session_id = %session.session_id,
|
||||||
|
"Task completed, updating session"
|
||||||
|
);
|
||||||
self.task_repository.save_task_session(&session).await?;
|
self.task_repository.save_task_session(&session).await?;
|
||||||
Ok(tool_result)
|
Ok(tool_result)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let mut session = session;
|
let mut session = session;
|
||||||
let status = e.as_status();
|
let status = e.as_status();
|
||||||
|
tracing::warn!(
|
||||||
|
task_id = %session.id,
|
||||||
|
session_id = %session.session_id,
|
||||||
|
status = %status,
|
||||||
|
error = %e,
|
||||||
|
"Task failed, updating session"
|
||||||
|
);
|
||||||
if status == "timeout" {
|
if status == "timeout" {
|
||||||
session.mark_timeout();
|
session.mark_timeout();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -121,6 +121,7 @@ fn test_tool_call_outbound_serialization() {
|
|||||||
arguments: serde_json::json!({"expression": "1 + 1"}),
|
arguments: serde_json::json!({"expression": "1 + 1"}),
|
||||||
content: "调用工具: calculator".to_string(),
|
content: "调用工具: calculator".to_string(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
|
subagent_task_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&msg).unwrap();
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
@ -152,6 +153,7 @@ fn test_tool_result_outbound_serialization() {
|
|||||||
tool_name: "calculator".to_string(),
|
tool_name: "calculator".to_string(),
|
||||||
content: "工具结果: calculator\n\n2".to_string(),
|
content: "工具结果: calculator\n\n2".to_string(),
|
||||||
role: "tool".to_string(),
|
role: "tool".to_string(),
|
||||||
|
subagent_task_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&msg).unwrap();
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
|
|||||||
124
web/src/App.tsx
124
web/src/App.tsx
@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { Zap, Cpu, MessageSquare } from 'lucide-react'
|
import { Zap, Cpu, MessageSquare, ArrowLeft, Bot } from 'lucide-react'
|
||||||
import { ChatContainer } from './components/Chat/ChatContainer'
|
import { ChatContainer } from './components/Chat/ChatContainer'
|
||||||
import { TopicList } from './components/Sidebar/TopicList'
|
import { TopicList } from './components/Sidebar/TopicList'
|
||||||
import { SessionInfo } from './components/Sidebar/SessionInfo'
|
import { SessionInfo } from './components/Sidebar/SessionInfo'
|
||||||
@ -7,7 +7,7 @@ import { ToolPanel } from './components/Panel/ToolPanel'
|
|||||||
import { ConnectionStatus } from './components/ConnectionStatus'
|
import { ConnectionStatus } from './components/ConnectionStatus'
|
||||||
import { useWebSocket } from './hooks/useWebSocket'
|
import { useWebSocket } from './hooks/useWebSocket'
|
||||||
import { useChat } from './hooks/useChat'
|
import { useChat } from './hooks/useChat'
|
||||||
import type { Command } from './types/protocol'
|
import type { ChatMessage, Command } from './types/protocol'
|
||||||
|
|
||||||
const WS_URL = 'ws://127.0.0.1:19876/ws'
|
const WS_URL = 'ws://127.0.0.1:19876/ws'
|
||||||
|
|
||||||
@ -29,6 +29,8 @@ function App() {
|
|||||||
messages,
|
messages,
|
||||||
isLoading,
|
isLoading,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
|
// 子智能体视图
|
||||||
|
subAgentView,
|
||||||
// 方法
|
// 方法
|
||||||
handleMessage,
|
handleMessage,
|
||||||
handleCommand,
|
handleCommand,
|
||||||
@ -38,6 +40,8 @@ function App() {
|
|||||||
switchTopic,
|
switchTopic,
|
||||||
requestSessionList,
|
requestSessionList,
|
||||||
requestTopicList,
|
requestTopicList,
|
||||||
|
enterSubAgentView,
|
||||||
|
exitSubAgentView,
|
||||||
} = useChat()
|
} = useChat()
|
||||||
|
|
||||||
const { status, sendMessage } = useWebSocket({
|
const { status, sendMessage } = useWebSocket({
|
||||||
@ -156,7 +160,58 @@ function App() {
|
|||||||
[sendMessage, handleCommand, switchTopic, selectTopic]
|
[sendMessage, handleCommand, switchTopic, selectTopic]
|
||||||
)
|
)
|
||||||
|
|
||||||
const chatMessages = messages.filter((message) => message.type !== 'tool_result')
|
const handleNavigateToSubAgent = useCallback(
|
||||||
|
(taskId: string, description: string) => {
|
||||||
|
const cmd = enterSubAgentView(taskId, description)
|
||||||
|
handleCommand(cmd)
|
||||||
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
||||||
|
},
|
||||||
|
[enterSubAgentView, handleCommand, sendMessage]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleExitSubAgentView = useCallback(() => {
|
||||||
|
exitSubAgentView()
|
||||||
|
}, [exitSubAgentView])
|
||||||
|
|
||||||
|
const chatMessages = useMemo(() => {
|
||||||
|
const result: ChatMessage[] = []
|
||||||
|
const toolCallIndex = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.type === 'tool_call') {
|
||||||
|
toolCallIndex.set(msg.toolCallId || msg.id, result.length)
|
||||||
|
result.push({
|
||||||
|
...msg,
|
||||||
|
type: 'merged_tool',
|
||||||
|
status: 'calling',
|
||||||
|
callContent: msg.content,
|
||||||
|
resultContent: '',
|
||||||
|
})
|
||||||
|
} else if (msg.type === 'tool_result') {
|
||||||
|
const idx = toolCallIndex.get(msg.toolCallId || msg.id)
|
||||||
|
if (idx !== undefined) {
|
||||||
|
result[idx] = {
|
||||||
|
...result[idx],
|
||||||
|
status: 'result',
|
||||||
|
resultContent: msg.content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'tool_pending') {
|
||||||
|
const idx = toolCallIndex.get(msg.toolCallId || msg.id)
|
||||||
|
if (idx !== undefined) {
|
||||||
|
result[idx] = {
|
||||||
|
...result[idx],
|
||||||
|
status: 'pending',
|
||||||
|
resultContent: msg.content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}, [messages])
|
||||||
const toolMessages = messages
|
const toolMessages = messages
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -192,18 +247,13 @@ function App() {
|
|||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Left Sidebar - 简化为 Session 信息 + Topic 列表 */}
|
{/* Left Sidebar */}
|
||||||
<div className="w-72 shrink-0 border-r border-white/8 bg-[#12121a]/50 flex flex-col">
|
<div className={`w-72 shrink-0 border-r border-white/8 bg-[#12121a]/50 flex flex-col ${subAgentView ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
{/* Session Info */}
|
|
||||||
<SessionInfo
|
<SessionInfo
|
||||||
session={session}
|
session={session}
|
||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="border-b border-white/8" />
|
<div className="border-b border-white/8" />
|
||||||
|
|
||||||
{/* Topic List */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<TopicList
|
<TopicList
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
@ -218,15 +268,59 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center - Chat */}
|
{/* Center - Chat */}
|
||||||
<div className="flex-1 min-w-0 bg-[#0a0a0f]">
|
<div className="flex-1 min-w-0 bg-[#0a0a0f] flex flex-col">
|
||||||
|
{/* Sub-agent back bar */}
|
||||||
|
{subAgentView && (
|
||||||
|
<div className="shrink-0 border-b border-white/8 bg-[#12121a]/80 px-4 py-2 flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={handleExitSubAgentView}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-[#00f0ff] hover:text-[#00f0ff]/80 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
<span>返回主会话</span>
|
||||||
|
</button>
|
||||||
|
<div className="h-4 w-px bg-white/20" />
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-zinc-300">
|
||||||
|
<Bot className="h-4 w-4 text-violet-400" />
|
||||||
|
<span className="text-zinc-500">子智能体:</span>
|
||||||
|
<span className="text-white font-medium">{subAgentView.description}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-4 w-px bg-white/20" />
|
||||||
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
|
<span className="text-zinc-500">类型:</span>
|
||||||
|
<span className="text-zinc-300">{subAgentView.subagentType || '...'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-4 w-px bg-white/20" />
|
||||||
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
|
<span className="text-zinc-500">状态:</span>
|
||||||
|
<span className={`font-medium ${
|
||||||
|
subAgentView.status === 'completed' ? 'text-emerald-400' :
|
||||||
|
subAgentView.status === 'failed' ? 'text-red-400' :
|
||||||
|
subAgentView.status === 'timeout' ? 'text-amber-400' :
|
||||||
|
subAgentView.status === 'running' ? 'text-amber-400' :
|
||||||
|
'text-zinc-400'
|
||||||
|
}`}>
|
||||||
|
{subAgentView.status === 'completed' ? '已完成' :
|
||||||
|
subAgentView.status === 'failed' ? '失败' :
|
||||||
|
subAgentView.status === 'timeout' ? '超时' :
|
||||||
|
subAgentView.status === 'running' ? '执行中' :
|
||||||
|
subAgentView.status === 'loading' ? '加载中...' :
|
||||||
|
subAgentView.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
<ChatContainer
|
<ChatContainer
|
||||||
messages={chatMessages}
|
messages={chatMessages}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={subAgentView ? true : isReadOnly}
|
||||||
channelName={session?.title ?? 'PicoBot'}
|
channelName={subAgentView ? `子智能体: ${subAgentView.description}` : (session?.title ?? 'PicoBot')}
|
||||||
onSendMessage={handleSendMessage}
|
onSendMessage={subAgentView ? () => {} : handleSendMessage}
|
||||||
|
onNavigateToSubAgent={handleNavigateToSubAgent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Right Sidebar - Tool Panel */}
|
{/* Right Sidebar - Tool Panel */}
|
||||||
<div className="w-80 shrink-0 border-l border-white/8 bg-[#12121a]/50">
|
<div className="w-80 shrink-0 border-l border-white/8 bg-[#12121a]/50">
|
||||||
|
|||||||
@ -8,6 +8,7 @@ interface ChatContainerProps {
|
|||||||
isReadOnly?: boolean
|
isReadOnly?: boolean
|
||||||
channelName?: string
|
channelName?: string
|
||||||
onSendMessage: (content: string) => void
|
onSendMessage: (content: string) => void
|
||||||
|
onNavigateToSubAgent?: (taskId: string, description: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatContainer({
|
export function ChatContainer({
|
||||||
@ -16,11 +17,12 @@ export function ChatContainer({
|
|||||||
isReadOnly = false,
|
isReadOnly = false,
|
||||||
channelName,
|
channelName,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
|
onNavigateToSubAgent,
|
||||||
}: ChatContainerProps) {
|
}: ChatContainerProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<MessageList messages={messages} />
|
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} />
|
||||||
</div>
|
</div>
|
||||||
<MessageInput
|
<MessageInput
|
||||||
onSend={onSendMessage}
|
onSend={onSendMessage}
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
|
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download, ChevronDown, ChevronRight, Copy, Check } from 'lucide-react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import type { ChatMessage, Attachment } from '../../types/protocol'
|
import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol'
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: ChatMessage
|
message: ChatMessage
|
||||||
|
onNavigateToSubAgent?: (taskId: string, description: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAttachmentIcon(mediaType: string) {
|
function getAttachmentIcon(mediaType: string) {
|
||||||
@ -22,6 +24,13 @@ function getFileName(path: string): string {
|
|||||||
return parts[parts.length - 1] || path
|
return parts[parts.length - 1] || path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp: number) {
|
||||||
|
return new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function AttachmentCard({ attachment }: { attachment: Attachment }) {
|
function AttachmentCard({ attachment }: { attachment: Attachment }) {
|
||||||
const fileName = attachment.file_name || getFileName(attachment.path)
|
const fileName = attachment.file_name || getFileName(attachment.path)
|
||||||
|
|
||||||
@ -72,9 +81,337 @@ function AttachmentCard({ attachment }: { attachment: Attachment }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
function CopyButton({ text, className = '' }: { text: string; className?: string }) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const handleCopy = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1500)
|
||||||
|
}).catch(() => {
|
||||||
|
// fallback silently
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={`opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-white/10 flex-shrink-0 ${className}`}
|
||||||
|
title="复制"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-3 w-3 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3 text-zinc-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTaskResult(content: string): TaskToolResult | null {
|
||||||
|
if (!content) return null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed.status === 'string' &&
|
||||||
|
typeof parsed.output === 'string' &&
|
||||||
|
typeof parsed.summary === 'string' &&
|
||||||
|
typeof parsed.task_id === 'string'
|
||||||
|
) {
|
||||||
|
return parsed as TaskToolResult
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubbleProps) {
|
||||||
const isUser = message.role === 'user'
|
const isUser = message.role === 'user'
|
||||||
const isTool = message.role === 'tool'
|
const isTool = message.role === 'tool'
|
||||||
|
const isMergedTool = message.type === 'merged_tool'
|
||||||
|
const [toolExpanded, setToolExpanded] = useState(false)
|
||||||
|
|
||||||
|
if (isMergedTool) {
|
||||||
|
const status = message.status || 'calling'
|
||||||
|
const hasResult = !!(message.resultContent)
|
||||||
|
const hasArgs = message.arguments !== undefined && message.arguments !== null
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
calling: {
|
||||||
|
dot: 'bg-amber-400 animate-pulse',
|
||||||
|
label: '执行中',
|
||||||
|
fullBorder: 'border-amber-500/30',
|
||||||
|
labelColor: 'text-amber-400',
|
||||||
|
avatarBg: 'bg-amber-500/20',
|
||||||
|
avatarIcon: 'text-amber-400',
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
dot: 'bg-emerald-400',
|
||||||
|
label: '已完成',
|
||||||
|
fullBorder: 'border-emerald-500/30',
|
||||||
|
labelColor: 'text-emerald-400',
|
||||||
|
avatarBg: 'bg-emerald-500/20',
|
||||||
|
avatarIcon: 'text-emerald-400',
|
||||||
|
},
|
||||||
|
pending: {
|
||||||
|
dot: 'bg-orange-400 animate-pulse',
|
||||||
|
label: '待确认',
|
||||||
|
fullBorder: 'border-orange-500/30',
|
||||||
|
labelColor: 'text-orange-400',
|
||||||
|
avatarBg: 'bg-orange-500/20',
|
||||||
|
avatarIcon: 'text-orange-400',
|
||||||
|
},
|
||||||
|
}[status]
|
||||||
|
|
||||||
|
const formatJSON = (text: string): string => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(text), null, 2)
|
||||||
|
} catch {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripToolResultPrefix(text: string): string {
|
||||||
|
const lines = text.split('\n')
|
||||||
|
if (lines[0]?.startsWith('工具结果')) {
|
||||||
|
let start = 1
|
||||||
|
while (start < lines.length && lines[start].trim() === '') {
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
return lines.slice(start).join('\n')
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const argsPreview = hasArgs
|
||||||
|
? JSON.stringify(message.arguments).slice(0, 500)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const displayContent = hasResult ? stripToolResultPrefix(message.resultContent!) : ''
|
||||||
|
|
||||||
|
const isTaskTool = message.toolName === 'task'
|
||||||
|
const taskResult = isTaskTool && hasResult ? parseTaskResult(message.resultContent!) : null
|
||||||
|
const isSubAgent = !!message.subagentTaskId
|
||||||
|
const subagentType = (message.arguments as Record<string, unknown> | null)?.subagent_type as string || 'general'
|
||||||
|
const taskDescription = (message.arguments as Record<string, unknown> | null)?.description as string || ''
|
||||||
|
const taskPrompt = (message.arguments as Record<string, unknown> | null)?.prompt as string || ''
|
||||||
|
|
||||||
|
// task tool 专用的状态配色
|
||||||
|
const taskStatusConfig = {
|
||||||
|
success: { dot: 'bg-emerald-400', label: '成功', borderColor: 'border-emerald-500/40', labelColor: 'text-emerald-400' },
|
||||||
|
failed: { dot: 'bg-red-400', label: '失败', borderColor: 'border-red-500/40', labelColor: 'text-red-400' },
|
||||||
|
timeout: { dot: 'bg-amber-400', label: '超时', borderColor: 'border-amber-500/40', labelColor: 'text-amber-400' },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 animate-slide-in">
|
||||||
|
<div className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full mt-0.5 ${
|
||||||
|
isTaskTool ? 'bg-violet-500/20' : statusConfig.avatarBg
|
||||||
|
}`}>
|
||||||
|
{isTaskTool ? (
|
||||||
|
<Bot className="h-3.5 w-3.5 text-violet-400" />
|
||||||
|
) : (
|
||||||
|
<Terminal className={`h-3.5 w-3.5 ${statusConfig.avatarIcon}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="max-w-[80%] min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs font-medium text-zinc-400">{message.toolName || 'Tool'}</span>
|
||||||
|
{isTaskTool && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-400">
|
||||||
|
子智能体·{subagentType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isTaskTool && isSubAgent && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-400">
|
||||||
|
子智能体
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-zinc-600">{formatTime(message.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => setToolExpanded(!toolExpanded)}
|
||||||
|
className={`cursor-pointer rounded-xl border bg-[#1a1a25]/60 w-full transition-all duration-500 hover:bg-[#1a1a25]/80 group ${
|
||||||
|
taskResult ? taskStatusConfig[taskResult.status].borderColor : statusConfig.fullBorder
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full flex-shrink-0 transition-colors duration-500 ${
|
||||||
|
taskResult ? taskStatusConfig[taskResult.status].dot : statusConfig.dot
|
||||||
|
}`} />
|
||||||
|
<span className="text-sm font-medium text-zinc-300 truncate">
|
||||||
|
{isTaskTool ? (taskDescription || '子智能体任务') : (message.toolName || 'Tool')}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs flex-shrink-0 transition-colors duration-500 ${
|
||||||
|
taskResult ? taskStatusConfig[taskResult.status].labelColor : statusConfig.labelColor
|
||||||
|
}`}>
|
||||||
|
{taskResult ? taskStatusConfig[taskResult.status].label : statusConfig.label}
|
||||||
|
</span>
|
||||||
|
{hasResult && <CopyButton text={taskResult ? taskResult.output : displayContent} />}
|
||||||
|
<span className="ml-auto flex-shrink-0">
|
||||||
|
{toolExpanded ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-zinc-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-zinc-500" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsed preview */}
|
||||||
|
{!toolExpanded && (
|
||||||
|
<>
|
||||||
|
{hasArgs && argsPreview && !taskResult && (
|
||||||
|
<div className="px-3 pb-1 text-xs text-zinc-500 font-mono line-clamp-3"
|
||||||
|
style={{
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{argsPreview}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{taskResult && taskResult.summary && (
|
||||||
|
<div className="px-3 pb-1 text-xs text-zinc-400 line-clamp-2">
|
||||||
|
{taskResult.summary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{taskResult ? (
|
||||||
|
<>
|
||||||
|
<div className="px-3 pb-1">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onNavigateToSubAgent?.(taskResult.task_id, taskDescription || '子智能体任务')
|
||||||
|
}}
|
||||||
|
className="text-xs text-[#00f0ff] hover:text-[#00f0ff]/80 hover:underline transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>查看完整会话</span>
|
||||||
|
<span>→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 pb-2 text-xs text-[#00f0ff]/50 flex items-center gap-1 select-none">
|
||||||
|
<span>点击查看子智能体输出</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : isTaskTool && message.subagentTaskId ? (
|
||||||
|
<div className="px-3 pb-1">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onNavigateToSubAgent?.(message.subagentTaskId!, taskDescription || '子智能体任务')
|
||||||
|
}}
|
||||||
|
className="text-xs text-[#00f0ff] hover:text-[#00f0ff]/80 hover:underline transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>查看实时进度</span>
|
||||||
|
<span>→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : hasResult ? (
|
||||||
|
<div className="px-3 pb-2 text-xs text-[#00f0ff]/50 flex items-center gap-1 select-none">
|
||||||
|
<span>点击查看工具结果</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-3 pb-2 text-xs text-zinc-500">
|
||||||
|
{isTaskTool ? '子智能体正在执行...' : '等待工具执行...'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expanded */}
|
||||||
|
{toolExpanded && (
|
||||||
|
<div className="border-t border-white/8 px-3 py-2 space-y-2">
|
||||||
|
{taskResult ? (
|
||||||
|
<>
|
||||||
|
{taskPrompt && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-zinc-500 mb-1">任务指令</div>
|
||||||
|
<pre className="text-xs text-zinc-400 font-mono whitespace-pre-wrap bg-black/20 rounded-lg p-2 overflow-x-auto max-h-32 overflow-y-auto">
|
||||||
|
{taskPrompt}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{taskResult.summary && (
|
||||||
|
<div className={`rounded-lg px-3 py-2 ${
|
||||||
|
taskResult.status === 'success' ? 'bg-emerald-500/10 border border-emerald-500/30' :
|
||||||
|
taskResult.status === 'failed' ? 'bg-red-500/10 border border-red-500/30' :
|
||||||
|
'bg-amber-500/10 border border-amber-500/30'
|
||||||
|
}`}>
|
||||||
|
<div className="text-xs font-medium text-zinc-500 mb-0.5">摘要</div>
|
||||||
|
<div className="text-sm text-zinc-300">{taskResult.summary}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-zinc-500 mb-1">输出</div>
|
||||||
|
<div className="markdown-content text-sm leading-relaxed bg-black/20 rounded-lg p-3 max-h-96 overflow-y-auto">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{taskResult.output}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-600 font-mono select-all">
|
||||||
|
task_id: {taskResult.task_id}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onNavigateToSubAgent?.(taskResult.task_id, taskDescription || '子智能体任务')
|
||||||
|
}}
|
||||||
|
className="text-xs text-[#00f0ff] hover:text-[#00f0ff]/80 hover:underline transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>查看完整会话</span>
|
||||||
|
<span>→</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{hasArgs && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-zinc-500 mb-1">参数</div>
|
||||||
|
<pre className="text-xs text-zinc-400 font-mono whitespace-pre-wrap bg-black/20 rounded-lg p-2 overflow-x-auto">
|
||||||
|
{JSON.stringify(message.arguments, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasResult && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-zinc-500 mb-1">结果</div>
|
||||||
|
<pre className="text-xs text-zinc-400 font-mono whitespace-pre-wrap bg-black/20 rounded-lg p-2 overflow-x-auto max-h-48 overflow-y-auto">
|
||||||
|
{formatJSON(displayContent)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!hasArgs && !hasResult && (
|
||||||
|
<div className="text-xs text-zinc-500">等待工具执行...</div>
|
||||||
|
)}
|
||||||
|
{isTaskTool && message.subagentTaskId && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onNavigateToSubAgent?.(message.subagentTaskId!, taskDescription || '子智能体任务')
|
||||||
|
}}
|
||||||
|
className="text-xs text-[#00f0ff] hover:text-[#00f0ff]/80 hover:underline transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>查看实时进度</span>
|
||||||
|
<span>→</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const getIcon = () => {
|
const getIcon = () => {
|
||||||
if (isUser) return <User className="h-4 w-4" />
|
if (isUser) return <User className="h-4 w-4" />
|
||||||
@ -111,15 +448,8 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
return 'bg-gradient-to-br from-[#8b5cf6] to-[#ec4899]'
|
return 'bg-gradient-to-br from-[#8b5cf6] to-[#ec4899]'
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (timestamp: number) => {
|
|
||||||
return new Date(timestamp).toLocaleTimeString('zh-CN', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in`}>
|
<div 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`}
|
||||||
>
|
>
|
||||||
@ -131,6 +461,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
{isUser ? 'You' : isTool ? message.toolName || 'Tool' : 'Assistant'}
|
{isUser ? 'You' : isTool ? message.toolName || 'Tool' : 'Assistant'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-zinc-600">{formatTime(message.timestamp)}</span>
|
<span className="text-xs text-zinc-600">{formatTime(message.timestamp)}</span>
|
||||||
|
<CopyButton text={message.content} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`inline-block rounded-2xl border px-5 py-3 text-left shadow-lg ${getContainerStyles()}`}
|
className={`inline-block rounded-2xl border px-5 py-3 text-left shadow-lg ${getContainerStyles()}`}
|
||||||
|
|||||||
@ -5,9 +5,10 @@ import { Sparkles } from 'lucide-react'
|
|||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
|
onNavigateToSubAgent?: (taskId: string, description: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageList({ messages }: MessageListProps) {
|
export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps) {
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@ -41,7 +42,7 @@ export function MessageList({ messages }: MessageListProps) {
|
|||||||
className="h-full overflow-y-auto p-6 space-y-6"
|
className="h-full overflow-y-auto p-6 space-y-6"
|
||||||
>
|
>
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<MessageBubble key={message.id} message={message} />
|
<MessageBubble key={message.id} message={message} onNavigateToSubAgent={onNavigateToSubAgent} />
|
||||||
))}
|
))}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,6 +15,16 @@ interface ToolCallItem {
|
|||||||
callContent: string
|
callContent: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatResultText(content: string): string {
|
||||||
|
if (!content) return ''
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
return JSON.stringify(parsed, null, 2)
|
||||||
|
} catch {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] {
|
function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] {
|
||||||
const map = new Map<string, ToolCallItem>()
|
const map = new Map<string, ToolCallItem>()
|
||||||
|
|
||||||
@ -68,31 +78,42 @@ export function ToolPanel({ messages }: ToolPanelProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusIcon = (status: ToolCallItem['status']) => {
|
const getStatusConfig = (status: ToolCallItem['status']) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'calling':
|
case 'calling':
|
||||||
return <Play className="h-3 w-3 text-amber-400 animate-pulse" />
|
return {
|
||||||
case 'result':
|
icon: Play,
|
||||||
return <Check className="h-3 w-3 text-emerald-400" />
|
iconColor: 'text-amber-400',
|
||||||
case 'pending':
|
bgClass: 'bg-amber-400',
|
||||||
return <AlertTriangle className="h-3 w-3 text-orange-400" />
|
borderClass: 'border-amber-500/30',
|
||||||
|
label: '执行中',
|
||||||
|
labelClass: 'text-amber-400',
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusText = (status: ToolCallItem['status']) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'calling':
|
|
||||||
return '执行中'
|
|
||||||
case 'result':
|
case 'result':
|
||||||
return '已完成'
|
return {
|
||||||
|
icon: Check,
|
||||||
|
iconColor: 'text-emerald-400',
|
||||||
|
bgClass: 'bg-emerald-400',
|
||||||
|
borderClass: 'border-emerald-500/30',
|
||||||
|
label: '已完成',
|
||||||
|
labelClass: 'text-emerald-400',
|
||||||
|
}
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return '待确认'
|
return {
|
||||||
|
icon: AlertTriangle,
|
||||||
|
iconColor: 'text-orange-400',
|
||||||
|
bgClass: 'bg-orange-400',
|
||||||
|
borderClass: 'border-orange-500/30',
|
||||||
|
label: '待确认',
|
||||||
|
labelClass: 'text-orange-400',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toolCalls.length === 0) {
|
if (toolCalls.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
|
<style>{animStyles}</style>
|
||||||
<div className="border-b border-white/8 p-4 font-semibold text-white flex items-center gap-2">
|
<div className="border-b border-white/8 p-4 font-semibold text-white flex items-center gap-2">
|
||||||
<Terminal className="h-4 w-4 text-[#00f0ff]" />
|
<Terminal className="h-4 w-4 text-[#00f0ff]" />
|
||||||
工具调用
|
工具调用
|
||||||
@ -109,6 +130,7 @@ export function ToolPanel({ messages }: ToolPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
|
<style>{animStyles}</style>
|
||||||
<div className="border-b border-white/8 p-4 font-semibold text-white flex items-center gap-2">
|
<div className="border-b border-white/8 p-4 font-semibold text-white flex items-center gap-2">
|
||||||
<Terminal className="h-4 w-4 text-[#00f0ff]" />
|
<Terminal className="h-4 w-4 text-[#00f0ff]" />
|
||||||
工具调用
|
工具调用
|
||||||
@ -118,48 +140,106 @@ export function ToolPanel({ messages }: ToolPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{toolCalls.map((tool) => (
|
{toolCalls.map((tool) => {
|
||||||
|
const config = getStatusConfig(tool.status)
|
||||||
|
const StatusIcon = config.icon
|
||||||
|
const isExpanded = expandedTools.has(tool.toolCallId)
|
||||||
|
const hasResult = tool.resultContent.length > 0
|
||||||
|
const displayContent = tool.resultContent || tool.callContent
|
||||||
|
const formattedContent = formatResultText(displayContent)
|
||||||
|
const previewLines = displayContent.split('\n').slice(0, 2).join('\n')
|
||||||
|
const hasMore = displayContent.split('\n').length > 2 || displayContent.length > 200
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={tool.toolCallId}
|
key={tool.toolCallId}
|
||||||
className="rounded-xl border border-white/8 bg-[#1a1a25]/50 text-sm overflow-hidden"
|
className={`rounded-xl border bg-[#1a1a25]/50 text-sm overflow-hidden tool-card transition-colors duration-500 ${config.borderClass}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleExpand(tool.toolCallId)}
|
onClick={() => toggleExpand(tool.toolCallId)}
|
||||||
className="flex w-full items-center justify-between px-3 py-2.5 hover:bg-white/5 transition-colors"
|
className="flex w-full items-center justify-between px-3 py-2.5 hover:bg-white/5 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{getStatusIcon(tool.status)}
|
<span className={`tool-status-icon ${tool.status === 'calling' && !hasResult ? 'animate-pulse' : ''}`}>
|
||||||
<span className="font-medium text-zinc-300">{tool.toolName}</span>
|
<StatusIcon className={`h-3.5 w-3.5 transition-colors duration-500 ${config.iconColor}`} />
|
||||||
<span className="text-xs text-zinc-500">
|
</span>
|
||||||
{getStatusText(tool.status)}
|
<span className="font-medium text-zinc-300 truncate">{tool.toolName}</span>
|
||||||
|
<span className={`text-xs flex-shrink-0 transition-colors duration-500 ${config.labelClass}`}>
|
||||||
|
{config.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{expandedTools.has(tool.toolCallId) ? (
|
<span className="flex-shrink-0 ml-2">
|
||||||
|
{isExpanded ? (
|
||||||
<ChevronDown className="h-4 w-4 text-zinc-500" />
|
<ChevronDown className="h-4 w-4 text-zinc-500" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{expandedTools.has(tool.toolCallId) && (
|
|
||||||
<div className="border-t border-white/8 px-3 py-2 bg-black/20">
|
{/* 结果预览区 — 始终可见 */}
|
||||||
{tool.arguments ? (
|
{hasResult && (
|
||||||
<div className="mb-2">
|
<div className="px-3 pb-2">
|
||||||
<div className="text-xs font-medium text-zinc-500 mb-1">参数:</div>
|
<div
|
||||||
<pre className="rounded-lg bg-black/40 p-2 text-xs overflow-x-auto text-zinc-400 font-mono">{String(JSON.stringify(tool.arguments, null, 2))}</pre>
|
className={`rounded-lg bg-black/30 px-2.5 py-2 text-xs text-zinc-400 font-mono cursor-pointer hover:bg-black/40 transition-colors ${
|
||||||
</div>
|
isExpanded ? '' : 'line-clamp-2'
|
||||||
) : null}
|
}`}
|
||||||
<div>
|
onClick={() => toggleExpand(tool.toolCallId)}
|
||||||
<div className="text-xs font-medium text-zinc-500 mb-1">结果:</div>
|
>
|
||||||
<div className="max-h-32 overflow-y-auto rounded-lg bg-black/40 p-2 text-xs whitespace-pre-wrap text-zinc-400 font-mono">
|
{isExpanded ? (
|
||||||
{tool.resultContent || tool.callContent}
|
<pre className="whitespace-pre-wrap break-all m-0">{formattedContent}</pre>
|
||||||
</div>
|
) : (
|
||||||
|
<span className="whitespace-pre-wrap break-all">{previewLines}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!isExpanded && hasMore && (
|
||||||
|
<div className="text-xs text-zinc-500 mt-1 px-1">
|
||||||
|
点击展开全部 ({displayContent.split('\n').length} 行)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
{/* 展开区域:参数 */}
|
||||||
|
{isExpanded && tool.arguments ? (
|
||||||
|
<div className="border-t border-white/8 px-3 py-2 bg-black/20">
|
||||||
|
<div className="text-xs font-medium text-zinc-500 mb-1">参数:</div>
|
||||||
|
<pre className="rounded-lg bg-black/40 p-2 text-xs overflow-x-auto text-zinc-400 font-mono whitespace-pre-wrap break-all">
|
||||||
|
{JSON.stringify(tool.arguments, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const animStyles = `
|
||||||
|
@keyframes tool-result-in {
|
||||||
|
from { max-height: 0; opacity: 0; }
|
||||||
|
to { max-height: 80px; opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card {
|
||||||
|
transition: border-color 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-status-icon {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-enter {
|
||||||
|
animation: tool-result-in 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useRef, useMemo } from 'react'
|
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||||||
import type {
|
import type {
|
||||||
Command,
|
Command,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
@ -13,6 +13,7 @@ import type {
|
|||||||
TopicList,
|
TopicList,
|
||||||
TopicSummary,
|
TopicSummary,
|
||||||
Session,
|
Session,
|
||||||
|
TaskMessagesLoaded,
|
||||||
} from '../types/protocol'
|
} from '../types/protocol'
|
||||||
|
|
||||||
// 简化后的层级状态
|
// 简化后的层级状态
|
||||||
@ -35,6 +36,9 @@ interface UseChatReturn {
|
|||||||
// 是否只读(WebSocket 通道始终可写)
|
// 是否只读(WebSocket 通道始终可写)
|
||||||
isReadOnly: boolean
|
isReadOnly: boolean
|
||||||
|
|
||||||
|
// 子智能体视图
|
||||||
|
subAgentView: SubAgentView | null
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
handleMessage: (content: string) => void
|
handleMessage: (content: string) => void
|
||||||
handleCommand: (command: Command) => void
|
handleCommand: (command: Command) => void
|
||||||
@ -49,6 +53,19 @@ interface UseChatReturn {
|
|||||||
// 初始化方法
|
// 初始化方法
|
||||||
requestSessionList: () => Command
|
requestSessionList: () => Command
|
||||||
requestTopicList: () => Command | null
|
requestTopicList: () => Command | null
|
||||||
|
|
||||||
|
// 子智能体导航方法
|
||||||
|
enterSubAgentView: (taskId: string, description: string) => Command
|
||||||
|
exitSubAgentView: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubAgentView {
|
||||||
|
taskId: string
|
||||||
|
description: string
|
||||||
|
subagentType: string
|
||||||
|
status: string
|
||||||
|
summary?: string
|
||||||
|
messages: ChatMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CHANNEL = 'websocket'
|
const DEFAULT_CHANNEL = 'websocket'
|
||||||
@ -63,6 +80,7 @@ export function useChat(): UseChatReturn {
|
|||||||
const [session, setSession] = useState<Session | null>(null)
|
const [session, setSession] = useState<Session | null>(null)
|
||||||
const [topics, setTopics] = useState<Topic[]>([])
|
const [topics, setTopics] = useState<Topic[]>([])
|
||||||
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
|
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
|
||||||
|
const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null)
|
||||||
|
|
||||||
// Message ID generator
|
// Message ID generator
|
||||||
const messageIdCounter = useRef(0)
|
const messageIdCounter = useRef(0)
|
||||||
@ -71,13 +89,152 @@ export function useChat(): UseChatReturn {
|
|||||||
return `msg_${Date.now()}_${messageIdCounter.current}`
|
return `msg_${Date.now()}_${messageIdCounter.current}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ref to track subAgentView for use in callbacks
|
||||||
|
const subAgentViewRef = useRef<SubAgentView | null>(null)
|
||||||
|
|
||||||
const isConnected = useMemo(() => connectionId !== null, [connectionId])
|
const isConnected = useMemo(() => connectionId !== null, [connectionId])
|
||||||
const sessionId = useMemo(() => session?.id ?? null, [session])
|
const sessionId = useMemo(() => session?.id ?? null, [session])
|
||||||
const chatId = useMemo(() => sessionId ?? DEFAULT_CHAT_ID, [sessionId])
|
const chatId = useMemo(() => sessionId ?? DEFAULT_CHAT_ID, [sessionId])
|
||||||
|
|
||||||
|
// Extract subagent_task_id from a message if present
|
||||||
|
const getSubagentTaskId = (message: WsOutbound): string | undefined => {
|
||||||
|
if (message.type === 'tool_call' || message.type === 'tool_result'
|
||||||
|
|| message.type === 'tool_pending' || message.type === 'assistant_response') {
|
||||||
|
return (message as ToolCall | ToolResult | ToolPending | AssistantResponse).subagent_task_id
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a server message to ChatMessage (extracted from handleServerMessage logic)
|
||||||
|
const serverMessageToChatMessage = (message: WsOutbound): ChatMessage | null => {
|
||||||
|
switch (message.type) {
|
||||||
|
case 'assistant_response': {
|
||||||
|
const msg = message as AssistantResponse
|
||||||
|
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant'
|
||||||
|
return {
|
||||||
|
id: msg.id,
|
||||||
|
role: role as ChatMessage['role'],
|
||||||
|
content: msg.content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
type: 'message',
|
||||||
|
attachments: msg.attachments,
|
||||||
|
subagentTaskId: msg.subagent_task_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'tool_call': {
|
||||||
|
const msg = message as ToolCall
|
||||||
|
return {
|
||||||
|
id: msg.id,
|
||||||
|
role: 'tool',
|
||||||
|
content: msg.content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
type: 'tool_call',
|
||||||
|
toolName: msg.tool_name,
|
||||||
|
toolCallId: msg.tool_call_id,
|
||||||
|
arguments: msg.arguments,
|
||||||
|
subagentTaskId: msg.subagent_task_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'tool_result': {
|
||||||
|
const msg = message as ToolResult
|
||||||
|
return {
|
||||||
|
id: msg.id,
|
||||||
|
role: 'tool',
|
||||||
|
content: msg.content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
type: 'tool_result',
|
||||||
|
toolName: msg.tool_name,
|
||||||
|
toolCallId: msg.tool_call_id,
|
||||||
|
subagentTaskId: msg.subagent_task_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'tool_pending': {
|
||||||
|
const msg = message as ToolPending
|
||||||
|
return {
|
||||||
|
id: msg.id,
|
||||||
|
role: 'tool',
|
||||||
|
content: `${msg.content}\n\n${msg.resume_hint}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
type: 'tool_pending',
|
||||||
|
toolName: msg.tool_name,
|
||||||
|
toolCallId: msg.tool_call_id,
|
||||||
|
subagentTaskId: msg.subagent_task_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'error': {
|
||||||
|
return {
|
||||||
|
id: generateMessageId(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: `Error: ${message.message}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
type: 'message',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append a server message to the sub-agent view
|
||||||
|
const appendToSubAgentViewMessage = (message: WsOutbound) => {
|
||||||
|
const chatMsg = serverMessageToChatMessage(message)
|
||||||
|
if (chatMsg) {
|
||||||
|
setSubAgentView((prev) =>
|
||||||
|
prev
|
||||||
|
? { ...prev, messages: [...prev.messages, chatMsg] }
|
||||||
|
: prev
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleServerMessage = useCallback((message: WsOutbound) => {
|
const handleServerMessage = useCallback((message: WsOutbound) => {
|
||||||
console.log('Received message:', message)
|
console.log('Received message:', message)
|
||||||
|
|
||||||
|
// Route to sub-agent view if active
|
||||||
|
const currentSubAgentView = subAgentViewRef.current
|
||||||
|
if (currentSubAgentView) {
|
||||||
|
if (message.type === 'task_messages_loaded') {
|
||||||
|
const msg = message as TaskMessagesLoaded
|
||||||
|
setSubAgentView((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
subagentType: msg.subagent_type,
|
||||||
|
status: msg.status,
|
||||||
|
summary: msg.summary,
|
||||||
|
}
|
||||||
|
: prev
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route messages to sub-agent view:
|
||||||
|
// - Messages without subagent_task_id = loaded history, always accept
|
||||||
|
// - Messages with subagent_task_id = live emitter, only accept if matching
|
||||||
|
const msgSubagentTaskId = getSubagentTaskId(message)
|
||||||
|
if (!msgSubagentTaskId || msgSubagentTaskId === currentSubAgentView.taskId) {
|
||||||
|
appendToSubAgentViewMessage(message)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// In main view, skip sub-agent messages (they belong to sub-agent view).
|
||||||
|
// But use the task_id to associate with the running task tool card.
|
||||||
|
const msgSubagentTaskId = getSubagentTaskId(message)
|
||||||
|
if (msgSubagentTaskId) {
|
||||||
|
setMessages((prev) => {
|
||||||
|
for (let i = prev.length - 1; i >= 0; i--) {
|
||||||
|
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].subagentTaskId) {
|
||||||
|
const updated = [...prev]
|
||||||
|
updated[i] = { ...updated[i], subagentTaskId: msgSubagentTaskId }
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'session_established': {
|
case 'session_established': {
|
||||||
const msg = message as SessionEstablished
|
const msg = message as SessionEstablished
|
||||||
@ -168,6 +325,7 @@ export function useChat(): UseChatReturn {
|
|||||||
toolName: msg.tool_name,
|
toolName: msg.tool_name,
|
||||||
toolCallId: msg.tool_call_id,
|
toolCallId: msg.tool_call_id,
|
||||||
arguments: msg.arguments,
|
arguments: msg.arguments,
|
||||||
|
subagentTaskId: msg.subagent_task_id,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
break
|
break
|
||||||
@ -185,6 +343,7 @@ export function useChat(): UseChatReturn {
|
|||||||
type: 'tool_result',
|
type: 'tool_result',
|
||||||
toolName: msg.tool_name,
|
toolName: msg.tool_name,
|
||||||
toolCallId: msg.tool_call_id,
|
toolCallId: msg.tool_call_id,
|
||||||
|
subagentTaskId: msg.subagent_task_id,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
break
|
break
|
||||||
@ -297,6 +456,41 @@ export function useChat(): UseChatReturn {
|
|||||||
}
|
}
|
||||||
}, [sessionId])
|
}, [sessionId])
|
||||||
|
|
||||||
|
// Keep ref in sync with state
|
||||||
|
useEffect(() => {
|
||||||
|
subAgentViewRef.current = subAgentView
|
||||||
|
}, [subAgentView])
|
||||||
|
|
||||||
|
const enterSubAgentView = useCallback((taskId: string, description: string): Command => {
|
||||||
|
const newView: SubAgentView = {
|
||||||
|
taskId,
|
||||||
|
description,
|
||||||
|
subagentType: '',
|
||||||
|
status: 'loading',
|
||||||
|
messages: [],
|
||||||
|
}
|
||||||
|
// Sync ref immediately so WebSocket response routing works correctly
|
||||||
|
subAgentViewRef.current = newView
|
||||||
|
setSubAgentView(newView)
|
||||||
|
return {
|
||||||
|
type: 'load_task_messages',
|
||||||
|
task_id: taskId,
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const exitSubAgentView = useCallback(() => {
|
||||||
|
subAgentViewRef.current = null
|
||||||
|
setSubAgentView(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Memoize messages: when in sub-agent view, return sub-agent messages
|
||||||
|
const resolvedMessages = useMemo(() => {
|
||||||
|
if (subAgentView) {
|
||||||
|
return subAgentView.messages
|
||||||
|
}
|
||||||
|
return messages
|
||||||
|
}, [subAgentView, messages])
|
||||||
|
|
||||||
// WebSocket 通道始终可写
|
// WebSocket 通道始终可写
|
||||||
const isReadOnly = false
|
const isReadOnly = false
|
||||||
|
|
||||||
@ -308,9 +502,10 @@ export function useChat(): UseChatReturn {
|
|||||||
chatId,
|
chatId,
|
||||||
topics,
|
topics,
|
||||||
selectedTopic,
|
selectedTopic,
|
||||||
messages,
|
messages: resolvedMessages,
|
||||||
isLoading,
|
isLoading,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
|
subAgentView,
|
||||||
handleMessage,
|
handleMessage,
|
||||||
handleCommand,
|
handleCommand,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
@ -320,5 +515,7 @@ export function useChat(): UseChatReturn {
|
|||||||
switchTopic,
|
switchTopic,
|
||||||
requestSessionList,
|
requestSessionList,
|
||||||
requestTopicList,
|
requestTopicList,
|
||||||
|
enterSubAgentView,
|
||||||
|
exitSubAgentView,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export interface AssistantResponse {
|
|||||||
content: string
|
content: string
|
||||||
role: string
|
role: string
|
||||||
attachments?: Attachment[]
|
attachments?: Attachment[]
|
||||||
|
subagent_task_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCall {
|
export interface ToolCall {
|
||||||
@ -51,6 +52,7 @@ export interface ToolCall {
|
|||||||
arguments: unknown
|
arguments: unknown
|
||||||
content: string
|
content: string
|
||||||
role: string
|
role: string
|
||||||
|
subagent_task_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolResult {
|
export interface ToolResult {
|
||||||
@ -60,6 +62,7 @@ export interface ToolResult {
|
|||||||
tool_name: string
|
tool_name: string
|
||||||
content: string
|
content: string
|
||||||
role: string
|
role: string
|
||||||
|
subagent_task_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolPending {
|
export interface ToolPending {
|
||||||
@ -70,6 +73,7 @@ export interface ToolPending {
|
|||||||
content: string
|
content: string
|
||||||
role: string
|
role: string
|
||||||
resume_hint: string
|
resume_hint: string
|
||||||
|
subagent_task_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WsError {
|
export interface WsError {
|
||||||
@ -151,6 +155,15 @@ export interface Pong {
|
|||||||
type: 'pong'
|
type: 'pong'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskMessagesLoaded {
|
||||||
|
type: 'task_messages_loaded'
|
||||||
|
task_id: string
|
||||||
|
description: string
|
||||||
|
subagent_type: string
|
||||||
|
status: string
|
||||||
|
summary?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type WsOutbound =
|
export type WsOutbound =
|
||||||
| AssistantResponse
|
| AssistantResponse
|
||||||
| ToolCall
|
| ToolCall
|
||||||
@ -164,6 +177,7 @@ export type WsOutbound =
|
|||||||
| SessionSaved
|
| SessionSaved
|
||||||
| TopicList
|
| TopicList
|
||||||
| ChannelList
|
| ChannelList
|
||||||
|
| TaskMessagesLoaded
|
||||||
| Pong
|
| Pong
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -226,6 +240,11 @@ export interface ListTopicsCommand {
|
|||||||
session_id: string
|
session_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoadTaskMessagesCommand {
|
||||||
|
type: 'load_task_messages'
|
||||||
|
task_id: string
|
||||||
|
}
|
||||||
|
|
||||||
export type Command =
|
export type Command =
|
||||||
| CreateSessionCommand
|
| CreateSessionCommand
|
||||||
| ListSessionsCommand
|
| ListSessionsCommand
|
||||||
@ -238,6 +257,7 @@ export type Command =
|
|||||||
| ListChannelsCommand
|
| ListChannelsCommand
|
||||||
| ListSessionsByChannelCommand
|
| ListSessionsByChannelCommand
|
||||||
| ListTopicsCommand
|
| ListTopicsCommand
|
||||||
|
| LoadTaskMessagesCommand
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// UI Types
|
// UI Types
|
||||||
@ -248,11 +268,23 @@ export interface ChatMessage {
|
|||||||
role: 'user' | 'assistant' | 'tool'
|
role: 'user' | 'assistant' | 'tool'
|
||||||
content: string
|
content: string
|
||||||
timestamp: number
|
timestamp: number
|
||||||
type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending'
|
type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending' | 'merged_tool'
|
||||||
toolName?: string
|
toolName?: string
|
||||||
toolCallId?: string
|
toolCallId?: string
|
||||||
arguments?: unknown
|
arguments?: unknown
|
||||||
attachments?: Attachment[]
|
attachments?: Attachment[]
|
||||||
|
status?: 'calling' | 'result' | 'pending'
|
||||||
|
resultContent?: string
|
||||||
|
callContent?: string
|
||||||
|
subagentTaskId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** task 工具返回的 JSON 结构 */
|
||||||
|
export interface TaskToolResult {
|
||||||
|
status: 'success' | 'failed' | 'timeout'
|
||||||
|
summary: string
|
||||||
|
output: string
|
||||||
|
task_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Topic {
|
export interface Topic {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user