Compare commits

..

8 Commits

Author SHA1 Message Date
oudecheng
5e5de7ce9f refactor: 移除 show_tool_results 开关,始终实时推送工具调用消息
简化工具消息推送逻辑,去掉条件判断,让所有工具消息(含结果)
直接通过 emit_live_tool_call_message 实时发送给用户。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:52:40 +08:00
oudecheng
7755164df5 feat:记忆修改隔离 2026-05-28 14:55:52 +08:00
oudecheng
598d425c28 feat: 话题添加描述字段,工具消息按 tool_call_id 合并展示
- TopicSummary 新增 description 字段,侧边栏优先显示描述
- ToolPanel 使用 toolCallId 将 tool_call 和 tool_result 配对合并展示
- 保存消息时同步更新 topics 表的 message_count 和 last_active_at
- ChatMessage 新增 toolCallId 字段

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:30:21 +08:00
oudecheng
44e82e8473 feat: 附件通过 base64 内容实现前端直接下载
- MediaItem/MediaSummary 新增 content_base64 和 file_name 字段
- 解析附件时读取文件内容并 base64 编码(限 50MB),前端 Blob 下载
- 创建 Session 后返回完整 topics 列表,前端侧边栏实时同步
- 简化话题历史加载逻辑,不再回退到 session 消息

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:01:37 +08:00
oudecheng
7898ca69e4 feat: 添加附件支持、自动选择话题及消息展示优化
- 消息协议新增 attachments 字段,支持图片/音频/视频/文件附件
- 文本和附件合并在一条消息中发送,不再拆分为多条
- Topics 加载后自动选中第一个话题并加载历史消息
- 用户消息现在通过 WebSocket 发送,可在前端展示
- 前端过滤 tool_result 消息,添加附件卡片展示组件

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:51:48 +08:00
oudecheng
542e11d0b3 refactor: 将 Session 命令重构为 Topic 命令
- 新增 LoadTopic 命令处理器,替代 LoadSession
- 新增 SwitchTopic 命令处理器,替代 SwitchSession
- 删除 LoadSession 和 SwitchSession 处理器
- 更新 Command 枚举:LoadSession -> LoadTopic, SwitchSession -> SwitchTopic
- 同步更新前端协议类型定义
- 调整适配器和网关代码以适应新命令

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:01:07 +08:00
oudecheng
10fb67320a refactor(web): 简化 UI 架构,移除三级选择器
- 移除 ChannelSelector 和 SessionSelector 组件
- 新增 SessionInfo 组件显示当前会话信息
- 简化 useChat hook,移除 channels/sessions 状态管理
- 优化 TopicList UI,添加时间格式化显示
- 将废弃组件移至 .deprecated/ 目录

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:39:50 +08:00
oudecheng
e9e1439428 feat: 添加通道和话题管理功能
后端:
- 新增 ListChannels 命令,列出所有可用通道 (WebSocket/CLI)
- 新增 ListSessionsByChannel 命令,支持按通道筛选会话
- 新增 ListTopics 命令,列出 Session 的所有 Topics
- 添加 Channel 和 TopicSummary 数据结构
- 更新 WebSocket 协议,支持 channel_list 和 topic_list 消息

前端:
- 新增 ChannelSelector 组件用于通道选择
- 新增 SessionSelector 组件用于会话选择
- 更新 TopicList 组件支持话题展示
- 更新 useChat hook 和协议类型定义

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:55:09 +08:00
35 changed files with 1614 additions and 243 deletions

1
.gitignore vendored
View File

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

View File

@ -975,7 +975,8 @@ impl AgentLoop {
},
);
messages.push(tool_message.clone());
emitted_messages.push(tool_message);
emitted_messages.push(tool_message.clone());
self.emit_live_tool_call_message(tool_message).await;
}
LoopDetectionResult::Ok => {
let tool_message = ChatMessage::tool_with_state(
@ -989,7 +990,8 @@ impl AgentLoop {
},
);
messages.push(tool_message.clone());
emitted_messages.push(tool_message);
emitted_messages.push(tool_message.clone());
self.emit_live_tool_call_message(tool_message).await;
}
}
}
@ -1111,10 +1113,6 @@ impl AgentLoop {
}
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 {
handler.handle(message).await;
}

View File

@ -24,6 +24,8 @@ pub struct MediaItem {
pub media_type: String, // "image", "audio", "file", "video"
pub mime_type: Option<String>,
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 {
@ -33,6 +35,8 @@ impl MediaItem {
media_type: media_type.into(),
mime_type: None,
original_key: None,
content_base64: None,
file_name: None,
}
}
}

View File

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

View File

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

View File

@ -77,20 +77,112 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
attachments: Vec::new(),
},
MessageKind::Notification => {
// 根据元数据判断具体类型
if let Some(session_id) = response.metadata.get("session_id") {
if let Some(topics_json) = response.metadata.get("topics") {
// Topic 列表响应 - 优先检查 topics
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 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(),
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 {
// 默认通知
WsOutbound::AssistantResponse {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
attachments: Vec::new(),
}
}
}
@ -102,6 +194,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
attachments: Vec::new(),
},
};
outbounds.push(outbound);

View File

@ -0,0 +1,75 @@
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

@ -0,0 +1,88 @@
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

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

View File

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

View File

@ -1,5 +1,6 @@
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::list_topics::TopicSummary;
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::gateway::session::SessionManager;
@ -94,8 +95,31 @@ 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)
.with_message(MessageKind::Notification, &topic.title)
.with_metadata("topics", &topics_json)
.with_metadata("topic_id", &topic.id)
.with_metadata("session_id", &topic.session_id)
.with_metadata("message_count", &topic.message_count.to_string()))

View File

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

View File

@ -27,13 +27,22 @@ pub enum Command {
/// 列出当前 Session 的所有话题
ListSessions { include_archived: bool },
/// 加载指定话题
LoadSession { session_id: String },
LoadTopic { topic_id: String },
/// 切换到指定话题(清理当前历史并加载新话题)
SwitchSession { session_id: String },
SwitchTopic { topic_id: String },
/// 获取当前话题信息
GetCurrentSession,
/// 显示所有支持的命令
Help,
/// 列出所有可用通道
ListChannels,
/// 列出指定通道的所有会话
ListSessionsByChannel {
channel_name: String,
include_archived: bool,
},
/// 列出 Session 的所有 Topics
ListTopics { session_id: String },
}
impl Command {
@ -44,10 +53,13 @@ impl Command {
Command::SaveTopic { .. } => "save_topic",
Command::SaveSession { .. } => "save_session",
Command::ListSessions { .. } => "list_sessions",
Command::LoadSession { .. } => "load_session",
Command::SwitchSession { .. } => "switch_session",
Command::LoadTopic { .. } => "load_topic",
Command::SwitchTopic { .. } => "switch_topic",
Command::GetCurrentSession => "get_current_session",
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::help::HelpCommandHandler;
use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
use crate::command::handlers::load_session::LoadSessionCommandHandler;
use crate::command::handlers::load_topic::LoadTopicCommandHandler;
use crate::command::handlers::save_session::SaveSessionCommandHandler;
use crate::command::handlers::save_topic::SaveTopicCommandHandler;
use crate::command::handlers::session::SessionCommandHandler;
use crate::command::handlers::switch_session::SwitchSessionCommandHandler;
use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
use crate::config::LLMProviderConfig;
use crate::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::providers::{create_provider, ProviderRuntimeConfig};
@ -52,8 +52,8 @@ impl InboundProcessor {
// 注册 list_sessions 处理器
command_router.register(Box::new(ListSessionsCommandHandler::new(store.clone())));
// 注册 switch_session 处理器
let switch_handler = SwitchSessionCommandHandler::new(store.clone())
// 注册 switch_topic 处理器
let switch_handler = SwitchTopicCommandHandler::new(store.clone())
.with_session_manager(session_manager.clone());
command_router.register(Box::new(switch_handler));
@ -76,8 +76,8 @@ impl InboundProcessor {
.with_system_prompt_provider(system_prompt_provider.clone())
));
// 注册 load_session 处理器
command_router.register(Box::new(LoadSessionCommandHandler::new(store.clone())));
// 注册 load_topic 处理器
command_router.register(Box::new(LoadTopicCommandHandler::new(store.clone())));
// 注册 save_session 处理器
command_router.register(Box::new(SaveSessionCommandHandler::new(
@ -223,7 +223,6 @@ impl InboundProcessor {
inbound.channel.clone(),
inbound.chat_id.clone(),
inbound.forwarded_metadata.clone(),
self.session_manager.show_tool_results(),
));
match self

View File

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

View File

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

View File

@ -7,11 +7,14 @@ use crate::command::context::CommandContext;
use crate::command::handler::CommandRouter;
use crate::command::handlers::get_current::GetCurrentSessionCommandHandler;
use crate::command::handlers::help::HelpCommandHandler;
use crate::command::handlers::list_channels::ListChannelsCommandHandler;
use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
use crate::command::handlers::load_session::LoadSessionCommandHandler;
use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler;
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::session::SessionCommandHandler;
use crate::command::handlers::switch_session::SwitchSessionCommandHandler;
use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
use crate::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::protocol::{WsInbound, WsOutbound, parse_inbound, serialize_outbound};
use crate::skills::SkillPromptProvider;
@ -35,12 +38,24 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
let (sender, receiver) = mpsc::channel::<WsOutbound>(100);
let cli_sessions = state.session_manager.cli_sessions();
let initial_record = match cli_sessions.create(None) {
let store = state.session_manager.store();
// 1. 先查询 websocket 通道的 Sessions
let websocket_sessions = store.list_sessions("websocket", false)
.unwrap_or_default();
// 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 CLI session");
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();
@ -55,7 +70,7 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
sender.clone(),
)
.await;
tracing::info!(runtime_session_id = %runtime_session_id, session_id = %current_session_id, "CLI session established");
tracing::info!(runtime_session_id = %runtime_session_id, session_id = %current_session_id, "WebSocket session established");
let _ = sender
.send(WsOutbound::SessionEstablished {
@ -63,6 +78,42 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
})
.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 receiver = receiver;
@ -234,14 +285,20 @@ async fn handle_inbound(
router.register(Box::new(session_handler));
// 注册 list_sessions 处理器
router.register(Box::new(ListSessionsCommandHandler::new(store.clone())));
// 注册 switch_session 处理器
let switch_handler = SwitchSessionCommandHandler::new(store.clone())
// 注册 list_sessions_by_channel 处理器
router.register(Box::new(ListSessionsByChannelCommandHandler::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());
router.register(Box::new(switch_handler));
// 注册 get_current 处理器
router.register(Box::new(GetCurrentSessionCommandHandler::new(store.clone())));
// 注册 load_session 处理器
router.register(Box::new(LoadSessionCommandHandler::new(store.clone())));
// 注册 load_topic 处理器
router.register(Box::new(LoadTopicCommandHandler::new(store.clone())));
router.register(Box::new(SaveSessionCommandHandler::new(
store.clone(),
state.task_repository.clone(),
@ -296,6 +353,29 @@ async fn handle_inbound(
"Updating current_topic_id"
);
*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 {
tracing::warn!(
@ -335,6 +415,86 @@ fn resolve_ws_sender_id(sender_id: Option<&str>, runtime_session_id: &str) -> St
.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)]
mod tests {
use super::resolve_ws_sender_id;

View File

@ -14,6 +14,40 @@ pub struct SessionSummary {
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)]
#[serde(tag = "type")]
pub enum WsInbound {
@ -43,6 +77,8 @@ pub enum WsOutbound {
id: String,
content: String,
role: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<MediaSummary>,
},
#[serde(rename = "tool_call")]
ToolCall {
@ -81,6 +117,17 @@ pub enum WsOutbound {
sessions: Vec<SessionSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
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")]
SessionLoaded {

View File

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

View File

@ -579,6 +579,13 @@ impl SessionStore {
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()?;
Ok(())
}

View File

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

View File

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

View File

@ -1,8 +1,10 @@
use std::io::Read;
use std::path::Path;
use std::sync::Arc;
use anyhow::anyhow;
use async_trait::async_trait;
use base64::Engine;
use serde_json::json;
use crate::bus::MediaItem;
@ -205,11 +207,27 @@ fn parse_attachments(value: &serde_json::Value) -> anyhow::Result<Vec<MediaItem>
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 mut item = MediaItem::new(raw_path.to_string(), media_type);
item.mime_type = mime_guess::from_path(&raw_path)
.first_raw()
.map(ToOwned::to_owned);
item.content_base64 = content_base64;
item.file_name = file_name;
attachments.push(item);
}

View File

@ -1,7 +1,8 @@
import { useCallback, useMemo } from 'react'
import { Zap, Cpu, Activity } from 'lucide-react'
import { useCallback, useEffect, useRef } from 'react'
import { Zap, Cpu, MessageSquare } from 'lucide-react'
import { ChatContainer } from './components/Chat/ChatContainer'
import { TopicList } from './components/Sidebar/TopicList'
import { SessionInfo } from './components/Sidebar/SessionInfo'
import { ToolPanel } from './components/Panel/ToolPanel'
import { ConnectionStatus } from './components/ConnectionStatus'
import { useWebSocket } from './hooks/useWebSocket'
@ -11,15 +12,32 @@ import type { Command } from './types/protocol'
const WS_URL = 'ws://127.0.0.1:19876/ws'
function App() {
const lastAutoSwitchedTopicRef = useRef<string | null>(null)
const {
messages,
currentSessionId,
currentTopicId,
// 连接状态
connectionId,
isConnected,
// Session 状态
session,
sessionId,
chatId,
// Topic 状态
topics,
selectedTopic,
// 消息状态
messages,
isLoading,
isReadOnly,
// 方法
handleMessage,
handleCommand,
handleServerMessage,
selectTopic,
createTopic,
switchTopic,
requestSessionList,
requestTopicList,
} = useChat()
const { status, sendMessage } = useWebSocket({
@ -27,8 +45,51 @@ function App() {
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(
(content: string) => {
if (isReadOnly || !sessionId) {
return
}
if (content.startsWith('/')) {
const parts = content.slice(1).split(' ')
const command = parts[0]
@ -44,9 +105,9 @@ function App() {
break
case 'use':
if (args[0]) {
cmd = { type: 'switch_session', session_id: args[0] }
cmd = { type: 'switch_topic', topic_id: args[0] }
} else {
alert('Usage: /use <session_id>')
alert('Usage: /use <topic_id>')
return
}
break
@ -65,32 +126,38 @@ function App() {
sendMessage({
type: 'message',
content,
chat_id: currentTopicId ?? undefined,
chat_id: chatId,
})
}
},
[sendMessage, handleMessage, handleCommand, currentTopicId]
[sendMessage, handleMessage, handleCommand, sessionId, chatId, isReadOnly]
)
const handleCreateTopic = useCallback(() => {
if (isReadOnly || !sessionId) {
return
}
const title = prompt('Enter topic title:')
if (title) {
const cmd: Command = { type: 'create_session', title }
const cmd = createTopic(title)
handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
}
}, [sendMessage, handleCommand])
}, [sendMessage, handleCommand, createTopic, sessionId, isReadOnly])
const handleSwitchTopic = useCallback(
(topicId: string) => {
const cmd: Command = { type: 'switch_session', session_id: topicId }
selectTopic(topicId)
const cmd = switchTopic(topicId)
handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
},
[sendMessage, handleCommand]
[sendMessage, handleCommand, switchTopic, selectTopic]
)
const toolMessages = useMemo(() => messages, [messages])
const chatMessages = messages.filter((message) => message.type !== 'tool_result')
const toolMessages = messages
return (
<div className="flex h-screen flex-col bg-[#0a0a0f] text-white overflow-hidden">
@ -112,11 +179,11 @@ function App() {
<Cpu className="h-4 w-4 text-[#00f0ff]" />
<span>AI Ready</span>
</div>
{currentSessionId && (
{session && (
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-emerald-400" />
<MessageSquare className="h-4 w-4 text-emerald-400" />
<span className="font-mono text-xs">
{currentSessionId.slice(0, 8)}...
{session.title}
</span>
</div>
)}
@ -125,21 +192,38 @@ function App() {
{/* Main Content */}
<div className="flex flex-1 overflow-hidden">
{/* Left Sidebar - Topic List */}
<div className="w-72 shrink-0 border-r border-white/8 bg-[#12121a]/50">
{/* Left Sidebar - 简化为 Session 信息 + Topic 列表 */}
<div className="w-72 shrink-0 border-r border-white/8 bg-[#12121a]/50 flex flex-col">
{/* Session Info */}
<SessionInfo
session={session}
connectionId={connectionId}
/>
{/* 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={currentTopicId}
currentTopicId={selectedTopic}
isReadOnly={isReadOnly}
onCreateTopic={handleCreateTopic}
onSwitchTopic={handleSwitchTopic}
/>
</div>
</div>
{/* Center - Chat */}
<div className="flex-1 min-w-0 bg-[#0a0a0f]">
<ChatContainer
messages={messages}
messages={chatMessages}
isLoading={isLoading}
isReadOnly={isReadOnly}
channelName={session?.title ?? 'PicoBot'}
onSendMessage={handleSendMessage}
/>
</div>

View File

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

View File

@ -1,12 +1,77 @@
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal } from 'lucide-react'
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { ChatMessage } from '../../types/protocol'
import type { ChatMessage, Attachment } from '../../types/protocol'
interface MessageBubbleProps {
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) {
const isUser = message.role === 'user'
const isTool = message.role === 'tool'
@ -160,6 +225,13 @@ export function MessageBubble({ message }: MessageBubbleProps) {
</ReactMarkdown>
</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>

View File

@ -1,16 +1,20 @@
import { Send, Loader2, Sparkles } from 'lucide-react'
import { Send, Loader2, Sparkles, Eye } from 'lucide-react'
import { useState, useRef, useEffect } from 'react'
interface MessageInputProps {
onSend: (content: string) => void
disabled?: boolean
placeholder?: string
isReadOnly?: boolean
channelName?: string
}
export function MessageInput({
onSend,
disabled = false,
placeholder = '输入消息...按 / 查看命令',
isReadOnly = false,
channelName,
}: MessageInputProps) {
const [content, setContent] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
@ -24,7 +28,7 @@ export function MessageInput({
}, [content])
const handleSend = () => {
if (content.trim() && !disabled) {
if (content.trim() && !disabled && !isReadOnly) {
onSend(content.trim())
setContent('')
if (textareaRef.current) {
@ -40,6 +44,34 @@ 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 (
<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">

View File

@ -1,5 +1,5 @@
import { ChevronDown, ChevronRight, Play, Check, AlertTriangle, Terminal } from 'lucide-react'
import { useState } from 'react'
import { useState, useMemo } from 'react'
import type { ChatMessage } from '../../types/protocol'
interface ToolPanelProps {
@ -7,25 +7,54 @@ interface ToolPanelProps {
}
interface ToolCallItem {
id: string
toolCallId: string
toolName: string
status: 'calling' | 'result' | 'pending'
arguments?: unknown
content: string
resultContent: 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) {
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set())
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 toolCalls = useMemo(() => mergeToolMessages(messages), [messages])
const toggleExpand = (id: string) => {
setExpandedTools((prev) => {
@ -42,7 +71,7 @@ export function ToolPanel({ messages }: ToolPanelProps) {
const getStatusIcon = (status: ToolCallItem['status']) => {
switch (status) {
case 'calling':
return <Play className="h-3 w-3 text-amber-400" />
return <Play className="h-3 w-3 text-amber-400 animate-pulse" />
case 'result':
return <Check className="h-3 w-3 text-emerald-400" />
case 'pending':
@ -91,11 +120,11 @@ export function ToolPanel({ messages }: ToolPanelProps) {
<div className="space-y-2">
{toolCalls.map((tool) => (
<div
key={tool.id}
key={tool.toolCallId}
className="rounded-xl border border-white/8 bg-[#1a1a25]/50 text-sm overflow-hidden"
>
<button
onClick={() => toggleExpand(tool.id)}
onClick={() => toggleExpand(tool.toolCallId)}
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">
@ -105,13 +134,13 @@ export function ToolPanel({ messages }: ToolPanelProps) {
{getStatusText(tool.status)}
</span>
</div>
{expandedTools.has(tool.id) ? (
{expandedTools.has(tool.toolCallId) ? (
<ChevronDown className="h-4 w-4 text-zinc-500" />
) : (
<ChevronRight className="h-4 w-4 text-zinc-500" />
)}
</button>
{expandedTools.has(tool.id) && (
{expandedTools.has(tool.toolCallId) && (
<div className="border-t border-white/8 px-3 py-2 bg-black/20">
{tool.arguments ? (
<div className="mb-2">
@ -122,7 +151,7 @@ export function ToolPanel({ messages }: ToolPanelProps) {
<div>
<div className="text-xs font-medium text-zinc-500 mb-1">:</div>
<div className="max-h-32 overflow-y-auto rounded-lg bg-black/40 p-2 text-xs whitespace-pre-wrap text-zinc-400 font-mono">
{tool.content}
{tool.resultContent || tool.callContent}
</div>
</div>
</div>

View File

@ -0,0 +1,127 @@
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

@ -0,0 +1,114 @@
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

@ -0,0 +1,50 @@
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,39 +1,86 @@
import { Plus, MessageSquare, Hash } from 'lucide-react'
import { Plus, MessageSquare, Layers, Hash, Clock } from 'lucide-react'
import type { Topic } from '../../types/protocol'
interface TopicListProps {
sessionId: string | null
sessionTitle: string
topics: Topic[]
currentTopicId: string | null
isReadOnly: boolean
onCreateTopic: () => 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({
sessionId,
sessionTitle,
topics,
currentTopicId,
isReadOnly,
onCreateTopic,
onSwitchTopic,
}: TopicListProps) {
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-white/8 p-4">
<h2 className="font-semibold text-white flex items-center gap-2">
<Hash className="h-4 w-4 text-[#00f0ff]" />
Topics
{/* 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">
<Layers className="h-4 w-4 text-[#00f0ff]" />
{topics.length > 0 && (
<span className="text-xs text-zinc-500">({topics.length})</span>
)}
</h2>
<button
onClick={onCreateTopic}
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"
disabled={isReadOnly || !sessionId}
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" />
</button>
</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">
{topics.length === 0 ? (
{!sessionId ? (
<div className="p-4 text-center text-sm text-zinc-500">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
Topic
<p>...</p>
</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 className="space-y-1">
@ -48,23 +95,30 @@ export function TopicList({
}`}
>
<div className="flex items-start gap-3">
<span className="mt-0.5 text-xs text-zinc-500 font-mono">{index + 1}</span>
<span className="mt-0.5 text-xs text-zinc-500 font-mono w-4">
{index + 1}
</span>
<div className="min-w-0 flex-1">
<div className={`truncate font-medium ${
topic.id === currentTopicId ? 'text-[#00f0ff]' : 'text-zinc-300'
}`}>
{topic.title}
{topic.description || topic.title}
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-zinc-500">
<div className="flex items-center gap-3 mt-1.5">
<span className="text-xs text-zinc-500 flex items-center gap-1">
<Hash className="h-3 w-3" />
{topic.message_count}
</span>
<span className="text-xs text-zinc-600 flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(topic.updated_at)}
</span>
</div>
</div>
{topic.id === currentTopicId && (
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[#00f0ff] shadow-lg shadow-[#00f0ff]/50" />
<span className="inline-block h-2 w-2 rounded-full bg-[#00f0ff] shadow-lg shadow-[#00f0ff]/50 mt-1.5" />
)}
</div>
</div>
</div>
</button>
))}
</div>

View File

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

View File

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