import { useState, useRef, useEffect, useCallback } from 'react' import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { useToast } from '@/hooks/use-toast' import { chatWsClient } from '@/lib/chat-ws-client' import { fetchWithAuth } from '@/lib/fetch-with-auth' import { cn } from '@/lib/utils' import { Bot, Edit2, Loader2, RefreshCw, User, Send, Wifi, WifiOff, UserCircle2 } from 'lucide-react' import { ChatTabBar } from './ChatTabBar' import { RenderMessageContent } from './MessageRenderer' import type { ChatTab, ChatMessage, PersonInfo, PlatformInfo, SavedVirtualTab, VirtualIdentityConfig, WsMessage } from './types' import { getOrCreateUserId, getStoredUserName, getSavedVirtualTabs, saveUserName, saveVirtualTabs } from './utils' import { VirtualIdentityDialog } from './VirtualIdentityDialog' export function ChatPage() { // 默认 WebUI 标签页 const defaultTab: ChatTab = { id: 'webui-default', type: 'webui', label: 'WebUI', messages: [], isConnected: false, isTyping: false, sessionInfo: {}, } // 从存储中恢复虚拟标签页 const initializeTabs = (): ChatTab[] => { const savedVirtualTabs = getSavedVirtualTabs() const restoredTabs: ChatTab[] = savedVirtualTabs.map(saved => { // 确保 virtualConfig 有 groupId(兼容旧数据) const config = saved.virtualConfig if (!config.groupId && config.platform && config.userId) { config.groupId = `webui_virtual_group_${config.platform}_${config.userId}` } return { id: saved.id, type: 'virtual' as const, label: saved.label, virtualConfig: config, messages: [], isConnected: false, isTyping: false, sessionInfo: {}, } }) return [defaultTab, ...restoredTabs] } // 多标签页状态 const [tabs, setTabs] = useState(initializeTabs) const [activeTabId, setActiveTabId] = useState('webui-default') // 当前活动标签页 const activeTab = tabs.find(t => t.id === activeTabId) || tabs[0] // 通用状态 const [inputValue, setInputValue] = useState('') const [isConnecting, setIsConnecting] = useState(false) const [isLoadingHistory, setIsLoadingHistory] = useState(true) const [userName, setUserName] = useState(getStoredUserName()) const [isEditingName, setIsEditingName] = useState(false) const [tempUserName, setTempUserName] = useState('') // 虚拟身份配置对话框状态 const [showVirtualConfig, setShowVirtualConfig] = useState(false) const [platforms, setPlatforms] = useState([]) const [persons, setPersons] = useState([]) const [isLoadingPlatforms, setIsLoadingPlatforms] = useState(false) const [isLoadingPersons, setIsLoadingPersons] = useState(false) const [personSearchQuery, setPersonSearchQuery] = useState('') const [tempVirtualConfig, setTempVirtualConfig] = useState({ platform: '', personId: '', userId: '', userName: '', groupName: '', groupId: '', }) // 持久化用户 ID const userIdRef = useRef(getOrCreateUserId()) const messagesEndRef = useRef(null) const messageIdCounterRef = useRef(0) const processedMessagesMapRef = useRef>>(new Map()) const sessionUnsubscribeMapRef = useRef void>>(new Map()) const tabsRef = useRef([]) const { toast } = useToast() useEffect(() => { tabsRef.current = tabs }, [tabs]) // 生成唯一消息 ID const generateMessageId = (prefix: string) => { messageIdCounterRef.current += 1 return `${prefix}-${Date.now()}-${messageIdCounterRef.current}-${Math.random().toString(36).substr(2, 9)}` } // 更新指定标签页 const updateTab = useCallback((tabId: string, updates: Partial) => { setTabs(prev => prev.map(tab => tab.id === tabId ? { ...tab, ...updates } : tab )) }, []) // 向指定标签页添加消息 const addMessageToTab = useCallback((tabId: string, message: ChatMessage) => { setTabs(prev => prev.map(tab => tab.id === tabId ? { ...tab, messages: [...tab.messages, message] } : tab )) }, []) // 滚动到底部 const scrollToBottom = useCallback(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, []) // 自动滚动 useEffect(() => { scrollToBottom() }, [activeTab?.messages, scrollToBottom]) // 获取平台列表 const fetchPlatforms = useCallback(async () => { setIsLoadingPlatforms(true) try { const response = await fetchWithAuth('/api/chat/platforms') console.log('[Chat] 平台列表响应:', response.status, response.headers.get('content-type')) if (response.ok) { const contentType = response.headers.get('content-type') if (contentType && contentType.includes('application/json')) { const data = await response.json() console.log('[Chat] 平台列表数据:', data) setPlatforms(data.platforms || []) } else { const text = await response.text() console.error('[Chat] 获取平台列表失败: 非 JSON 响应:', text.substring(0, 200)) toast({ title: '连接失败', description: '无法连接到后端服务,请确保 MaiBot 已启动', variant: 'destructive', }) } } else { console.error('[Chat] 获取平台列表失败: HTTP', response.status) toast({ title: '获取平台失败', description: `服务器返回错误: ${response.status}`, variant: 'destructive', }) } } catch (e) { console.error('[Chat] 获取平台列表失败:', e) toast({ title: '网络错误', description: '无法连接到后端服务', variant: 'destructive', }) } finally { setIsLoadingPlatforms(false) } }, [toast]) // 获取用户列表 const fetchPersons = useCallback(async (platform: string, search?: string) => { setIsLoadingPersons(true) try { const params = new URLSearchParams() if (platform) params.append('platform', platform) if (search) params.append('search', search) params.append('limit', '50') const response = await fetchWithAuth(`/api/chat/persons?${params.toString()}`) if (response.ok) { const contentType = response.headers.get('content-type') if (contentType && contentType.includes('application/json')) { const data = await response.json() setPersons(data.persons || []) } else { console.error('[Chat] 获取用户列表失败: 后端返回非 JSON 响应') } } } catch (e) { console.error('[Chat] 获取用户列表失败:', e) } finally { setIsLoadingPersons(false) } }, []) // 当平台选择变化时获取用户列表 useEffect(() => { if (tempVirtualConfig.platform) { fetchPersons(tempVirtualConfig.platform, personSearchQuery) } }, [tempVirtualConfig.platform, personSearchQuery, fetchPersons]) const handleSessionMessage = useCallback(( tabId: string, tabType: 'webui' | 'virtual', config: VirtualIdentityConfig | undefined, data: WsMessage, ) => { switch (data.type) { case 'session_info': updateTab(tabId, { sessionInfo: { session_id: data.session_id, user_id: data.user_id, user_name: data.user_name, bot_name: data.bot_name, } }) break case 'system': addMessageToTab(tabId, { id: generateMessageId('sys'), type: 'system', content: data.content || '', timestamp: data.timestamp || Date.now() / 1000, }) break case 'user_message': { const senderUserId = data.sender?.user_id const currentUserId = tabType === 'virtual' && config ? config.userId : userIdRef.current const normalizeSenderId = senderUserId ? senderUserId.replace(/^webui_user_/, '') : '' const normalizeCurrentId = currentUserId ? currentUserId.replace(/^webui_user_/, '') : '' if (normalizeSenderId && normalizeCurrentId && normalizeSenderId === normalizeCurrentId) { break } const processedSet = processedMessagesMapRef.current.get(tabId) || new Set() const contentHash = `user-${data.content}-${Math.floor((data.timestamp || 0) * 1000)}` if (processedSet.has(contentHash)) { break } processedSet.add(contentHash) processedMessagesMapRef.current.set(tabId, processedSet) if (processedSet.size > 100) { const firstKey = processedSet.values().next().value if (firstKey) processedSet.delete(firstKey) } addMessageToTab(tabId, { id: data.message_id || generateMessageId('user'), type: 'user', content: data.content || '', timestamp: data.timestamp || Date.now() / 1000, sender: data.sender, }) break } case 'bot_message': { updateTab(tabId, { isTyping: false }) const processedSet = processedMessagesMapRef.current.get(tabId) || new Set() const contentHash = `bot-${data.content}-${Math.floor((data.timestamp || 0) * 1000)}` if (processedSet.has(contentHash)) { break } processedSet.add(contentHash) processedMessagesMapRef.current.set(tabId, processedSet) if (processedSet.size > 100) { const firstKey = processedSet.values().next().value if (firstKey) processedSet.delete(firstKey) } setTabs(prev => prev.map(tab => { if (tab.id !== tabId) return tab const filteredMessages = tab.messages.filter(msg => msg.type !== 'thinking') const newMessage: ChatMessage = { id: generateMessageId('bot'), type: 'bot', content: data.content || '', message_type: (data.message_type === 'rich' ? 'rich' : 'text') as 'text' | 'rich', segments: data.segments, timestamp: data.timestamp || Date.now() / 1000, sender: data.sender, } return { ...tab, messages: [...filteredMessages, newMessage] } })) break } case 'typing': updateTab(tabId, { isTyping: data.is_typing || false }) break case 'error': setTabs(prev => prev.map(tab => { if (tab.id !== tabId) return tab const filteredMessages = tab.messages.filter(msg => msg.type !== 'thinking') return { ...tab, messages: [...filteredMessages, { id: generateMessageId('error'), type: 'error' as const, content: data.content || '发生错误', timestamp: data.timestamp || Date.now() / 1000, }] } })) toast({ title: '错误', description: data.content, variant: 'destructive', }) break case 'history': { const historyMessages = data.messages || [] const processedSet = new Set() const formattedMessages: ChatMessage[] = historyMessages.map((msg: { id?: string content: string timestamp: number sender_name?: string sender_id?: string is_bot?: boolean }) => { const isBot = msg.is_bot || false const msgId = msg.id || generateMessageId(isBot ? 'bot' : 'user') const contentHash = `${isBot ? 'bot' : 'user'}-${msg.content}-${Math.floor(msg.timestamp * 1000)}` processedSet.add(contentHash) return { id: msgId, type: isBot ? 'bot' : 'user' as const, content: msg.content, timestamp: msg.timestamp, sender: { name: msg.sender_name || (isBot ? '麦麦' : '用户'), user_id: msg.sender_id, is_bot: isBot, }, } }) processedMessagesMapRef.current.set(tabId, processedSet) updateTab(tabId, { messages: formattedMessages }) setIsLoadingHistory(false) break } default: break } }, [addMessageToTab, toast, updateTab]) const ensureSessionListener = useCallback(( tabId: string, tabType: 'webui' | 'virtual', config?: VirtualIdentityConfig, ) => { if (sessionUnsubscribeMapRef.current.has(tabId)) { return } const unsubscribe = chatWsClient.onSessionMessage(tabId, (message) => { handleSessionMessage(tabId, tabType, config, message as unknown as WsMessage) }) sessionUnsubscribeMapRef.current.set(tabId, unsubscribe) }, [handleSessionMessage]) const openSessionForTab = useCallback(async ( tabId: string, tabType: 'webui' | 'virtual', config?: VirtualIdentityConfig, ) => { ensureSessionListener(tabId, tabType, config) setIsLoadingHistory(true) try { if (tabType === 'virtual' && config) { await chatWsClient.openSession(tabId, { user_id: config.userId, user_name: config.userName, platform: config.platform, person_id: config.personId, group_name: config.groupName || 'WebUI虚拟群聊', group_id: config.groupId, }) } else { await chatWsClient.openSession(tabId, { user_id: userIdRef.current, user_name: userName, }) } updateTab(tabId, { isConnected: true }) } catch (error) { console.error(`[Tab ${tabId}] 打开聊天会话失败:`, error) setIsLoadingHistory(false) toast({ title: '连接失败', description: '无法建立聊天会话,请稍后重试', variant: 'destructive', }) } }, [ensureSessionListener, toast, updateTab, userName]) // 用于追踪组件是否已卸载 const isUnmountedRef = useRef(false) // 初始化连接(默认 WebUI 标签页) useEffect(() => { isUnmountedRef.current = false const unsubscribeConnection = chatWsClient.onConnectionChange((connected) => { if (isUnmountedRef.current) { return } setTabs(prev => prev.map(tab => ({ ...tab, isConnected: connected, }))) }) const unsubscribeStatus = chatWsClient.onStatusChange((status) => { if (!isUnmountedRef.current) { setIsConnecting(status === 'connecting') } }) tabs.forEach(tab => { processedMessagesMapRef.current.set(tab.id, new Set()) void openSessionForTab(tab.id, tab.type, tab.virtualConfig) }) return () => { isUnmountedRef.current = true unsubscribeConnection() unsubscribeStatus() sessionUnsubscribeMapRef.current.forEach((unsubscribe) => { unsubscribe() }) sessionUnsubscribeMapRef.current.clear() tabsRef.current.forEach(tab => { void chatWsClient.closeSession(tab.id) }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // 发送消息到当前活动标签页 const sendMessage = useCallback(async () => { if (!inputValue.trim() || !activeTab?.isConnected) { return } const displayName = activeTab?.type === 'virtual' ? activeTab.virtualConfig?.userName || userName : userName const messageContent = inputValue.trim() const currentTimestamp = Date.now() / 1000 // 添加到去重缓存,防止服务器广播回来的消息重复显示 const processedSet = processedMessagesMapRef.current.get(activeTabId) || new Set() const contentHash = `user-${messageContent}-${Math.floor(currentTimestamp * 1000)}` processedSet.add(contentHash) processedMessagesMapRef.current.set(activeTabId, processedSet) if (processedSet.size > 100) { const firstKey = processedSet.values().next().value if (firstKey) processedSet.delete(firstKey) } // 先添加用户消息(立即显示) const userMessage: ChatMessage = { id: generateMessageId('user'), type: 'user', content: messageContent, timestamp: currentTimestamp, sender: { name: displayName, is_bot: false, } } addMessageToTab(activeTabId, userMessage) // 再添加"思考中"占位消息 const thinkingMessage: ChatMessage = { id: generateMessageId('thinking'), type: 'thinking', content: '', timestamp: currentTimestamp + 0.001, // 稍微晚一点确保顺序 sender: { name: activeTab?.sessionInfo.bot_name || '麦麦', is_bot: true, } } addMessageToTab(activeTabId, thinkingMessage) setInputValue('') try { await chatWsClient.sendMessage(activeTabId, messageContent, displayName) } catch (error) { console.error('发送聊天消息失败:', error) setTabs(prev => prev.map(tab => { if (tab.id !== activeTabId) return tab return { ...tab, isTyping: false, messages: tab.messages.filter(msg => msg.type !== 'thinking') } })) toast({ title: '发送失败', description: '当前聊天会话不可用,请稍后重试', variant: 'destructive', }) } }, [activeTab, activeTabId, addMessageToTab, inputValue, toast, userName]) // 处理键盘事件 const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() void sendMessage() } } // 处理昵称编辑 const startEditingName = () => { setTempUserName(userName) setIsEditingName(true) } const saveEditedName = () => { const newName = tempUserName.trim() || 'WebUI用户' setUserName(newName) saveUserName(newName) setIsEditingName(false) if (activeTab?.isConnected) { void chatWsClient.updateNickname(activeTabId, newName) } } const cancelEditingName = () => { setTempUserName('') setIsEditingName(false) } // 格式化时间 const formatTime = (timestamp: number) => { const date = new Date(timestamp * 1000) return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', }) } // 重新连接当前标签页 const handleReconnect = () => { void chatWsClient.restart() } // 打开虚拟身份配置对话框(新建标签页用) const openVirtualConfig = () => { setTempVirtualConfig({ platform: '', personId: '', userId: '', userName: '', groupName: '', groupId: '', }) setPersonSearchQuery('') fetchPlatforms() setShowVirtualConfig(true) } // 创建新的虚拟身份标签页 const createVirtualTab = () => { if (!tempVirtualConfig.platform || !tempVirtualConfig.personId) { toast({ title: '配置不完整', description: '请选择平台和用户', variant: 'destructive', }) return } // 生成稳定的虚拟群 ID(基于平台和用户 ID,不包含时间戳) const stableGroupId = `webui_virtual_group_${tempVirtualConfig.platform}_${tempVirtualConfig.userId}` // 生成新标签页ID const newTabId = `virtual-${tempVirtualConfig.platform}-${tempVirtualConfig.userId}-${Date.now()}` const tabLabel = tempVirtualConfig.userName || tempVirtualConfig.userId // 创建新标签页,包含稳定的 groupId const newTab: ChatTab = { id: newTabId, type: 'virtual', label: tabLabel, virtualConfig: { ...tempVirtualConfig, groupId: stableGroupId, }, messages: [], isConnected: false, isTyping: false, sessionInfo: {}, } setTabs(prev => { const newTabs = [...prev, newTab] // 保存虚拟标签页到 localStorage const virtualTabsToSave: SavedVirtualTab[] = newTabs .filter(t => t.type === 'virtual' && t.virtualConfig) .map(t => ({ id: t.id, label: t.label, virtualConfig: t.virtualConfig!, createdAt: Date.now(), })) saveVirtualTabs(virtualTabsToSave) return newTabs }) setActiveTabId(newTabId) setShowVirtualConfig(false) // 初始化去重缓存 processedMessagesMapRef.current.set(newTabId, new Set()) void openSessionForTab(newTabId, 'virtual', { ...tempVirtualConfig, groupId: stableGroupId, }) toast({ title: '虚拟身份标签页', description: `已创建 ${tabLabel} 的对话`, }) } // 关闭标签页 const closeTab = (tabId: string, e?: React.MouseEvent | React.KeyboardEvent) => { e?.stopPropagation() // 不能关闭默认 WebUI 标签页 if (tabId === 'webui-default') { return } const unsubscribe = sessionUnsubscribeMapRef.current.get(tabId) if (unsubscribe) { unsubscribe() sessionUnsubscribeMapRef.current.delete(tabId) } void chatWsClient.closeSession(tabId) // 清理去重缓存 processedMessagesMapRef.current.delete(tabId) // 移除标签页并更新存储 setTabs(prev => { const newTabs = prev.filter(t => t.id !== tabId) // 更新 localStorage 中的虚拟标签页 const virtualTabsToSave: SavedVirtualTab[] = newTabs .filter(t => t.type === 'virtual' && t.virtualConfig) .map(t => ({ id: t.id, label: t.label, virtualConfig: t.virtualConfig!, createdAt: Date.now(), })) saveVirtualTabs(virtualTabsToSave) return newTabs }) // 如果关闭的是当前标签页,切换到默认标签页 if (activeTabId === tabId) { setActiveTabId('webui-default') } } // 切换标签页 const switchTab = (tabId: string) => { setActiveTabId(tabId) } // 选择用户 const selectPerson = (person: PersonInfo) => { setTempVirtualConfig(prev => ({ ...prev, personId: person.person_id, userId: person.user_id, userName: person.nickname || person.person_name, })) } return (
{/* 虚拟身份配置对话框 */} {/* 标签页栏 */} {/* 头部信息栏 */}
{/* 标题行 */}

{activeTab?.sessionInfo.bot_name || '麦麦'}

{activeTab?.isConnected ? ( <> 已连接 ) : isConnecting ? ( <> 连接中... ) : ( <> 未连接 )}
{/* 右侧操作按钮 */}
{isLoadingHistory && ( )}
{/* 用户身份(桌面端显示更多信息) */}
{activeTab?.type === 'virtual' && activeTab.virtualConfig ? ( <> 虚拟身份: {activeTab.virtualConfig.userName} ({activeTab.virtualConfig.platform}) {activeTab.virtualConfig.groupName && ( <> · 群:{activeTab.virtualConfig.groupName} )} ) : ( <> 当前身份: {isEditingName ? (
setTempUserName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') saveEditedName() if (e.key === 'Escape') cancelEditingName() }} className="h-7 w-32" placeholder="输入昵称" autoFocus />
) : (
{userName}
)} )}
{/* 消息列表区域 */}
{activeTab?.messages.length === 0 && !isLoadingHistory && (

开始与 {activeTab?.sessionInfo.bot_name || '麦麦'} 对话吧!

)} {activeTab?.messages.map((message) => (
{/* 系统消息 */} {message.type === 'system' && (
{message.content}
)} {/* 错误消息 */} {message.type === 'error' && (
{message.content}
)} {/* 思考中占位消息 */} {message.type === 'thinking' && ( <>
{message.sender?.name || activeTab?.sessionInfo.bot_name}
思考中...
)} {/* 用户/机器人消息 */} {(message.type === 'user' || message.type === 'bot') && ( <> {message.type === 'bot' ? ( ) : ( )}
{message.sender?.name || (message.type === 'bot' ? activeTab?.sessionInfo.bot_name : userName)} {formatTime(message.timestamp)}
)}
))}
{/* 输入区域 - 固定在底部 */}
setInputValue(e.target.value)} onKeyDown={handleKeyDown} placeholder={activeTab?.isConnected ? '输入消息...' : '等待连接...'} disabled={!activeTab?.isConnected} className="flex-1 h-10 sm:h-10" />
) }