'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import { useChatStore } from '@/lib/store'
import { MessageList } from './message-list'
import { ChatInput, PromptSuggestion } from './chat-input'
import { ChatHeader } from './chat-header'
import { WelcomeScreen } from './welcome-screen'
import { QuestionFlow } from './question-flow'
import { useQuestionFlow } from '@/hooks/use-question-flow'
import type { Message } from '@/lib/types'
import { MessageStatus } from '@/lib/types'
import { generateUUID } from '@/lib/utils'
import { sessionService } from '@/services/session-service'
import { messageService } from '@/services/message-service'
import { handleStreamEvent } from '@/lib/stream-event-handler'
export function ChatArea() {
const [initialResolved, setInitialResolved] = useState(false)
const [presetPrompts, setPresetPrompts] = useState<PromptSuggestion[]>([])
const {
conversations,
currentConversationId,
addMessage,
updateMessage,
deleteMessage,
setStreaming,
loadMoreMessages,
} = useChatStore()
const {
hasActiveQuestions,
activeQuestions,
activeQuestionId,
handleSubmitAnswers,
handleSkipQuestions,
submitting: submittingQuestions,
submitError,
} = useQuestionFlow()
const handleAddPresetPrompt = useCallback((prompt: PromptSuggestion) => {
setPresetPrompts(prev => {
if (prev.some(p => p.id === prompt.id)) {
return prev
}
return [...prev, prompt]
})
}, [])
const handleRemovePresetPrompt = useCallback((promptId: string) => {
setPresetPrompts(prev => prev.filter(p => p.id !== promptId))
}, [])
const handleClearPresetPrompts = useCallback(() => {
setPresetPrompts([])
}, [])
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const urlAgentId = params.get('agent')
const urlSessionId = params.get('session')
if (urlAgentId && urlSessionId) {
useChatStore
.getState()
.refreshConversation(urlAgentId, urlSessionId)
.finally(() => setInitialResolved(true))
} else if (urlAgentId) {
useChatStore.getState().setCurrentAgent(urlAgentId)
setInitialResolved(true)
} else {
setInitialResolved(true)
}
}, [])
const currentConversation = conversations.find(c => c.id === currentConversationId)
const messages = currentConversation?.messages || []
const scrollRef = useRef<HTMLDivElement>(null)
const [loadingMessages, setLoadingMessages] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const loadedSessionIds = useRef<Set<string>>(new Set())
const skipAutoScroll = useRef(false)
const scrollHeightBeforeLoad = useRef(0)
const scrollCtx = useRef({
hasMore: false,
agentId: '',
sessionId: '',
messages: [] as Message[],
cooldown: false,
})
useEffect(() => {
scrollCtx.current.hasMore = currentConversation?.hasMore ?? false
scrollCtx.current.agentId = currentConversation?.agentId ?? ''
scrollCtx.current.sessionId = currentConversation?.sessionId ?? ''
}, [currentConversation?.hasMore, currentConversation?.agentId, currentConversation?.sessionId])
useEffect(() => {
scrollCtx.current.messages = messages
}, [messages])
const locallyCreatedMessageIds = useRef<Set<string>>(new Set())
const reconnectRetryRef = useRef(0)
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (!currentConversation) return
if (currentConversation.messages.length > 0) return
if (!currentConversation.sessionId || !currentConversation.agentId) return
if (loadedSessionIds.current.has(currentConversation.sessionId)) return
loadedSessionIds.current.add(currentConversation.sessionId)
let cancelled = false
setLoadingMessages(true)
sessionService
.getConversation(currentConversation.agentId, currentConversation.sessionId)
.then(detail => {
if (cancelled) return
const latestConversation = useChatStore
.getState()
.conversations.find(c => c.id === currentConversation.id)
if (
!latestConversation ||
latestConversation.messages.length > 0 ||
latestConversation.isStreaming
) {
return
}
const msgs = (detail.messages || []).map((msg: any) => sessionService.transformMessage(msg))
const hasStreaming = msgs.some((m: Message) => m.isStreaming)
if (hasStreaming && currentConversation.sessionId) {
loadedSessionIds.current.delete(currentConversation.sessionId)
}
useChatStore.setState(s => ({
conversations: s.conversations.map(c =>
c.id === currentConversation.id
? {
...c,
messages: msgs,
updatedAt: new Date(detail.updated_at),
isStreaming: hasStreaming,
hasMore: detail.has_more ?? false,
}
: c
),
}))
})
.catch(err => {
console.error('Failed to load conversation messages:', err)
})
.finally(() => {
if (!cancelled) setLoadingMessages(false)
})
return () => {
cancelled = true
}
}, [currentConversation?.id])
const loadMore = useCallback(() => {
const el = scrollRef.current
if (!el) return
const ctx = scrollCtx.current
if (!ctx.hasMore || ctx.cooldown) return
const oldestMsg = ctx.messages[0]
if (!oldestMsg?.timestamp || !ctx.agentId || !ctx.sessionId) return
ctx.cooldown = true
scrollHeightBeforeLoad.current = el.scrollHeight
setLoadingMore(true)
skipAutoScroll.current = true
const before =
oldestMsg.timestamp instanceof Date
? oldestMsg.timestamp.toISOString()
: new Date(oldestMsg.timestamp).toISOString()
loadMoreMessages(ctx.agentId, ctx.sessionId, before).finally(() => {
setLoadingMore(false)
setTimeout(() => {
scrollCtx.current.cooldown = false
}, 500)
})
}, [loadMoreMessages])
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
if (el.scrollTop > 5) return
loadMore()
}, [loadMore])
const streamingMsg = messages.find(m => m.isStreaming)
useEffect(() => {
if (!streamingMsg || !currentConversation?.sessionId || !currentConversation?.agentId) return
if (!currentConversationId) return
if (currentConversation.skipReconnect) return
if (streamingMsg.skipReconnect) return
if (locallyCreatedMessageIds.current.has(streamingMsg.id)) return
const agentId = currentConversation.agentId
const sessionId = currentConversation.sessionId
const msgId = streamingMsg.id
let cancelled = false
updateMessage(currentConversationId, msgId, {
content: '',
events: [],
})
setStreaming(currentConversationId, true)
const MAX_RETRIES = 3
const BASE_DELAY = 1000
const attemptReconnect = (attempt: number) => {
if (cancelled) return
messageService
.reconnectStream(agentId, sessionId, eventData => {
if (cancelled) return
handleStreamEvent(eventData, currentConversationId, msgId, updateMessage, setStreaming)
})
.then(() => {
if (!cancelled) reconnectRetryRef.current = 0
})
.catch(err => {
if (cancelled) return
console.error('Reconnect stream failed:', err)
if (attempt < MAX_RETRIES) {
const delay = BASE_DELAY * Math.pow(2, attempt - 1)
reconnectTimerRef.current = setTimeout(() => attemptReconnect(attempt + 1), delay)
} else {
console.error('Reconnect stream failed after max retries')
updateMessage(currentConversationId, msgId, {
isStreaming: false,
status: MessageStatus.ERROR,
content:
'Sorry, there was an error reconnecting the stream. Please refresh and try again.',
})
setStreaming(currentConversationId, false)
}
})
}
reconnectRetryRef.current = 1
attemptReconnect(1)
return () => {
cancelled = true
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current)
}
}
}, [streamingMsg?.id, currentConversationId])
const scrollToBottom = useCallback(() => {
requestAnimationFrame(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
})
}, [])
const initialScrollDone = useRef(false)
useEffect(() => {
if (!initialResolved) return
if (loadingMessages) return
if (messages.length === 0) return
if (initialScrollDone.current) return
initialScrollDone.current = true
scrollToBottom()
}, [initialResolved, loadingMessages, messages.length])
useEffect(() => {
if (!scrollRef.current) return
if (!initialScrollDone.current) return
if (skipAutoScroll.current) {
skipAutoScroll.current = false
if (scrollHeightBeforeLoad.current > 0) {
requestAnimationFrame(() => {
if (scrollRef.current) {
const heightDelta = scrollRef.current.scrollHeight - scrollHeightBeforeLoad.current
scrollRef.current.scrollTop = heightDelta
scrollHeightBeforeLoad.current = 0
}
})
}
return
}
scrollToBottom()
}, [messages])
const handleSendMessage = async (content: string, attachments?: File[]) => {
let convId = currentConversationId
if (!convId) {
const state = useChatStore.getState()
const agentId = state.currentAgentId
if (!agentId) return
const agent = state.agents.find(a => a.id === agentId)
convId = state.createLocalConversation(agentId, agent?.name)
}
const userMessage: Message = {
id: generateUUID(),
role: 'user',
content,
timestamp: new Date(),
attachments: attachments?.map(file => ({
id: generateUUID(),
name: file.name,
type: file.type.startsWith('image/') ? 'image' : 'file',
size: file.size,
})),
}
addMessage(convId, userMessage)
await streamResponse(content, convId)
}
const streamResponse = useCallback(
async (content: string, convId: string) => {
const { currentAgentId, sendMessageToAgent, createNewSession, initializeAgent } =
useChatStore.getState()
let agentId = currentAgentId
if (!agentId) {
try {
const defaultAgent = await initializeAgent({
name: 'Default Agent',
adapterType: 'openclaw',
sandboxType: 'docker',
idleTimeoutSeconds: 300,
})
agentId = defaultAgent.id
} catch (error) {
console.error('Failed to create default agent:', error)
return
}
}
const currentConv = useChatStore.getState().conversations.find(c => c.id === convId)
let sessionId = currentConv?.sessionId
if (!sessionId) {
try {
const session = await createNewSession(agentId)
sessionId = session.id
} catch (error) {
console.error('Failed to create session:', error)
return
}
useChatStore.getState().assignSessionToConversation(convId, sessionId)
}
setStreaming(convId, true)
const thinkingMessageId = generateUUID()
const thinkingMessage: Message = {
id: thinkingMessageId,
role: 'assistant',
content: '',
timestamp: new Date(),
isStreaming: true,
status: MessageStatus.GENERATING,
}
addMessage(convId, thinkingMessage)
locallyCreatedMessageIds.current.add(thinkingMessageId)
try {
let assistantMessageId: string | null = null
let assistantMessage: Message | null = null
await sendMessageToAgent(agentId, sessionId, content, eventData => {
if (!assistantMessageId) {
deleteMessage(convId, thinkingMessageId)
locallyCreatedMessageIds.current.delete(thinkingMessageId)
assistantMessageId = generateUUID()
assistantMessage = {
id: assistantMessageId,
role: 'assistant',
content: '',
timestamp: new Date(),
isStreaming: true,
status: MessageStatus.GENERATING,
toolCalls: [],
events: [],
}
addMessage(convId, assistantMessage)
locallyCreatedMessageIds.current.add(assistantMessageId)
}
if (assistantMessageId) {
handleStreamEvent(
eventData,
convId,
assistantMessageId,
updateMessage,
setStreaming,
locallyCreatedMessageIds
)
}
})
} catch (error) {
console.error('Failed to send message:', error)
deleteMessage(convId, thinkingMessageId)
locallyCreatedMessageIds.current.clear()
const errorMessageId = generateUUID()
const errorMessage: Message = {
id: errorMessageId,
role: 'assistant',
content: 'Sorry, there was an error sending your message. Please try again.',
timestamp: new Date(),
isStreaming: false,
status: MessageStatus.ERROR,
}
addMessage(convId, errorMessage)
setStreaming(convId, false)
}
},
[addMessage, updateMessage, deleteMessage, setStreaming]
)
const handleRegenerate = useCallback(
async (assistantMessageId: string) => {
if (!currentConversationId) return
deleteMessage(currentConversationId, assistantMessageId)
const regenerateContent = '/regenerate'
await streamResponse(regenerateContent, currentConversationId)
},
[currentConversationId, deleteMessage, streamResponse]
)
if (!initialResolved) {
return (
<div className="flex h-full flex-col bg-background">
<ChatHeader conversation={currentConversation} />
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm">加载中...</p>
</div>
</div>
</div>
)
}
if (messages.length === 0 && !loadingMessages) {
return (
<div className="flex h-full flex-col bg-background">
<ChatHeader conversation={currentConversation} />
<WelcomeScreen onAddPrompt={handleAddPresetPrompt} />
<div className="border-t border-border p-4">
<ChatInput
onSend={handleSendMessage}
presetPrompts={presetPrompts}
onRemovePresetPrompt={handleRemovePresetPrompt}
onClearPresetPrompts={handleClearPresetPrompts}
/>
</div>
</div>
)
}
if (loadingMessages) {
return (
<div className="flex h-full flex-col bg-background">
<ChatHeader conversation={currentConversation} />
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm">加载历史会话中...</p>
</div>
</div>
<div className="border-t border-border p-4">
<ChatInput
onSend={handleSendMessage}
presetPrompts={presetPrompts}
onRemovePresetPrompt={handleRemovePresetPrompt}
onClearPresetPrompts={handleClearPresetPrompts}
/>
</div>
</div>
)
}
return (
<div className="flex h-full flex-col bg-background">
<ChatHeader conversation={currentConversation} />
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto scrollbar-thin"
>
{loadingMore && (
<div className="flex items-center justify-center py-3">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="ml-2 text-xs text-muted-foreground">加载更早的消息...</span>
</div>
)}
{currentConversation?.hasMore && !loadingMore && (
<div className="flex items-center justify-center py-2">
<button
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={loadMore}
>
查看更早的消息
</button>
</div>
)}
<MessageList
messages={messages}
onRegenerate={handleRegenerate}
agentName={currentConversation?.agentName}
/>
</div>
<div className="border-t border-border p-4">
{hasActiveQuestions && activeQuestions && activeQuestionId ? (
<QuestionFlow
questions={activeQuestions}
questionId={activeQuestionId}
submitting={submittingQuestions}
submitError={submitError}
onSubmit={handleSubmitAnswers}
onSkip={handleSkipQuestions}
/>
) : (
<ChatInput
onSend={handleSendMessage}
presetPrompts={presetPrompts}
onRemovePresetPrompt={handleRemovePresetPrompt}
onClearPresetPrompts={handleClearPresetPrompts}
/>
)}
</div>
</div>
)
}