'use client'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { MessageSquare, Paperclip, Plus, Send, Trash2, X } from 'lucide-react'; import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { cancelDelegation, createSession, deleteSession, getSession, getStatus, listCommands, listSessions, sendMessage, uploadFile, wsManager, } from '@/lib/api'; import { useChatStore } from '@/lib/store'; import type { ChatMessage, FileAttachment, ProcessWsEvent, SlashCommand, WsEvent } from '@/types'; function scheduleWhenIdle(task: () => void, timeout = 1200): () => void { if (typeof window === 'undefined') { task(); return () => {}; } const idleWindow = window as Window & typeof globalThis & { requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number; cancelIdleCallback?: (handle: number) => void; }; if (typeof idleWindow.requestIdleCallback === 'function') { const id = idleWindow.requestIdleCallback(() => task(), { timeout }); return () => idleWindow.cancelIdleCallback?.(id); } const id = globalThis.setTimeout(task, 250); return () => globalThis.clearTimeout(id); } function messageFingerprint(msg: ChatMessage): string { const attachmentKey = (msg.attachments ?? []) .map((a) => `${a.file_id ?? ''}:${a.name}:${a.content_type}:${a.size ?? ''}`) .join('|'); return `${msg.role}::${String(msg.content)}::${attachmentKey}`; } function mergeServerWithPendingUsers(serverMessages: ChatMessage[], localMessages: ChatMessage[]): ChatMessage[] { const counts = new Map(); for (const message of serverMessages) { const key = messageFingerprint(message); counts.set(key, (counts.get(key) ?? 0) + 1); } const pendingUsers: ChatMessage[] = []; for (const message of localMessages) { const key = messageFingerprint(message); const count = counts.get(key) ?? 0; if (count > 0) { counts.set(key, count - 1); continue; } if (message.role === 'user') { pendingUsers.push(message); } } return [...serverMessages, ...pendingUsers]; } function isProcessEvent(data: WsEvent | Record): data is ProcessWsEvent { const type = typeof data.type === 'string' ? data.type : ''; return type.startsWith('process_') || type === 'process_cancel_ack'; } export default function ChatPage() { const { sessionId, messages, isLoading, isThinking, sessions, processRuns, processEvents, processArtifacts, selectedRunId, setSessionId, setMessages, addMessage, setIsLoading, setSessions, clearMessages, setWsStatus, setIsThinking, setNanobotReady, resetProcessState, ingestProcessEvent, setSelectedRunId, } = useChatStore(); const [input, setInput] = useState(''); const [commands, setCommands] = useState([]); const [showCommandPicker, setShowCommandPicker] = useState(false); const [pickerIndex, setPickerIndex] = useState(0); const [pendingFiles, setPendingFiles] = useState>([]); const messagesEndRef = useRef(null); const messageViewportRef = useRef(null); const textareaRef = useRef(null); const pickerRef = useRef(null); const fileInputRef = useRef(null); const loadSessionReqSeq = useRef(0); const commandsLoadedRef = useRef(false); const refreshSessionOnReconnectRef = useRef(false); const hasConnectedRef = useRef(false); const statusCheckCleanupRef = useRef<(() => void) | null>(null); const statusCheckInFlightRef = useRef(false); const shouldSnapToLatestRef = useRef(true); const filteredCommands = useMemo(() => { if (!input.startsWith('/') || input.includes(' ')) return []; const filter = input.slice(1).toLowerCase(); return commands.filter( (command) => command.name.startsWith(filter) || (filter === '' ? true : command.name.includes(filter)) ); }, [commands, input]); const loadSessions = useCallback(async () => { try { const list = await listSessions(); setSessions(list); } catch { // backend may be offline during first render } }, [setSessions]); const loadSessionMessages = useCallback(async (key: string) => { const reqSeq = ++loadSessionReqSeq.current; const localSnapshot = useChatStore.getState().messages; const waitingForReply = useChatStore.getState().isLoading || useChatStore.getState().isThinking; try { const detail = await getSession(key); if (reqSeq !== loadSessionReqSeq.current) return; if (useChatStore.getState().sessionId !== key) return; const nextMessages = waitingForReply ? mergeServerWithPendingUsers(detail.messages, localSnapshot) : detail.messages; setMessages(nextMessages); const last = nextMessages[nextMessages.length - 1]; if (last?.role === 'assistant') { setIsThinking(false); setIsLoading(false); } } catch { if (reqSeq !== loadSessionReqSeq.current) return; if (useChatStore.getState().sessionId !== key) return; } }, [setIsLoading, setIsThinking, setMessages]); const loadCommands = useCallback(async () => { if (commandsLoadedRef.current) return; commandsLoadedRef.current = true; try { const nextCommands = await listCommands(); setCommands(nextCommands); } catch { commandsLoadedRef.current = false; } }, []); const scheduleStatusCheck = useCallback(() => { if (statusCheckInFlightRef.current) return; statusCheckCleanupRef.current?.(); statusCheckCleanupRef.current = scheduleWhenIdle(async () => { statusCheckInFlightRef.current = true; try { await getStatus(); setNanobotReady(true); } catch { setNanobotReady(false); } finally { statusCheckInFlightRef.current = false; } }); }, [setNanobotReady]); useEffect(() => { if (input.startsWith('/') && !input.includes(' ')) { void loadCommands(); } }, [input, loadCommands]); useEffect(() => { setShowCommandPicker(filteredCommands.length > 0); setPickerIndex(0); }, [filteredCommands]); useEffect(() => { loadSessions(); }, [loadSessions]); useEffect(() => { clearMessages(); setIsLoading(false); setIsThinking(false); resetProcessState(); const wsSessionId = sessionId.startsWith('web:') ? sessionId.slice(4) : sessionId; wsManager.connect(wsSessionId); loadSessionMessages(sessionId); }, [clearMessages, loadSessionMessages, resetProcessState, sessionId, setIsLoading, setIsThinking]); useEffect(() => { const unsubStatus = wsManager.onStatusChange(async (status) => { setWsStatus(status); if (status === 'connected') { if (hasConnectedRef.current && refreshSessionOnReconnectRef.current) { refreshSessionOnReconnectRef.current = false; void loadSessionMessages(useChatStore.getState().sessionId); } hasConnectedRef.current = true; scheduleStatusCheck(); } else { if (status === 'disconnected' && hasConnectedRef.current) { refreshSessionOnReconnectRef.current = true; } statusCheckCleanupRef.current?.(); statusCheckCleanupRef.current = null; setNanobotReady(null); } }); const unsubMessage = wsManager.onMessage((data) => { if (isProcessEvent(data)) { ingestProcessEvent(data); return; } if (data.type === 'status' && data.status === 'thinking') { setIsThinking(true); } else if (data.type === 'message' && data.role === 'assistant') { setIsThinking(false); setIsLoading(false); addMessage({ role: 'assistant', content: typeof data.content === 'string' ? data.content : '', timestamp: new Date().toISOString(), attachments: Array.isArray(data.attachments) ? data.attachments : undefined, }); loadSessions(); } }); return () => { statusCheckCleanupRef.current?.(); statusCheckCleanupRef.current = null; unsubStatus(); unsubMessage(); }; }, [addMessage, ingestProcessEvent, loadSessionMessages, loadSessions, scheduleStatusCheck, setIsLoading, setIsThinking, setNanobotReady, setWsStatus]); useEffect(() => { if (!isLoading && !isThinking) { return; } const timer = setInterval(() => { loadSessionMessages(useChatStore.getState().sessionId); }, 1500); return () => clearInterval(timer); }, [isLoading, isThinking, loadSessionMessages]); const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => { const viewport = messageViewportRef.current; if (!viewport) return; viewport.scrollTo({ top: viewport.scrollHeight, behavior }); }, []); useEffect(() => { shouldSnapToLatestRef.current = true; }, [sessionId]); useLayoutEffect(() => { if (messages.length === 0 && !isThinking && processEvents.length === 0) { return; } scrollMessagesToLatest(shouldSnapToLatestRef.current ? 'auto' : 'smooth'); shouldSnapToLatestRef.current = false; }, [isThinking, messages, processEvents, scrollMessagesToLatest]); useEffect(() => { if (!showCommandPicker || !pickerRef.current) return; const item = pickerRef.current.children[pickerIndex] as HTMLElement | undefined; item?.scrollIntoView({ block: 'nearest' }); }, [pickerIndex, showCommandPicker]); const selectCommand = useCallback((command: SlashCommand) => { setInput(command.argument_hint ? `/${command.name} ` : `/${command.name}`); setShowCommandPicker(false); textareaRef.current?.focus(); }, []); const handleSend = useCallback(async () => { const text = input.trim(); if ((!text && pendingFiles.length === 0) || isLoading) return; const readyFiles = pendingFiles.filter((p) => p.id && !p.error); const attachments: FileAttachment[] = readyFiles.map((item) => ({ file_id: item.id!, name: item.file.name, content_type: item.file.type || 'application/octet-stream', size: item.file.size, })); setInput(''); setPendingFiles([]); setShowCommandPicker(false); const msgContent = text || '(仅附件)'; addMessage({ role: 'user', content: msgContent, timestamp: new Date().toISOString(), attachments: attachments.length > 0 ? attachments : undefined, }); setIsLoading(true); setIsThinking(false); if (wsManager.getStatus() === 'connected') { const wsPayload: Record = { type: 'message', content: msgContent }; if (attachments.length > 0) { wsPayload.attachments = attachments; } wsManager.sendRaw(wsPayload); } else { try { const result = await sendMessage(msgContent, sessionId, attachments.length > 0 ? attachments : undefined); setIsThinking(false); setIsLoading(false); if (result.response) { if (useChatStore.getState().sessionId !== sessionId) { await loadSessions(); return; } addMessage({ role: 'assistant', content: result.response, timestamp: new Date().toISOString(), }); loadSessions(); } else { await loadSessionMessages(sessionId); loadSessions(); } } catch { setIsThinking(false); setIsLoading(false); if (useChatStore.getState().sessionId !== sessionId) { return; } addMessage({ role: 'assistant', content: '发送失败,请检查后端服务是否正在运行。', timestamp: new Date().toISOString(), }); } } }, [addMessage, input, isLoading, loadSessionMessages, loadSessions, pendingFiles, sessionId, setIsLoading, setIsThinking]); const handleKeyDown = (e: React.KeyboardEvent) => { if (showCommandPicker && filteredCommands.length > 0) { if (e.key === 'ArrowUp') { e.preventDefault(); setPickerIndex((i) => (i <= 0 ? filteredCommands.length - 1 : i - 1)); return; } if (e.key === 'ArrowDown') { e.preventDefault(); setPickerIndex((i) => (i >= filteredCommands.length - 1 ? 0 : i + 1)); return; } if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing)) { e.preventDefault(); selectCommand(filteredCommands[pickerIndex]); return; } if (e.key === 'Escape') { e.preventDefault(); setShowCommandPicker(false); return; } } if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); handleSend(); } }; const handleFileSelect = useCallback(async (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (!files.length) return; e.target.value = ''; for (const file of files) { if (file.size > 50 * 1024 * 1024) { setPendingFiles((prev) => [...prev, { file, progress: 0, error: '文件过大(最大 50MB)' }]); continue; } setPendingFiles((prev) => [...prev, { file, progress: 0 }]); try { const result = await uploadFile(file, sessionId, (pct) => { setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, progress: pct } : item))); }); setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, id: result.file_id, progress: 100 } : item))); } catch (err: any) { setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, error: err.message || '上传失败' } : item))); } } }, [sessionId]); const handleNewSession = async () => { const id = `web:${Date.now()}`; setSessionId(id); clearMessages(); resetProcessState(); try { await createSession(id); } catch { // ignore transient create failures; first message can still create the session server-side } void loadSessions(); }; const handleDeleteSession = async (key: string, e: React.MouseEvent) => { e.stopPropagation(); try { await deleteSession(key); if (key === sessionId) { setSessionId('web:default'); clearMessages(); resetProcessState(); } loadSessions(); } catch { // ignore transient errors } }; const handleSelectSession = (key: string) => { setSessionId(key); }; const handleCancelRun = async (runId: string) => { try { await cancelDelegation(runId); } catch (err: any) { addMessage({ role: 'assistant', content: `取消任务 ${runId} 失败:${err.message || '未知错误'}`, timestamp: new Date().toISOString(), }); } }; const removePendingFile = useCallback((file: File) => { setPendingFiles((prev) => prev.filter((item) => item.file !== file)); }, []); const formatSessionName = (key: string) => { if (key.startsWith('web:')) { const id = key.slice(4); if (id === 'default') return '默认'; const numeric = Number(id); if (!Number.isNaN(numeric)) { return new Date(numeric).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } return id; } return key; }; return (
{sessions.length === 0 && (

暂无对话记录

)} {sessions.map((session) => (
handleSelectSession(session.key)} className={`group flex items-center justify-between px-2 py-1.5 rounded-md cursor-pointer text-sm ${ session.key === sessionId ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent/50' }`} >
{formatSessionName(session.key)}
))}
{pendingFiles.length > 0 && (
{pendingFiles.map((item, index) => (
{item.file.name}{' '} ({(item.file.size / 1024).toFixed(0)}KB) {item.error ? ( {item.error} ) : item.progress < 100 ? (
) : ( 就绪 )}
))}
)}
{showCommandPicker && filteredCommands.length > 0 && (
{filteredCommands.map((command, index) => ( ))}
)}