Spaces:
Sleeping
Sleeping
| import { useState, useCallback, useRef, useEffect } from 'react' | |
| import { sendChatMessage } from '../api/chat.js' | |
| import { parseSsePayload } from '../utils/sse.js' | |
| import { loadMessagesFromApi } from '../api/conversations.js' | |
| export function useMessages({ activeId, refreshConversations }) { | |
| const [messages, setMessages] = useState([]) | |
| const [isThinking, setIsThinking] = useState(false) | |
| const [statusText, setStatusText] = useState('') | |
| const bottomRef = useRef(null) | |
| const chunkedHistory = (msgs) => | |
| msgs.map((msg) => ({ role: msg.role, content: msg.content })) | |
| // Load messages from API when active conversation changes | |
| useEffect(() => { | |
| if (!activeId) return | |
| let cancelled = false | |
| loadMessagesFromApi(activeId).then((data) => { | |
| if (!cancelled) setMessages(data) | |
| }) | |
| return () => { | |
| cancelled = true | |
| } | |
| }, [activeId]) | |
| const sendMessage = useCallback(async ({ | |
| inputValue, | |
| uploadedImage, | |
| imageAnalysis, | |
| imageUrl, | |
| selectedTools, | |
| selectedDocuments, | |
| useRag, | |
| setInputValue, | |
| clearImageState, | |
| setShowTools, | |
| }) => { | |
| const trimmed = inputValue.trim() | |
| if ((!trimmed && !uploadedImage) || !activeId) return | |
| const history = chunkedHistory(messages) | |
| let messageContent = trimmed || (uploadedImage ? 'Please analyze the attached image' : '') | |
| const userMsg = { | |
| id: `user-${Date.now()}`, | |
| role: 'user', | |
| content: messageContent, | |
| ...(uploadedImage ? { image: uploadedImage, image_url: imageUrl } : {}), | |
| } | |
| setMessages((prev) => [...prev, userMsg, { id: `assistant-${Date.now()}`, role: 'assistant', content: '' }]) | |
| setInputValue('') | |
| clearImageState() | |
| setIsThinking(true) | |
| setStatusText('Thinking...') | |
| setShowTools(false) | |
| try { | |
| const reader = await sendChatMessage({ | |
| message: messageContent, | |
| history, | |
| conversationId: activeId, | |
| toolNames: selectedTools, | |
| documentIds: selectedDocuments, | |
| useRag, | |
| imageUrl, | |
| imageAnalysis, | |
| }) | |
| const decoder = new TextDecoder() | |
| let buffer = '' | |
| let receivedAnyEvent = false | |
| // Track whether timeout has fired — if so, the readLoop is still running | |
| // and will populate the message naturally when chunks arrive. | |
| let slowResponse = false | |
| // 90-second timeout: show friendly status instead of replacing message with "Error:" | |
| const SLOW_MS = 90000 | |
| const slowTimer = setTimeout(() => { | |
| slowResponse = true | |
| setStatusText(receivedAnyEvent | |
| ? 'Still working on it...' | |
| : 'Waiting for the response...') | |
| // Safety net: if still no data after another 2 minutes, show fallback text | |
| setTimeout(() => { | |
| setMessages((prev) => { | |
| const lastIndex = prev.length - 1 | |
| return prev.map((m, i) => | |
| i === lastIndex && m.role === 'assistant' && m.content === '' | |
| ? { ...m, content: 'The server did not respond. Please try again.' } | |
| : m, | |
| ) | |
| }) | |
| setIsThinking(false) | |
| setStatusText('') | |
| }, 120000) | |
| }, SLOW_MS) | |
| const readLoop = (async () => { | |
| while (true) { | |
| const { value, done } = await reader.read() | |
| if (done) break | |
| receivedAnyEvent = true | |
| buffer += decoder.decode(value, { stream: true }) | |
| const parts = buffer.split('\n\n') | |
| buffer = parts.pop() || '' | |
| for (const part of parts) { | |
| if (!part.trim()) continue | |
| const { event, data } = parseSsePayload(part) | |
| let payload = {} | |
| try { | |
| payload = JSON.parse(data) | |
| } catch { | |
| payload = { text: data } | |
| } | |
| if (event === 'status') { | |
| setStatusText(payload.state || 'Thinking...') | |
| } | |
| if (event === 'tool') { | |
| setStatusText(`Using tool: ${payload.tool || 'tool'}`) | |
| } | |
| if (event === 'chunk') { | |
| // If timeout previously fired, update status so user sees progress | |
| if (slowResponse) { | |
| setStatusText('Receiving response...') | |
| } | |
| setMessages((prev) => { | |
| const lastIndex = prev.length - 1 | |
| const content = prev[lastIndex]?.content || '' | |
| return prev.map((m, i) => | |
| i === lastIndex && m.role === 'assistant' | |
| ? { ...m, content: `${content}${payload.text || ''}` } | |
| : m, | |
| ) | |
| }) | |
| } | |
| if (event === 'done') { | |
| clearTimeout(slowTimer) | |
| setIsThinking(false) | |
| setStatusText('') | |
| refreshConversations() | |
| } | |
| } | |
| } | |
| })() | |
| await readLoop | |
| } catch (error) { | |
| // Only catch genuine errors (network failure, server error) — not timeout | |
| setMessages((prev) => | |
| prev.map((m) => | |
| m.role === 'assistant' && m.content === '' | |
| ? { ...m, content: `${error.message}` } | |
| : m, | |
| ), | |
| ) | |
| setIsThinking(false) | |
| setStatusText('') | |
| } | |
| }, [activeId, messages, refreshConversations]) | |
| return { | |
| messages, | |
| setMessages, | |
| isThinking, | |
| setIsThinking, | |
| statusText, | |
| setStatusText, | |
| bottomRef, | |
| sendMessage, | |
| } | |
| } | |