import { useRef, useEffect, useCallback, useState } from 'react'; import { Topbar } from '../../components/layout/Topbar'; import { Send, Download, Zap, Paperclip, X, FileText, AlertCircle } from 'lucide-react'; import { usePageState } from '../../contexts'; import type { RagCitation } from '../../contexts'; import { useLanguage } from '../../contexts/LanguageContext'; import { agenticChat } from '../../api/rag'; import type { SSEMessage } from '../../api/index'; const TOKEN_KEY = 'auth_token'; function authHeader(): Record { const t = localStorage.getItem(TOKEN_KEY); return t ? { Authorization: `Bearer ${t}` } : {}; } // ── Document context state ───────────────────────────────────────────────────── interface DocContext { filename: string; text: string; charCount: number; truncated: boolean; /** 'extracting' while the backend is parsing; 'ready' when text is available; 'error' on failure */ status: 'extracting' | 'ready' | 'error'; errorMsg?: string; } // ── Agentic-mode types ──────────────────────────────────────────────────────── interface ThinkingStep { id: string; step: string; status: 'running' | 'done'; intent_type?: string; reason?: string; requires_decomposition?: boolean; sub_queries?: string[]; query?: string; index?: number; total?: number; found?: number; sufficient?: boolean; confidence?: number; retry?: boolean; } const STEP_ICONS: Record = { intent_analysis: '🔍', query_planning: '📋', retrieving: '📚', grounding_check: '🔗', }; // ── Helpers ─────────────────────────────────────────────────────────────────── // Map a raw source doc from the backend "retrieved" event to our Citation shape. function mapSource(s: Record, idx: number): RagCitation { const rawScore = typeof s.score === 'number' ? s.score : 0; const displayScore = rawScore <= 1 ? Math.round(rawScore * 100) : Math.round(rawScore); return { index: idx, score: displayScore, name: String(s.doc_name ?? ''), clause: String(s.clause ?? s.section_title ?? ''), snippet: String(s.preview ?? s.text ?? ''), docId: s.doc_id ? String(s.doc_id) : undefined, }; } // Parse message text and replace [N] with clickable ); } return part; }); } const MOCK_QUICK = [ 'What does EU AI Act Art. 9 require for risk management?', 'Which documents need CSMS certification?', 'Summarize MIIT training data rules', 'What are high-risk AI categories under Annex III?', ]; export function RagChatPage() { // All persistent state lives in PageStateContext — survives route changes const { ragState, setRagState, ragStreamingRef, ragAbortRef } = usePageState(); const { t } = useLanguage(); const { messages, citations, sessionId, inputDraft } = ragState; // Local-only UI state: highlighted citation and streaming indicator // These are fine to reset on navigation since they're transient UI feedback const [highlightedCit, setHighlightedCit] = useState(null); const [streaming, setStreaming] = useState(ragStreamingRef.current); const [quickPrompts, setQuickPrompts] = useState(MOCK_QUICK); // P0-1 Agentic mode state const [agenticMode, setAgenticMode] = useState(false); const [thinkingSteps, setThinkingSteps] = useState([]); const [thinkingExpanded, setThinkingExpanded] = useState(true); // ── Document context state ───────────────────────────────────────────────── // Holds the extracted text from the attached file; sent to the backend as // conversation context on every message while it is set. const [docContext, setDocContext] = useState(null); const fileInputRef = useRef(null); const bottomRef = useRef(null); const citRailRef = useRef(null); const citItemRefs = useRef>({}); // ── Document context helpers ─────────────────────────────────────────────── /** Upload file to /rag/upload-context, extract its text, store as context. */ async function handleFileAttach(file: File) { setDocContext({ filename: file.name, text: '', charCount: 0, truncated: false, status: 'extracting' }); const fd = new FormData(); fd.append('file', file); try { const res = await fetch('/api/v1/rag/upload-context', { method: 'POST', headers: authHeader(), body: fd, }); if (!res.ok) { const errText = await res.text().catch(() => t.ragchat.attachErrorMsg); setDocContext(prev => prev ? { ...prev, status: 'error', errorMsg: errText.slice(0, 120) } : null); return; } const data = await res.json(); setDocContext({ filename: data.filename ?? file.name, text: data.text ?? '', charCount: data.char_count ?? 0, truncated: data.truncated ?? false, status: 'ready', }); } catch (err) { setDocContext(prev => prev ? { ...prev, status: 'error', errorMsg: String(err).slice(0, 120) } : null ); } } function handleFileInputChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (file) void handleFileAttach(file); // Reset so the same file can be re-selected e.target.value = ''; } function handleFileDrop(e: React.DragEvent) { e.preventDefault(); const file = Array.from(e.dataTransfer.files).find(f => /\.(pdf|docx?|txt|md)$/i.test(f.name) ); if (file) void handleFileAttach(file); } // Fetch quick questions from backend on mount (only once per session) useEffect(() => { fetch('/api/v1/rag/quick-questions', { headers: authHeader() }) .then(r => r.json()) .then(d => { if (Array.isArray(d?.questions) && d.questions.length > 0) { setQuickPrompts(d.questions.map((q: { question: string }) => q.question)); } }) .catch(() => { /* keep mock */ }); }, []); // Auto-scroll to latest message useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); // Jump to citation N in the rail and highlight it const jumpToCitation = useCallback((n: number) => { setHighlightedCit(n); const el = citItemRefs.current[n]; if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } setTimeout(() => setHighlightedCit(h => h === n ? null : h), 3000); }, []); async function send(text?: string) { const q = (text ?? inputDraft).trim(); // Block send while a document is still being extracted if (!q || ragStreamingRef.current || docContext?.status === 'extracting') return; setRagState(s => ({ ...s, inputDraft: '' })); // Show document context badge in user message bubble when active const docPrefix = docContext?.status === 'ready' ? `📄 ${docContext.filename}\n` : ''; const displayQuery = docPrefix + q; const userMsgId = Date.now().toString(); const assistantId = (Date.now() + 1).toString(); setRagState(s => ({ ...s, messages: [ ...s.messages, { id: userMsgId, role: 'user', text: displayQuery }, { id: assistantId, role: 'assistant', text: '' }, ], citations: [], })); ragStreamingRef.current = true; setStreaming(true); setHighlightedCit(null); // P0-1: reset thinking panel for new query if (agenticMode) { setThinkingSteps([]); setThinkingExpanded(true); } const ctrl = new AbortController(); ragAbortRef.current = ctrl; if (agenticMode) { // ── Agentic path ──────────────────────────────────────────────────── const newCitations: RagCitation[] = []; const handleMessage = (msg: SSEMessage) => { if (msg.type === 'session') { if (msg.session_id) setRagState(s => ({ ...s, sessionId: msg.session_id! })); } else if (msg.type === 'thinking') { // Build a stable step id so we can upsert running→done transitions. const stepId = `${msg.step}-${msg.retry ? 'retry' : (msg.index ?? 0)}`; setThinkingSteps(prev => { const idx = prev.findIndex(s => s.id === stepId); const stepObj: ThinkingStep = { id: stepId, step: msg.step ?? '', status: (msg.status as 'running' | 'done') ?? 'running', intent_type: msg.intent_type, reason: msg.reason, sub_queries: msg.sub_queries, query: msg.query, index: msg.index, total: msg.total, found: msg.found, sufficient: msg.sufficient, confidence: msg.confidence, retry: msg.retry, }; if (idx >= 0) { const updated = [...prev]; updated[idx] = stepObj; return updated; } return [...prev, stepObj]; }); } else if (msg.type === 'retrieved' && Array.isArray(msg.docs)) { const mapped = (msg.docs as unknown as Record[]).map((d, i) => mapSource(d, i + 1)); newCitations.push(...mapped); setRagState(s => ({ ...s, citations: [...mapped] })); } else if (msg.type === 'chunk' && msg.text) { setRagState(s => ({ ...s, messages: s.messages.map(m => m.id === assistantId ? { ...m, text: m.text + msg.text! } : m ), })); } else if (msg.type === 'done') { setThinkingExpanded(false); setRagState(s => ({ ...s, messages: s.messages.map(m => { if (m.id !== assistantId) return m; const refs = [...new Set( [...m.text.matchAll(/\[(\d+)\]/g)].map(r => parseInt(r[1], 10)) )].filter(n => n >= 1 && n <= newCitations.length); return { ...m, citationRefs: refs }; }), })); } else if (msg.type === 'error') { setRagState(s => ({ ...s, messages: s.messages.map(m => m.id === assistantId ? { ...m, text: `Error: ${msg.text ?? 'Unknown error'}` } : m ), })); } }; try { await agenticChat( q, 5, handleMessage, (err) => { setRagState(s => ({ ...s, messages: s.messages.map(m => m.id === assistantId ? { ...m, text: t.ragchat.apiError } : m ), })); console.error('agenticChat error:', err); }, undefined, undefined, sessionId ?? undefined, ctrl.signal, // Pass document context to agentic pipeline docContext?.status === 'ready' ? docContext.text : undefined, docContext?.status === 'ready' ? docContext.filename : undefined, ); } finally { ragStreamingRef.current = false; setStreaming(false); } } else { // ── Standard RAG path (unchanged) ─────────────────────────────────── try { const body: Record = { query: q, top_k: 5 }; if (sessionId) body.session_id = sessionId; // Inject document text as conversation context when a file is attached if (docContext?.status === 'ready') { body.context_text = docContext.text; body.context_filename = docContext.filename; } const res = await fetch('/api/v1/rag/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeader() }, body: JSON.stringify(body), signal: ctrl.signal, }); if (!res.body) throw new Error('No stream'); const reader = res.body.getReader(); const dec = new TextDecoder(); let buffer = ''; const newCitations: RagCitation[] = []; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += dec.decode(value, { stream: true }); const blocks = buffer.split('\n\n'); buffer = blocks.pop() ?? ''; for (const block of blocks) { const dataLine = block.split('\n').find(l => l.startsWith('data: ')); if (!dataLine) continue; const raw = dataLine.slice(6).trim(); if (!raw) continue; try { const j = JSON.parse(raw); if (j.type === 'session') { if (j.session_id) setRagState(s => ({ ...s, sessionId: j.session_id })); } else if (j.type === 'retrieved' && Array.isArray(j.docs)) { const mapped = j.docs.map((d: Record, i: number) => mapSource(d, i + 1)); newCitations.push(...mapped); setRagState(s => ({ ...s, citations: [...mapped] })); } else if (j.type === 'chunk' && j.text) { setRagState(s => ({ ...s, messages: s.messages.map(msg => msg.id === assistantId ? { ...msg, text: msg.text + (j.text as string) } : msg ), })); } else if (j.type === 'done') { setRagState(s => ({ ...s, messages: s.messages.map(msg => { if (msg.id !== assistantId) return msg; const refs = [...new Set( [...msg.text.matchAll(/\[(\d+)\]/g)].map(r => parseInt(r[1], 10)) )].filter(n => n >= 1 && n <= newCitations.length); return { ...msg, citationRefs: refs }; }), })); break; } else if (j.type === 'error') { setRagState(s => ({ ...s, messages: s.messages.map(msg => msg.id === assistantId ? { ...msg, text: `Error: ${j.text ?? 'Unknown error'}` } : msg ), })); } } catch { /* malformed JSON chunk, skip */ } } } } catch (e: unknown) { if (e instanceof Error && e.name !== 'AbortError') { setRagState(s => ({ ...s, messages: s.messages.map(msg => msg.id === assistantId ? { ...msg, text: t.ragchat.apiError } : msg ), })); } } finally { ragStreamingRef.current = false; setStreaming(false); } } } const lastAssistantId = [...messages].reverse().find(m => m.role === 'assistant')?.id; return (
{ const text = messages.map(m => `${m.role === 'user' ? 'Q' : 'A'}: ${m.text}`).join('\n\n'); const blob = new Blob([text], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'chat-export.txt'; a.click(); URL.revokeObjectURL(url); }} > {t.ragchat.exportBtn} } />
{/* ── History pane ── */}
{t.ragchat.quickPromptsHeader}
{quickPrompts.map(q => ( ))}
{/* ── Chat main ── */}
{/* P0-1: Agentic Thinking Panel — shown when agentic mode is active */} {agenticMode && thinkingSteps.length > 0 && (
{/* Panel header — clickable to collapse/expand */} {/* Step list */} {thinkingExpanded && (
{thinkingSteps.map(step => { const stepLabels: Record = { intent_analysis: t.ragchat.stepIntentAnalysis, query_planning: t.ragchat.stepQueryPlanning, retrieving: t.ragchat.stepRetrieving, grounding_check: t.ragchat.stepGrounding, }; const intentLabels: Record = { simple_qa: t.ragchat.intentSimpleQa, compare: t.ragchat.intentCompare, multi_hop: t.ragchat.intentMultiHop, ambiguous: t.ragchat.intentAmbiguous, }; return (
{step.status === 'running' ? : (STEP_ICONS[step.step] ?? '·') } {stepLabels[step.step] ?? step.step} {/* Intent analysis detail */} {step.step === 'intent_analysis' && step.status === 'done' && step.intent_type && ( → {intentLabels[step.intent_type] ?? step.intent_type} {step.requires_decomposition && ` · ${t.ragchat.intentNeedsDecomposition}`} )} {/* Query planning detail */} {step.step === 'query_planning' && step.status === 'done' && step.sub_queries && ( → {step.sub_queries.length} {t.ragchat.subQueriesCountSuffix} )} {/* Retrieval detail */} {step.step === 'retrieving' && ( {step.total && step.total > 1 && `[${step.index}/${step.total}] `} {step.retry && t.ragchat.retryLabel} {step.query && step.query.length > 50 ? step.query.slice(0, 50) + '…' : step.query} {step.status === 'done' && step.found !== undefined && ( 0 ? 'var(--success-fg, #16a34a)' : 'var(--warning, #ca8a04)' }}> {' '}· {step.found} {t.ragchat.chunksFoundSuffix} )} )} {/* Grounding check detail */} {step.step === 'grounding_check' && step.status === 'done' && ( → {step.sufficient ? t.ragchat.groundingSufficient : t.ragchat.groundingInsufficient} {step.confidence !== undefined && ` (${Math.round(step.confidence * 100)}%)`} )}
); })}
)}
)} {/* Messages area — accepts drag-and-drop document context attachment */}
{ e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }} onDrop={handleFileDrop} > {messages.map(msg => (
{msg.role === 'assistant' &&
AI
}
{msg.role === 'assistant' ? renderWithCitations(msg.text, jumpToCitation) : msg.text } {streaming && msg.id === lastAssistantId && ( )}
{msg.role === 'user' &&
You
}
))}
{quickPrompts.slice(0, 3).map(q => ( ))}
{/* P0-1: Agentic mode toggle */}
{agenticMode && ( {t.ragchat.agenticModeHint} )}
{/* ── Document context badge ── */} {docContext && (
{docContext.status === 'extracting' && ( )} {docContext.status === 'ready' && } {docContext.status === 'error' && } {t.ragchat.attachContextBadge} {docContext.filename} {docContext.status === 'ready' && ( {(docContext.charCount / 1000).toFixed(1)}k chars {docContext.truncated ? ` · ${t.ragchat.attachTruncated}` : ''} )} {docContext.status === 'extracting' && ( {t.ragchat.attachExtracting} )} {docContext.status === 'error' && ( {t.ragchat.attachError} )} {/* Clear button */}
)} {/* Hidden file input */}
{/* Paperclip button — replaces attached doc when clicked again */}