Files
AIRegulation-DocAnalysis/frontend/src/pages/RagChat/RagChatPage.tsx
T

334 lines
12 KiB
TypeScript
Raw Normal View History

2026-06-08 11:16:28 +08:00
import { useRef, useEffect, useCallback, useState } from 'react';
import { Topbar } from '../../components/layout/Topbar';
import { Send, Download } from 'lucide-react';
2026-06-08 11:16:28 +08:00
import { usePageState } from '../../contexts';
import type { RagCitation } from '../../contexts';
2026-06-05 18:00:31 +08:00
const TOKEN_KEY = 'auth_token';
function authHeader(): Record<string, string> {
const t = localStorage.getItem(TOKEN_KEY);
return t ? { Authorization: `Bearer ${t}` } : {};
}
2026-06-04 15:43:44 +08:00
// Map a raw source doc from the backend "retrieved" event to our Citation shape.
2026-06-08 11:16:28 +08:00
function mapSource(s: Record<string, unknown>, idx: number): RagCitation {
2026-06-04 15:43:44 +08:00
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,
};
}
2026-06-04 15:43:44 +08:00
// Parse message text and replace [N] with clickable <button class="cite-ref"> elements.
function renderWithCitations(
text: string,
onCiteClick: (n: number) => void,
): React.ReactNode[] {
const parts = text.split(/(\[\d+\])/g);
return parts.map((part, i) => {
const m = part.match(/^\[(\d+)\]$/);
if (m) {
const n = parseInt(m[1], 10);
return (
<button
key={i}
className="cite-ref"
onClick={() => onCiteClick(n)}
title={`Jump to source [${n}]`}
>
{n}
</button>
);
}
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() {
2026-06-08 11:16:28 +08:00
// All persistent state lives in PageStateContext — survives route changes
const { ragState, setRagState, ragStreamingRef, ragAbortRef } = usePageState();
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
2026-06-04 15:43:44 +08:00
const [highlightedCit, setHighlightedCit] = useState<number | null>(null);
2026-06-08 11:16:28 +08:00
const [streaming, setStreaming] = useState(ragStreamingRef.current);
const [quickPrompts, setQuickPrompts] = useState<string[]>(MOCK_QUICK);
2026-06-04 15:43:44 +08:00
const bottomRef = useRef<HTMLDivElement>(null);
2026-06-04 15:43:44 +08:00
const citRailRef = useRef<HTMLDivElement>(null);
const citItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
2026-06-08 11:16:28 +08:00
// Fetch quick questions from backend on mount (only once per session)
2026-06-04 15:43:44 +08:00
useEffect(() => {
2026-06-05 18:00:31 +08:00
fetch('/api/v1/rag/quick-questions', { headers: authHeader() })
2026-06-04 15:43:44 +08:00
.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]);
2026-06-04 15:43:44 +08:00
// 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) {
2026-06-08 11:16:28 +08:00
const q = (text ?? inputDraft).trim();
if (!q || ragStreamingRef.current) return;
setRagState(s => ({ ...s, inputDraft: '' }));
2026-06-08 11:16:28 +08:00
const userMsgId = Date.now().toString();
const assistantId = (Date.now() + 1).toString();
2026-06-08 11:16:28 +08:00
setRagState(s => ({
...s,
messages: [
...s.messages,
{ id: userMsgId, role: 'user', text: q },
{ id: assistantId, role: 'assistant', text: '' },
],
citations: [],
}));
ragStreamingRef.current = true;
setStreaming(true);
2026-06-04 15:43:44 +08:00
setHighlightedCit(null);
const ctrl = new AbortController();
2026-06-08 11:16:28 +08:00
ragAbortRef.current = ctrl;
try {
2026-06-04 15:43:44 +08:00
const body: Record<string, unknown> = { query: q, top_k: 5 };
if (sessionId) body.session_id = sessionId;
const res = await fetch('/api/v1/rag/chat', {
method: 'POST',
2026-06-05 18:00:31 +08:00
headers: { 'Content-Type': 'application/json', ...authHeader() },
2026-06-04 15:43:44 +08:00
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 = '';
2026-06-08 11:16:28 +08:00
const newCitations: RagCitation[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
2026-06-04 15:43:44 +08:00
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') {
2026-06-08 11:16:28 +08:00
if (j.session_id) setRagState(s => ({ ...s, sessionId: j.session_id }));
2026-06-04 15:43:44 +08:00
} else if (j.type === 'retrieved' && Array.isArray(j.docs)) {
const mapped = j.docs.map((d: Record<string, unknown>, i: number) => mapSource(d, i + 1));
newCitations.push(...mapped);
2026-06-08 11:16:28 +08:00
setRagState(s => ({ ...s, citations: [...mapped] }));
2026-06-04 15:43:44 +08:00
} else if (j.type === 'chunk' && j.text) {
2026-06-08 11:16:28 +08:00
setRagState(s => ({
...s,
messages: s.messages.map(msg =>
msg.id === assistantId
? { ...msg, text: msg.text + (j.text as string) }
: msg
),
}));
2026-06-04 15:43:44 +08:00
} else if (j.type === 'done') {
2026-06-08 11:16:28 +08:00
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 };
}),
2026-06-04 15:43:44 +08:00
}));
break;
} else if (j.type === 'error') {
2026-06-08 11:16:28 +08:00
setRagState(s => ({
...s,
messages: s.messages.map(msg =>
msg.id === assistantId
? { ...msg, text: `Error: ${j.text ?? 'Unknown error'}` }
: msg
),
}));
}
2026-06-04 15:43:44 +08:00
} catch { /* malformed JSON chunk, skip */ }
}
}
} catch (e: unknown) {
if (e instanceof Error && e.name !== 'AbortError') {
2026-06-08 11:16:28 +08:00
setRagState(s => ({
...s,
messages: s.messages.map(msg =>
msg.id === assistantId
? { ...msg, text: 'Could not reach the RAG API. Please check the backend.' }
: msg
),
}));
}
} finally {
2026-06-08 11:16:28 +08:00
ragStreamingRef.current = false;
setStreaming(false);
}
}
const lastAssistantId = [...messages].reverse().find(m => m.role === 'assistant')?.id;
return (
<div className="chat-page">
<Topbar
title="Regulation Q&A"
2026-06-04 15:43:44 +08:00
actions={
<button
className="btn sm"
onClick={() => {
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);
}}
>
<Download size={13} />Export chat
</button>
}
/>
2026-06-04 15:43:44 +08:00
<div className="chat-body">
2026-06-04 15:43:44 +08:00
{/* ── History pane ── */}
<div className="history-pane">
2026-06-04 15:43:44 +08:00
<div className="history-header">Quick prompts</div>
{quickPrompts.map(q => (
<button key={q} className="quick-item" onClick={() => send(q)}>
{q}
</button>
))}
</div>
2026-06-04 15:43:44 +08:00
{/* ── Chat main ── */}
<div className="chat-main">
<div className="messages">
{messages.map(msg => (
<div key={msg.id} className={`message msg-${msg.role}`}>
{msg.role === 'assistant' && <div className="msg-avatar">AI</div>}
<div className="msg-bubble">
2026-06-04 15:43:44 +08:00
{msg.role === 'assistant'
? renderWithCitations(msg.text, jumpToCitation)
: msg.text
}
{streaming && msg.id === lastAssistantId && (
<span className="blink-cursor"></span>
)}
</div>
{msg.role === 'user' && <div className="msg-avatar user-av">You</div>}
</div>
))}
<div ref={bottomRef} />
</div>
<div className="composer">
<div className="quick-chips">
2026-06-04 15:43:44 +08:00
{quickPrompts.slice(0, 3).map(q => (
<button key={q} className="chip" onClick={() => send(q)}>
{q.length > 42 ? q.slice(0, 42) + '…' : q}
</button>
))}
</div>
<div className="composer-row">
<textarea
className="composer-input"
2026-06-04 15:43:44 +08:00
placeholder="Ask about your regulations…"
2026-06-08 11:16:28 +08:00
value={inputDraft}
onChange={e => setRagState(s => ({ ...s, inputDraft: e.target.value }))}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
rows={2}
/>
<button
className="btn primary"
onClick={() => send()}
2026-06-08 11:16:28 +08:00
disabled={!inputDraft.trim() || streaming}
>
<Send size={14} />
</button>
</div>
</div>
</div>
2026-06-04 15:43:44 +08:00
{/* ── Citation rail ── */}
<div className="citation-rail" ref={citRailRef}>
<div className="citation-header">
Sources {citations.length > 0 && `(${citations.length})`}
</div>
{citations.length === 0 && (
<p style={{ padding: '12px 16px', fontSize: 12, color: 'var(--muted)', lineHeight: 1.5 }}>
Citations will appear here after a response is generated.
</p>
)}
{citations.map(c => (
2026-06-04 15:43:44 +08:00
<div
key={c.index}
ref={el => { citItemRefs.current[c.index] = el; }}
className={`citation-item${highlightedCit === c.index ? ' highlighted' : ''}`}
>
<div className="cit-index">[{c.index}]</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginBottom: 3 }}>
<div className="cit-name">{c.name}</div>
{c.clause && <span className="cit-clause">{c.clause}</span>}
<span className="cit-score" style={{ marginLeft: 'auto' }}>{c.score}%</span>
</div>
<div className="cit-snippet">{c.snippet}</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}