diff --git a/src/command/adapters/websocket.rs b/src/command/adapters/websocket.rs index d6c82d8..d6dd796 100644 --- a/src/command/adapters/websocket.rs +++ b/src/command/adapters/websocket.rs @@ -80,10 +80,96 @@ impl OutputAdapter for WebSocketOutputAdapter { }, MessageKind::Notification => { // 根据元数据判断具体类型 - if let Some(session_id) = response.metadata.get("session_id") { - WsOutbound::SessionCreated { - session_id: session_id.clone(), + if let Some(topics_json) = response.metadata.get("topics") { + // Topic 列表响应 - 优先检查 topics + match serde_json::from_str::>(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(), + }, + } + } else if let Some(session_id) = response.metadata.get("session_id") { + // 有 session_id 但没有 topic_id 的是创建会话 + if response.metadata.get("topic_id").is_none() { + WsOutbound::SessionCreated { + session_id: session_id.clone(), + title: msg.content.clone(), + } + } else { + // 加载会话 + let message_count = response.metadata.get("message_count") + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + WsOutbound::SessionLoaded { + session_id: session_id.clone(), + title: msg.content.clone(), + message_count, + } + } + } else if let Some(topic_id) = response.metadata.get("topic_id") { + // 只有 topic_id,可能是加载话题 + let message_count = response.metadata.get("message_count") + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + WsOutbound::SessionLoaded { + session_id: topic_id.clone(), title: msg.content.clone(), + message_count, + } + } else if let Some(channels_json) = response.metadata.get("channels") { + // 通道列表响应 + match serde_json::from_str::>(channels_json) { + Ok(channels) => WsOutbound::ChannelList { channels }, + Err(_) => WsOutbound::AssistantResponse { + id: response.request_id.to_string(), + content: msg.content.clone(), + role: "assistant".to_string(), + }, + } + } else if let Some(sessions_json) = response.metadata.get("sessions") { + // 会话列表响应 + match serde_json::from_str::>(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(), + }, + } + } else if let Some(topics_json) = response.metadata.get("topics") { + // Topic 列表响应 + match serde_json::from_str::>(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(), + }, } } else { // 默认通知 diff --git a/src/command/handlers/list_channels.rs b/src/command/handlers/list_channels.rs new file mode 100644 index 0000000..8d2b11a --- /dev/null +++ b/src/command/handlers/list_channels.rs @@ -0,0 +1,75 @@ +use crate::command::context::CommandContext; +use crate::command::handler::{CommandHandler, CommandMetadata}; +use crate::command::response::{CommandError, CommandResponse, MessageKind}; +use crate::command::Command; +use crate::protocol::Channel; +use async_trait::async_trait; + +/// 列出通道命令处理器 +pub struct ListChannelsCommandHandler; + +impl ListChannelsCommandHandler { + pub fn new() -> Self { + Self + } + + /// 获取默认通道列表(公开供其他模块使用) + pub fn get_default_channels() -> Vec { + 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 { + Some(CommandMetadata { + name: "channels", + description: "列出所有可用通道", + usage: "/channels", + }) + } + + async fn handle( + &self, + cmd: Command, + ctx: CommandContext, + ) -> Result { + match cmd { + Command::ListChannels => handle_list_channels(ctx).await, + _ => unreachable!(), + } + } +} + +async fn handle_list_channels( + ctx: CommandContext, +) -> Result { + 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())) +} diff --git a/src/command/handlers/list_sessions_by_channel.rs b/src/command/handlers/list_sessions_by_channel.rs new file mode 100644 index 0000000..992eb66 --- /dev/null +++ b/src/command/handlers/list_sessions_by_channel.rs @@ -0,0 +1,88 @@ +use crate::command::context::CommandContext; +use crate::command::handler::{CommandHandler, CommandMetadata}; +use crate::command::response::{CommandError, CommandResponse, MessageKind}; +use crate::command::Command; +use crate::protocol::SessionSummary; +use crate::storage::SessionStore; +use async_trait::async_trait; +use std::sync::Arc; + +/// 按通道列出会话命令处理器 +pub struct ListSessionsByChannelCommandHandler { + store: Arc, +} + +impl ListSessionsByChannelCommandHandler { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl CommandHandler for ListSessionsByChannelCommandHandler { + fn can_handle(&self, cmd: &Command) -> bool { + matches!(cmd, Command::ListSessionsByChannel { .. }) + } + + fn metadata(&self) -> Option { + Some(CommandMetadata { + name: "list_by_channel", + description: "列出指定通道的所有会话", + usage: "/list_by_channel ", + }) + } + + async fn handle( + &self, + cmd: Command, + ctx: CommandContext, + ) -> Result { + 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 { + let sessions = handler + .store + .list_sessions(&channel_name, include_archived) + .map_err(|e| CommandError::new("LIST_SESSIONS_ERROR", e.to_string()))?; + + let summaries: Vec = 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())) +} diff --git a/src/command/handlers/list_topics.rs b/src/command/handlers/list_topics.rs new file mode 100644 index 0000000..aaf681b --- /dev/null +++ b/src/command/handlers/list_topics.rs @@ -0,0 +1,96 @@ +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 message_count: i64, + pub created_at: i64, + pub last_active_at: i64, +} + +/// 列出 Session 的 Topics 命令处理器 +pub struct ListTopicsCommandHandler { + store: Arc, +} + +impl ListTopicsCommandHandler { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl CommandHandler for ListTopicsCommandHandler { + fn can_handle(&self, cmd: &Command) -> bool { + matches!(cmd, Command::ListTopics { .. }) + } + + fn metadata(&self) -> Option { + Some(CommandMetadata { + name: "topics", + description: "列出 Session 的所有 Topics", + usage: "/topics ", + }) + } + + async fn handle( + &self, + cmd: Command, + ctx: CommandContext, + ) -> Result { + 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 { + let topics = handler + .store + .list_topics(&session_id) + .map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?; + + let summaries: Vec = topics + .into_iter() + .map(|t| TopicSummary { + topic_id: t.id, + session_id: t.session_id, + title: t.title, + 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())) +} diff --git a/src/command/handlers/mod.rs b/src/command/handlers/mod.rs index 87f173e..fae3d06 100644 --- a/src/command/handlers/mod.rs +++ b/src/command/handlers/mod.rs @@ -1,6 +1,9 @@ pub mod get_current; pub mod help; +pub mod list_channels; pub mod list_sessions; +pub mod list_sessions_by_channel; +pub mod list_topics; pub mod load_session; pub mod save_session; pub mod save_topic; diff --git a/src/command/mod.rs b/src/command/mod.rs index 88d0d72..1ee46b7 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -34,6 +34,15 @@ pub enum Command { GetCurrentSession, /// 显示所有支持的命令 Help, + /// 列出所有可用通道 + ListChannels, + /// 列出指定通道的所有会话 + ListSessionsByChannel { + channel_name: String, + include_archived: bool, + }, + /// 列出 Session 的所有 Topics + ListTopics { session_id: String }, } impl Command { @@ -48,6 +57,9 @@ impl Command { Command::SwitchSession { .. } => "switch_session", Command::GetCurrentSession => "get_current_session", Command::Help => "help", + Command::ListChannels => "list_channels", + Command::ListSessionsByChannel { .. } => "list_sessions_by_channel", + Command::ListTopics { .. } => "list_topics", } } } diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index 65a6af6..69fee98 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -7,7 +7,10 @@ use crate::command::context::CommandContext; use crate::command::handler::CommandRouter; use crate::command::handlers::get_current::GetCurrentSessionCommandHandler; use crate::command::handlers::help::HelpCommandHandler; +use crate::command::handlers::list_channels::ListChannelsCommandHandler; use crate::command::handlers::list_sessions::ListSessionsCommandHandler; +use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler; +use crate::command::handlers::list_topics::ListTopicsCommandHandler; use crate::command::handlers::load_session::LoadSessionCommandHandler; use crate::command::handlers::save_session::SaveSessionCommandHandler; use crate::command::handlers::session::SessionCommandHandler; @@ -35,12 +38,24 @@ async fn handle_socket(ws: WebSocket, state: Arc) { let (sender, receiver) = mpsc::channel::(100); let cli_sessions = state.session_manager.cli_sessions(); - let initial_record = match cli_sessions.create(None) { - Ok(record) => record, - Err(e) => { - tracing::error!(error = %e, "Failed to create initial CLI session"); - return; + let store = state.session_manager.store(); + + // 1. 先查询 websocket 通道的 Sessions + let websocket_sessions = store.list_sessions("websocket", false) + .unwrap_or_default(); + + // 2. 如果没有,自动创建一个默认 Session + let initial_record = if websocket_sessions.is_empty() { + match cli_sessions.create_with_channel("websocket", Some("默认会话")) { + Ok(record) => record, + Err(e) => { + tracing::error!(error = %e, "Failed to create initial WebSocket session"); + return; + } } + } else { + // 使用最新的 Session + websocket_sessions[0].clone() }; let runtime_session_id = uuid::Uuid::new_v4().to_string(); @@ -55,7 +70,7 @@ async fn handle_socket(ws: WebSocket, state: Arc) { sender.clone(), ) .await; - tracing::info!(runtime_session_id = %runtime_session_id, session_id = %current_session_id, "CLI session established"); + tracing::info!(runtime_session_id = %runtime_session_id, session_id = %current_session_id, "WebSocket session established"); let _ = sender .send(WsOutbound::SessionEstablished { @@ -63,6 +78,42 @@ async fn handle_socket(ws: WebSocket, state: Arc) { }) .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 = final_sessions + .into_iter() + .map(|s| crate::protocol::SessionSummary { + session_id: s.id, + title: s.title, + channel_name: s.channel_name, + chat_id: s.chat_id, + message_count: s.message_count, + last_active_at: s.last_active_at, + archived_at: s.archived_at, + }) + .collect(); + + let _ = sender + .send(WsOutbound::SessionList { + sessions: session_summaries, + current_session_id: Some(current_session_id.clone()), + channel_name: Some("websocket".to_string()), + }) + .await; + let (mut ws_sender, mut ws_receiver) = ws.split(); let mut receiver = receiver; @@ -234,6 +285,12 @@ async fn handle_inbound( router.register(Box::new(session_handler)); // 注册 list_sessions 处理器 router.register(Box::new(ListSessionsCommandHandler::new(store.clone()))); + // 注册 list_sessions_by_channel 处理器 + router.register(Box::new(ListSessionsByChannelCommandHandler::new(store.clone()))); + // 注册 list_channels 处理器 + router.register(Box::new(ListChannelsCommandHandler::new())); + // 注册 list_topics 处理器 + router.register(Box::new(ListTopicsCommandHandler::new(store.clone()))); // 注册 switch_session 处理器 let switch_handler = SwitchSessionCommandHandler::new(store.clone()) .with_session_manager(state.session_manager.clone()); diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 2882b06..cada396 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -14,6 +14,26 @@ pub struct SessionSummary { pub archived_at: Option, } +#[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, + #[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, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum WsInbound { @@ -81,6 +101,17 @@ pub enum WsOutbound { sessions: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] current_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + channel_name: Option, + }, + #[serde(rename = "channel_list")] + ChannelList { + channels: Vec, + }, + #[serde(rename = "topic_list")] + TopicList { + topics: Vec, + session_id: String, }, #[serde(rename = "session_loaded")] SessionLoaded { diff --git a/web/src/App.tsx b/web/src/App.tsx index e405437..18566ce 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,9 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useEffect } from 'react' import { Zap, Cpu, Activity } from 'lucide-react' import { ChatContainer } from './components/Chat/ChatContainer' import { TopicList } from './components/Sidebar/TopicList' +import { ChannelSelector } from './components/Sidebar/ChannelSelector' +import { SessionSelector } from './components/Sidebar/SessionSelector' import { ToolPanel } from './components/Panel/ToolPanel' import { ConnectionStatus } from './components/ConnectionStatus' import { useWebSocket } from './hooks/useWebSocket' @@ -12,14 +14,24 @@ const WS_URL = 'ws://127.0.0.1:19876/ws' function App() { const { + // 消息 messages, - currentSessionId, - currentTopicId, - topics, isLoading, + // 三级状态 + channels, + selectedChannel, + sessions, + selectedSession, + topics, + selectedTopic, + isReadOnly, + // 方法 handleMessage, handleCommand, handleServerMessage, + selectChannel, + selectSession, + selectTopic, } = useChat() const { status, sendMessage } = useWebSocket({ @@ -27,8 +39,64 @@ function App() { onMessage: handleServerMessage, }) + // 获取选中通道的 Session + const channelSessions = useMemo(() => { + if (!selectedChannel) return [] + return sessions.filter((s) => s.channel_name === selectedChannel) + }, [sessions, selectedChannel]) + + // 获取选中 Session 的 title + const selectedSessionTitle = useMemo(() => { + const session = sessions.find((s) => s.id === selectedSession) + return session?.title || '' + }, [sessions, selectedSession]) + + // 获取当前通道名称 + const currentChannelName = useMemo(() => { + const channel = channels.find((c) => c.id === selectedChannel) + return channel?.name || selectedChannel || '' + }, [channels, selectedChannel]) + + // 通道变化时加载该通道的 Sessions + useEffect(() => { + if (selectedChannel && status === 'connected') { + const cmd: Command = { + type: 'list_sessions_by_channel', + channel_name: selectedChannel, + include_archived: false, + } + handleCommand(cmd) + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + } + }, [selectedChannel, status, handleCommand, sendMessage]) + + // Session 变化时加载该 Session 的 Topics + useEffect(() => { + if (selectedSession && status === 'connected') { + // 1. 加载 Session 信息 + const cmd: Command = { + type: 'load_session', + session_id: selectedSession, + } + handleCommand(cmd) + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + + // 2. 加载 Topics 列表 + const topicsCmd: Command = { + type: 'list_topics', + session_id: selectedSession, + } + handleCommand(topicsCmd) + sendMessage({ type: 'command', payload: JSON.stringify(topicsCmd) }) + } + }, [selectedSession, status, handleCommand, sendMessage]) + const handleSendMessage = useCallback( (content: string) => { + if (isReadOnly) { + return + } + if (content.startsWith('/')) { const parts = content.slice(1).split(' ') const command = parts[0] @@ -44,7 +112,7 @@ function App() { break case 'use': if (args[0]) { - cmd = { type: 'switch_session', session_id: args[0] } + cmd = { type: 'load_session', session_id: args[0] } } else { alert('Usage: /use ') return @@ -65,29 +133,51 @@ function App() { sendMessage({ type: 'message', content, - chat_id: currentTopicId ?? undefined, + chat_id: selectedSession ?? undefined, }) } }, - [sendMessage, handleMessage, handleCommand, currentTopicId] + [sendMessage, handleMessage, handleCommand, selectedSession, isReadOnly] ) const handleCreateTopic = useCallback(() => { + if (isReadOnly || !selectedSession) { + return + } + const title = prompt('Enter topic title:') if (title) { + // TODO: 实现 create_topic 命令 + // 目前 Session 和 Topic 是同一个概念,简化处理 const cmd: Command = { type: 'create_session', title } handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) } - }, [sendMessage, handleCommand]) + }, [sendMessage, handleCommand, selectedSession, isReadOnly]) const handleSwitchTopic = useCallback( (topicId: string) => { - const cmd: Command = { type: 'switch_session', session_id: topicId } + selectTopic(topicId) + // Topic 切换时重新加载 + const cmd: Command = { type: 'load_session', session_id: topicId } handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) }, - [sendMessage, handleCommand] + [sendMessage, handleCommand, selectTopic] + ) + + const handleSelectChannel = useCallback( + (channelId: string) => { + selectChannel(channelId) + }, + [selectChannel] + ) + + const handleSelectSession = useCallback( + (sessionId: string) => { + selectSession(sessionId) + }, + [selectSession] ) const toolMessages = useMemo(() => messages, [messages]) @@ -112,11 +202,11 @@ function App() { AI Ready - {currentSessionId && ( + {selectedSession && (
- {currentSessionId.slice(0, 8)}... + {selectedSession.slice(0, 8)}...
)} @@ -125,14 +215,41 @@ function App() { {/* Main Content */}
- {/* Left Sidebar - Topic List */} -
- + {/* Channel Selector */} + + + {/* Divider */} +
+ + {/* Session Selector */} + + + {/* Divider */} +
+ + {/* Topic List */} +
+ +
{/* Center - Chat */} @@ -140,6 +257,8 @@ function App() {
diff --git a/web/src/components/Chat/ChatContainer.tsx b/web/src/components/Chat/ChatContainer.tsx index f26d86a..b1e4aca 100644 --- a/web/src/components/Chat/ChatContainer.tsx +++ b/web/src/components/Chat/ChatContainer.tsx @@ -5,12 +5,16 @@ import type { ChatMessage } from '../../types/protocol' interface ChatContainerProps { messages: ChatMessage[] isLoading: boolean + isReadOnly?: boolean + channelName?: string onSendMessage: (content: string) => void } export function ChatContainer({ messages, isLoading, + isReadOnly = false, + channelName, onSendMessage, }: ChatContainerProps) { return ( @@ -18,7 +22,12 @@ export function ChatContainer({
- +
) } diff --git a/web/src/components/Chat/MessageInput.tsx b/web/src/components/Chat/MessageInput.tsx index 18f3fc4..8afc61d 100644 --- a/web/src/components/Chat/MessageInput.tsx +++ b/web/src/components/Chat/MessageInput.tsx @@ -1,16 +1,20 @@ -import { Send, Loader2, Sparkles } from 'lucide-react' +import { Send, Loader2, Sparkles, Eye } from 'lucide-react' import { useState, useRef, useEffect } from 'react' interface MessageInputProps { onSend: (content: string) => void disabled?: boolean placeholder?: string + isReadOnly?: boolean + channelName?: string } export function MessageInput({ onSend, disabled = false, placeholder = '输入消息...按 / 查看命令', + isReadOnly = false, + channelName, }: MessageInputProps) { const [content, setContent] = useState('') const textareaRef = useRef(null) @@ -24,7 +28,7 @@ export function MessageInput({ }, [content]) const handleSend = () => { - if (content.trim() && !disabled) { + if (content.trim() && !disabled && !isReadOnly) { onSend(content.trim()) setContent('') if (textareaRef.current) { @@ -40,6 +44,34 @@ export function MessageInput({ } } + // 只读模式:显示提示占位符 + if (isReadOnly) { + return ( +
+
+
+
+
+ + 只读模式 +
+

+ {channelName ? ( + <>{channelName} 通道仅支持查看历史消息 + ) : ( + '当前通道仅支持查看历史消息' + )} +

+

+ 请切换至 WebSocket 通道进行输入 +

+
+
+
+
+ ) + } + return (
diff --git a/web/src/components/Sidebar/ChannelSelector.tsx b/web/src/components/Sidebar/ChannelSelector.tsx new file mode 100644 index 0000000..923e386 --- /dev/null +++ b/web/src/components/Sidebar/ChannelSelector.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react' +import { Monitor, Smartphone, MessageSquare, Hash, ChevronDown, Eye, Pencil } from 'lucide-react' +import type { Channel } from '../../types/protocol' + +interface ChannelSelectorProps { + channels: Channel[] + selectedChannel: string | null + onSelectChannel: (channelId: string) => void +} + +const CHANNEL_ICONS: Record = { + cli: , + websocket: , + feishu: , + weixin: , + wechat: , +} + +export function ChannelSelector({ + channels, + selectedChannel, + onSelectChannel, +}: ChannelSelectorProps) { + const [isOpen, setIsOpen] = useState(false) + + const selected = channels.find((c) => c.id === selectedChannel) + + return ( +
+ {/* Header */} +
+

+ + 通道 +

+
+ + {/* Channel Dropdown */} +
+ + + {/* Dropdown Menu */} + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+ {channels.length === 0 ? ( +
+ 暂无可用通道 +
+ ) : ( + channels.map((channel) => ( + + )) + )} +
+ + )} +
+
+ ) +} diff --git a/web/src/components/Sidebar/SessionSelector.tsx b/web/src/components/Sidebar/SessionSelector.tsx new file mode 100644 index 0000000..69b375b --- /dev/null +++ b/web/src/components/Sidebar/SessionSelector.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react' +import { FolderOpen, ChevronDown, Hash } from 'lucide-react' +import type { Session } from '../../hooks/useChat' + +interface SessionSelectorProps { + sessions: Session[] + selectedSession: string | null + channelId: string // 使用 channelId 而不是 channelName + onSelectSession: (sessionId: string) => void +} + +export function SessionSelector({ + sessions, + selectedSession, + channelId, + onSelectSession, +}: SessionSelectorProps) { + const [isOpen, setIsOpen] = useState(false) + + const selected = sessions.find((s) => s.id === selectedSession) + + // 按通道 ID 筛选 Session + const channelSessions = sessions.filter( + (s) => s.channel_name === channelId + ) + + return ( +
+ {/* Header */} +
+

+ + Session +

+
+ + {/* Session Dropdown */} +
+ + + {/* Dropdown Menu */} + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+ {channelSessions.length === 0 ? ( +
+ 暂无 Session +
+ ) : ( + channelSessions.map((session, index) => ( + + )) + )} +
+ + )} +
+
+ ) +} diff --git a/web/src/components/Sidebar/TopicList.tsx b/web/src/components/Sidebar/TopicList.tsx index e77aa97..a983fe0 100644 --- a/web/src/components/Sidebar/TopicList.tsx +++ b/web/src/components/Sidebar/TopicList.tsx @@ -1,39 +1,70 @@ -import { Plus, MessageSquare, Hash } from 'lucide-react' +import { Plus, MessageSquare, Eye, Layers } from 'lucide-react' import type { Topic } from '../../types/protocol' interface TopicListProps { + sessionId: string | null + sessionTitle: string topics: Topic[] currentTopicId: string | null + isReadOnly: boolean onCreateTopic: () => void onSwitchTopic: (topicId: string) => void } export function TopicList({ + sessionId, + sessionTitle, topics, currentTopicId, + isReadOnly, onCreateTopic, onSwitchTopic, }: TopicListProps) { return (
-
-

- +
+

+ Topics + {isReadOnly && ( + + + 只读 + + )}

+ + {/* Session 信息 */} + {sessionId && ( +
+

当前 Session

+

{sessionTitle}

+
+ )} +
- {topics.length === 0 ? ( + {!sessionId ? (
- 暂无 Topic + 请先选择 Session +
+ ) : topics.length === 0 ? ( +
+ + 该 Session 暂无 Topics
) : (
diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index 0f8c36c..6a10578 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from 'react' +import { useState, useCallback, useRef, useMemo } from 'react' import type { Command, ChatMessage, @@ -11,27 +11,68 @@ import type { SessionEstablished, SessionCreated, SessionList, + SessionLoaded, + Channel, + ChannelList, + TopicList, + TopicSummary, } from '../types/protocol' +// Session 类型 +export interface Session { + id: string + title: string + channel_name: string + message_count: number + created_at: number + updated_at: number +} + +// 三级状态管理 interface UseChatReturn { + // 消息 messages: ChatMessage[] - currentSessionId: string | null - currentTopicId: string | null - topics: Topic[] isLoading: boolean + + // 三级选择状态 + channels: Channel[] + selectedChannel: string | null + + sessions: Session[] + selectedSession: string | null + + topics: Topic[] + selectedTopic: string | null + + // 是否只读 + isReadOnly: boolean + + // 方法 handleMessage: (content: string) => void handleCommand: (command: Command) => void clearMessages: () => void handleServerMessage: (message: WsOutbound) => void + + // 三级选择方法 + selectChannel: (channelId: string) => void + selectSession: (sessionId: string) => void + selectTopic: (topicId: string) => void } export function useChat(): UseChatReturn { const [messages, setMessages] = useState([]) - const [currentSessionId, setCurrentSessionId] = useState(null) - const [currentTopicId, setCurrentTopicId] = useState(null) - const [topics, setTopics] = useState([]) const [isLoading, setIsLoading] = useState(false) + // 三级选择状态 + const [channels, setChannels] = useState([]) + const [selectedChannel, setSelectedChannel] = useState(null) + + const [sessions, setSessions] = useState([]) + const [selectedSession, setSelectedSession] = useState(null) + + const [topics, setTopics] = useState([]) + const [selectedTopic, setSelectedTopic] = useState(null) + // Message ID generator const messageIdCounter = useRef(0) const generateMessageId = () => { @@ -40,38 +81,94 @@ export function useChat(): UseChatReturn { } const handleServerMessage = useCallback((message: WsOutbound) => { + console.log('Received message:', message) // 调试日志 switch (message.type) { case 'session_established': { const msg = message as SessionEstablished - setCurrentSessionId(msg.session_id) + // 不在这里自动选择,等 channel_list 和 session_list + console.log('Session established:', msg.session_id) break } - case 'session_created': { - const msg = message as SessionCreated - setCurrentTopicId(msg.session_id) - setIsLoading(false) + case 'channel_list': { + const msg = message as ChannelList + setChannels(msg.channels) + // 默认选中第一个可写通道 + if (!selectedChannel && msg.channels.length > 0) { + const writableChannel = msg.channels.find((c) => c.isWritable) + const defaultChannel = writableChannel || msg.channels[0] + setSelectedChannel(defaultChannel.id) + } break } case 'session_list': { const msg = message as SessionList - // Convert sessions to topics format - const newTopics = msg.sessions.map((s) => ({ + console.log('Session list received:', msg) // 调试日志 + // 按通道筛选 Session + const newSessions = msg.sessions.map((s) => ({ id: s.session_id, - session_id: s.session_id, title: s.title, + channel_name: s.channel_name || msg.channel_name || 'unknown', message_count: Number(s.message_count), created_at: s.last_active_at, updated_at: s.last_active_at, })) - setTopics(newTopics) - if (msg.current_session_id) { - setCurrentTopicId(msg.current_session_id) + console.log('Parsed sessions:', newSessions) // 调试日志 + setSessions(newSessions) + // 默认选中最新的 Session + if (!selectedSession && newSessions.length > 0) { + setSelectedSession(newSessions[0].id) } break } + case 'session_created': { + const msg = message as SessionCreated + // 添加到 Session 列表 + const newSession: Session = { + id: msg.session_id, + title: msg.title, + channel_name: selectedChannel || 'websocket', + message_count: 0, + created_at: Date.now(), + updated_at: Date.now(), + } + setSessions((prev) => [newSession, ...prev]) + setSelectedSession(msg.session_id) + setIsLoading(false) + break + } + + case 'session_loaded': { + const msg = message as SessionLoaded + setSelectedSession(msg.session_id) + setIsLoading(false) + setMessages([]) + 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, + message_count: Number(t.message_count), + created_at: t.created_at, + updated_at: t.last_active_at, + })) + setTopics(newTopics) + // 默认选中第一个 Topic + if (newTopics.length > 0 && !selectedTopic) { + setSelectedTopic(newTopics[0].id) + } + setIsLoading(false) + break + } + case 'assistant_response': { const msg = message as AssistantResponse setMessages((prev) => [ @@ -152,10 +249,9 @@ export function useChat(): UseChatReturn { break } } - }, []) + }, [selectedChannel, selectedSession]) const handleMessage = useCallback((content: string) => { - // Add user message to list setMessages((prev) => [ ...prev, { @@ -170,20 +266,25 @@ export function useChat(): UseChatReturn { }, []) const handleCommand = useCallback((command: Command) => { - // Handle local state updates for commands switch (command.type) { case 'create_session': - // Optimistically update setIsLoading(true) break case 'list_sessions': + case 'list_sessions_by_channel': setIsLoading(true) break case 'switch_session': - setCurrentTopicId(command.session_id) - // Clear messages when switching topic + case 'load_session': + setIsLoading(true) setMessages([]) break + case 'list_topics': + setIsLoading(true) + break + case 'list_channels': + setIsLoading(true) + break } }, []) @@ -191,15 +292,53 @@ export function useChat(): UseChatReturn { setMessages([]) }, []) + // 三级选择方法 + const selectChannel = useCallback((channelId: string) => { + setSelectedChannel(channelId) + // 切换通道时重置 Session 和 Topic + setSelectedSession(null) + setSelectedTopic(null) + setSessions([]) + setTopics([]) + setMessages([]) + }, []) + + const selectSession = useCallback((sessionId: string) => { + setSelectedSession(sessionId) + // 切换 Session 时重置 Topic + setSelectedTopic(null) + setTopics([]) + setMessages([]) + }, []) + + const selectTopic = useCallback((topicId: string) => { + setSelectedTopic(topicId) + setMessages([]) + }, []) + + // 计算是否只读 + const isReadOnly = useMemo(() => { + if (!selectedChannel) return true + const channel = channels.find((c) => c.id === selectedChannel) + return !channel?.isWritable + }, [selectedChannel, channels]) + return { messages, - currentSessionId, - currentTopicId, - topics, isLoading, + channels, + selectedChannel, + sessions, + selectedSession, + topics, + selectedTopic, + isReadOnly, handleMessage, handleCommand, clearMessages, handleServerMessage, + selectChannel, + selectSession, + selectTopic, } } diff --git a/web/src/types/protocol.ts b/web/src/types/protocol.ts index b5c1f4e..61f7fbc 100644 --- a/web/src/types/protocol.ts +++ b/web/src/types/protocol.ts @@ -94,6 +94,7 @@ export interface SessionList { type: 'session_list' sessions: SessionSummary[] current_session_id?: string + channel_name?: string // 新增:标识所属通道 } export interface SessionLoaded { @@ -109,6 +110,33 @@ export interface SessionSaved { filepath: string } +export interface TopicSummary { + topic_id: string + session_id: string + title: string + message_count: number + created_at: number + last_active_at: number +} + +export interface TopicList { + type: 'topic_list' + topics: TopicSummary[] + session_id: string +} + +export interface Channel { + id: string + name: string + description?: string + isWritable: boolean +} + +export interface ChannelList { + type: 'channel_list' + channels: Channel[] +} + export interface Pong { type: 'pong' } @@ -124,6 +152,8 @@ export type WsOutbound = | SessionList | SessionLoaded | SessionSaved + | TopicList + | ChannelList | Pong // ============================================================================ @@ -171,6 +201,21 @@ export interface HelpCommand { type: 'help' } +export interface ListChannelsCommand { + type: 'list_channels' +} + +export interface ListSessionsByChannelCommand { + type: 'list_sessions_by_channel' + channel_name: string + include_archived: boolean +} + +export interface ListTopicsCommand { + type: 'list_topics' + session_id: string +} + export type Command = | CreateSessionCommand | ListSessionsCommand @@ -180,6 +225,9 @@ export type Command = | LoadSessionCommand | GetCurrentSessionCommand | HelpCommand + | ListChannelsCommand + | ListSessionsByChannelCommand + | ListTopicsCommand // ============================================================================ // UI Types