fix somethings
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import { Topbar } from '../../components/layout/Topbar';
|
||||
import { Send, Download } from 'lucide-react';
|
||||
import { usePageState } from '../../contexts';
|
||||
import type { RagCitation } from '../../contexts';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
@@ -8,26 +10,8 @@ function authHeader(): Record<string, string> {
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
// citation indices mentioned in this assistant message (1-based, matching citations array)
|
||||
citationRefs?: number[];
|
||||
}
|
||||
|
||||
interface Citation {
|
||||
index: number; // 1-based, matches [N] markers in text
|
||||
score: number; // 0–100 display percentage
|
||||
name: string; // doc_name
|
||||
clause: string; // section_title or clause
|
||||
snippet: string; // preview text
|
||||
docId?: string;
|
||||
}
|
||||
|
||||
// Map a raw source doc from the backend "retrieved" event to our Citation shape.
|
||||
// Backend fields: { id, score(0-1), preview, doc_name, clause, doc_id }
|
||||
function mapSource(s: Record<string, unknown>, idx: number): Citation {
|
||||
function mapSource(s: Record<string, unknown>, idx: number): RagCitation {
|
||||
const rawScore = typeof s.score === 'number' ? s.score : 0;
|
||||
const displayScore = rawScore <= 1 ? Math.round(rawScore * 100) : Math.round(rawScore);
|
||||
return {
|
||||
@@ -73,25 +57,21 @@ const MOCK_QUICK = [
|
||||
];
|
||||
|
||||
export function RagChatPage() {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: 'init', role: 'assistant',
|
||||
text: 'Hello! I can answer questions about your indexed regulations and compliance documents. Try asking about EU AI Act requirements, MIIT rules, or ISO/SAE 21434 scope.',
|
||||
}
|
||||
]);
|
||||
const [quickPrompts, setQuickPrompts] = useState<string[]>(MOCK_QUICK);
|
||||
const [input, setInput] = useState('');
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [citations, setCitations] = useState<Citation[]>([]);
|
||||
// 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
|
||||
const [highlightedCit, setHighlightedCit] = useState<number | null>(null);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [streaming, setStreaming] = useState(ragStreamingRef.current);
|
||||
const [quickPrompts, setQuickPrompts] = useState<string[]>(MOCK_QUICK);
|
||||
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const citRailRef = useRef<HTMLDivElement>(null);
|
||||
const citItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Fetch quick questions from backend on mount
|
||||
// Fetch quick questions from backend on mount (only once per session)
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/rag/quick-questions', { headers: authHeader() })
|
||||
.then(r => r.json())
|
||||
@@ -115,26 +95,33 @@ export function RagChatPage() {
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
// Clear highlight after 3s
|
||||
setTimeout(() => setHighlightedCit(h => h === n ? null : h), 3000);
|
||||
}, []);
|
||||
|
||||
async function send(text?: string) {
|
||||
const q = (text ?? input).trim();
|
||||
if (!q || streaming) return;
|
||||
setInput('');
|
||||
|
||||
const userMsg: Message = { id: Date.now().toString(), role: 'user', text: q };
|
||||
setMessages(m => [...m, userMsg]);
|
||||
const q = (text ?? inputDraft).trim();
|
||||
if (!q || ragStreamingRef.current) return;
|
||||
setRagState(s => ({ ...s, inputDraft: '' }));
|
||||
|
||||
const userMsgId = Date.now().toString();
|
||||
const assistantId = (Date.now() + 1).toString();
|
||||
setMessages(m => [...m, { id: assistantId, role: 'assistant', text: '' }]);
|
||||
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: [
|
||||
...s.messages,
|
||||
{ id: userMsgId, role: 'user', text: q },
|
||||
{ id: assistantId, role: 'assistant', text: '' },
|
||||
],
|
||||
citations: [],
|
||||
}));
|
||||
|
||||
ragStreamingRef.current = true;
|
||||
setStreaming(true);
|
||||
setCitations([]);
|
||||
setHighlightedCit(null);
|
||||
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
ragAbortRef.current = ctrl;
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = { query: q, top_k: 5 };
|
||||
@@ -151,14 +138,13 @@ export function RagChatPage() {
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buffer = '';
|
||||
const newCitations: Citation[] = [];
|
||||
const newCitations: RagCitation[] = [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += dec.decode(value, { stream: true });
|
||||
|
||||
// SSE blocks separated by double newline
|
||||
const blocks = buffer.split('\n\n');
|
||||
buffer = blocks.pop() ?? '';
|
||||
|
||||
@@ -171,56 +157,62 @@ export function RagChatPage() {
|
||||
const j = JSON.parse(raw);
|
||||
|
||||
if (j.type === 'session') {
|
||||
// Backend assigned a session_id — persist for next request
|
||||
if (j.session_id) setSessionId(j.session_id);
|
||||
if (j.session_id) setRagState(s => ({ ...s, sessionId: j.session_id }));
|
||||
|
||||
} else if (j.type === 'retrieved' && Array.isArray(j.docs)) {
|
||||
// Sources arrive before the answer starts
|
||||
const mapped = j.docs.map((d: Record<string, unknown>, i: number) => mapSource(d, i + 1));
|
||||
newCitations.push(...mapped);
|
||||
setCitations([...mapped]);
|
||||
setRagState(s => ({ ...s, citations: [...mapped] }));
|
||||
|
||||
} else if (j.type === 'chunk' && j.text) {
|
||||
setMessages(m => m.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: msg.text + (j.text as string) }
|
||||
: msg
|
||||
));
|
||||
|
||||
} else if (j.type === 'status') {
|
||||
// Status message (e.g. "找到N条相关法规…") — could show in UI if desired
|
||||
// For now we ignore it to keep the bubble clean
|
||||
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') {
|
||||
// Extract which citation numbers appear in the final answer
|
||||
setMessages(m => m.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 };
|
||||
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') {
|
||||
setMessages(m => m.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: `Error: ${j.text ?? 'Unknown error'}` }
|
||||
: msg
|
||||
));
|
||||
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') {
|
||||
setMessages(m => m.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: 'Could not reach the RAG API. Please check the backend.' }
|
||||
: msg
|
||||
));
|
||||
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 {
|
||||
ragStreamingRef.current = false;
|
||||
setStreaming(false);
|
||||
}
|
||||
}
|
||||
@@ -291,15 +283,15 @@ export function RagChatPage() {
|
||||
<textarea
|
||||
className="composer-input"
|
||||
placeholder="Ask about your regulations…"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
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()}
|
||||
disabled={!input.trim() || streaming}
|
||||
disabled={!inputDraft.trim() || streaming}
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user