Compare commits

..

No commits in common. "5e5de7ce9ffa4d03ceca45923b51709a27bb9a2e" and "624d8e89439565492e6eca210f9f9ca6909d9b35" have entirely different histories.

35 changed files with 250 additions and 1621 deletions

1
.gitignore vendored
View File

@ -30,4 +30,3 @@ output
.python-version .python-version
pyproject.toml pyproject.toml
uv.lock uv.lock
node_modules

View File

@ -975,8 +975,7 @@ impl AgentLoop {
}, },
); );
messages.push(tool_message.clone()); messages.push(tool_message.clone());
emitted_messages.push(tool_message.clone()); emitted_messages.push(tool_message);
self.emit_live_tool_call_message(tool_message).await;
} }
LoopDetectionResult::Ok => { LoopDetectionResult::Ok => {
let tool_message = ChatMessage::tool_with_state( let tool_message = ChatMessage::tool_with_state(
@ -990,8 +989,7 @@ impl AgentLoop {
}, },
); );
messages.push(tool_message.clone()); messages.push(tool_message.clone());
emitted_messages.push(tool_message.clone()); emitted_messages.push(tool_message);
self.emit_live_tool_call_message(tool_message).await;
} }
} }
} }
@ -1113,6 +1111,10 @@ impl AgentLoop {
} }
async fn emit_live_tool_call_message(&self, message: ChatMessage) { async fn emit_live_tool_call_message(&self, message: ChatMessage) {
if !message.is_assistant_tool_call_message() {
return;
}
if let Some(handler) = &self.emitted_message_handler { if let Some(handler) = &self.emitted_message_handler {
handler.handle(message).await; handler.handle(message).await;
} }

View File

@ -24,8 +24,6 @@ pub struct MediaItem {
pub media_type: String, // "image", "audio", "file", "video" pub media_type: String, // "image", "audio", "file", "video"
pub mime_type: Option<String>, pub mime_type: Option<String>,
pub original_key: Option<String>, // Feishu file_key for download pub original_key: Option<String>, // Feishu file_key for download
pub content_base64: Option<String>, // Base64-encoded file content for web download
pub file_name: Option<String>, // Display file name
} }
impl MediaItem { impl MediaItem {
@ -35,8 +33,6 @@ impl MediaItem {
media_type: media_type.into(), media_type: media_type.into(),
mime_type: None, mime_type: None,
original_key: None, original_key: None,
content_base64: None,
file_name: None,
} }
} }
} }

View File

@ -120,11 +120,11 @@ impl InputAdapter for ChannelInputAdapter {
})); }));
} }
// 解析 /use 命令 - 切换话题(支持 topic_id 或序号) // 解析 /use 命令 - 切换会话(支持 session_id 或序号)
if let Some(topic_id) = trimmed.strip_prefix("/use ") { if let Some(session_id) = trimmed.strip_prefix("/use ") {
let topic_id = topic_id.trim(); let session_id = session_id.trim();
return Ok(Some(Command::SwitchTopic { return Ok(Some(Command::SwitchSession {
topic_id: topic_id.to_string(), session_id: session_id.to_string(),
})); }));
} }

View File

@ -121,11 +121,11 @@ impl InputAdapter for CliInputAdapter {
})); }));
} }
// 解析 /use 命令 - 切换话题(支持 topic_id 或序号) // 解析 /use 命令 - 切换会话(支持 session_id 或序号)
if let Some(topic_id) = trimmed.strip_prefix("/use ") { if let Some(session_id) = trimmed.strip_prefix("/use ") {
let topic_id = topic_id.trim(); let session_id = session_id.trim();
return Ok(Some(Command::SwitchTopic { return Ok(Some(Command::SwitchSession {
topic_id: topic_id.to_string(), session_id: session_id.to_string(),
})); }));
} }

View File

@ -77,104 +77,13 @@ 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(),
}, },
MessageKind::Notification => { MessageKind::Notification => {
// 根据元数据判断具体类型 // 根据元数据判断具体类型
if let Some(topics_json) = response.metadata.get("topics") { if let Some(session_id) = response.metadata.get("session_id") {
// Topic 列表响应 - 优先检查 topics WsOutbound::SessionCreated {
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) { session_id: session_id.clone(),
Ok(topics) => {
let session_id = response.metadata.get("session_id")
.cloned()
.unwrap_or_default();
WsOutbound::TopicList {
topics,
session_id,
}
}
Err(_) => WsOutbound::AssistantResponse {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
attachments: Vec::new(),
},
}
} else if let Some(session_id) = response.metadata.get("session_id") {
// 有 session_id 但没有 topic_id 的是创建会话
if response.metadata.get("topic_id").is_none() {
WsOutbound::SessionCreated {
session_id: session_id.clone(),
title: msg.content.clone(),
}
} else {
// 加载会话
let message_count = response.metadata.get("message_count")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
WsOutbound::SessionLoaded {
session_id: session_id.clone(),
title: msg.content.clone(),
message_count,
}
}
} else if let Some(topic_id) = response.metadata.get("topic_id") {
// 只有 topic_id可能是加载话题
let message_count = response.metadata.get("message_count")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
WsOutbound::SessionLoaded {
session_id: topic_id.clone(),
title: msg.content.clone(), title: msg.content.clone(),
message_count,
}
} else if let Some(channels_json) = response.metadata.get("channels") {
// 通道列表响应
match serde_json::from_str::<Vec<crate::protocol::Channel>>(channels_json) {
Ok(channels) => WsOutbound::ChannelList { channels },
Err(_) => WsOutbound::AssistantResponse {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
attachments: Vec::new(),
},
}
} else if let Some(sessions_json) = response.metadata.get("sessions") {
// 会话列表响应
match serde_json::from_str::<Vec<crate::protocol::SessionSummary>>(sessions_json) {
Ok(sessions) => {
let channel_name = response.metadata.get("channel_name").cloned();
WsOutbound::SessionList {
sessions,
current_session_id: None,
channel_name,
}
}
Err(_) => WsOutbound::AssistantResponse {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
attachments: Vec::new(),
},
}
} else if let Some(topics_json) = response.metadata.get("topics") {
// Topic 列表响应
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
Ok(topics) => {
let session_id = response.metadata.get("session_id")
.cloned()
.unwrap_or_default();
WsOutbound::TopicList {
topics,
session_id,
}
}
Err(_) => WsOutbound::AssistantResponse {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
attachments: Vec::new(),
},
} }
} else { } else {
// 默认通知 // 默认通知
@ -182,7 +91,6 @@ 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(),
} }
} }
} }
@ -194,7 +102,6 @@ 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(),
}, },
}; };
outbounds.push(outbound); outbounds.push(outbound);

View File

@ -1,75 +0,0 @@
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::protocol::Channel;
use async_trait::async_trait;
/// 列出通道命令处理器
pub struct ListChannelsCommandHandler;
impl ListChannelsCommandHandler {
pub fn new() -> Self {
Self
}
/// 获取默认通道列表(公开供其他模块使用)
pub fn get_default_channels() -> Vec<Channel> {
vec![
Channel {
id: "websocket".to_string(),
name: "WebSocket".to_string(),
description: Some("Web 前端通道".to_string()),
is_writable: true,
},
Channel {
id: "cli".to_string(),
name: "命令行".to_string(),
description: Some("CLI 命令行通道".to_string()),
is_writable: false,
},
]
}
}
#[async_trait]
impl CommandHandler for ListChannelsCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::ListChannels)
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "channels",
description: "列出所有可用通道",
usage: "/channels",
})
}
async fn handle(
&self,
cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::ListChannels => handle_list_channels(ctx).await,
_ => unreachable!(),
}
}
}
async fn handle_list_channels(
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
let channels = ListChannelsCommandHandler::get_default_channels();
let channels_json = serde_json::to_string(&channels)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
let message = format!("Available channels: {}", channels.len());
Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &message)
.with_metadata("channels", &channels_json)
.with_metadata("count", &channels.len().to_string()))
}

View File

@ -1,88 +0,0 @@
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::protocol::SessionSummary;
use crate::storage::SessionStore;
use async_trait::async_trait;
use std::sync::Arc;
/// 按通道列出会话命令处理器
pub struct ListSessionsByChannelCommandHandler {
store: Arc<SessionStore>,
}
impl ListSessionsByChannelCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self {
Self { store }
}
}
#[async_trait]
impl CommandHandler for ListSessionsByChannelCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::ListSessionsByChannel { .. })
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "list_by_channel",
description: "列出指定通道的所有会话",
usage: "/list_by_channel <channel_name>",
})
}
async fn handle(
&self,
cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::ListSessionsByChannel {
channel_name,
include_archived,
} => handle_list_sessions_by_channel(self, channel_name, include_archived, ctx).await,
_ => unreachable!(),
}
}
}
async fn handle_list_sessions_by_channel(
handler: &ListSessionsByChannelCommandHandler,
channel_name: String,
include_archived: bool,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
let sessions = handler
.store
.list_sessions(&channel_name, include_archived)
.map_err(|e| CommandError::new("LIST_SESSIONS_ERROR", e.to_string()))?;
let summaries: Vec<SessionSummary> = sessions
.into_iter()
.map(|s| SessionSummary {
session_id: s.id,
title: s.title,
channel_name: s.channel_name,
chat_id: s.chat_id,
message_count: s.message_count,
last_active_at: s.last_active_at,
archived_at: s.archived_at,
})
.collect();
let sessions_json = serde_json::to_string(&summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
let message = format!(
"Found {} session(s) in channel '{}'",
summaries.len(),
channel_name
);
Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &message)
.with_metadata("sessions", &sessions_json)
.with_metadata("channel_name", &channel_name)
.with_metadata("count", &summaries.len().to_string()))
}

View File

@ -1,98 +0,0 @@
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::SessionStore;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
/// Topic 摘要信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopicSummary {
pub topic_id: String,
pub session_id: String,
pub title: String,
pub description: Option<String>,
pub message_count: i64,
pub created_at: i64,
pub last_active_at: i64,
}
/// 列出 Session 的 Topics 命令处理器
pub struct ListTopicsCommandHandler {
store: Arc<SessionStore>,
}
impl ListTopicsCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self {
Self { store }
}
}
#[async_trait]
impl CommandHandler for ListTopicsCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::ListTopics { .. })
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "topics",
description: "列出 Session 的所有 Topics",
usage: "/topics <session_id>",
})
}
async fn handle(
&self,
cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::ListTopics { session_id } => {
handle_list_topics(self, session_id, ctx).await
}
_ => unreachable!(),
}
}
}
async fn handle_list_topics(
handler: &ListTopicsCommandHandler,
session_id: String,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
let topics = handler
.store
.list_topics(&session_id)
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
let summaries: Vec<TopicSummary> = topics
.into_iter()
.map(|t| TopicSummary {
topic_id: t.id,
session_id: t.session_id,
title: t.title,
description: t.description.filter(|d| !d.is_empty()),
message_count: t.message_count,
created_at: t.created_at,
last_active_at: t.last_active_at,
})
.collect();
let topics_json = serde_json::to_string(&summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
let message = format!(
"Found {} topic(s) in session '{}'",
summaries.len(),
session_id
);
Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &message)
.with_metadata("topics", &topics_json)
.with_metadata("session_id", &session_id)
.with_metadata("count", &summaries.len().to_string()))
}

View File

@ -7,27 +7,27 @@ use async_trait::async_trait;
use std::sync::Arc; use std::sync::Arc;
/// 加载话题命令处理器 /// 加载话题命令处理器
pub struct LoadTopicCommandHandler { pub struct LoadSessionCommandHandler {
store: Arc<SessionStore>, store: Arc<SessionStore>,
} }
impl LoadTopicCommandHandler { impl LoadSessionCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self { pub fn new(store: Arc<SessionStore>) -> Self {
Self { store } Self { store }
} }
} }
#[async_trait] #[async_trait]
impl CommandHandler for LoadTopicCommandHandler { impl CommandHandler for LoadSessionCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool { fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::LoadTopic { .. }) matches!(cmd, Command::LoadSession { .. })
} }
fn metadata(&self) -> Option<CommandMetadata> { fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata { Some(CommandMetadata {
name: "load", name: "load",
description: "加载指定话题", description: "加载指定话题",
usage: "/load <topic_id>", usage: "/load <session_id>",
}) })
} }
@ -37,16 +37,16 @@ impl CommandHandler for LoadTopicCommandHandler {
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
match cmd { match cmd {
Command::LoadTopic { topic_id } => { Command::LoadSession { session_id } => {
handle_load_topic(self, topic_id, ctx).await handle_load_session(self, session_id, ctx).await
} }
_ => unreachable!(), _ => unreachable!(),
} }
} }
} }
async fn handle_load_topic( async fn handle_load_session(
handler: &LoadTopicCommandHandler, handler: &LoadSessionCommandHandler,
topic_id: String, topic_id: String,
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
@ -61,4 +61,4 @@ async fn handle_load_topic(
.with_metadata("topic_id", &topic.id) .with_metadata("topic_id", &topic.id)
.with_metadata("title", &topic.title) .with_metadata("title", &topic.title)
.with_metadata("message_count", &topic.message_count.to_string())) .with_metadata("message_count", &topic.message_count.to_string()))
} }

View File

@ -1,14 +1,11 @@
pub mod get_current; pub mod get_current;
pub mod help; pub mod help;
pub mod list_channels;
pub mod list_sessions; pub mod list_sessions;
pub mod list_sessions_by_channel; pub mod load_session;
pub mod list_topics;
pub mod load_topic;
pub mod save_session; pub mod save_session;
pub mod save_topic; pub mod save_topic;
pub mod session; pub mod session;
pub mod switch_topic; pub mod switch_session;
// 导出公共函数供其他模块复用 // 导出公共函数供其他模块复用
pub use save_session::{ pub use save_session::{

View File

@ -1,6 +1,5 @@
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::list_topics::TopicSummary;
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command; use crate::command::Command;
use crate::gateway::session::SessionManager; use crate::gateway::session::SessionManager;
@ -95,31 +94,8 @@ async fn handle_create_session(
} }
} }
// Query the full topic list so the frontend sidebar can update
let topics = handler
.store
.list_topics(session_id)
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
let topic_summaries: Vec<TopicSummary> = topics
.into_iter()
.map(|t| TopicSummary {
topic_id: t.id,
session_id: t.session_id,
title: t.title,
description: t.description.filter(|d| !d.is_empty()),
message_count: t.message_count,
created_at: t.created_at,
last_active_at: t.last_active_at,
})
.collect();
let topics_json = serde_json::to_string(&topic_summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
Ok(CommandResponse::success(ctx.request_id) Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &topic.title) .with_message(MessageKind::Notification, &topic.title)
.with_metadata("topics", &topics_json)
.with_metadata("topic_id", &topic.id) .with_metadata("topic_id", &topic.id)
.with_metadata("session_id", &topic.session_id) .with_metadata("session_id", &topic.session_id)
.with_metadata("message_count", &topic.message_count.to_string())) .with_metadata("message_count", &topic.message_count.to_string()))

View File

@ -8,12 +8,12 @@ use async_trait::async_trait;
use std::sync::Arc; use std::sync::Arc;
/// 切换话题命令处理器 /// 切换话题命令处理器
pub struct SwitchTopicCommandHandler { pub struct SwitchSessionCommandHandler {
store: Arc<SessionStore>, store: Arc<SessionStore>,
session_manager: Option<SessionManager>, session_manager: Option<SessionManager>,
} }
impl SwitchTopicCommandHandler { impl SwitchSessionCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self { pub fn new(store: Arc<SessionStore>) -> Self {
Self { store, session_manager: None } Self { store, session_manager: None }
} }
@ -25,16 +25,16 @@ impl SwitchTopicCommandHandler {
} }
#[async_trait] #[async_trait]
impl CommandHandler for SwitchTopicCommandHandler { impl CommandHandler for SwitchSessionCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool { fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::SwitchTopic { .. }) matches!(cmd, Command::SwitchSession { .. })
} }
fn metadata(&self) -> Option<CommandMetadata> { fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata { Some(CommandMetadata {
name: "use", name: "use",
description: "切换到指定话题", description: "切换到指定话题",
usage: "/use <topic_id>", usage: "/use <session_id>",
}) })
} }
@ -44,16 +44,16 @@ impl CommandHandler for SwitchTopicCommandHandler {
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
match cmd { match cmd {
Command::SwitchTopic { topic_id } => { Command::SwitchSession { session_id } => {
handle_switch_topic(self, topic_id, ctx).await handle_switch_session(self, session_id, ctx).await
} }
_ => unreachable!(), _ => unreachable!(),
} }
} }
} }
async fn handle_switch_topic( async fn handle_switch_session(
handler: &SwitchTopicCommandHandler, handler: &SwitchSessionCommandHandler,
topic_id: String, topic_id: String,
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
@ -112,4 +112,4 @@ async fn handle_switch_topic(
.with_metadata("topic_id", &topic.id) .with_metadata("topic_id", &topic.id)
.with_metadata("title", &topic.title) .with_metadata("title", &topic.title)
.with_metadata("message_count", &msg_count.to_string())) .with_metadata("message_count", &msg_count.to_string()))
} }

View File

@ -27,22 +27,13 @@ pub enum Command {
/// 列出当前 Session 的所有话题 /// 列出当前 Session 的所有话题
ListSessions { include_archived: bool }, ListSessions { include_archived: bool },
/// 加载指定话题 /// 加载指定话题
LoadTopic { topic_id: String }, LoadSession { session_id: String },
/// 切换到指定话题(清理当前历史并加载新话题) /// 切换到指定话题(清理当前历史并加载新话题)
SwitchTopic { topic_id: String }, SwitchSession { session_id: String },
/// 获取当前话题信息 /// 获取当前话题信息
GetCurrentSession, GetCurrentSession,
/// 显示所有支持的命令 /// 显示所有支持的命令
Help, Help,
/// 列出所有可用通道
ListChannels,
/// 列出指定通道的所有会话
ListSessionsByChannel {
channel_name: String,
include_archived: bool,
},
/// 列出 Session 的所有 Topics
ListTopics { session_id: String },
} }
impl Command { impl Command {
@ -53,13 +44,10 @@ impl Command {
Command::SaveTopic { .. } => "save_topic", Command::SaveTopic { .. } => "save_topic",
Command::SaveSession { .. } => "save_session", Command::SaveSession { .. } => "save_session",
Command::ListSessions { .. } => "list_sessions", Command::ListSessions { .. } => "list_sessions",
Command::LoadTopic { .. } => "load_topic", Command::LoadSession { .. } => "load_session",
Command::SwitchTopic { .. } => "switch_topic", Command::SwitchSession { .. } => "switch_session",
Command::GetCurrentSession => "get_current_session", Command::GetCurrentSession => "get_current_session",
Command::Help => "help", Command::Help => "help",
Command::ListChannels => "list_channels",
Command::ListSessionsByChannel { .. } => "list_sessions_by_channel",
Command::ListTopics { .. } => "list_topics",
} }
} }
} }

View File

@ -10,11 +10,11 @@ use crate::command::handler::CommandRouter;
use crate::command::handlers::get_current::GetCurrentSessionCommandHandler; use crate::command::handlers::get_current::GetCurrentSessionCommandHandler;
use crate::command::handlers::help::HelpCommandHandler; use crate::command::handlers::help::HelpCommandHandler;
use crate::command::handlers::list_sessions::ListSessionsCommandHandler; use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
use crate::command::handlers::load_topic::LoadTopicCommandHandler; use crate::command::handlers::load_session::LoadSessionCommandHandler;
use crate::command::handlers::save_session::SaveSessionCommandHandler; use crate::command::handlers::save_session::SaveSessionCommandHandler;
use crate::command::handlers::save_topic::SaveTopicCommandHandler; use crate::command::handlers::save_topic::SaveTopicCommandHandler;
use crate::command::handlers::session::SessionCommandHandler; use crate::command::handlers::session::SessionCommandHandler;
use crate::command::handlers::switch_topic::SwitchTopicCommandHandler; use crate::command::handlers::switch_session::SwitchSessionCommandHandler;
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
use crate::gateway::agent_prompt_provider::AgentPromptProvider; use crate::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::providers::{create_provider, ProviderRuntimeConfig}; use crate::providers::{create_provider, ProviderRuntimeConfig};
@ -52,8 +52,8 @@ impl InboundProcessor {
// 注册 list_sessions 处理器 // 注册 list_sessions 处理器
command_router.register(Box::new(ListSessionsCommandHandler::new(store.clone()))); command_router.register(Box::new(ListSessionsCommandHandler::new(store.clone())));
// 注册 switch_topic 处理器 // 注册 switch_session 处理器
let switch_handler = SwitchTopicCommandHandler::new(store.clone()) let switch_handler = SwitchSessionCommandHandler::new(store.clone())
.with_session_manager(session_manager.clone()); .with_session_manager(session_manager.clone());
command_router.register(Box::new(switch_handler)); command_router.register(Box::new(switch_handler));
@ -76,8 +76,8 @@ impl InboundProcessor {
.with_system_prompt_provider(system_prompt_provider.clone()) .with_system_prompt_provider(system_prompt_provider.clone())
)); ));
// 注册 load_topic 处理器 // 注册 load_session 处理器
command_router.register(Box::new(LoadTopicCommandHandler::new(store.clone()))); command_router.register(Box::new(LoadSessionCommandHandler::new(store.clone())));
// 注册 save_session 处理器 // 注册 save_session 处理器
command_router.register(Box::new(SaveSessionCommandHandler::new( command_router.register(Box::new(SaveSessionCommandHandler::new(
@ -223,6 +223,7 @@ impl InboundProcessor {
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.show_tool_results(),
)); ));
match self match self

View File

@ -17,7 +17,6 @@ use uuid::Uuid;
use super::agent_factory::{AgentBuildRequest, AgentFactory}; use super::agent_factory::{AgentBuildRequest, AgentFactory};
use super::cli_session::CliSessionService; use super::cli_session::CliSessionService;
#[cfg(test)]
use super::execution::should_display_message_to_user; use super::execution::should_display_message_to_user;
#[cfg(test)] #[cfg(test)]
use super::memory_maintenance::{ use super::memory_maintenance::{
@ -52,6 +51,7 @@ pub struct BusToolCallEmitter {
channel_name: String, channel_name: String,
chat_id: String, chat_id: String,
metadata: HashMap<String, String>, metadata: HashMap<String, String>,
show_tool_results: bool,
} }
impl BusToolCallEmitter { impl BusToolCallEmitter {
@ -60,12 +60,14 @@ impl BusToolCallEmitter {
channel_name: impl Into<String>, channel_name: impl Into<String>,
chat_id: impl Into<String>, chat_id: impl Into<String>,
metadata: HashMap<String, String>, metadata: HashMap<String, String>,
show_tool_results: bool,
) -> Self { ) -> Self {
Self { Self {
bus, bus,
channel_name: channel_name.into(), channel_name: channel_name.into(),
chat_id: chat_id.into(), chat_id: chat_id.into(),
metadata, metadata,
show_tool_results,
} }
} }
} }
@ -73,6 +75,10 @@ impl BusToolCallEmitter {
#[async_trait] #[async_trait]
impl EmittedMessageHandler for BusToolCallEmitter { impl EmittedMessageHandler for BusToolCallEmitter {
async fn handle(&self, message: ChatMessage) { async fn handle(&self, message: ChatMessage) {
if !should_display_message_to_user(self.show_tool_results, &message) {
return;
}
for outbound in OutboundMessage::from_chat_message( for outbound in OutboundMessage::from_chat_message(
&self.channel_name, &self.channel_name,
&self.chat_id, &self.chat_id,
@ -658,7 +664,6 @@ impl SessionManager {
mod tests { mod tests {
use super::*; use super::*;
use crate::bus::MessageBus; use crate::bus::MessageBus;
use crate::bus::message::OutboundEventKind;
use crate::gateway::tool_registry_factory::ToolRegistryFactory; use crate::gateway::tool_registry_factory::ToolRegistryFactory;
use crate::storage::MemoryRecord; use crate::storage::MemoryRecord;
use crate::tools::NoopSessionMessageSender; use crate::tools::NoopSessionMessageSender;
@ -1736,7 +1741,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn test_bus_tool_call_emitter_emits_completed_tool_results() { async fn test_bus_tool_call_emitter_hides_completed_tool_results_when_disabled() {
let bus = MessageBus::new(4); let bus = MessageBus::new(4);
let emitter = let emitter =
BusToolCallEmitter::new( BusToolCallEmitter::new(
@ -1744,16 +1749,18 @@ mod tests {
"test-channel", "test-channel",
"chat-1", "chat-1",
HashMap::new(), HashMap::new(),
false,
); );
emitter emitter
.handle(ChatMessage::tool("call-1", "calculator", "2")) .handle(ChatMessage::tool("call-1", "calculator", "2"))
.await; .await;
let msg = tokio::time::timeout(std::time::Duration::from_millis(500), bus.consume_outbound()) assert!(
.await tokio::time::timeout(std::time::Duration::from_millis(50), bus.consume_outbound())
.expect("should have received an outbound message"); .await
assert_eq!(msg.event_kind, OutboundEventKind::ToolResult); .is_err()
);
} }
#[tokio::test] #[tokio::test]

View File

@ -33,7 +33,6 @@ impl SessionMessageSender for BusSessionMessageSender {
.ok_or_else(|| anyhow::anyhow!("missing chat_id in tool context"))?; .ok_or_else(|| anyhow::anyhow!("missing chat_id in tool context"))?;
let metadata = HashMap::new(); let metadata = HashMap::new();
let attachment_count = request.attachments.len();
let mut published_messages = 0; let mut published_messages = 0;
let text_sent = request let text_sent = request
.text .text
@ -44,49 +43,47 @@ impl SessionMessageSender for BusSessionMessageSender {
if let Some(text) = request.text.filter(|value| !value.trim().is_empty()) { if let Some(text) = request.text.filter(|value| !value.trim().is_empty()) {
let content_len = text.len(); let content_len = text.len();
let mut outbound = OutboundMessage::assistant( self.bus
channel_name.to_string(), .publish_outbound(OutboundMessage::assistant(
chat_id.to_string(), channel_name.to_string(),
None, // session_id chat_id.to_string(),
text, None, // session_id
None, text,
metadata.clone(), None,
); metadata.clone(),
if attachment_count > 0 { ))
outbound.media = request.attachments.clone(); .await?;
}
self.bus.publish_outbound(outbound).await?;
published_messages += 1; published_messages += 1;
tracing::info!( tracing::info!(
channel = %channel_name, channel = %channel_name,
chat_id = %chat_id, chat_id = %chat_id,
content_len = content_len, content_len = content_len,
attachment_count = attachment_count,
"Published session text message to outbound bus" "Published session text message to outbound bus"
); );
} else { }
for attachment in request.attachments {
let media_path = attachment.path.clone(); let attachment_count = request.attachments.len();
let media_type = attachment.media_type.clone(); for attachment in request.attachments {
let mut outbound = OutboundMessage::assistant( let media_path = attachment.path.clone();
channel_name.to_string(), let media_type = attachment.media_type.clone();
chat_id.to_string(), let mut outbound = OutboundMessage::assistant(
None, // session_id channel_name.to_string(),
String::new(), chat_id.to_string(),
None, None, // session_id
metadata.clone(), String::new(),
); None,
outbound.media = vec![attachment]; metadata.clone(),
self.bus.publish_outbound(outbound).await?; );
published_messages += 1; outbound.media = vec![attachment];
tracing::info!( self.bus.publish_outbound(outbound).await?;
channel = %channel_name, published_messages += 1;
chat_id = %chat_id, tracing::info!(
media_type = %media_type, channel = %channel_name,
media_path = %media_path, chat_id = %chat_id,
"Published session attachment to outbound bus" media_type = %media_type,
); media_path = %media_path,
} "Published session attachment to outbound bus"
);
} }
Ok(SessionSendOutcome { Ok(SessionSendOutcome {
@ -132,15 +129,19 @@ mod tests {
assert_eq!( assert_eq!(
outcome, outcome,
SessionSendOutcome { SessionSendOutcome {
published_messages: 1, published_messages: 2,
text_sent: true, text_sent: true,
attachment_count: 1, attachment_count: 1,
} }
); );
let msg = bus.consume_outbound().await; let first = bus.consume_outbound().await;
assert_eq!(msg.content, "hello"); assert_eq!(first.content, "hello");
assert_eq!(msg.media.len(), 1); assert!(first.media.is_empty());
assert_eq!(msg.media[0].media_type, "image");
let second = bus.consume_outbound().await;
assert_eq!(second.content, "");
assert_eq!(second.media.len(), 1);
assert_eq!(second.media[0].media_type, "image");
} }
} }

View File

@ -7,14 +7,11 @@ use crate::command::context::CommandContext;
use crate::command::handler::CommandRouter; use crate::command::handler::CommandRouter;
use crate::command::handlers::get_current::GetCurrentSessionCommandHandler; use crate::command::handlers::get_current::GetCurrentSessionCommandHandler;
use crate::command::handlers::help::HelpCommandHandler; use crate::command::handlers::help::HelpCommandHandler;
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::load_session::LoadSessionCommandHandler;
use crate::command::handlers::list_topics::ListTopicsCommandHandler;
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;
use crate::command::handlers::switch_topic::SwitchTopicCommandHandler; use crate::command::handlers::switch_session::SwitchSessionCommandHandler;
use crate::gateway::agent_prompt_provider::AgentPromptProvider; use crate::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::protocol::{WsInbound, WsOutbound, parse_inbound, serialize_outbound}; use crate::protocol::{WsInbound, WsOutbound, parse_inbound, serialize_outbound};
use crate::skills::SkillPromptProvider; use crate::skills::SkillPromptProvider;
@ -38,24 +35,12 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
let (sender, receiver) = mpsc::channel::<WsOutbound>(100); let (sender, receiver) = mpsc::channel::<WsOutbound>(100);
let cli_sessions = state.session_manager.cli_sessions(); let cli_sessions = state.session_manager.cli_sessions();
let store = state.session_manager.store(); let initial_record = match cli_sessions.create(None) {
Ok(record) => record,
// 1. 先查询 websocket 通道的 Sessions Err(e) => {
let websocket_sessions = store.list_sessions("websocket", false) tracing::error!(error = %e, "Failed to create initial CLI session");
.unwrap_or_default(); return;
// 2. 如果没有,自动创建一个默认 Session
let initial_record = if websocket_sessions.is_empty() {
match cli_sessions.create_with_channel("websocket", Some("默认会话")) {
Ok(record) => record,
Err(e) => {
tracing::error!(error = %e, "Failed to create initial WebSocket session");
return;
}
} }
} else {
// 使用最新的 Session
websocket_sessions[0].clone()
}; };
let runtime_session_id = uuid::Uuid::new_v4().to_string(); let runtime_session_id = uuid::Uuid::new_v4().to_string();
@ -70,7 +55,7 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
sender.clone(), sender.clone(),
) )
.await; .await;
tracing::info!(runtime_session_id = %runtime_session_id, session_id = %current_session_id, "WebSocket session established"); tracing::info!(runtime_session_id = %runtime_session_id, session_id = %current_session_id, "CLI session established");
let _ = sender let _ = sender
.send(WsOutbound::SessionEstablished { .send(WsOutbound::SessionEstablished {
@ -78,42 +63,6 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
}) })
.await; .await;
// 连接建立后立即发送通道列表
let channels = ListChannelsCommandHandler::get_default_channels();
let _ = sender
.send(WsOutbound::ChannelList { channels })
.await;
// 3. 重新查询 websocket 通道的 Session 列表(包含刚创建的)
let final_sessions = store.list_sessions("websocket", false)
.unwrap_or_default();
tracing::info!("Sending {} websocket sessions to client", final_sessions.len());
for s in &final_sessions {
tracing::info!(" - {}: {} (channel: {})", s.id, s.title, s.channel_name);
}
let session_summaries: Vec<crate::protocol::SessionSummary> = final_sessions
.into_iter()
.map(|s| crate::protocol::SessionSummary {
session_id: s.id,
title: s.title,
channel_name: s.channel_name,
chat_id: s.chat_id,
message_count: s.message_count,
last_active_at: s.last_active_at,
archived_at: s.archived_at,
})
.collect();
let _ = sender
.send(WsOutbound::SessionList {
sessions: session_summaries,
current_session_id: Some(current_session_id.clone()),
channel_name: Some("websocket".to_string()),
})
.await;
let (mut ws_sender, mut ws_receiver) = ws.split(); let (mut ws_sender, mut ws_receiver) = ws.split();
let mut receiver = receiver; let mut receiver = receiver;
@ -285,20 +234,14 @@ async fn handle_inbound(
router.register(Box::new(session_handler)); router.register(Box::new(session_handler));
// 注册 list_sessions 处理器 // 注册 list_sessions 处理器
router.register(Box::new(ListSessionsCommandHandler::new(store.clone()))); router.register(Box::new(ListSessionsCommandHandler::new(store.clone())));
// 注册 list_sessions_by_channel 处理器 // 注册 switch_session 处理器
router.register(Box::new(ListSessionsByChannelCommandHandler::new(store.clone()))); let switch_handler = SwitchSessionCommandHandler::new(store.clone())
// 注册 list_channels 处理器
router.register(Box::new(ListChannelsCommandHandler::new()));
// 注册 list_topics 处理器
router.register(Box::new(ListTopicsCommandHandler::new(store.clone())));
// 注册 switch_topic 处理器
let switch_handler = SwitchTopicCommandHandler::new(store.clone())
.with_session_manager(state.session_manager.clone()); .with_session_manager(state.session_manager.clone());
router.register(Box::new(switch_handler)); router.register(Box::new(switch_handler));
// 注册 get_current 处理器 // 注册 get_current 处理器
router.register(Box::new(GetCurrentSessionCommandHandler::new(store.clone()))); router.register(Box::new(GetCurrentSessionCommandHandler::new(store.clone())));
// 注册 load_topic 处理器 // 注册 load_session 处理器
router.register(Box::new(LoadTopicCommandHandler::new(store.clone()))); router.register(Box::new(LoadSessionCommandHandler::new(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(),
@ -353,29 +296,6 @@ async fn handle_inbound(
"Updating current_topic_id" "Updating current_topic_id"
); );
*current_topic_id = Some(topic_id.clone()); *current_topic_id = Some(topic_id.clone());
// 加载并发送该话题的历史消息
if let Err(e) = send_topic_history(&store, current_session_id, topic_id, sender).await {
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send topic history");
}
}
if current_topic_id.is_none() {
if let Some(topics_json) = response.metadata.get("topics") {
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
Ok(topics) => {
if let Some(first_topic) = topics.first() {
let topic_id = first_topic.topic_id.clone();
*current_topic_id = Some(topic_id.clone());
if let Err(e) = send_topic_history(&store, current_session_id, &topic_id, sender).await {
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send initial topic history");
}
}
}
Err(e) => {
tracing::warn!(error = %e, "Failed to parse topics metadata for initial history");
}
}
}
} }
} else if let Some(ref error) = response.error { } else if let Some(ref error) = response.error {
tracing::warn!( tracing::warn!(
@ -415,86 +335,6 @@ fn resolve_ws_sender_id(sender_id: Option<&str>, runtime_session_id: &str) -> St
.unwrap_or_else(|| runtime_session_id.to_string()) .unwrap_or_else(|| runtime_session_id.to_string())
} }
/// 加载并发送话题历史消息
async fn send_topic_history(
store: &Arc<crate::storage::SessionStore>,
_session_id: &str,
topic_id: &str,
sender: &mpsc::Sender<WsOutbound>,
) -> Result<(), Box<dyn std::error::Error>> {
// 加载话题消息
let messages = store.load_messages_for_topic(topic_id)?;
tracing::info!(topic_id = %topic_id, message_count = messages.len(), "Sending topic history");
// 将消息转换为 WsOutbound 并发送
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
fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbound> {
use crate::bus::message::ToolMessageState;
match msg.role.as_str() {
"assistant" => {
if let Some(tool_calls) = &msg.tool_calls {
// 如果有 tool_calls发送 ToolCall 消息
if let Some(tool_call) = tool_calls.first() {
return Some(WsOutbound::ToolCall {
id: msg.id.clone(),
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
arguments: tool_call.arguments.clone(),
content: format!("{}\nargs: {}", tool_call.name, tool_call.arguments),
role: msg.role.clone(),
});
}
}
// 普通助手消息
Some(WsOutbound::AssistantResponse {
id: msg.id.clone(),
content: msg.content.clone(),
role: msg.role.clone(),
attachments: Vec::new(),
})
}
"tool" => {
let tool_state = msg.tool_state.as_ref().unwrap_or(&ToolMessageState::Completed);
match tool_state {
ToolMessageState::Completed => Some(WsOutbound::ToolResult {
id: msg.id.clone(),
tool_call_id: msg.tool_call_id.clone().unwrap_or_default(),
tool_name: msg.tool_name.clone().unwrap_or_default(),
content: msg.content.clone(),
role: msg.role.clone(),
}),
ToolMessageState::PendingUserAction => Some(WsOutbound::ToolPending {
id: msg.id.clone(),
tool_call_id: msg.tool_call_id.clone().unwrap_or_default(),
tool_name: msg.tool_name.clone().unwrap_or_default(),
content: msg.content.clone(),
role: msg.role.clone(),
resume_hint: "完成外部操作后,直接发一条继续消息即可。".to_string(),
}),
}
}
"user" => Some(WsOutbound::AssistantResponse {
id: msg.id.clone(),
content: msg.content.clone(),
role: msg.role.clone(),
attachments: Vec::new(),
}),
_ => None,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::resolve_ws_sender_id; use super::resolve_ws_sender_id;

View File

@ -14,40 +14,6 @@ pub struct SessionSummary {
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Channel {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "isWritable")]
pub is_writable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopicSummary {
pub topic_id: String,
pub session_id: String,
pub title: String,
pub message_count: i64,
pub created_at: i64,
pub last_active_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaSummary {
pub path: String,
pub media_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_base64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum WsInbound { pub enum WsInbound {
@ -77,8 +43,6 @@ pub enum WsOutbound {
id: String, id: String,
content: String, content: String,
role: String, role: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<MediaSummary>,
}, },
#[serde(rename = "tool_call")] #[serde(rename = "tool_call")]
ToolCall { ToolCall {
@ -117,17 +81,6 @@ pub enum WsOutbound {
sessions: Vec<SessionSummary>, sessions: Vec<SessionSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
current_session_id: Option<String>, current_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
channel_name: Option<String>,
},
#[serde(rename = "channel_list")]
ChannelList {
channels: Vec<Channel>,
},
#[serde(rename = "topic_list")]
TopicList {
topics: Vec<TopicSummary>,
session_id: String,
}, },
#[serde(rename = "session_loaded")] #[serde(rename = "session_loaded")]
SessionLoaded { SessionLoaded {

View File

@ -5,7 +5,7 @@ use crate::bus::message::OutboundEventKind;
#[cfg(test)] #[cfg(test)]
use crate::bus::message::{ToolMessageState, format_tool_call_content}; use crate::bus::message::{ToolMessageState, format_tool_call_content};
use super::{MediaSummary, WsOutbound}; use super::WsOutbound;
const TOOL_PENDING_RESUME_HINT: &str = "完成外部操作后,直接发一条继续消息即可。"; const TOOL_PENDING_RESUME_HINT: &str = "完成外部操作后,直接发一条继续消息即可。";
@ -20,7 +20,6 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
id: message.id.clone(), id: message.id.clone(),
content: message.content.clone(), content: message.content.clone(),
role: message.role.clone(), role: message.role.clone(),
attachments: Vec::new(),
}); });
} }
@ -38,7 +37,6 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
id: message.id.clone(), id: message.id.clone(),
content: message.content.clone(), content: message.content.clone(),
role: message.role.clone(), role: message.role.clone(),
attachments: Vec::new(),
}] }]
} }
} }
@ -70,22 +68,10 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Vec<WsOutbound> { pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Vec<WsOutbound> {
match message.event_kind { match message.event_kind {
OutboundEventKind::AssistantResponse | OutboundEventKind::SchedulerNotification => { OutboundEventKind::AssistantResponse | OutboundEventKind::SchedulerNotification => {
let attachments: Vec<MediaSummary> = message
.media
.iter()
.map(|m| MediaSummary {
path: m.path.clone(),
media_type: m.media_type.clone(),
mime_type: m.mime_type.clone(),
content_base64: m.content_base64.clone(),
file_name: m.file_name.clone(),
})
.collect();
vec![WsOutbound::AssistantResponse { vec![WsOutbound::AssistantResponse {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
content: message.content.clone(), content: message.content.clone(),
role: message.role.clone(), role: message.role.clone(),
attachments,
}] }]
} }
OutboundEventKind::ToolCall => vec![WsOutbound::ToolCall { OutboundEventKind::ToolCall => vec![WsOutbound::ToolCall {

View File

@ -579,13 +579,6 @@ impl SessionStore {
params![session_id, now, if is_user_message { 1 } else { 0 }], params![session_id, now, if is_user_message { 1 } else { 0 }],
)?; )?;
if let Some(tid) = topic_id {
tx.execute(
"UPDATE topics SET message_count = message_count + 1, last_active_at = ?2 WHERE id = ?1",
params![tid, now],
)?;
}
tx.commit()?; tx.commit()?;
Ok(()) Ok(())
} }

View File

@ -182,7 +182,11 @@ fn scope_key_from_context(context: &ToolContext) -> Result<String, ToolResult> {
.channel_name .channel_name
.as_deref() .as_deref()
.ok_or_else(|| error_result("memory_manage requires channel_name in tool context"))?; .ok_or_else(|| error_result("memory_manage requires channel_name in tool context"))?;
Ok(channel_name.to_string()) let sender_id = context
.sender_id
.as_deref()
.ok_or_else(|| error_result("memory_manage requires sender_id in tool context"))?;
Ok(format!("{}:{}", channel_name, sender_id))
} }
fn memory_to_json(memory: MemoryRecord) -> serde_json::Value { fn memory_to_json(memory: MemoryRecord) -> serde_json::Value {
@ -225,6 +229,7 @@ mod tests {
let tool = MemoryManageTool::new(store); let tool = MemoryManageTool::new(store);
let context = ToolContext { let context = ToolContext {
channel_name: Some(TEST_CHANNEL.to_string()), channel_name: Some(TEST_CHANNEL.to_string()),
sender_id: Some("user-1".to_string()),
chat_id: Some("chat-1".to_string()), chat_id: Some("chat-1".to_string()),
session_id: Some(format!("{}:chat-1", TEST_CHANNEL)), session_id: Some(format!("{}:chat-1", TEST_CHANNEL)),
message_id: Some("msg-1".to_string()), message_id: Some("msg-1".to_string()),
@ -274,6 +279,7 @@ mod tests {
let tool = MemoryManageTool::new(store); let tool = MemoryManageTool::new(store);
let context = ToolContext { let context = ToolContext {
channel_name: Some(TEST_CHANNEL.to_string()), channel_name: Some(TEST_CHANNEL.to_string()),
sender_id: Some("user-1".to_string()),
..ToolContext::default() ..ToolContext::default()
}; };

View File

@ -189,7 +189,11 @@ fn scope_key_from_context(context: &ToolContext) -> Result<String, ToolResult> {
.channel_name .channel_name
.as_deref() .as_deref()
.ok_or_else(|| error_result("memory_search requires channel_name in tool context"))?; .ok_or_else(|| error_result("memory_search requires channel_name in tool context"))?;
Ok(channel_name.to_string()) let sender_id = context
.sender_id
.as_deref()
.ok_or_else(|| error_result("memory_search requires sender_id in tool context"))?;
Ok(format!("{}:{}", channel_name, sender_id))
} }
fn memory_to_json(memory: MemoryRecord) -> serde_json::Value { fn memory_to_json(memory: MemoryRecord) -> serde_json::Value {
@ -232,7 +236,7 @@ mod tests {
store store
.put_memory(&crate::storage::MemoryUpsert { .put_memory(&crate::storage::MemoryUpsert {
scope_kind: "user".to_string(), scope_kind: "user".to_string(),
scope_key: TEST_CHANNEL.to_string(), scope_key: format!("{}:user-1", TEST_CHANNEL),
namespace: "preferences".to_string(), namespace: "preferences".to_string(),
memory_key: "language".to_string(), memory_key: "language".to_string(),
content: "User prefers Chinese responses".to_string(), content: "User prefers Chinese responses".to_string(),
@ -248,6 +252,7 @@ mod tests {
let tool = MemorySearchTool::new(store); let tool = MemorySearchTool::new(store);
let context = ToolContext { let context = ToolContext {
channel_name: Some(TEST_CHANNEL.to_string()), channel_name: Some(TEST_CHANNEL.to_string()),
sender_id: Some("user-1".to_string()),
chat_id: Some("chat-1".to_string()), chat_id: Some("chat-1".to_string()),
session_id: Some(format!("{}:chat-1", TEST_CHANNEL)), session_id: Some(format!("{}:chat-1", TEST_CHANNEL)),
message_id: Some("msg-2".to_string()), message_id: Some("msg-2".to_string()),
@ -305,6 +310,7 @@ mod tests {
let tool = MemorySearchTool::new(store); let tool = MemorySearchTool::new(store);
let context = ToolContext { let context = ToolContext {
channel_name: Some(TEST_CHANNEL.to_string()), channel_name: Some(TEST_CHANNEL.to_string()),
sender_id: Some("user-1".to_string()),
..ToolContext::default() ..ToolContext::default()
}; };

View File

@ -1,10 +1,8 @@
use std::io::Read;
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use anyhow::anyhow; use anyhow::anyhow;
use async_trait::async_trait; use async_trait::async_trait;
use base64::Engine;
use serde_json::json; use serde_json::json;
use crate::bus::MediaItem; use crate::bus::MediaItem;
@ -207,27 +205,11 @@ fn parse_attachments(value: &serde_json::Value) -> anyhow::Result<Vec<MediaItem>
return Err(anyhow!("attachment file is empty: {}", raw_path)); return Err(anyhow!("attachment file is empty: {}", raw_path));
} }
let content_base64 = (metadata.len() <= 50 * 1024 * 1024)
.then(|| {
let mut file = std::fs::File::open(&raw_path)?;
let mut buf = Vec::with_capacity(metadata.len() as usize);
file.read_to_end(&mut buf)?;
Ok::<_, anyhow::Error>(base64::engine::general_purpose::STANDARD.encode(&buf))
})
.transpose()?;
let file_name = Path::new(&raw_path)
.file_name()
.and_then(|n| n.to_str())
.map(ToOwned::to_owned);
let media_type = infer_media_type(&raw_path); let media_type = infer_media_type(&raw_path);
let mut item = MediaItem::new(raw_path.to_string(), media_type); let mut item = MediaItem::new(raw_path.to_string(), media_type);
item.mime_type = mime_guess::from_path(&raw_path) item.mime_type = mime_guess::from_path(&raw_path)
.first_raw() .first_raw()
.map(ToOwned::to_owned); .map(ToOwned::to_owned);
item.content_base64 = content_base64;
item.file_name = file_name;
attachments.push(item); attachments.push(item);
} }

View File

@ -1,8 +1,7 @@
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useMemo } from 'react'
import { Zap, Cpu, MessageSquare } from 'lucide-react' import { Zap, Cpu, Activity } 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 { ToolPanel } from './components/Panel/ToolPanel' 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'
@ -12,32 +11,15 @@ 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'
function App() { function App() {
const lastAutoSwitchedTopicRef = useRef<string | null>(null)
const { const {
// 连接状态
connectionId,
isConnected,
// Session 状态
session,
sessionId,
chatId,
// Topic 状态
topics,
selectedTopic,
// 消息状态
messages, messages,
currentSessionId,
currentTopicId,
topics,
isLoading, isLoading,
isReadOnly,
// 方法
handleMessage, handleMessage,
handleCommand, handleCommand,
handleServerMessage, handleServerMessage,
selectTopic,
createTopic,
switchTopic,
requestSessionList,
requestTopicList,
} = useChat() } = useChat()
const { status, sendMessage } = useWebSocket({ const { status, sendMessage } = useWebSocket({
@ -45,51 +27,8 @@ function App() {
onMessage: handleServerMessage, onMessage: handleServerMessage,
}) })
// 连接建立后自动加载 Session
useEffect(() => {
if (isConnected && status === 'connected') {
// 1. 请求 Session 列表(会自动选择第一个)
const sessionCmd = requestSessionList()
handleCommand(sessionCmd)
sendMessage({ type: 'command', payload: JSON.stringify(sessionCmd) })
}
}, [isConnected, status, handleCommand, sendMessage, requestSessionList])
// Session 加载后自动加载 Topics
useEffect(() => {
if (sessionId && status === 'connected') {
const topicCmd = requestTopicList()
if (topicCmd) {
handleCommand(topicCmd)
sendMessage({ type: 'command', payload: JSON.stringify(topicCmd) })
}
}
}, [sessionId, status, handleCommand, sendMessage, requestTopicList])
// Topics 加载后,自动选择第一个并通知后端切换,以便加载历史消息
useEffect(() => {
if (topics.length === 0 || status !== 'connected') {
return
}
const firstTopic = topics[0]
if (lastAutoSwitchedTopicRef.current === firstTopic.id) {
return
}
lastAutoSwitchedTopicRef.current = firstTopic.id
selectTopic(firstTopic.id)
const cmd = switchTopic(firstTopic.id)
handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
}, [topics, status, selectTopic, switchTopic, handleCommand, sendMessage]);
const handleSendMessage = useCallback( const handleSendMessage = useCallback(
(content: string) => { (content: string) => {
if (isReadOnly || !sessionId) {
return
}
if (content.startsWith('/')) { if (content.startsWith('/')) {
const parts = content.slice(1).split(' ') const parts = content.slice(1).split(' ')
const command = parts[0] const command = parts[0]
@ -105,9 +44,9 @@ function App() {
break break
case 'use': case 'use':
if (args[0]) { if (args[0]) {
cmd = { type: 'switch_topic', topic_id: args[0] } cmd = { type: 'switch_session', session_id: args[0] }
} else { } else {
alert('Usage: /use <topic_id>') alert('Usage: /use <session_id>')
return return
} }
break break
@ -126,38 +65,32 @@ function App() {
sendMessage({ sendMessage({
type: 'message', type: 'message',
content, content,
chat_id: chatId, chat_id: currentTopicId ?? undefined,
}) })
} }
}, },
[sendMessage, handleMessage, handleCommand, sessionId, chatId, isReadOnly] [sendMessage, handleMessage, handleCommand, currentTopicId]
) )
const handleCreateTopic = useCallback(() => { const handleCreateTopic = useCallback(() => {
if (isReadOnly || !sessionId) {
return
}
const title = prompt('Enter topic title:') const title = prompt('Enter topic title:')
if (title) { if (title) {
const cmd = createTopic(title) const cmd: Command = { type: 'create_session', title }
handleCommand(cmd) handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
} }
}, [sendMessage, handleCommand, createTopic, sessionId, isReadOnly]) }, [sendMessage, handleCommand])
const handleSwitchTopic = useCallback( const handleSwitchTopic = useCallback(
(topicId: string) => { (topicId: string) => {
selectTopic(topicId) const cmd: Command = { type: 'switch_session', session_id: topicId }
const cmd = switchTopic(topicId)
handleCommand(cmd) handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
}, },
[sendMessage, handleCommand, switchTopic, selectTopic] [sendMessage, handleCommand]
) )
const chatMessages = messages.filter((message) => message.type !== 'tool_result') const toolMessages = useMemo(() => messages, [messages])
const toolMessages = messages
return ( return (
<div className="flex h-screen flex-col bg-[#0a0a0f] text-white overflow-hidden"> <div className="flex h-screen flex-col bg-[#0a0a0f] text-white overflow-hidden">
@ -179,11 +112,11 @@ function App() {
<Cpu className="h-4 w-4 text-[#00f0ff]" /> <Cpu className="h-4 w-4 text-[#00f0ff]" />
<span>AI Ready</span> <span>AI Ready</span>
</div> </div>
{session && ( {currentSessionId && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-emerald-400" /> <Activity className="h-4 w-4 text-emerald-400" />
<span className="font-mono text-xs"> <span className="font-mono text-xs">
{session.title} {currentSessionId.slice(0, 8)}...
</span> </span>
</div> </div>
)} )}
@ -192,38 +125,21 @@ 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 - Topic List */}
<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">
{/* Session Info */} <TopicList
<SessionInfo topics={topics}
session={session} currentTopicId={currentTopicId}
connectionId={connectionId} onCreateTopic={handleCreateTopic}
onSwitchTopic={handleSwitchTopic}
/> />
{/* Divider */}
<div className="border-b border-white/8" />
{/* Topic List */}
<div className="flex-1 overflow-hidden">
<TopicList
sessionId={sessionId}
sessionTitle={session?.title ?? ''}
topics={topics}
currentTopicId={selectedTopic}
isReadOnly={isReadOnly}
onCreateTopic={handleCreateTopic}
onSwitchTopic={handleSwitchTopic}
/>
</div>
</div> </div>
{/* Center - Chat */} {/* Center - Chat */}
<div className="flex-1 min-w-0 bg-[#0a0a0f]"> <div className="flex-1 min-w-0 bg-[#0a0a0f]">
<ChatContainer <ChatContainer
messages={chatMessages} messages={messages}
isLoading={isLoading} isLoading={isLoading}
isReadOnly={isReadOnly}
channelName={session?.title ?? 'PicoBot'}
onSendMessage={handleSendMessage} onSendMessage={handleSendMessage}
/> />
</div> </div>

View File

@ -5,16 +5,12 @@ import type { ChatMessage } from '../../types/protocol'
interface ChatContainerProps { interface ChatContainerProps {
messages: ChatMessage[] messages: ChatMessage[]
isLoading: boolean isLoading: boolean
isReadOnly?: boolean
channelName?: string
onSendMessage: (content: string) => void onSendMessage: (content: string) => void
} }
export function ChatContainer({ export function ChatContainer({
messages, messages,
isLoading, isLoading,
isReadOnly = false,
channelName,
onSendMessage, onSendMessage,
}: ChatContainerProps) { }: ChatContainerProps) {
return ( return (
@ -22,12 +18,7 @@ export function ChatContainer({
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<MessageList messages={messages} /> <MessageList messages={messages} />
</div> </div>
<MessageInput <MessageInput onSend={onSendMessage} disabled={isLoading} />
onSend={onSendMessage}
disabled={isLoading}
isReadOnly={isReadOnly}
channelName={channelName}
/>
</div> </div>
) )
} }

View File

@ -1,77 +1,12 @@
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download } from 'lucide-react' import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal } 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 } from '../../types/protocol'
interface MessageBubbleProps { interface MessageBubbleProps {
message: ChatMessage message: ChatMessage
} }
function getAttachmentIcon(mediaType: string) {
switch (mediaType) {
case 'image': return <Image className="h-4 w-4" />
case 'audio': return <Music className="h-4 w-4" />
case 'video': return <Video className="h-4 w-4" />
case 'file': return <FileText className="h-4 w-4" />
default: return <File className="h-4 w-4" />
}
}
function getFileName(path: string): string {
const parts = path.replace(/\\/g, '/').split('/')
return parts[parts.length - 1] || path
}
function AttachmentCard({ attachment }: { attachment: Attachment }) {
const fileName = attachment.file_name || getFileName(attachment.path)
const handleDownload = (e: React.MouseEvent) => {
if (!attachment.content_base64) return
e.preventDefault()
const mimeType = attachment.mime_type || 'application/octet-stream'
const byteChars = atob(attachment.content_base64)
const byteNums = new Array(byteChars.length)
for (let i = 0; i < byteChars.length; i++) {
byteNums[i] = byteChars.charCodeAt(i)
}
const byteArr = new Uint8Array(byteNums)
const blob = new Blob([byteArr], { type: mimeType })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const canDownload = !!attachment.content_base64
return (
<div
onClick={canDownload ? handleDownload : undefined}
className={`flex items-center gap-2 rounded-lg bg-white/5 border border-white/10 px-3 py-2 text-xs transition-colors group ${
canDownload ? 'hover:bg-white/10 hover:border-[#00f0ff]/30 cursor-pointer' : ''
}`}
title={canDownload ? `下载 ${fileName}` : attachment.path}
>
<span className={`text-zinc-400 transition-colors ${canDownload ? 'group-hover:text-[#00f0ff]' : ''}`}>
{getAttachmentIcon(attachment.media_type)}
</span>
<span className={`text-zinc-300 truncate max-w-[200px] transition-colors ${canDownload ? 'group-hover:text-white' : ''}`} title={attachment.path}>
{fileName}
</span>
<span className="text-zinc-600 ml-auto shrink-0">{attachment.media_type}</span>
{canDownload && (
<Download className="h-3 w-3 text-zinc-600 group-hover:text-[#00f0ff] transition-colors" />
)}
</div>
)
}
export function MessageBubble({ message }: MessageBubbleProps) { export function MessageBubble({ message }: MessageBubbleProps) {
const isUser = message.role === 'user' const isUser = message.role === 'user'
const isTool = message.role === 'tool' const isTool = message.role === 'tool'
@ -225,13 +160,6 @@ export function MessageBubble({ message }: MessageBubbleProps) {
</ReactMarkdown> </ReactMarkdown>
</div> </div>
)} )}
{message.attachments && message.attachments.length > 0 && (
<div className="mt-2 space-y-1">
{message.attachments.map((att: Attachment, idx: number) => (
<AttachmentCard key={idx} attachment={att} />
))}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,20 +1,16 @@
import { Send, Loader2, Sparkles, Eye } from 'lucide-react' import { Send, Loader2, Sparkles } from 'lucide-react'
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
interface MessageInputProps { interface MessageInputProps {
onSend: (content: string) => void onSend: (content: string) => void
disabled?: boolean disabled?: boolean
placeholder?: string placeholder?: string
isReadOnly?: boolean
channelName?: string
} }
export function MessageInput({ export function MessageInput({
onSend, onSend,
disabled = false, disabled = false,
placeholder = '输入消息...按 / 查看命令', placeholder = '输入消息...按 / 查看命令',
isReadOnly = false,
channelName,
}: MessageInputProps) { }: MessageInputProps) {
const [content, setContent] = useState('') const [content, setContent] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
@ -28,7 +24,7 @@ export function MessageInput({
}, [content]) }, [content])
const handleSend = () => { const handleSend = () => {
if (content.trim() && !disabled && !isReadOnly) { if (content.trim() && !disabled) {
onSend(content.trim()) onSend(content.trim())
setContent('') setContent('')
if (textareaRef.current) { if (textareaRef.current) {
@ -44,34 +40,6 @@ export function MessageInput({
} }
} }
// 只读模式:显示提示占位符
if (isReadOnly) {
return (
<div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4">
<div className="max-w-5xl mx-auto">
<div className="rounded-xl border border-zinc-500/20 bg-[#1a1a25]/50 px-4 py-5 text-center">
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2 text-zinc-400">
<Eye className="h-5 w-5" />
<span className="font-medium"></span>
</div>
<p className="text-sm text-zinc-500">
{channelName ? (
<>{channelName} </>
) : (
'当前通道仅支持查看历史消息'
)}
</p>
<p className="text-xs text-zinc-600">
WebSocket
</p>
</div>
</div>
</div>
</div>
)
}
return ( return (
<div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4"> <div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4">
<div className="flex gap-3 items-end max-w-5xl mx-auto"> <div className="flex gap-3 items-end max-w-5xl mx-auto">

View File

@ -1,5 +1,5 @@
import { ChevronDown, ChevronRight, Play, Check, AlertTriangle, Terminal } from 'lucide-react' import { ChevronDown, ChevronRight, Play, Check, AlertTriangle, Terminal } from 'lucide-react'
import { useState, useMemo } from 'react' import { useState } from 'react'
import type { ChatMessage } from '../../types/protocol' import type { ChatMessage } from '../../types/protocol'
interface ToolPanelProps { interface ToolPanelProps {
@ -7,54 +7,25 @@ interface ToolPanelProps {
} }
interface ToolCallItem { interface ToolCallItem {
toolCallId: string id: string
toolName: string toolName: string
status: 'calling' | 'result' | 'pending' status: 'calling' | 'result' | 'pending'
arguments?: unknown arguments?: unknown
resultContent: string content: string
callContent: string
}
function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] {
const map = new Map<string, ToolCallItem>()
for (const m of messages) {
if (m.role !== 'tool' || !m.type?.startsWith('tool_')) continue
const key = m.toolCallId || m.id
let entry = map.get(key)
if (!entry) {
entry = {
toolCallId: key,
toolName: m.toolName || 'Unknown',
status: 'calling',
arguments: undefined,
resultContent: '',
callContent: '',
}
map.set(key, entry)
}
if (m.type === 'tool_call') {
entry.arguments = m.arguments
entry.callContent = m.content
} else if (m.type === 'tool_result') {
entry.status = 'result'
entry.resultContent = m.content
} else if (m.type === 'tool_pending') {
entry.status = 'pending'
entry.resultContent = m.content
}
}
return Array.from(map.values())
} }
export function ToolPanel({ messages }: ToolPanelProps) { export function ToolPanel({ messages }: ToolPanelProps) {
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set()) const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set())
const toolCalls = useMemo(() => mergeToolMessages(messages), [messages]) const toolCalls: ToolCallItem[] = messages
.filter((m) => m.role === 'tool' && m.type && m.type.startsWith('tool_'))
.map((m) => ({
id: m.id,
toolName: m.toolName || 'Unknown',
status: m.type === 'tool_call' ? 'calling' : m.type === 'tool_result' ? 'result' : 'pending',
arguments: m.arguments,
content: m.content,
}))
const toggleExpand = (id: string) => { const toggleExpand = (id: string) => {
setExpandedTools((prev) => { setExpandedTools((prev) => {
@ -71,7 +42,7 @@ export function ToolPanel({ messages }: ToolPanelProps) {
const getStatusIcon = (status: ToolCallItem['status']) => { const getStatusIcon = (status: ToolCallItem['status']) => {
switch (status) { switch (status) {
case 'calling': case 'calling':
return <Play className="h-3 w-3 text-amber-400 animate-pulse" /> return <Play className="h-3 w-3 text-amber-400" />
case 'result': case 'result':
return <Check className="h-3 w-3 text-emerald-400" /> return <Check className="h-3 w-3 text-emerald-400" />
case 'pending': case 'pending':
@ -120,11 +91,11 @@ export function ToolPanel({ messages }: ToolPanelProps) {
<div className="space-y-2"> <div className="space-y-2">
{toolCalls.map((tool) => ( {toolCalls.map((tool) => (
<div <div
key={tool.toolCallId} key={tool.id}
className="rounded-xl border border-white/8 bg-[#1a1a25]/50 text-sm overflow-hidden" className="rounded-xl border border-white/8 bg-[#1a1a25]/50 text-sm overflow-hidden"
> >
<button <button
onClick={() => toggleExpand(tool.toolCallId)} onClick={() => toggleExpand(tool.id)}
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">
@ -134,13 +105,13 @@ export function ToolPanel({ messages }: ToolPanelProps) {
{getStatusText(tool.status)} {getStatusText(tool.status)}
</span> </span>
</div> </div>
{expandedTools.has(tool.toolCallId) ? ( {expandedTools.has(tool.id) ? (
<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" />
)} )}
</button> </button>
{expandedTools.has(tool.toolCallId) && ( {expandedTools.has(tool.id) && (
<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 ? ( {tool.arguments ? (
<div className="mb-2"> <div className="mb-2">
@ -151,7 +122,7 @@ export function ToolPanel({ messages }: ToolPanelProps) {
<div> <div>
<div className="text-xs font-medium text-zinc-500 mb-1">:</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"> <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} {tool.content}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,127 +0,0 @@
import { useState } from 'react'
import { Monitor, Smartphone, MessageSquare, Hash, ChevronDown, Eye, Pencil } from 'lucide-react'
import type { Channel } from '../../types/protocol'
interface ChannelSelectorProps {
channels: Channel[]
selectedChannel: string | null
onSelectChannel: (channelId: string) => void
}
const CHANNEL_ICONS: Record<string, React.ReactNode> = {
cli: <Monitor className="h-4 w-4" />,
websocket: <MessageSquare className="h-4 w-4" />,
feishu: <Smartphone className="h-4 w-4" />,
weixin: <Smartphone className="h-4 w-4" />,
wechat: <Smartphone className="h-4 w-4" />,
}
export function ChannelSelector({
channels,
selectedChannel,
onSelectChannel,
}: ChannelSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const selected = channels.find((c) => c.id === selectedChannel)
return (
<div className="relative">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/8 px-4 py-3">
<h2 className="font-semibold text-white flex items-center gap-2 text-sm">
<Hash className="h-4 w-4 text-[#00f0ff]" />
</h2>
</div>
{/* Channel Dropdown */}
<div className="px-3 py-2">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between rounded-lg border border-white/10 bg-[#1a1a25]/80 px-3 py-2.5 text-left hover:bg-[#1a1a25] transition-all"
>
<div className="flex items-center gap-2.5">
<span className="text-zinc-400">
{selected ? CHANNEL_ICONS[selected.id] || <MessageSquare className="h-4 w-4" /> : <MessageSquare className="h-4 w-4" />}
</span>
<div className="flex flex-col">
<span className="text-sm font-medium text-white">
{selected?.name || '选择通道'}
</span>
{selected && (
<span className={`text-xs flex items-center gap-1 ${selected.isWritable ? 'text-emerald-400' : 'text-zinc-500'}`}>
{selected.isWritable ? (
<>
<Pencil className="h-3 w-3" />
</>
) : (
<>
<Eye className="h-3 w-3" />
</>
)}
</span>
)}
</div>
</div>
<ChevronDown
className={`h-4 w-4 text-zinc-500 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{/* Dropdown Menu */}
{isOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
<div className="absolute left-3 right-3 z-20 mt-1 rounded-lg border border-white/10 bg-[#1a1a25] shadow-xl shadow-black/50 overflow-hidden">
{channels.length === 0 ? (
<div className="px-3 py-3 text-sm text-zinc-500 text-center">
</div>
) : (
channels.map((channel) => (
<button
key={channel.id}
onClick={() => {
onSelectChannel(channel.id)
setIsOpen(false)
}}
className={`w-full flex items-center justify-between px-3 py-2.5 text-left hover:bg-white/5 transition-colors ${
channel.id === selectedChannel ? 'bg-[#00f0ff]/10' : ''
}`}
>
<div className="flex items-center gap-2.5">
<span className={channel.id === selectedChannel ? 'text-[#00f0ff]' : 'text-zinc-400'}>
{CHANNEL_ICONS[channel.id] || <MessageSquare className="h-4 w-4" />}
</span>
<div className="flex flex-col">
<span className={`text-sm ${channel.id === selectedChannel ? 'text-white font-medium' : 'text-zinc-300'}`}>
{channel.name}
</span>
{channel.description && (
<span className="text-xs text-zinc-500">{channel.description}</span>
)}
</div>
</div>
<span className={`text-xs px-1.5 py-0.5 rounded ${
channel.isWritable
? 'bg-emerald-400/10 text-emerald-400'
: 'bg-zinc-500/10 text-zinc-500'
}`}>
{channel.isWritable ? '可输入' : '只读'}
</span>
</button>
))
)}
</div>
</>
)}
</div>
</div>
)
}

View File

@ -1,114 +0,0 @@
import { useState } from 'react'
import { FolderOpen, ChevronDown, Hash } from 'lucide-react'
import type { Session } from '../../hooks/useChat'
interface SessionSelectorProps {
sessions: Session[]
selectedSession: string | null
channelId: string // 使用 channelId 而不是 channelName
onSelectSession: (sessionId: string) => void
}
export function SessionSelector({
sessions,
selectedSession,
channelId,
onSelectSession,
}: SessionSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const selected = sessions.find((s) => s.id === selectedSession)
// 按通道 ID 筛选 Session
const channelSessions = sessions.filter(
(s) => s.channel_name === channelId
)
return (
<div className="relative">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/8 px-4 py-3">
<h2 className="font-semibold text-white flex items-center gap-2 text-sm">
<FolderOpen className="h-4 w-4 text-[#00f0ff]" />
Session
</h2>
</div>
{/* Session Dropdown */}
<div className="px-3 py-2">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between rounded-lg border border-white/10 bg-[#1a1a25]/80 px-3 py-2.5 text-left hover:bg-[#1a1a25] transition-all"
>
<div className="flex items-center gap-2.5">
<span className="text-zinc-400">
<FolderOpen className="h-4 w-4" />
</span>
<div className="flex flex-col">
<span className="text-sm font-medium text-white truncate max-w-[160px]">
{selected?.title || '选择 Session'}
</span>
{selected && (
<span className="text-xs text-zinc-500">
{selected.message_count}
</span>
)}
</div>
</div>
<ChevronDown
className={`h-4 w-4 text-zinc-500 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{/* Dropdown Menu */}
{isOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
<div className="absolute left-3 right-3 z-20 mt-1 rounded-lg border border-white/10 bg-[#1a1a25] shadow-xl shadow-black/50 overflow-hidden">
{channelSessions.length === 0 ? (
<div className="px-3 py-3 text-sm text-zinc-500 text-center">
Session
</div>
) : (
channelSessions.map((session, index) => (
<button
key={session.id}
onClick={() => {
onSelectSession(session.id)
setIsOpen(false)
}}
className={`w-full flex items-center justify-between px-3 py-2.5 text-left hover:bg-white/5 transition-colors ${
session.id === selectedSession ? 'bg-[#00f0ff]/10' : ''
}`}
>
<div className="flex items-center gap-2.5">
<span className={session.id === selectedSession ? 'text-[#00f0ff]' : 'text-zinc-400'}>
<Hash className="h-4 w-4" />
</span>
<div className="flex flex-col">
<span className={`text-sm truncate max-w-[140px] ${
session.id === selectedSession ? 'text-white font-medium' : 'text-zinc-300'
}`}>
{session.title}
</span>
<span className="text-xs text-zinc-500">
{session.message_count}
</span>
</div>
</div>
<span className="text-xs text-zinc-600 font-mono">
{index + 1}
</span>
</button>
))
)}
</div>
</>
)}
</div>
</div>
)
}

View File

@ -1,50 +0,0 @@
import { Wifi, FolderOpen, Hash } from 'lucide-react'
import type { Session } from '../../types/protocol'
interface SessionInfoProps {
session: Session | null
connectionId: string | null
}
export function SessionInfo({ session, connectionId }: SessionInfoProps) {
return (
<div className="p-4">
<div className="flex items-center gap-2 mb-3">
<Wifi className="h-4 w-4 text-[#00f0ff]" />
<span className="text-sm font-medium text-white">WebSocket</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400">
线
</span>
</div>
<div className="rounded-xl border border-white/10 bg-[#1a1a25]/80 p-3">
<div className="flex items-center gap-2 mb-2">
<FolderOpen className="h-4 w-4 text-zinc-400" />
<span className="text-xs text-zinc-500 uppercase tracking-wider"></span>
</div>
{session ? (
<div className="space-y-1">
<p className="text-sm font-medium text-white truncate">
{session.title}
</p>
<div className="flex items-center gap-1 text-xs text-zinc-500">
<Hash className="h-3 w-3" />
<span className="font-mono">{session.id.slice(0, 8)}...</span>
</div>
</div>
) : (
<p className="text-sm text-zinc-500">...</p>
)}
{connectionId && (
<div className="mt-2 pt-2 border-t border-white/10">
<p className="text-xs text-zinc-600 font-mono">
conn: {connectionId.slice(0, 8)}...
</p>
</div>
)}
</div>
</div>
)
}

View File

@ -1,86 +1,39 @@
import { Plus, MessageSquare, Layers, Hash, Clock } from 'lucide-react' import { Plus, MessageSquare, Hash } from 'lucide-react'
import type { Topic } from '../../types/protocol' import type { Topic } from '../../types/protocol'
interface TopicListProps { interface TopicListProps {
sessionId: string | null
sessionTitle: string
topics: Topic[] topics: Topic[]
currentTopicId: string | null currentTopicId: string | null
isReadOnly: boolean
onCreateTopic: () => void onCreateTopic: () => void
onSwitchTopic: (topicId: string) => void onSwitchTopic: (topicId: string) => void
} }
function formatTime(timestamp: number): string {
const date = new Date(timestamp)
const now = new Date()
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays === 0) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
} else if (diffDays === 1) {
return '昨天'
} else if (diffDays < 7) {
return `${diffDays}天前`
} else {
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
}
export function TopicList({ export function TopicList({
sessionId,
sessionTitle,
topics, topics,
currentTopicId, currentTopicId,
isReadOnly,
onCreateTopic, onCreateTopic,
onSwitchTopic, onSwitchTopic,
}: TopicListProps) { }: TopicListProps) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Header */} <div className="flex items-center justify-between border-b border-white/8 p-4">
<div className="flex items-center justify-between border-b border-white/8 px-4 py-3"> <h2 className="font-semibold text-white flex items-center gap-2">
<h2 className="font-semibold text-white flex items-center gap-2 text-sm"> <Hash className="h-4 w-4 text-[#00f0ff]" />
<Layers className="h-4 w-4 text-[#00f0ff]" /> Topics
{topics.length > 0 && (
<span className="text-xs text-zinc-500">({topics.length})</span>
)}
</h2> </h2>
<button <button
onClick={onCreateTopic} onClick={onCreateTopic}
disabled={isReadOnly || !sessionId} className="flex items-center gap-1 rounded-lg bg-[#00f0ff]/10 px-3 py-1.5 text-sm text-[#00f0ff] hover:bg-[#00f0ff]/20 border border-[#00f0ff]/30 transition-all"
className={`flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm transition-all ${
isReadOnly || !sessionId
? 'bg-zinc-500/10 text-zinc-500 cursor-not-allowed'
: 'bg-[#00f0ff]/10 text-[#00f0ff] hover:bg-[#00f0ff]/20 border border-[#00f0ff]/30'
}`}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</button> </button>
</div> </div>
{/* Session 标题 */}
{sessionTitle && (
<div className="px-4 py-2 border-b border-white/8 bg-[#00f0ff]/5">
<p className="text-xs text-zinc-500 mb-1"></p>
<p className="text-sm text-zinc-300 font-medium truncate">{sessionTitle}</p>
</div>
)}
{/* Topics 列表 */}
<div className="flex-1 overflow-y-auto p-3"> <div className="flex-1 overflow-y-auto p-3">
{!sessionId ? ( {topics.length === 0 ? (
<div className="p-4 text-center text-sm text-zinc-500"> <div className="p-4 text-center text-sm text-zinc-500">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" /> <MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>...</p> Topic
</div>
) : topics.length === 0 ? (
<div className="p-4 text-center text-sm text-zinc-500">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p></p>
<p className="text-xs mt-1">"新建"</p>
</div> </div>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
@ -95,29 +48,22 @@ export function TopicList({
}`} }`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className="mt-0.5 text-xs text-zinc-500 font-mono w-4"> <span className="mt-0.5 text-xs text-zinc-500 font-mono">{index + 1}</span>
{index + 1}
</span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className={`truncate font-medium ${ <div className={`truncate font-medium ${
topic.id === currentTopicId ? 'text-[#00f0ff]' : 'text-zinc-300' topic.id === currentTopicId ? 'text-[#00f0ff]' : 'text-zinc-300'
}`}> }`}>
{topic.description || topic.title} {topic.title}
</div> </div>
<div className="flex items-center gap-3 mt-1.5"> <div className="flex items-center gap-2 mt-1">
<span className="text-xs text-zinc-500 flex items-center gap-1"> <span className="text-xs text-zinc-500">
<Hash className="h-3 w-3" />
{topic.message_count} {topic.message_count}
</span> </span>
<span className="text-xs text-zinc-600 flex items-center gap-1"> {topic.id === currentTopicId && (
<Clock className="h-3 w-3" /> <span className="inline-block h-1.5 w-1.5 rounded-full bg-[#00f0ff] shadow-lg shadow-[#00f0ff]/50" />
{formatTime(topic.updated_at)} )}
</span>
</div> </div>
</div> </div>
{topic.id === currentTopicId && (
<span className="inline-block h-2 w-2 rounded-full bg-[#00f0ff] shadow-lg shadow-[#00f0ff]/50 mt-1.5" />
)}
</div> </div>
</button> </button>
))} ))}

View File

@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useMemo } from 'react' import { useState, useCallback, useRef } from 'react'
import type { import type {
Command, Command,
ChatMessage, ChatMessage,
@ -9,60 +9,28 @@ import type {
ToolResult, ToolResult,
ToolPending, ToolPending,
SessionEstablished, SessionEstablished,
SessionCreated,
SessionList, SessionList,
TopicList,
TopicSummary,
Session,
} from '../types/protocol' } from '../types/protocol'
// 简化后的层级状态
interface UseChatReturn { interface UseChatReturn {
// 连接状态
connectionId: string | null
isConnected: boolean
// 简化的层级状态
session: Session | null
sessionId: string | null
chatId: string
topics: Topic[]
selectedTopic: string | null
// 消息
messages: ChatMessage[] messages: ChatMessage[]
currentSessionId: string | null
currentTopicId: string | null
topics: Topic[]
isLoading: boolean isLoading: boolean
// 是否只读WebSocket 通道始终可写)
isReadOnly: boolean
// 方法
handleMessage: (content: string) => void handleMessage: (content: string) => void
handleCommand: (command: Command) => void handleCommand: (command: Command) => void
clearMessages: () => void clearMessages: () => void
handleServerMessage: (message: WsOutbound) => void handleServerMessage: (message: WsOutbound) => void
// Topic 方法
selectTopic: (topicId: string) => void
createTopic: (title: string) => Command
switchTopic: (topicId: string) => Command
// 初始化方法
requestSessionList: () => Command
requestTopicList: () => Command | null
} }
const DEFAULT_CHANNEL = 'websocket'
const DEFAULT_CHAT_ID = 'default'
export function useChat(): UseChatReturn { export function useChat(): UseChatReturn {
const [messages, setMessages] = useState<ChatMessage[]>([]) const [messages, setMessages] = useState<ChatMessage[]>([])
const [isLoading, setIsLoading] = useState(false) const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
const [currentTopicId, setCurrentTopicId] = useState<string | null>(null)
// 简化的状态管理
const [connectionId, setConnectionId] = useState<string | 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 [isLoading, setIsLoading] = useState(false)
// Message ID generator // Message ID generator
const messageIdCounter = useRef(0) const messageIdCounter = useRef(0)
@ -71,84 +39,49 @@ export function useChat(): UseChatReturn {
return `msg_${Date.now()}_${messageIdCounter.current}` return `msg_${Date.now()}_${messageIdCounter.current}`
} }
const isConnected = useMemo(() => connectionId !== null, [connectionId])
const sessionId = useMemo(() => session?.id ?? null, [session])
const chatId = useMemo(() => sessionId ?? DEFAULT_CHAT_ID, [sessionId])
const handleServerMessage = useCallback((message: WsOutbound) => { const handleServerMessage = useCallback((message: WsOutbound) => {
console.log('Received message:', message)
switch (message.type) { switch (message.type) {
case 'session_established': { case 'session_established': {
const msg = message as SessionEstablished const msg = message as SessionEstablished
setConnectionId(msg.session_id) setCurrentSessionId(msg.session_id)
console.log('Connection established:', msg.session_id) break
}
case 'session_created': {
const msg = message as SessionCreated
setCurrentTopicId(msg.session_id)
setIsLoading(false)
break break
} }
case 'session_list': { case 'session_list': {
const msg = message as SessionList const msg = message as SessionList
console.log('Session list received:', msg) // Convert sessions to topics format
const newTopics = msg.sessions.map((s) => ({
// 自动选择第一个 SessionWebSocket 通道只有一个) id: s.session_id,
if (msg.sessions.length > 0) { session_id: s.session_id,
const firstSession = msg.sessions[0] title: s.title,
setSession({ message_count: Number(s.message_count),
id: firstSession.session_id, created_at: s.last_active_at,
title: firstSession.title, updated_at: s.last_active_at,
channel_name: firstSession.channel_name,
created_at: Date.now(),
updated_at: Date.now(),
})
}
setIsLoading(false)
break
}
case 'session_created': {
// 创建新 Topic 后更新列表
setIsLoading(false)
break
}
case 'session_loaded': {
setIsLoading(false)
break
}
case 'topic_list': {
const msg = message as TopicList
console.log('Topic list received:', msg)
// 转换 topics 格式
const newTopics: Topic[] = msg.topics.map((t: TopicSummary) => ({
id: t.topic_id,
session_id: t.session_id,
title: t.title,
description: t.description || undefined,
message_count: Number(t.message_count),
created_at: t.created_at,
updated_at: t.last_active_at,
})) }))
setTopics(newTopics) setTopics(newTopics)
if (msg.current_session_id) {
// 默认选中第一个 Topic如果没有选中 setCurrentTopicId(msg.current_session_id)
setIsLoading(false) }
break break
} }
case 'assistant_response': { case 'assistant_response': {
const msg = message as AssistantResponse const msg = message as AssistantResponse
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant'
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ {
id: msg.id, id: msg.id,
role, role: 'assistant',
content: msg.content, content: msg.content,
timestamp: Date.now(), timestamp: Date.now(),
type: 'message', type: 'message',
attachments: msg.attachments,
}, },
]) ])
setIsLoading(false) setIsLoading(false)
@ -166,7 +99,6 @@ export function useChat(): UseChatReturn {
timestamp: Date.now(), timestamp: Date.now(),
type: 'tool_call', type: 'tool_call',
toolName: msg.tool_name, toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
arguments: msg.arguments, arguments: msg.arguments,
}, },
]) ])
@ -184,7 +116,6 @@ export function useChat(): UseChatReturn {
timestamp: Date.now(), timestamp: Date.now(),
type: 'tool_result', type: 'tool_result',
toolName: msg.tool_name, toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
}, },
]) ])
break break
@ -201,7 +132,6 @@ export function useChat(): UseChatReturn {
timestamp: Date.now(), timestamp: Date.now(),
type: 'tool_pending', type: 'tool_pending',
toolName: msg.tool_name, toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
}, },
]) ])
break break
@ -221,15 +151,11 @@ export function useChat(): UseChatReturn {
setIsLoading(false) setIsLoading(false)
break break
} }
case 'channel_list':
case 'pong':
// 忽略这些消息
break
} }
}, []) }, [])
const handleMessage = useCallback((content: string) => { const handleMessage = useCallback((content: string) => {
// Add user message to list
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ {
@ -244,15 +170,20 @@ export function useChat(): UseChatReturn {
}, []) }, [])
const handleCommand = useCallback((command: Command) => { const handleCommand = useCallback((command: Command) => {
// Handle local state updates for commands
switch (command.type) { switch (command.type) {
case 'create_session': case 'create_session':
case 'switch_topic': // Optimistically update
case 'load_topic':
case 'list_sessions':
case 'list_sessions_by_channel':
case 'list_topics':
setIsLoading(true) setIsLoading(true)
break break
case 'list_sessions':
setIsLoading(true)
break
case 'switch_session':
setCurrentTopicId(command.session_id)
// Clear messages when switching topic
setMessages([])
break
} }
}, []) }, [])
@ -260,65 +191,15 @@ export function useChat(): UseChatReturn {
setMessages([]) setMessages([])
}, []) }, [])
// Topic 操作方法
const selectTopic = useCallback((topicId: string) => {
setSelectedTopic(topicId)
setMessages([])
}, [])
const createTopic = useCallback((title: string): Command => {
return {
type: 'create_session',
title,
}
}, [])
const switchTopic = useCallback((topicId: string): Command => {
return {
type: 'switch_topic',
topic_id: topicId,
}
}, [])
// 初始化方法
const requestSessionList = useCallback((): Command => {
return {
type: 'list_sessions_by_channel',
channel_name: DEFAULT_CHANNEL,
include_archived: false,
}
}, [])
const requestTopicList = useCallback((): Command | null => {
if (!sessionId) return null
return {
type: 'list_topics',
session_id: sessionId,
}
}, [sessionId])
// WebSocket 通道始终可写
const isReadOnly = false
return { return {
connectionId,
isConnected,
session,
sessionId,
chatId,
topics,
selectedTopic,
messages, messages,
currentSessionId,
currentTopicId,
topics,
isLoading, isLoading,
isReadOnly,
handleMessage, handleMessage,
handleCommand, handleCommand,
clearMessages, clearMessages,
handleServerMessage, handleServerMessage,
selectTopic,
createTopic,
switchTopic,
requestSessionList,
requestTopicList,
} }
} }

View File

@ -27,20 +27,11 @@ export type WsInbound = WsInboundMessage | WsInboundCommand | WsInboundPing
// Outbound Messages (Server -> Client) // Outbound Messages (Server -> Client)
// ============================================================================ // ============================================================================
export interface Attachment {
path: string
media_type: string
mime_type?: string
content_base64?: string
file_name?: string
}
export interface AssistantResponse { export interface AssistantResponse {
type: 'assistant_response' type: 'assistant_response'
id: string id: string
content: string content: string
role: string role: string
attachments?: Attachment[]
} }
export interface ToolCall { export interface ToolCall {
@ -103,7 +94,6 @@ export interface SessionList {
type: 'session_list' type: 'session_list'
sessions: SessionSummary[] sessions: SessionSummary[]
current_session_id?: string current_session_id?: string
channel_name?: string // 新增:标识所属通道
} }
export interface SessionLoaded { export interface SessionLoaded {
@ -119,34 +109,6 @@ export interface SessionSaved {
filepath: string filepath: string
} }
export interface TopicSummary {
topic_id: string
session_id: string
title: string
description?: string
message_count: number
created_at: number
last_active_at: number
}
export interface TopicList {
type: 'topic_list'
topics: TopicSummary[]
session_id: string
}
export interface Channel {
id: string
name: string
description?: string
isWritable: boolean
}
export interface ChannelList {
type: 'channel_list'
channels: Channel[]
}
export interface Pong { export interface Pong {
type: 'pong' type: 'pong'
} }
@ -162,8 +124,6 @@ export type WsOutbound =
| SessionList | SessionList
| SessionLoaded | SessionLoaded
| SessionSaved | SessionSaved
| TopicList
| ChannelList
| Pong | Pong
// ============================================================================ // ============================================================================
@ -180,9 +140,9 @@ export interface ListSessionsCommand {
include_archived: boolean include_archived: boolean
} }
export interface SwitchTopicCommand { export interface SwitchSessionCommand {
type: 'switch_topic' type: 'switch_session'
topic_id: string session_id: string
} }
export interface SaveTopicCommand { export interface SaveTopicCommand {
@ -198,9 +158,9 @@ export interface SaveSessionCommand {
include_subagents: boolean include_subagents: boolean
} }
export interface LoadTopicCommand { export interface LoadSessionCommand {
type: 'load_topic' type: 'load_session'
topic_id: string session_id: string
} }
export interface GetCurrentSessionCommand { export interface GetCurrentSessionCommand {
@ -211,33 +171,15 @@ export interface HelpCommand {
type: 'help' type: 'help'
} }
export interface ListChannelsCommand {
type: 'list_channels'
}
export interface ListSessionsByChannelCommand {
type: 'list_sessions_by_channel'
channel_name: string
include_archived: boolean
}
export interface ListTopicsCommand {
type: 'list_topics'
session_id: string
}
export type Command = export type Command =
| CreateSessionCommand | CreateSessionCommand
| ListSessionsCommand | ListSessionsCommand
| SwitchTopicCommand | SwitchSessionCommand
| SaveTopicCommand | SaveTopicCommand
| SaveSessionCommand | SaveSessionCommand
| LoadTopicCommand | LoadSessionCommand
| GetCurrentSessionCommand | GetCurrentSessionCommand
| HelpCommand | HelpCommand
| ListChannelsCommand
| ListSessionsByChannelCommand
| ListTopicsCommand
// ============================================================================ // ============================================================================
// UI Types // UI Types
@ -250,9 +192,7 @@ export interface ChatMessage {
timestamp: number timestamp: number
type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending' type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending'
toolName?: string toolName?: string
toolCallId?: string
arguments?: unknown arguments?: unknown
attachments?: Attachment[]
} }
export interface Topic { export interface Topic {