From 10fb67320ac8728c81181baaecf0f35175a01f34 Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Wed, 27 May 2026 15:39:50 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=E7=AE=80=E5=8C=96=20UI=20?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=EF=BC=8C=E7=A7=BB=E9=99=A4=E4=B8=89=E7=BA=A7?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 ChannelSelector 和 SessionSelector 组件 - 新增 SessionInfo 组件显示当前会话信息 - 简化 useChat hook,移除 channels/sessions 状态管理 - 优化 TopicList UI,添加时间格式化显示 - 将废弃组件移至 .deprecated/ 目录 Co-Authored-By: Claude Opus 4.7 --- src/gateway/ws.rs | 5 + web/src/App.tsx | 164 ++++--------- .../{ => .deprecated}/ChannelSelector.tsx | 0 .../{ => .deprecated}/SessionSelector.tsx | 0 web/src/components/Sidebar/SessionInfo.tsx | 50 ++++ web/src/components/Sidebar/TopicList.tsx | 61 +++-- web/src/hooks/useChat.ts | 232 ++++++++---------- 7 files changed, 256 insertions(+), 256 deletions(-) rename web/src/components/Sidebar/{ => .deprecated}/ChannelSelector.tsx (100%) rename web/src/components/Sidebar/{ => .deprecated}/SessionSelector.tsx (100%) create mode 100644 web/src/components/Sidebar/SessionInfo.tsx diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index 69fee98..49a6a16 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -353,6 +353,11 @@ async fn handle_inbound( "Updating current_topic_id" ); *current_topic_id = Some(topic_id.clone()); + + // 加载并发送该话题的历史消息 + if let Err(e) = send_topic_history(&store, topic_id, sender).await { + tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send topic history"); + } } } else if let Some(ref error) = response.error { tracing::warn!( diff --git a/web/src/App.tsx b/web/src/App.tsx index 18566ce..924389f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,9 +1,8 @@ -import { useCallback, useMemo, useEffect } from 'react' -import { Zap, Cpu, Activity } from 'lucide-react' +import { useCallback, useEffect } from 'react' +import { Zap, Cpu, MessageSquare } 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 { SessionInfo } from './components/Sidebar/SessionInfo' import { ToolPanel } from './components/Panel/ToolPanel' import { ConnectionStatus } from './components/ConnectionStatus' import { useWebSocket } from './hooks/useWebSocket' @@ -14,24 +13,29 @@ const WS_URL = 'ws://127.0.0.1:19876/ws' function App() { const { - // 消息 - messages, - isLoading, - // 三级状态 - channels, - selectedChannel, - sessions, - selectedSession, + // 连接状态 + connectionId, + isConnected, + // Session 状态 + session, + sessionId, + chatId, + // Topic 状态 topics, selectedTopic, + // 消息状态 + messages, + isLoading, isReadOnly, // 方法 handleMessage, handleCommand, handleServerMessage, - selectChannel, - selectSession, selectTopic, + createTopic, + switchTopic, + requestSessionList, + requestTopicList, } = useChat() const { status, sendMessage } = useWebSocket({ @@ -39,61 +43,30 @@ 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 + // 连接建立后自动加载 Session 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) }) + if (isConnected && status === 'connected') { + // 1. 请求 Session 列表(会自动选择第一个) + const sessionCmd = requestSessionList() + handleCommand(sessionCmd) + sendMessage({ type: 'command', payload: JSON.stringify(sessionCmd) }) } - }, [selectedChannel, status, handleCommand, sendMessage]) + }, [isConnected, status, handleCommand, sendMessage, requestSessionList]) - // Session 变化时加载该 Session 的 Topics + // Session 加载后自动加载 Topics useEffect(() => { - if (selectedSession && status === 'connected') { - // 1. 加载 Session 信息 - const cmd: Command = { - type: 'load_session', - session_id: selectedSession, + if (sessionId && status === 'connected') { + const topicCmd = requestTopicList() + if (topicCmd) { + handleCommand(topicCmd) + sendMessage({ type: 'command', payload: JSON.stringify(topicCmd) }) } - 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]) + }, [sessionId, status, handleCommand, sendMessage, requestTopicList]) const handleSendMessage = useCallback( (content: string) => { - if (isReadOnly) { + if (isReadOnly || !sessionId) { return } @@ -112,9 +85,9 @@ function App() { break case 'use': if (args[0]) { - cmd = { type: 'load_session', session_id: args[0] } + cmd = { type: 'switch_session', session_id: args[0] } } else { - alert('Usage: /use ') + alert('Usage: /use ') return } break @@ -133,54 +106,37 @@ function App() { sendMessage({ type: 'message', content, - chat_id: selectedSession ?? undefined, + chat_id: chatId, }) } }, - [sendMessage, handleMessage, handleCommand, selectedSession, isReadOnly] + [sendMessage, handleMessage, handleCommand, sessionId, chatId, isReadOnly] ) const handleCreateTopic = useCallback(() => { - if (isReadOnly || !selectedSession) { + if (isReadOnly || !sessionId) { return } const title = prompt('Enter topic title:') if (title) { - // TODO: 实现 create_topic 命令 - // 目前 Session 和 Topic 是同一个概念,简化处理 - const cmd: Command = { type: 'create_session', title } + const cmd = createTopic(title) handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) } - }, [sendMessage, handleCommand, selectedSession, isReadOnly]) + }, [sendMessage, handleCommand, createTopic, sessionId, isReadOnly]) const handleSwitchTopic = useCallback( (topicId: string) => { selectTopic(topicId) - // Topic 切换时重新加载 - const cmd: Command = { type: 'load_session', session_id: topicId } + const cmd = switchTopic(topicId) handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) }, - [sendMessage, handleCommand, selectTopic] + [sendMessage, handleCommand, switchTopic, selectTopic] ) - const handleSelectChannel = useCallback( - (channelId: string) => { - selectChannel(channelId) - }, - [selectChannel] - ) - - const handleSelectSession = useCallback( - (sessionId: string) => { - selectSession(sessionId) - }, - [selectSession] - ) - - const toolMessages = useMemo(() => messages, [messages]) + const toolMessages = messages return (
@@ -202,11 +158,11 @@ function App() { AI Ready
- {selectedSession && ( + {session && (
- + - {selectedSession.slice(0, 8)}... + {session.title}
)} @@ -215,24 +171,12 @@ function App() { {/* Main Content */}
- {/* Left Sidebar - 三级选择器 */} + {/* Left Sidebar - 简化为 Session 信息 + Topic 列表 */}
- {/* Channel Selector */} - - - {/* Divider */} -
- - {/* Session Selector */} - {/* Divider */} @@ -241,8 +185,8 @@ function App() { {/* Topic List */}
diff --git a/web/src/components/Sidebar/ChannelSelector.tsx b/web/src/components/Sidebar/.deprecated/ChannelSelector.tsx similarity index 100% rename from web/src/components/Sidebar/ChannelSelector.tsx rename to web/src/components/Sidebar/.deprecated/ChannelSelector.tsx diff --git a/web/src/components/Sidebar/SessionSelector.tsx b/web/src/components/Sidebar/.deprecated/SessionSelector.tsx similarity index 100% rename from web/src/components/Sidebar/SessionSelector.tsx rename to web/src/components/Sidebar/.deprecated/SessionSelector.tsx diff --git a/web/src/components/Sidebar/SessionInfo.tsx b/web/src/components/Sidebar/SessionInfo.tsx new file mode 100644 index 0000000..ab6bdae --- /dev/null +++ b/web/src/components/Sidebar/SessionInfo.tsx @@ -0,0 +1,50 @@ +import { Wifi, FolderOpen, Hash } from 'lucide-react' +import type { Session } from '../../types/protocol' + +interface SessionInfoProps { + session: Session | null + connectionId: string | null +} + +export function SessionInfo({ session, connectionId }: SessionInfoProps) { + return ( +
+
+ + WebSocket + + 在线 + +
+ +
+
+ + 当前会话 +
+ + {session ? ( +
+

+ {session.title} +

+
+ + {session.id.slice(0, 8)}... +
+
+ ) : ( +

连接中...

+ )} + + {connectionId && ( +
+

+ conn: {connectionId.slice(0, 8)}... +

+
+ )} +
+
+ ) +} diff --git a/web/src/components/Sidebar/TopicList.tsx b/web/src/components/Sidebar/TopicList.tsx index a983fe0..b9297d0 100644 --- a/web/src/components/Sidebar/TopicList.tsx +++ b/web/src/components/Sidebar/TopicList.tsx @@ -1,4 +1,4 @@ -import { Plus, MessageSquare, Eye, Layers } from 'lucide-react' +import { Plus, MessageSquare, Layers, Hash, Clock } from 'lucide-react' import type { Topic } from '../../types/protocol' interface TopicListProps { @@ -11,6 +11,22 @@ interface TopicListProps { onSwitchTopic: (topicId: string) => void } +function formatTime(timestamp: number): string { + const date = new Date(timestamp) + const now = new Date() + const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)) + + if (diffDays === 0) { + return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) + } else if (diffDays === 1) { + return '昨天' + } else if (diffDays < 7) { + return `${diffDays}天前` + } else { + return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) + } +} + export function TopicList({ sessionId, sessionTitle, @@ -22,15 +38,13 @@ export function TopicList({ }: TopicListProps) { return (
+ {/* Header */}

- Topics - {isReadOnly && ( - - - 只读 - + 话题列表 + {topics.length > 0 && ( + ({topics.length}) )}

- {/* Session 信息 */} - {sessionId && ( + {/* Session 标题 */} + {sessionTitle && (
-

当前 Session

-

{sessionTitle}

+

所属会话

+

{sessionTitle}

)} + {/* Topics 列表 */}
{!sessionId ? (
- 请先选择 Session +

等待连接...

) : topics.length === 0 ? (
- 该 Session 暂无 Topics +

暂无话题

+

点击上方"新建"创建话题

) : (
@@ -79,22 +95,29 @@ export function TopicList({ }`} >
- {index + 1} + + {index + 1} +
{topic.title}
-
- +
+ + {topic.message_count} 条消息 - {topic.id === currentTopicId && ( - - )} + + + {formatTime(topic.updated_at)} +
+ {topic.id === currentTopicId && ( + + )}
))} diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index 6a10578..afe6a4c 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -9,42 +9,30 @@ import type { ToolResult, ToolPending, SessionEstablished, - SessionCreated, SessionList, - SessionLoaded, - Channel, - ChannelList, TopicList, TopicSummary, + Session, } 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 { + // 连接状态 + connectionId: string | null + isConnected: boolean + + // 简化的层级状态 + session: Session | null + sessionId: string | null + chatId: string + topics: Topic[] + selectedTopic: string | null + // 消息 messages: ChatMessage[] isLoading: boolean - // 三级选择状态 - channels: Channel[] - selectedChannel: string | null - - sessions: Session[] - selectedSession: string | null - - topics: Topic[] - selectedTopic: string | null - - // 是否只读 + // 是否只读(WebSocket 通道始终可写) isReadOnly: boolean // 方法 @@ -53,23 +41,26 @@ interface UseChatReturn { clearMessages: () => void handleServerMessage: (message: WsOutbound) => void - // 三级选择方法 - selectChannel: (channelId: string) => void - selectSession: (sessionId: string) => void + // Topic 方法 selectTopic: (topicId: string) => void + createTopic: (title: string) => Command + switchTopic: (topicId: string) => Command + + // 初始化方法 + requestSessionList: () => Command + requestTopicList: () => Command | null } +const DEFAULT_CHANNEL = 'websocket' +const DEFAULT_CHAT_ID = 'default' + export function useChat(): UseChatReturn { const [messages, setMessages] = useState([]) const [isLoading, setIsLoading] = useState(false) - // 三级选择状态 - const [channels, setChannels] = useState([]) - const [selectedChannel, setSelectedChannel] = useState(null) - - const [sessions, setSessions] = useState([]) - const [selectedSession, setSelectedSession] = useState(null) - + // 简化的状态管理 + const [connectionId, setConnectionId] = useState(null) + const [session, setSession] = useState(null) const [topics, setTopics] = useState([]) const [selectedTopic, setSelectedTopic] = useState(null) @@ -80,69 +71,47 @@ export function useChat(): UseChatReturn { return `msg_${Date.now()}_${messageIdCounter.current}` } + const isConnected = useMemo(() => connectionId !== null, [connectionId]) + const sessionId = useMemo(() => session?.id ?? null, [session]) + const chatId = useMemo(() => sessionId ?? DEFAULT_CHAT_ID, [sessionId]) + const handleServerMessage = useCallback((message: WsOutbound) => { - console.log('Received message:', message) // 调试日志 + console.log('Received message:', message) + switch (message.type) { case 'session_established': { const msg = message as SessionEstablished - // 不在这里自动选择,等 channel_list 和 session_list - console.log('Session established:', msg.session_id) - break - } - - 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) - } + setConnectionId(msg.session_id) + console.log('Connection established:', msg.session_id) break } case 'session_list': { const msg = message as SessionList - console.log('Session list received:', msg) // 调试日志 - // 按通道筛选 Session - const newSessions = msg.sessions.map((s) => ({ - 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, - })) - console.log('Parsed sessions:', newSessions) // 调试日志 - setSessions(newSessions) - // 默认选中最新的 Session - if (!selectedSession && newSessions.length > 0) { - setSelectedSession(newSessions[0].id) + 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(), + }) } + setIsLoading(false) 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) + // 创建新 Topic 后更新列表 setIsLoading(false) break } case 'session_loaded': { - const msg = message as SessionLoaded - setSelectedSession(msg.session_id) setIsLoading(false) setMessages([]) break @@ -151,6 +120,7 @@ export function useChat(): UseChatReturn { 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, @@ -161,7 +131,8 @@ export function useChat(): UseChatReturn { updated_at: t.last_active_at, })) setTopics(newTopics) - // 默认选中第一个 Topic + + // 默认选中第一个 Topic(如果没有选中) if (newTopics.length > 0 && !selectedTopic) { setSelectedTopic(newTopics[0].id) } @@ -248,8 +219,13 @@ export function useChat(): UseChatReturn { setIsLoading(false) break } + + case 'channel_list': + case 'pong': + // 忽略这些消息 + break } - }, [selectedChannel, selectedSession]) + }, [selectedTopic]) const handleMessage = useCallback((content: string) => { setMessages((prev) => [ @@ -268,23 +244,13 @@ export function useChat(): UseChatReturn { const handleCommand = useCallback((command: Command) => { switch (command.type) { case 'create_session': - setIsLoading(true) - break - case 'list_sessions': - case 'list_sessions_by_channel': - setIsLoading(true) - break case 'switch_session': case 'load_session': - setIsLoading(true) - setMessages([]) - break + case 'list_sessions': + case 'list_sessions_by_channel': case 'list_topics': setIsLoading(true) break - case 'list_channels': - setIsLoading(true) - break } }, []) @@ -292,53 +258,65 @@ 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([]) - }, []) - + // Topic 操作方法 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]) + const createTopic = useCallback((title: string): Command => { + return { + type: 'create_session', + title, + } + }, []) + + const switchTopic = useCallback((topicId: string): Command => { + return { + type: 'switch_session', + session_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 { - messages, - isLoading, - channels, - selectedChannel, - sessions, - selectedSession, + connectionId, + isConnected, + session, + sessionId, + chatId, topics, selectedTopic, + messages, + isLoading, isReadOnly, handleMessage, handleCommand, clearMessages, handleServerMessage, - selectChannel, - selectSession, selectTopic, + createTopic, + switchTopic, + requestSessionList, + requestTopicList, } }