'use client'; import Link from 'next/link'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Brain, Menu, Plus, Send, Trash2, X } from 'lucide-react'; import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench'; import { CurrentSessionProgressSidebar } from '@/components/chat-workbench/CurrentSessionProgressSidebar'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { ScrollArea } from '@/components/ui/scroll-area'; import { archiveSession, createSession, getActiveTask, getBackendTask, getSession, getSessionProcess, listSessions, sendMessage, submitChatFeedback, uploadFile, wsManager, } from '@/lib/api'; import { getSessionRefreshIntervalMs, mergeServerWithPendingUsers, shouldDisplayChatMessage, shouldMergePendingUsers, } from '@/lib/chat-messages'; import { pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; import { useChatStore } from '@/lib/store'; import { buildTaskTimelineView } from '@/lib/task-timeline-view'; import type { ActiveTask, BackendTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types'; function isSessionUpdatedEvent(data: WsEvent | Record): data is SessionUpdatedEvent { return data.type === 'session_updated' && typeof data.session_id === 'string'; } function activeTaskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') { if (status === 'needs_revision') return pickAppText(locale, '待修改', 'Needs revision'); if (status === 'awaiting_acceptance') return pickAppText(locale, '待验收', 'Awaiting acceptance'); if (status === 'running') return pickAppText(locale, '进行中', 'Running'); return pickAppText(locale, '进行中', 'Active'); } const THINKING_MODE_STORAGE_KEY = 'beaver_chat_thinking_enabled'; function loadThinkingModePreference(): boolean { if (typeof window === 'undefined') { return false; } const stored = window.localStorage.getItem(THINKING_MODE_STORAGE_KEY); return stored == null ? false : stored !== 'false'; } function isDocumentHidden(): boolean { return typeof document !== 'undefined' && document.visibilityState === 'hidden'; } export default function ChatPage() { const { locale } = useAppI18n(); const { sessionId, messages, isLoading, isThinking, sessions, processRuns, processEvents, processArtifacts, selectedRunId, setSessionId, setMessages, addMessage, setInputDraft, getInputDraft, clearInputDraft, setIsLoading, clearMessages, setIsThinking, setSelectedRunId, setSessionProcess, updateMessageFeedback, } = useChatStore(); const [input, setInput] = useState(() => useChatStore.getState().getInputDraft(useChatStore.getState().sessionId)); const [thinkingModeEnabled, setThinkingModeEnabled] = useState(loadThinkingModePreference); const [pendingFiles, setPendingFiles] = useState>([]); const [activeTask, setActiveTask] = useState(null); const [activeTaskDetail, setActiveTaskDetail] = useState(null); const [revisionTargetRunId, setRevisionTargetRunId] = useState(null); const [documentHidden, setDocumentHidden] = useState(isDocumentHidden); const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false); const [archiveTargetSessionId, setArchiveTargetSessionId] = useState(null); const messagesEndRef = useRef(null); const messageViewportRef = useRef(null); const textareaRef = useRef(null); const fileInputRef = useRef(null); const loadSessionReqSeq = useRef(0); const loadActiveTaskReqSeq = useRef(0); const loadedSessionIdRef = useRef(null); const refreshSessionOnReconnectRef = useRef(false); const hasConnectedRef = useRef(false); const shouldSnapToLatestRef = useRef(true); const wsStatus = useChatStore((state) => state.wsStatus); const sessionProcessRuns = useMemo( () => processRuns.filter((run) => run.session_id === sessionId), [processRuns, sessionId] ); const sessionRunIds = useMemo( () => new Set(sessionProcessRuns.map((run) => run.run_id)), [sessionProcessRuns] ); const sessionProcessEvents = useMemo( () => processEvents.filter((event) => sessionRunIds.has(event.run_id)), [processEvents, sessionRunIds] ); const sessionProcessArtifacts = useMemo( () => processArtifacts.filter((artifact) => sessionRunIds.has(artifact.run_id)), [processArtifacts, sessionRunIds] ); const selectedSessionRunId = selectedRunId && sessionRunIds.has(selectedRunId) ? selectedRunId : null; const activeTaskTimelineView = useMemo( () => buildTaskTimelineView({ task: activeTaskDetail, liveRuns: processRuns, liveEvents: processEvents, liveArtifacts: processArtifacts, }), [activeTaskDetail, processArtifacts, processEvents, processRuns] ); const loadSessions = useCallback(async () => { try { const list = await listSessions(); useChatStore.getState().setSessions(list); } catch { // backend may be offline during first render } }, []); const loadActiveTask = useCallback(async (key: string) => { const reqSeq = ++loadActiveTaskReqSeq.current; try { const nextActiveTask = await getActiveTask(key); if (reqSeq !== loadActiveTaskReqSeq.current || useChatStore.getState().sessionId !== key) return; setActiveTask(nextActiveTask); if (!nextActiveTask) { setActiveTaskDetail(null); return; } setActiveTaskDetail((current) => (current?.task_id === nextActiveTask.task_id ? current : null)); try { const detail = await getBackendTask(nextActiveTask.task_id); if (reqSeq !== loadActiveTaskReqSeq.current || useChatStore.getState().sessionId !== key) return; if (detail.is_open === false) { setActiveTask(null); setActiveTaskDetail(null); return; } setActiveTaskDetail(detail); } catch { if (reqSeq === loadActiveTaskReqSeq.current && useChatStore.getState().sessionId === key) { setActiveTaskDetail(null); } } } catch { if (reqSeq === loadActiveTaskReqSeq.current && useChatStore.getState().sessionId === key) { setActiveTask(null); setActiveTaskDetail(null); } } }, []); 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, process] = await Promise.all([ getSession(key), getSessionProcess(key).catch(() => null), ]); if (reqSeq !== loadSessionReqSeq.current) return; if (useChatStore.getState().sessionId !== key) return; if (process) { setSessionProcess(key, process); } void loadActiveTask(key); const displayMessages = detail.messages.filter(shouldDisplayChatMessage); const shouldMergePending = shouldMergePendingUsers(displayMessages, localSnapshot, waitingForReply); const nextMessages = shouldMergePending ? mergeServerWithPendingUsers(displayMessages, localSnapshot) : displayMessages; setMessages(nextMessages); shouldSnapToLatestRef.current = true; 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; } }, [loadActiveTask, setIsLoading, setIsThinking, setMessages, setSessionProcess]); useEffect(() => { const didSwitchSession = loadedSessionIdRef.current !== null && loadedSessionIdRef.current !== sessionId; loadedSessionIdRef.current = sessionId; if (didSwitchSession) { clearMessages(); setIsLoading(false); setIsThinking(false); } setActiveTask(null); setActiveTaskDetail(null); setRevisionTargetRunId(null); setInput(useChatStore.getState().getInputDraft(sessionId)); void loadSessionMessages(sessionId); void loadActiveTask(sessionId); }, [clearMessages, loadActiveTask, loadSessionMessages, sessionId, setIsLoading, setIsThinking]); useEffect(() => { if (wsStatus === 'connected') { if (hasConnectedRef.current && refreshSessionOnReconnectRef.current) { refreshSessionOnReconnectRef.current = false; void loadSessionMessages(useChatStore.getState().sessionId); } hasConnectedRef.current = true; return; } if (wsStatus === 'disconnected' && hasConnectedRef.current) { refreshSessionOnReconnectRef.current = true; } }, [loadSessionMessages, wsStatus]); useEffect(() => { const unsubMessage = wsManager.onMessage((data) => { if (isSessionUpdatedEvent(data)) { void loadSessions(); if (data.session_id === useChatStore.getState().sessionId) { void loadSessionMessages(data.session_id); } return; } if (data.type === 'status' && data.status === 'thinking') { setIsThinking(true); } else if (data.type === 'message' && data.role === 'assistant') { setIsThinking(false); setIsLoading(false); const rawEvidenceStatus = data.evidence_status ?? data.metadata?.evidence_status; const evidenceStatus = rawEvidenceStatus === 'recorded' ? 'recorded' : undefined; const assistantMessage = { role: 'assistant', content: typeof data.content === 'string' ? data.content : '', timestamp: new Date().toISOString(), attachments: Array.isArray(data.attachments) ? data.attachments : undefined, run_id: typeof data.run_id === 'string' ? data.run_id : undefined, task_id: data.task_id ?? data.metadata?.task_id ?? null, task_status: data.task_status ?? data.metadata?.task_status ?? null, evidence_status: evidenceStatus, } as const; if (shouldDisplayChatMessage(assistantMessage)) { addMessage(assistantMessage); } void loadSessionMessages(typeof data.session_id === 'string' ? data.session_id : useChatStore.getState().sessionId); void loadActiveTask(typeof data.session_id === 'string' ? data.session_id : useChatStore.getState().sessionId); loadSessions(); } }); return () => { unsubMessage(); }; }, [addMessage, loadActiveTask, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]); useEffect(() => { const intervalMs = getSessionRefreshIntervalMs({ isLoading, isThinking, documentHidden }); if (intervalMs == null) { return; } const timer = setInterval(() => { const currentSessionId = useChatStore.getState().sessionId; void loadSessionMessages(currentSessionId); void loadSessions(); }, intervalMs); return () => clearInterval(timer); }, [documentHidden, isLoading, isThinking, loadSessionMessages, loadSessions]); useEffect(() => { if (typeof document === 'undefined') { return; } const updateVisibility = () => setDocumentHidden(isDocumentHidden()); document.addEventListener('visibilitychange', updateVisibility); return () => document.removeEventListener('visibilitychange', updateVisibility); }, []); const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => { const viewport = messageViewportRef.current; if (!viewport) return; messagesEndRef.current?.scrollIntoView({ block: 'end', behavior }); viewport.scrollTo({ top: viewport.scrollHeight, behavior }); }, []); const scheduleScrollToLatest = useCallback((behavior: ScrollBehavior) => { if (typeof window === 'undefined') { scrollMessagesToLatest(behavior); return; } window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { scrollMessagesToLatest(behavior); }); }); }, [scrollMessagesToLatest]); useEffect(() => { shouldSnapToLatestRef.current = true; setSessionDrawerOpen(false); }, [sessionId]); useLayoutEffect(() => { if (messages.length === 0 && !isThinking && sessionProcessEvents.length === 0) { return; } scheduleScrollToLatest(shouldSnapToLatestRef.current ? 'auto' : 'smooth'); shouldSnapToLatestRef.current = false; }, [isThinking, messages.length, scheduleScrollToLatest, sessionProcessEvents.length]); 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, })); const msgContent = text || pickAppText(locale, '(仅附件)', '(Attachments only)'); if (revisionTargetRunId && text) { setIsLoading(true); setIsThinking(false); updateMessageFeedback(revisionTargetRunId, 'revise'); try { await submitChatFeedback({ sessionId, runId: revisionTargetRunId, feedbackType: 'revise', comment: msgContent, }); } catch (err: any) { setIsThinking(false); setIsLoading(false); updateMessageFeedback(revisionTargetRunId, undefined, err?.message || pickAppText(locale, '反馈提交失败', 'Feedback failed')); return; } finally { setRevisionTargetRunId(null); } } else { setRevisionTargetRunId(null); } setInput(''); clearInputDraft(sessionId); setPendingFiles([]); 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, thinking_enabled: thinkingModeEnabled, }; if (attachments.length > 0) { wsPayload.attachments = attachments; } wsManager.sendRaw(wsPayload); } else { try { const result = await sendMessage(msgContent, sessionId, attachments.length > 0 ? attachments : undefined, { thinkingEnabled: thinkingModeEnabled, }); setIsThinking(false); setIsLoading(false); if (result.response) { if (useChatStore.getState().sessionId !== sessionId) { await loadSessions(); return; } const assistantMessage = { role: 'assistant', content: result.response, timestamp: new Date().toISOString(), run_id: result.run_id, task_id: result.task_id, task_status: result.task_status, evidence_status: result.evidence_status === 'recorded' ? 'recorded' : undefined, } as const; if (shouldDisplayChatMessage(assistantMessage)) { addMessage(assistantMessage); } void getSessionProcess(sessionId).then((process) => setSessionProcess(sessionId, process)).catch(() => null); void loadActiveTask(sessionId); loadSessions(); } else { await loadSessionMessages(sessionId); void loadActiveTask(sessionId); loadSessions(); } } catch { setIsThinking(false); setIsLoading(false); if (useChatStore.getState().sessionId !== sessionId) { return; } addMessage({ role: 'assistant', content: pickAppText(locale, '发送失败,请检查后端服务是否正在运行。', 'Send failed. Please check whether the backend service is running.'), timestamp: new Date().toISOString(), }); } } }, [addMessage, clearInputDraft, input, isLoading, loadActiveTask, loadSessionMessages, loadSessions, locale, pendingFiles, revisionTargetRunId, sessionId, setIsLoading, setIsThinking, setSessionProcess, thinkingModeEnabled, updateMessageFeedback]); const handleFeedback = useCallback(async (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => { updateMessageFeedback(runId, feedbackType); try { await submitChatFeedback({ sessionId, runId, feedbackType, comment, }); void loadSessionMessages(sessionId); void getSessionProcess(sessionId).then((process) => setSessionProcess(sessionId, process)).catch(() => null); void loadActiveTask(sessionId); void loadSessions(); } catch (err: any) { updateMessageFeedback(runId, undefined, err?.message || pickAppText(locale, '反馈提交失败', 'Feedback failed')); } }, [loadActiveTask, loadSessionMessages, loadSessions, locale, sessionId, setSessionProcess, updateMessageFeedback]); const handleRequestRevision = useCallback((runId: string) => { setRevisionTargetRunId(runId); textareaRef.current?.focus(); }, []); const handleKeyDown = (e: React.KeyboardEvent) => { 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: pickAppText(locale, '文件过大(最大 50MB)', 'File is too large (max 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 || pickAppText(locale, '上传失败', 'Upload failed') } : item))); } } }, [locale, sessionId]); const handleNewSession = async () => { const id = `web:${Date.now()}`; setSessionId(id); setSelectedRunId(null); setActiveTask(null); setActiveTaskDetail(null); setRevisionTargetRunId(null); clearInputDraft(id); setInput(''); clearMessages(); useChatStore.getState().resetProcessState(); try { await createSession(id); } catch { // ignore transient create failures; first message can still create the session server-side } void loadSessions(); }; const handleArchiveSession = async (key: string) => { try { await archiveSession(key); setArchiveTargetSessionId(null); useChatStore.getState().setSessions(useChatStore.getState().sessions.filter((session) => session.key !== key)); if (key === sessionId) { setSessionId('web:default'); setActiveTask(null); setActiveTaskDetail(null); setRevisionTargetRunId(null); clearInputDraft(key); setInput(useChatStore.getState().getInputDraft('web:default')); clearMessages(); useChatStore.getState().resetProcessState(); } void loadSessions(); } catch { addMessage({ role: 'assistant', content: pickAppText(locale, '归档会话失败,请稍后重试。', 'Failed to archive the session. Please try again later.'), timestamp: new Date().toISOString(), }); } }; const handleSelectSession = (key: string) => { setSelectedRunId(null); setActiveTask(null); setActiveTaskDetail(null); setRevisionTargetRunId(null); setInput(useChatStore.getState().getInputDraft(key)); setSessionId(key); setSessionDrawerOpen(false); }; const removePendingFile = useCallback((file: File) => { setPendingFiles((prev) => prev.filter((item) => item.file !== file)); }, []); const toggleThinkingMode = useCallback(() => { setThinkingModeEnabled((current) => { const next = !current; if (typeof window !== 'undefined') { window.localStorage.setItem(THINKING_MODE_STORAGE_KEY, String(next)); } return next; }); }, []); const formatSessionName = (key: string) => { if (key.startsWith('web:')) { const id = key.slice(4); if (id === 'default') return pickAppText(locale, '默认', 'Default'); const numeric = Number(id); if (!Number.isNaN(numeric)) { return new Date(numeric).toLocaleDateString(locale, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } return id; } return key; }; const archiveTargetSessionName = archiveTargetSessionId ? formatSessionName(archiveTargetSessionId) : ''; const renderSessionSidebar = (variant: 'desktop' | 'drawer') => ( <>
{pickAppText(locale, '最近对话', 'Recent chats')}
{sessions.length === 0 && (

{pickAppText(locale, '暂无对话记录', 'No chat history yet')}

)} {sessions.map((session) => { const sessionName = formatSessionName(session.key); const isCurrent = session.key === sessionId; return (
); })}
); return (
{sessionDrawerOpen && (
)}
{formatSessionName(sessionId)}
setSelectedRunId(selectedSessionRunId === runId ? null : runId)} onFeedback={handleFeedback} onRequestRevision={handleRequestRevision} />
{(activeTask || revisionTargetRunId) && (
{activeTask ? ( {revisionTargetRunId ? pickAppText(locale, '修改任务', 'Revising task') : pickAppText(locale, '当前任务', 'Current task')}: {activeTask.short_title} {revisionTargetRunId ? pickAppText(locale, '待输入修改要求', 'Awaiting revision') : activeTaskStatusLabel(activeTask.status, locale)} ) : null}
)} {pendingFiles.length > 0 && (
{pendingFiles.map((item, index) => (
{item.file.name}{' '} ({(item.file.size / 1024).toFixed(0)}KB) {item.error ? ( {item.error} ) : item.progress < 100 ? (
) : ( {pickAppText(locale, '就绪', 'Ready')} )}
))}
)}