Compare commits

..

No commits in common. "06756a4816ac7da0b7da1faee625f8f478731679" and "5e5de7ce9ffa4d03ceca45923b51709a27bb9a2e" have entirely different histories.

30 changed files with 157 additions and 1440 deletions

View File

@ -2,7 +2,6 @@ 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,
@ -658,38 +657,6 @@ 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>;
@ -917,7 +884,6 @@ 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,
@ -942,7 +908,6 @@ 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,
@ -1048,7 +1013,6 @@ 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,
@ -1125,7 +1089,6 @@ 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,
@ -1141,7 +1104,6 @@ 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,

View File

@ -4,8 +4,7 @@ pub mod runtime_config;
pub mod system_prompt; pub mod system_prompt;
pub use agent_loop::{ pub use agent_loop::{
AgentError, AgentLoop, AgentProcessResult, EmittedMessageHandler, AgentError, AgentLoop, AgentProcessResult, EmittedMessageHandler, SkillProvider,
PersistingEmittedMessageHandler, SkillProvider,
}; };
pub use context_compressor::ContextCompressor; pub use context_compressor::ContextCompressor;
pub use runtime_config::AgentRuntimeConfig; pub use runtime_config::AgentRuntimeConfig;

View File

@ -11,7 +11,6 @@ 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};
@ -2402,12 +2401,6 @@ 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 {

View File

@ -13,7 +13,6 @@ 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};
@ -287,12 +286,6 @@ 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;

View File

@ -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(), subagent_task_id: None, attachments: Vec::new(),
}, },
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(), subagent_task_id: None, attachments: Vec::new(),
}, },
} }
} 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(), subagent_task_id: None, attachments: Vec::new(),
}, },
} }
} 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(), subagent_task_id: None, attachments: Vec::new(),
}, },
} }
} 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(), subagent_task_id: None, attachments: Vec::new(),
}, },
} }
} 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(), subagent_task_id: None, attachments: Vec::new(),
} }
} }
} }
@ -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(), subagent_task_id: None, attachments: Vec::new(),
}, },
}; };
outbounds.push(outbound); outbounds.push(outbound);

View File

@ -1,171 +0,0 @@
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,
}))
}

View File

@ -4,7 +4,6 @@ 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;

View File

@ -43,8 +43,6 @@ pub enum Command {
}, },
/// 列出 Session 的所有 Topics /// 列出 Session 的所有 Topics
ListTopics { session_id: String }, ListTopics { session_id: String },
/// 加载子智能体任务的消息历史
LoadTaskMessages { task_id: String },
} }
impl Command { impl Command {
@ -62,7 +60,6 @@ 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",
} }
} }
} }

View File

@ -157,8 +157,7 @@ impl AgentExecutionService {
.emitted_messages .emitted_messages
.iter() .iter()
.filter(|message| { .filter(|message| {
// 当存在 live_emitter 时,所有消息已在 loop 中实时广播,不需要 post-loop 发送 (!message.is_assistant_tool_call_message() || !request.suppress_live_tool_calls)
!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| {

View File

@ -91,7 +91,6 @@ 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 {

View File

@ -2,7 +2,7 @@ use std::sync::Arc;
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use crate::agent::{AgentError, CompositeSystemPromptProvider, PersistingEmittedMessageHandler}; use crate::agent::{AgentError, CompositeSystemPromptProvider};
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,16 +218,11 @@ impl InboundProcessor {
} }
// 普通消息进入 AgentLoop // 普通消息进入 AgentLoop
let live_emitter = Arc::new(PersistingEmittedMessageHandler::new( let live_emitter = Arc::new(BusToolCallEmitter::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

View File

@ -4,7 +4,6 @@ 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;
@ -45,7 +44,6 @@ 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,
@ -61,7 +59,6 @@ pub(crate) fn build_session_manager(
maintenance_config, maintenance_config,
session_ttl_hours, session_ttl_hours,
mcp_config, mcp_config,
bus,
) )
} }
@ -80,7 +77,6 @@ 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()
@ -192,7 +188,6 @@ 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)

View File

@ -505,7 +505,6 @@ 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)
} }

View File

@ -145,6 +145,23 @@ 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,
@ -153,28 +170,30 @@ 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.add_message(chat_id, message); self.append_persisted_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> {
if messages.is_empty() { let session_id = self.persistent_session_id(chat_id);
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(())
} }

View File

@ -11,7 +11,6 @@ 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;
@ -300,11 +299,6 @@ 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(),
@ -365,28 +359,6 @@ 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) {
@ -466,26 +438,6 @@ 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;
@ -502,7 +454,6 @@ 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,
}); });
} }
} }
@ -512,7 +463,6 @@ 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" => {
@ -524,7 +474,6 @@ 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(),
@ -533,7 +482,6 @@ 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,
}), }),
} }
} }
@ -542,7 +490,6 @@ 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,
} }

View File

@ -79,8 +79,6 @@ 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 {
@ -90,8 +88,6 @@ 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 {
@ -100,8 +96,6 @@ 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 {
@ -111,8 +105,6 @@ 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 },
@ -145,15 +137,6 @@ 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,
} }

View File

@ -21,7 +21,6 @@ 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,
}); });
} }
@ -32,7 +31,6 @@ 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 {
@ -41,7 +39,6 @@ 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,
}] }]
} }
} }
@ -56,7 +53,6 @@ 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(),
@ -65,7 +61,6 @@ 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(),
@ -91,7 +86,6 @@ 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 {
@ -107,7 +101,6 @@ 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
@ -118,7 +111,6 @@ 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
@ -130,7 +122,6 @@ 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(),

View File

@ -319,33 +319,6 @@ 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,
@ -617,92 +590,6 @@ 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,

View File

@ -33,13 +33,6 @@ 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(
@ -185,15 +178,6 @@ 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)
} }

View File

@ -3,7 +3,6 @@ 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;
@ -188,13 +187,8 @@ 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
@ -318,28 +312,4 @@ 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);
}
} }

View File

@ -57,35 +57,15 @@ 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> {
let sessions = self.sessions.read().unwrap(); Ok(self.sessions.read().unwrap().get(task_id).cloned())
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> {

View File

@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet}; use std::collections::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,10 +7,8 @@ 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, EmittedMessageHandler, PersistingEmittedMessageHandler, SystemPrompt, SystemPromptContext, SystemPromptProvider}; use crate::agent::{AgentLoop, AgentRuntimeConfig, 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};
@ -99,37 +97,6 @@ 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 {
@ -148,7 +115,6 @@ pub struct DefaultSubAgentRuntime {
provider_config: LLMProviderConfig, provider_config: LLMProviderConfig,
/// 子代理定义目录(内置 + 自定义) /// 子代理定义目录(内置 + 自定义)
catalog: Arc<SubagentCatalog>, catalog: Arc<SubagentCatalog>,
bus: Option<Arc<MessageBus>>,
} }
impl DefaultSubAgentRuntime { impl DefaultSubAgentRuntime {
@ -159,7 +125,6 @@ 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,
@ -168,7 +133,6 @@ impl DefaultSubAgentRuntime {
subagent_tools, subagent_tools,
provider_config, provider_config,
catalog, catalog,
bus,
} }
} }
@ -210,39 +174,16 @@ impl DefaultSubAgentRuntime {
None, // 子代理不需要 skill provider None, // 子代理不需要 skill provider
) )
.map(|agent| { .map(|agent| {
let agent = agent.with_tool_context(ToolContext { 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: Some(session.parent_chat_id.clone()), // 使用父会话 chat_id
session_id: Some(session.session_id.clone()), session_id: Some(session.session_id.clone()), // 子代理自己的 session_id
topic_id: session.parent_topic_id.clone(), topic_id: session.parent_topic_id.clone(), // 继承父话题 ID
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()))
} }
@ -279,6 +220,13 @@ 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(),
@ -324,6 +272,13 @@ 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(),
@ -385,13 +340,6 @@ 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. 构建子代理系统提示词
@ -416,24 +364,12 @@ 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 {

View File

@ -121,7 +121,6 @@ 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();
@ -153,7 +152,6 @@ 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();

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { Zap, Cpu, MessageSquare, ArrowLeft, Bot } from 'lucide-react' import { Zap, Cpu, MessageSquare } 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 { ChatMessage, Command } from './types/protocol' import type { 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,8 +29,6 @@ function App() {
messages, messages,
isLoading, isLoading,
isReadOnly, isReadOnly,
// 子智能体视图
subAgentView,
// 方法 // 方法
handleMessage, handleMessage,
handleCommand, handleCommand,
@ -40,8 +38,6 @@ function App() {
switchTopic, switchTopic,
requestSessionList, requestSessionList,
requestTopicList, requestTopicList,
enterSubAgentView,
exitSubAgentView,
} = useChat() } = useChat()
const { status, sendMessage } = useWebSocket({ const { status, sendMessage } = useWebSocket({
@ -160,58 +156,7 @@ function App() {
[sendMessage, handleCommand, switchTopic, selectTopic] [sendMessage, handleCommand, switchTopic, selectTopic]
) )
const handleNavigateToSubAgent = useCallback( const chatMessages = messages.filter((message) => message.type !== 'tool_result')
(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 (
@ -247,13 +192,18 @@ function App() {
{/* Main Content */} {/* Main Content */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Left Sidebar */} {/* Left Sidebar - 简化为 Session 信息 + Topic 列表 */}
<div className={`w-72 shrink-0 border-r border-white/8 bg-[#12121a]/50 flex flex-col ${subAgentView ? 'opacity-50 pointer-events-none' : ''}`}> <div className="w-72 shrink-0 border-r border-white/8 bg-[#12121a]/50 flex flex-col">
{/* 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}
@ -268,59 +218,15 @@ function App() {
</div> </div>
{/* Center - Chat */} {/* Center - Chat */}
<div className="flex-1 min-w-0 bg-[#0a0a0f] flex flex-col"> <div className="flex-1 min-w-0 bg-[#0a0a0f]">
{/* 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={subAgentView ? true : isReadOnly} isReadOnly={isReadOnly}
channelName={subAgentView ? `子智能体: ${subAgentView.description}` : (session?.title ?? 'PicoBot')} channelName={session?.title ?? 'PicoBot'}
onSendMessage={subAgentView ? () => {} : handleSendMessage} onSendMessage={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">

View File

@ -8,7 +8,6 @@ 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({
@ -17,12 +16,11 @@ 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} onNavigateToSubAgent={onNavigateToSubAgent} /> <MessageList messages={messages} />
</div> </div>
<MessageInput <MessageInput
onSend={onSendMessage} onSend={onSendMessage}

View File

@ -1,12 +1,10 @@
import { useState } from 'react' import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download } from 'lucide-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, TaskToolResult } from '../../types/protocol' import type { ChatMessage, Attachment } 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) {
@ -24,13 +22,6 @@ 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)
@ -81,337 +72,9 @@ function AttachmentCard({ attachment }: { attachment: Attachment }) {
) )
} }
function CopyButton({ text, className = '' }: { text: string; className?: string }) { export function MessageBubble({ message }: MessageBubbleProps) {
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" />
@ -448,8 +111,15 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
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 group`}> <div className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in`}>
<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`}
> >
@ -461,7 +131,6 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
{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()}`}

View File

@ -5,10 +5,9 @@ import { Sparkles } from 'lucide-react'
interface MessageListProps { interface MessageListProps {
messages: ChatMessage[] messages: ChatMessage[]
onNavigateToSubAgent?: (taskId: string, description: string) => void
} }
export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps) { export function MessageList({ messages }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@ -42,7 +41,7 @@ export function MessageList({ messages, onNavigateToSubAgent }: 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} onNavigateToSubAgent={onNavigateToSubAgent} /> <MessageBubble key={message.id} message={message} />
))} ))}
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>

View File

@ -15,16 +15,6 @@ 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>()
@ -78,42 +68,31 @@ export function ToolPanel({ messages }: ToolPanelProps) {
}) })
} }
const getStatusConfig = (status: ToolCallItem['status']) => { const getStatusIcon = (status: ToolCallItem['status']) => {
switch (status) { switch (status) {
case 'calling': case 'calling':
return { return <Play className="h-3 w-3 text-amber-400 animate-pulse" />
icon: Play,
iconColor: 'text-amber-400',
bgClass: 'bg-amber-400',
borderClass: 'border-amber-500/30',
label: '执行中',
labelClass: 'text-amber-400',
}
case 'result': case 'result':
return { return <Check className="h-3 w-3 text-emerald-400" />
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 <AlertTriangle className="h-3 w-3 text-orange-400" />
icon: AlertTriangle,
iconColor: 'text-orange-400',
bgClass: 'bg-orange-400',
borderClass: 'border-orange-500/30',
label: '待确认',
labelClass: 'text-orange-400',
} }
} }
const getStatusText = (status: ToolCallItem['status']) => {
switch (status) {
case 'calling':
return '执行中'
case 'result':
return '已完成'
case 'pending':
return '待确认'
}
} }
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]" />
@ -130,7 +109,6 @@ 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]" />
@ -140,106 +118,48 @@ 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 bg-[#1a1a25]/50 text-sm overflow-hidden tool-card transition-colors duration-500 ${config.borderClass}`} className="rounded-xl border border-white/8 bg-[#1a1a25]/50 text-sm overflow-hidden"
> >
<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 min-w-0"> <div className="flex items-center gap-2">
<span className={`tool-status-icon ${tool.status === 'calling' && !hasResult ? 'animate-pulse' : ''}`}> {getStatusIcon(tool.status)}
<StatusIcon className={`h-3.5 w-3.5 transition-colors duration-500 ${config.iconColor}`} /> <span className="font-medium text-zinc-300">{tool.toolName}</span>
</span> <span className="text-xs text-zinc-500">
<span className="font-medium text-zinc-300 truncate">{tool.toolName}</span> {getStatusText(tool.status)}
<span className={`text-xs flex-shrink-0 transition-colors duration-500 ${config.labelClass}`}>
{config.label}
</span> </span>
</div> </div>
<span className="flex-shrink-0 ml-2"> {expandedTools.has(tool.toolCallId) ? (
{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) && (
{/* 结果预览区 — 始终可见 */}
{hasResult && (
<div className="px-3 pb-2">
<div
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 ${
isExpanded ? '' : 'line-clamp-2'
}`}
onClick={() => toggleExpand(tool.toolCallId)}
>
{isExpanded ? (
<pre className="whitespace-pre-wrap break-all m-0">{formattedContent}</pre>
) : (
<span className="whitespace-pre-wrap break-all">{previewLines}</span>
)}
</div>
{!isExpanded && hasMore && (
<div className="text-xs text-zinc-500 mt-1 px-1">
({displayContent.split('\n').length} )
</div>
)}
</div>
)}
{/* 展开区域:参数 */}
{isExpanded && tool.arguments ? (
<div className="border-t border-white/8 px-3 py-2 bg-black/20"> <div className="border-t border-white/8 px-3 py-2 bg-black/20">
{tool.arguments ? (
<div className="mb-2">
<div className="text-xs font-medium text-zinc-500 mb-1">:</div> <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"> <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>
{JSON.stringify(tool.arguments, null, 2)}
</pre>
</div> </div>
) : null} ) : null}
<div>
<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">
{tool.resultContent || tool.callContent}
</div> </div>
) </div>
})} </div>
)}
</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;
}
`

View File

@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef, useMemo } from 'react' import { useState, useCallback, useRef, useMemo } from 'react'
import type { import type {
Command, Command,
ChatMessage, ChatMessage,
@ -13,7 +13,6 @@ import type {
TopicList, TopicList,
TopicSummary, TopicSummary,
Session, Session,
TaskMessagesLoaded,
} from '../types/protocol' } from '../types/protocol'
// 简化后的层级状态 // 简化后的层级状态
@ -36,9 +35,6 @@ 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
@ -53,19 +49,6 @@ 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'
@ -80,7 +63,6 @@ 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)
@ -89,152 +71,13 @@ 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
@ -325,7 +168,6 @@ 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
@ -343,7 +185,6 @@ 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
@ -456,41 +297,6 @@ 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
@ -502,10 +308,9 @@ export function useChat(): UseChatReturn {
chatId, chatId,
topics, topics,
selectedTopic, selectedTopic,
messages: resolvedMessages, messages,
isLoading, isLoading,
isReadOnly, isReadOnly,
subAgentView,
handleMessage, handleMessage,
handleCommand, handleCommand,
clearMessages, clearMessages,
@ -515,7 +320,5 @@ export function useChat(): UseChatReturn {
switchTopic, switchTopic,
requestSessionList, requestSessionList,
requestTopicList, requestTopicList,
enterSubAgentView,
exitSubAgentView,
} }
} }

View File

@ -41,7 +41,6 @@ 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 {
@ -52,7 +51,6 @@ 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 {
@ -62,7 +60,6 @@ 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 {
@ -73,7 +70,6 @@ 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 {
@ -155,15 +151,6 @@ 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
@ -177,7 +164,6 @@ export type WsOutbound =
| SessionSaved | SessionSaved
| TopicList | TopicList
| ChannelList | ChannelList
| TaskMessagesLoaded
| Pong | Pong
// ============================================================================ // ============================================================================
@ -240,11 +226,6 @@ 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
@ -257,7 +238,6 @@ export type Command =
| ListChannelsCommand | ListChannelsCommand
| ListSessionsByChannelCommand | ListSessionsByChannelCommand
| ListTopicsCommand | ListTopicsCommand
| LoadTaskMessagesCommand
// ============================================================================ // ============================================================================
// UI Types // UI Types
@ -268,23 +248,11 @@ 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' | 'merged_tool' type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending'
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 {