diff --git a/src/channels/manager.rs b/src/channels/manager.rs index 879a1eb..a237971 100644 --- a/src/channels/manager.rs +++ b/src/channels/manager.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use tokio::sync::RwLock; @@ -8,6 +8,7 @@ use crate::channels::cli::CliChannel; use crate::channels::feishu::FeishuChannel; use crate::channels::wechat::WechatChannel; use crate::config::{Config, TaggedChannelConfig}; +use crate::protocol::Channel as ProtocolChannel; /// ChannelManager manages all Channel instances and the MessageBus #[derive(Clone)] @@ -135,6 +136,65 @@ impl ChannelManager { .map(|(name, channel)| (name.clone(), channel.clone())) .collect() } + + /// 构建面向前端的通道列表(合并 websocket + 动态注册的通道) + pub async fn build_channel_list(&self) -> Vec { + let mut seen = HashSet::new(); + let mut channels: Vec = Vec::new(); + + // 1. WebSocket 通道 — Web 前端自己的连接,始终存在 + seen.insert("websocket".to_string()); + channels.push(ProtocolChannel { + id: "websocket".to_string(), + name: "WebSocket".to_string(), + description: Some("Web 前端通道".to_string()), + is_writable: true, + }); + + // 2. 所有动态注册的通道(cli, feishu, wechat 等) + for (name, _channel) in self.channels().await { + if seen.contains(&name) { + continue; + } + seen.insert(name.clone()); + channels.push(ProtocolChannel { + id: name.clone(), + name: ChannelManager::channel_display_name(&name), + description: ChannelManager::channel_description(&name), + is_writable: ChannelManager::is_channel_writable(&name), + }); + } + + channels + } + + /// 通道名称 → 显示名称 + fn channel_display_name(name: &str) -> String { + match name { + "websocket" => "WebSocket".to_string(), + "cli" => "命令行".to_string(), + "feishu" => "飞书".to_string(), + "wechat" => "微信".to_string(), + other => other.to_string(), + } + } + + /// 通道名称 → 描述 + fn channel_description(name: &str) -> Option { + match name { + "websocket" => Some("Web 前端通道".to_string()), + "cli" => Some("命令行终端通道".to_string()), + "feishu" => Some("飞书消息通道".to_string()), + "wechat" => Some("微信消息通道".to_string()), + _ => None, + } + } + + /// 判断通道是否可写(从 Web 前端视角) + fn is_channel_writable(name: &str) -> bool { + // 只有 WebSocket 通道可写,其他通道(CLI、飞书、微信等)均为只读 + name == "websocket" + } } #[cfg(test)] diff --git a/src/command/handlers/list_channels.rs b/src/command/handlers/list_channels.rs index 8d2b11a..d9d9fd7 100644 --- a/src/command/handlers/list_channels.rs +++ b/src/command/handlers/list_channels.rs @@ -1,34 +1,19 @@ +use crate::channels::manager::ChannelManager; 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; +use std::sync::Arc; /// 列出通道命令处理器 -pub struct ListChannelsCommandHandler; +pub struct ListChannelsCommandHandler { + channel_manager: Arc, +} 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, - }, - ] + pub fn new(channel_manager: Arc) -> Self { + Self { channel_manager } } } @@ -52,16 +37,17 @@ impl CommandHandler for ListChannelsCommandHandler { ctx: CommandContext, ) -> Result { match cmd { - Command::ListChannels => handle_list_channels(ctx).await, + Command::ListChannels => handle_list_channels(self, ctx).await, _ => unreachable!(), } } } async fn handle_list_channels( + handler: &ListChannelsCommandHandler, ctx: CommandContext, ) -> Result { - let channels = ListChannelsCommandHandler::get_default_channels(); + let channels = handler.channel_manager.build_channel_list().await; let channels_json = serde_json::to_string(&channels) .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; diff --git a/src/gateway/session.rs b/src/gateway/session.rs index b21d5ae..5e7f386 100644 --- a/src/gateway/session.rs +++ b/src/gateway/session.rs @@ -618,6 +618,31 @@ impl SessionManager { topic_title = %latest_topic.title, "Restored current topic from database" ); + } else { + // 数据库中也没有话题,自动创建默认话题 + let title = format!( + "话题 {}", + chrono::Local::now().format("%m/%d %H:%M") + ); + match self.store.create_topic(&session_id, &title, None) { + Ok(topic) => { + guard.set_current_topic(chat_id, Some(topic.id.clone())); + tracing::info!( + chat_id = %chat_id, + topic_id = %topic.id, + topic_title = %topic.title, + session_id = %session_id, + "Auto-created default topic for new chat" + ); + } + Err(e) => { + tracing::error!( + error = %e, + session_id = %session_id, + "Failed to auto-create default topic" + ); + } + } } } diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index 616b0ff..98fdb6b 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -164,8 +164,8 @@ async fn handle_socket(ws: WebSocket, state: Arc) { }) .await; - // 连接建立后立即发送通道列表 - let channels = ListChannelsCommandHandler::get_default_channels(); + // 连接建立后立即发送通道列表(合并 websocket + ChannelManager 动态通道) + let channels = state.channel_manager.build_channel_list().await; let _ = sender .send(WsOutbound::ChannelList { channels }) .await; @@ -378,7 +378,7 @@ async fn handle_inbound( // 注册 list_sessions_by_channel 处理器 router.register(Box::new(ListSessionsByChannelCommandHandler::new(store.clone()))); // 注册 list_channels 处理器 - router.register(Box::new(ListChannelsCommandHandler::new())); + router.register(Box::new(ListChannelsCommandHandler::new(Arc::new(state.channel_manager.clone())))); // 注册 list_topics 处理器 router.register(Box::new(ListTopicsCommandHandler::new(store.clone()))); // 注册 switch_topic 处理器 diff --git a/web/src/App.tsx b/web/src/App.tsx index 124dd52..f3ba345 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Zap, Cpu, MessageSquare, ArrowLeft, Bot, Clock, Sun, Moon } from 'lucide-react' +import { Zap, ArrowLeft, Bot, Clock, Sun, Moon } from 'lucide-react' import { ChatContainer } from './components/Chat/ChatContainer' import { TopicList } from './components/Sidebar/TopicList' -import { SessionInfo } from './components/Sidebar/SessionInfo' import { SchedulerJobList } from './components/Sidebar/SchedulerJobList' import { ToolPanel } from './components/Panel/ToolPanel' import { ConnectionStatus } from './components/ConnectionStatus' +import { ChannelSelector } from './components/Header/ChannelSelector' +import { SessionSelector } from './components/Header/SessionSelector' import { useWebSocket } from './hooks/useWebSocket' import { useChat } from './hooks/useChat' import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup } from './types/protocol' @@ -17,7 +18,6 @@ function App() { const { // 连接状态 - connectionId, isConnected, // Session 状态 session, @@ -40,6 +40,15 @@ function App() { schedulerView, enterSchedulerJobView, exitSchedulerJobView, + // 通道 + channels, + selectedChannel, + selectChannel, + requestChannelList, + // Session + sessions, + selectedSessionId, + selectSession, // 方法 handleMessage, handleCommand, @@ -85,15 +94,23 @@ function App() { // ---- WebSocket 初始化 ---- - // 连接建立后自动加载 Session + // Step 1: 连接建立后先请求通道列表 useEffect(() => { if (isConnected && status === 'connected') { - // 1. 请求 Session 列表(会自动选择第一个) + const cmd = requestChannelList() + handleCommand(cmd) + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + } + }, [isConnected, status, handleCommand, sendMessage, requestChannelList]) + + // Step 2: 通道列表加载后,请求选中通道的 Session 列表 + useEffect(() => { + if (channels.length > 0 && status === 'connected') { const sessionCmd = requestSessionList() handleCommand(sessionCmd) sendMessage({ type: 'command', payload: JSON.stringify(sessionCmd) }) } - }, [isConnected, status, handleCommand, sendMessage, requestSessionList]) + }, [channels.length, status, handleCommand, sendMessage, requestSessionList]) // Session 加载后自动加载 Topics useEffect(() => { @@ -193,6 +210,15 @@ function App() { sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) }, [sendMessage, handleCommand, createTopic, sessionId, isReadOnly]) + const handleRefreshTopics = useCallback(() => { + if (!sessionId) return + const cmd = requestTopicList() + if (cmd) { + handleCommand(cmd) + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + } + }, [sessionId, requestTopicList, handleCommand, sendMessage]) + const handleSwitchTopic = useCallback( (topicId: string) => { selectTopic(topicId) @@ -244,6 +270,24 @@ function App() { exitSchedulerJobView() }, [exitSchedulerJobView]) + const handleSwitchChannel = useCallback( + (channelId: string) => { + if (channelId === selectedChannel) return + lastAutoSwitchedTopicRef.current = null + selectChannel(channelId) + }, + [selectedChannel, selectChannel] + ) + + const handleSelectSession = useCallback( + (sessionId: string) => { + if (sessionId === selectedSessionId) return + lastAutoSwitchedTopicRef.current = null + selectSession(sessionId) + }, + [selectedSessionId, selectSession] + ) + const chatMessages = useMemo(() => { const result: ChatMessage[] = [] const toolCallIndex = new Map() @@ -312,18 +356,16 @@ function App() {
-
- - AI Ready -
- {session && ( -
- - - {session.title} - -
- )} + +
@@ -331,12 +373,6 @@ function App() {
{/* Left Sidebar */}
- -
- {/* Tab 栏 */}
+ + {/* Dropdown Panel — rendered to body via Portal to avoid stacking context clipping */} + {isOpen && createPortal( +
+
+ {/* Channel List */} +
+ {channels.length === 0 ? ( +
+ 暂无可用通道 +
+ ) : ( + channels.map((channel, index) => { + const cfg = CHANNEL_ICONS[channel.id] || DEFAULT_ICON + const isActive = channel.id === selectedChannel + + return ( + + ) + }) + )} +
+
+
, + document.body + )} + + ) +} diff --git a/web/src/components/Header/SessionSelector.tsx b/web/src/components/Header/SessionSelector.tsx new file mode 100644 index 0000000..d59b598 --- /dev/null +++ b/web/src/components/Header/SessionSelector.tsx @@ -0,0 +1,176 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { MessageSquare, ChevronDown } from 'lucide-react' +import type { SessionSummary } from '../../types/protocol' + +interface SessionSelectorProps { + sessions: SessionSummary[] + selectedSessionId: string | null + onSelectSession: (sessionId: string) => void +} + +export function SessionSelector({ + sessions, + selectedSessionId, + onSelectSession, +}: SessionSelectorProps) { + const [isOpen, setIsOpen] = useState(false) + const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 }) + const triggerRef = useRef(null) + const dropdownRef = useRef(null) + const selected = sessions.find((s) => s.session_id === selectedSessionId) + + const updatePosition = useCallback(() => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect() + setDropdownPos({ + top: rect.bottom + 8, + right: window.innerWidth - rect.right, + }) + } + }, []) + + useEffect(() => { + if (isOpen) { + updatePosition() + window.addEventListener('resize', updatePosition) + window.addEventListener('scroll', updatePosition, true) + return () => { + window.removeEventListener('resize', updatePosition) + window.removeEventListener('scroll', updatePosition, true) + } + } + }, [isOpen, updatePosition]) + + useEffect(() => { + if (!isOpen) return + const handleClick = (e: MouseEvent) => { + const target = e.target as Node + if ( + dropdownRef.current && !dropdownRef.current.contains(target) && + triggerRef.current && !triggerRef.current.contains(target) + ) { + setIsOpen(false) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [isOpen]) + + useEffect(() => { + if (!isOpen) return + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setIsOpen(false) + } + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [isOpen]) + + if (sessions.length === 0) return null + + return ( + <> + + + {isOpen && sessions.length > 1 && createPortal( +
+
+
+ {sessions.map((s, index) => { + const isActive = s.session_id === selectedSessionId + return ( + + ) + })} +
+
+
, + document.body + )} + + ) +} diff --git a/web/src/components/Sidebar/SessionSelector.tsx b/web/src/components/Sidebar/SessionSelector.tsx new file mode 100644 index 0000000..bb40b9c --- /dev/null +++ b/web/src/components/Sidebar/SessionSelector.tsx @@ -0,0 +1,78 @@ +import { MessageSquare } from 'lucide-react' +import type { SessionSummary } from '../../types/protocol' + +interface SessionSelectorProps { + sessions: SessionSummary[] + selectedSessionId: string | null + onSelectSession: (sessionId: string) => void +} + +export function SessionSelector({ + sessions, + selectedSessionId, + onSelectSession, +}: SessionSelectorProps) { + if (sessions.length === 0) { + return ( +
+

暂无会话

+
+ ) + } + + return ( +
+
+ + 会话 + + {sessions.length} +
+
+ {sessions.map((s) => { + const isActive = s.session_id === selectedSessionId + return ( + + ) + })} +
+
+ ) +} diff --git a/web/src/components/Sidebar/TopicList.tsx b/web/src/components/Sidebar/TopicList.tsx index 5e18650..f5cd010 100644 --- a/web/src/components/Sidebar/TopicList.tsx +++ b/web/src/components/Sidebar/TopicList.tsx @@ -1,13 +1,13 @@ -import { Plus, MessageSquare, Layers, Hash, Clock } from 'lucide-react' +import { Plus, MessageSquare, Layers, Hash, Clock, RefreshCw } 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 + onRefresh: () => void onSwitchTopic: (topicId: string) => void } @@ -29,11 +29,11 @@ function formatTime(timestamp: number): string { export function TopicList({ sessionId, - sessionTitle, topics, currentTopicId, isReadOnly, onCreateTopic, + onRefresh, onSwitchTopic, }: TopicListProps) { return ( @@ -47,28 +47,36 @@ export function TopicList({ ({topics.length}) )} - + {isReadOnly ? ( + + ) : ( + + )}
- {/* Session 标题 */} - {sessionTitle && ( -
-

所属会话

-

{sessionTitle}

-
- )} - {/* Topics 列表 */}
{!sessionId ? ( diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index 49c7340..ae0e04b 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -10,15 +10,17 @@ import type { ToolPending, SessionEstablished, SessionList, + SessionSummary, TopicList, TopicSummary, - Session, TaskMessagesLoaded, TaskStarted, Attachment, SchedulerJobList, SchedulerJobSummary, SchedulerJobSessionLookup, + Channel, + ChannelList, } from '../types/protocol' // 简化后的层级状态 @@ -28,7 +30,9 @@ interface UseChatReturn { isConnected: boolean // 简化的层级状态 - session: Session | null + sessions: SessionSummary[] + selectedSessionId: string | null + session: SessionSummary | null sessionId: string | null chatId: string topics: Topic[] @@ -38,7 +42,12 @@ interface UseChatReturn { messages: ChatMessage[] isLoading: boolean - // 是否只读(WebSocket 通道始终可写) + // 通道状态 + channels: Channel[] + selectedChannel: string + isWritable: boolean + + // 是否只读 isReadOnly: boolean // 子智能体视图 @@ -58,6 +67,9 @@ interface UseChatReturn { // 初始化方法 requestSessionList: () => Command requestTopicList: () => Command | null + requestChannelList: () => Command + selectChannel: (channelId: string) => void + selectSession: (sessionId: string) => void // 子智能体导航方法 enterSubAgentView: (taskId: string, description: string) => Command @@ -95,7 +107,6 @@ interface SchedulerJobView { messages: ChatMessage[] } -const DEFAULT_CHANNEL = 'websocket' const DEFAULT_CHAT_ID = 'default' export function useChat(): UseChatReturn { @@ -104,13 +115,16 @@ export function useChat(): UseChatReturn { // 简化的状态管理 const [connectionId, setConnectionId] = useState(null) - const [session, setSession] = useState(null) const [topics, setTopics] = useState([]) const [selectedTopic, setSelectedTopic] = useState(null) + const [sessions, setSessions] = useState([]) + const [selectedSessionId, setSelectedSessionId] = useState(null) const [subAgentView, setSubAgentView] = useState(null) const [schedulerJobs, setSchedulerJobs] = useState([]) const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics') const [schedulerView, setSchedulerView] = useState(null) + const [channels, setChannels] = useState([]) + const [selectedChannel, setSelectedChannel] = useState('websocket') // Message ID generator const messageIdCounter = useRef(0) @@ -124,8 +138,16 @@ export function useChat(): UseChatReturn { const schedulerViewRef = useRef(null) const isConnected = useMemo(() => connectionId !== null, [connectionId]) - const sessionId = useMemo(() => session?.id ?? null, [session]) + const selectedSession = useMemo( + () => sessions.find(s => s.session_id === selectedSessionId) ?? null, + [sessions, selectedSessionId] + ) + const sessionId = useMemo(() => selectedSession?.session_id ?? null, [selectedSession]) const chatId = useMemo(() => sessionId ?? DEFAULT_CHAT_ID, [sessionId]) + const isWritable = useMemo( + () => channels.find(c => c.id === selectedChannel)?.isWritable ?? false, + [channels, selectedChannel] + ) // Extract subagent_task_id from a message if present const getSubagentTaskId = (message: WsOutbound): string | undefined => { @@ -225,7 +247,7 @@ export function useChat(): UseChatReturn { // Route to scheduler job view if active const currentSchedulerView = schedulerViewRef.current if (currentSchedulerView) { - // Route all chat messages to the scheduler view + // Route chat messages to the scheduler view const chatMsg = serverMessageToChatMessage(message) if (chatMsg) { setSchedulerView((prev) => @@ -233,8 +255,9 @@ export function useChat(): UseChatReturn { ? { ...prev, messages: [...prev.messages, chatMsg] } : prev ) + return } - return + // Non-chat messages (session_list, topic_list, etc.) fall through to main handler } // Route to sub-agent view if active @@ -310,17 +333,22 @@ export function useChat(): UseChatReturn { const msg = message as SessionList console.log('Session list received:', msg) - // 自动选择第一个 Session(WebSocket 通道只有一个) - if (msg.sessions.length > 0) { - const firstSession = msg.sessions[0] - setSession({ - id: firstSession.session_id, - title: firstSession.title, - channel_name: firstSession.channel_name, - created_at: Date.now(), - updated_at: Date.now(), - }) - } + // 清空旧数据(切换通道时避免数据污染) + setTopics([]) + setSelectedTopic(null) + setMessages([]) + + // 存储全部 session + setSessions(msg.sessions) + + // 自动选中:优先保持当前选中,否则选第一个 + setSelectedSessionId(prev => { + if (prev && msg.sessions.some(s => s.session_id === prev)) { + return prev + } + return msg.sessions.length > 0 ? msg.sessions[0].session_id : null + }) + setIsLoading(false) break } @@ -466,7 +494,12 @@ export function useChat(): UseChatReturn { break } - case 'channel_list': + case 'channel_list': { + const msg = message as ChannelList + console.log('Channel list received:', msg) + setChannels(msg.channels) + break + } case 'pong': // 忽略这些消息 break @@ -529,11 +562,35 @@ export function useChat(): UseChatReturn { const requestSessionList = useCallback((): Command => { return { type: 'list_sessions_by_channel', - channel_name: DEFAULT_CHANNEL, + channel_name: selectedChannel, include_archived: false, } + }, [selectedChannel]) + + const requestChannelList = useCallback((): Command => { + return { type: 'list_channels' } }, []) + const selectChannel = useCallback((channelId: string) => { + if (channelId === selectedChannel) return + setSelectedChannel(channelId) + setSessions([]) + setSelectedSessionId(null) + setTopics([]) + setSelectedTopic(null) + setMessages([]) + setIsLoading(true) + }, [selectedChannel]) + + const selectSession = useCallback((sessionId: string) => { + if (sessionId === selectedSessionId) return + setSelectedSessionId(sessionId) + setTopics([]) + setSelectedTopic(null) + setMessages([]) + setIsLoading(true) + }, [selectedSessionId]) + const requestTopicList = useCallback((): Command | null => { if (!sessionId) return null return { @@ -618,13 +675,15 @@ export function useChat(): UseChatReturn { return messages }, [subAgentView, schedulerView, messages]) - // WebSocket 通道始终可写 - const isReadOnly = false + // 只读状态由当前通道决定 + const isReadOnly = !isWritable return { connectionId, isConnected, - session, + sessions, + selectedSessionId, + session: selectedSession, sessionId, chatId, topics, @@ -632,6 +691,9 @@ export function useChat(): UseChatReturn { messages: resolvedMessages, isLoading, isReadOnly, + isWritable, + channels, + selectedChannel, subAgentView, handleMessage, handleCommand, @@ -642,6 +704,9 @@ export function useChat(): UseChatReturn { switchTopic, requestSessionList, requestTopicList, + requestChannelList, + selectChannel, + selectSession, enterSubAgentView, exitSubAgentView, schedulerJobs,