Compare commits
No commits in common. "5e5de7ce9ffa4d03ceca45923b51709a27bb9a2e" and "624d8e89439565492e6eca210f9f9ca6909d9b35" have entirely different histories.
5e5de7ce9f
...
624d8e8943
1
.gitignore
vendored
1
.gitignore
vendored
@ -30,4 +30,3 @@ output
|
|||||||
.python-version
|
.python-version
|
||||||
pyproject.toml
|
pyproject.toml
|
||||||
uv.lock
|
uv.lock
|
||||||
node_modules
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -77,112 +77,20 @@ 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
|
|
||||||
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 {
|
WsOutbound::SessionCreated {
|
||||||
session_id: session_id.clone(),
|
session_id: session_id.clone(),
|
||||||
title: msg.content.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 {
|
} else {
|
||||||
// 默认通知
|
// 默认通知
|
||||||
WsOutbound::AssistantResponse {
|
WsOutbound::AssistantResponse {
|
||||||
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);
|
||||||
|
|||||||
@ -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()))
|
|
||||||
}
|
|
||||||
@ -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()))
|
|
||||||
}
|
|
||||||
@ -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()))
|
|
||||||
}
|
|
||||||
@ -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> {
|
||||||
@ -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::{
|
||||||
|
|||||||
@ -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()))
|
||||||
|
|||||||
@ -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> {
|
||||||
@ -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",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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!(
|
||||||
|
tokio::time::timeout(std::time::Duration::from_millis(50), bus.consume_outbound())
|
||||||
.await
|
.await
|
||||||
.expect("should have received an outbound message");
|
.is_err()
|
||||||
assert_eq!(msg.event_kind, OutboundEventKind::ToolResult);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@ -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,27 +43,26 @@ 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
|
||||||
|
.publish_outbound(OutboundMessage::assistant(
|
||||||
channel_name.to_string(),
|
channel_name.to_string(),
|
||||||
chat_id.to_string(),
|
chat_id.to_string(),
|
||||||
None, // session_id
|
None, // session_id
|
||||||
text,
|
text,
|
||||||
None,
|
None,
|
||||||
metadata.clone(),
|
metadata.clone(),
|
||||||
);
|
))
|
||||||
if attachment_count > 0 {
|
.await?;
|
||||||
outbound.media = request.attachments.clone();
|
|
||||||
}
|
|
||||||
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 {
|
}
|
||||||
|
|
||||||
|
let attachment_count = request.attachments.len();
|
||||||
for attachment in request.attachments {
|
for attachment in request.attachments {
|
||||||
let media_path = attachment.path.clone();
|
let media_path = attachment.path.clone();
|
||||||
let media_type = attachment.media_type.clone();
|
let media_type = attachment.media_type.clone();
|
||||||
@ -87,7 +85,6 @@ impl SessionMessageSender for BusSessionMessageSender {
|
|||||||
"Published session attachment to outbound bus"
|
"Published session attachment to outbound bus"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(SessionSendOutcome {
|
Ok(SessionSendOutcome {
|
||||||
published_messages,
|
published_messages,
|
||||||
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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) {
|
||||||
|
|
||||||
// 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,
|
Ok(record) => record,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "Failed to create initial WebSocket session");
|
tracing::error!(error = %e, "Failed to create initial CLI session");
|
||||||
return;
|
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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
126
web/src/App.tsx
126
web/src/App.tsx
@ -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 */}
|
|
||||||
<SessionInfo
|
|
||||||
session={session}
|
|
||||||
connectionId={connectionId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="border-b border-white/8" />
|
|
||||||
|
|
||||||
{/* Topic List */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<TopicList
|
<TopicList
|
||||||
sessionId={sessionId}
|
|
||||||
sessionTitle={session?.title ?? ''}
|
|
||||||
topics={topics}
|
topics={topics}
|
||||||
currentTopicId={selectedTopic}
|
currentTopicId={currentTopicId}
|
||||||
isReadOnly={isReadOnly}
|
|
||||||
onCreateTopic={handleCreateTopic}
|
onCreateTopic={handleCreateTopic}
|
||||||
onSwitchTopic={handleSwitchTopic}
|
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>
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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,30 +48,23 @@ 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">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{formatTime(topic.updated_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{topic.id === currentTopicId && (
|
{topic.id === currentTopicId && (
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-[#00f0ff] shadow-lg shadow-[#00f0ff]/50 mt-1.5" />
|
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[#00f0ff] shadow-lg shadow-[#00f0ff]/50" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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) => ({
|
||||||
// 自动选择第一个 Session(WebSocket 通道只有一个)
|
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 {
|
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 {
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user