import { useCallback, useEffect, useRef, useState } from 'react' import type { FormEvent } from 'react' import { streamQuery } from '../api/client.ts' import { useSession } from '../context/SessionContext.tsx' import type { ChatMessage } from '../context/SessionContext.tsx' import { useToast } from './shared/Toast.tsx' import MarkdownRenderer from './shared/MarkdownRenderer.tsx' import './chat.css' function makeId(): string { return crypto.randomUUID ? crypto.randomUUID() : `m_${Date.now()}_${Math.random().toString(36).slice(2)}` } export default function ChatView() { const { sessionId, messages, loadHistory, appendMessage, updateLastMessage, isLoadingHistory } = useSession() const [question, setQuestion] = useState('') const [isStreaming, setIsStreaming] = useState(false) const abortControllerRef = useRef(null) const messagesAreaRef = useRef(null) const { showToast } = useToast() useEffect(() => { loadHistory() // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]) useEffect(() => { const el = messagesAreaRef.current if (el) { el.scrollTop = el.scrollHeight } }, [messages]) const handleCancel = useCallback(() => { abortControllerRef.current?.abort() }, []) const handleSubmit = useCallback( async (e: FormEvent) => { e.preventDefault() const trimmed = question.trim() if (!trimmed || isStreaming) return const userMessage: ChatMessage = { id: makeId(), role: 'user', content: trimmed, sources: null, status: 'done', createdAt: new Date().toISOString(), } appendMessage(userMessage) setQuestion('') const assistantMessage: ChatMessage = { id: makeId(), role: 'assistant', content: '', sources: null, status: 'pending', createdAt: new Date().toISOString(), } appendMessage(assistantMessage) const controller = new AbortController() abortControllerRef.current = controller setIsStreaming(true) try { for await (const event of streamQuery(trimmed, sessionId, controller.signal)) { if (event.type === 'token') { updateLastMessage((msg) => ({ ...msg, content: msg.content + event.token, status: 'streaming', })) } else if (event.type === 'final') { updateLastMessage((msg) => ({ ...msg, content: event.answer, sources: event.sources, status: 'done', })) } else if (event.type === 'error') { updateLastMessage((msg) => ({ ...msg, content: msg.content || `Error: ${event.error}`, status: 'error', })) showToast(event.error, 'error') } } } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') { updateLastMessage((msg) => ({ ...msg, status: 'done', content: msg.content || '(cancelled)', })) } else { const message = err instanceof Error ? err.message : 'Streaming failed' updateLastMessage((msg) => ({ ...msg, status: 'error', content: msg.content || `Error: ${message}` })) showToast(message, 'error') } } finally { setIsStreaming(false) abortControllerRef.current = null } }, [question, isStreaming, sessionId, appendMessage, updateLastMessage, showToast], ) return (

Interactive Q&A

Ask questions and get intelligent answers from your documents

{isLoadingHistory && messages.length === 0 ? (
⏳

Loading history…

) : messages.length === 0 ? (
💬

Start a Conversation

Ask me anything about your indexed documents

) : ( messages.map((msg) => (
{msg.role === 'assistant' ? ( msg.content ? ( ) : (
) ) : (
{msg.content}
)} {msg.role === 'assistant' && msg.status === 'streaming' && (
Sources loading…
)} {msg.role === 'assistant' && msg.status === 'done' && msg.sources && msg.sources.length > 0 && (
Sources {msg.sources.map((source, idx) => (
{idx + 1}. {source.file_name} {source.score != null ? ` (${source.score.toFixed(3)})` : ''}

{source.text}…

))}
)}
)) )}
setQuestion(e.target.value)} disabled={isStreaming} required /> {isStreaming ? ( ) : ( )}
) }