fix somethings

This commit is contained in:
wangwei
2026-06-08 11:16:28 +08:00
parent 9fea9c6a53
commit e7963b267e
34 changed files with 5195 additions and 246 deletions
+70 -78
View File
@@ -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; // 0100 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>