import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTheme } from '../../contexts'; import type { ChatMessage, RetrievalData } from '../../types'; import { getQuickQuestions, ragChat } from '../../api/rag'; import { CitedAnswer } from './CitedAnswer'; const ragQuickQuestionsDefault = [ '电动自行车上路需要什么条件?', '驾驶证如何申请?', '超速行驶如何处罚?', '车辆年检有哪些规定?', '电动汽车电池安全标准?', '正面碰撞测试要求?', 'AEB系统测试标准?', '高速公路安全距离?', ]; export const RagChatPage: React.FC = () => { const { theme } = useTheme(); const nextMessageIdRef = useRef(1); const [messages, setMessages] = useState([]); // retrievals: right-panel shows sources of the most recent assistant reply const [retrievals, setRetrievals] = useState([]); const [input, setInput] = useState(''); const [loading, setLoading] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false); const [selectedRetrieval, setSelectedRetrieval] = useState(null); const [quickQuestions, setQuickQuestions] = useState(ragQuickQuestionsDefault); const [filterRegulationType, setFilterRegulationType] = useState(''); const [highlightedSourceIdx, setHighlightedSourceIdx] = useState(null); const [sessionId, setSessionId] = useState(); // Auto-scroll ref const messagesEndRef = useRef(null); // AbortController for cancelling in-flight requests const abortRef = useRef(null); function nextMessageId() { const id = nextMessageIdRef.current; nextMessageIdRef.current += 1; return id; } // Scroll to bottom whenever messages change useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); async function loadQuickQuestions() { try { const response = await getQuickQuestions(); setQuickQuestions(response.questions.map(q => q.question)); } catch { // keep defaults } } useEffect(() => { const timerId = window.setTimeout(() => { void loadQuickQuestions(); }, 0); return () => window.clearTimeout(timerId); }, []); /** * Core query executor — shared by sendMessage and regenerateLastAnswer. * Manages session_id, AbortController, SSE parsing, and state updates. */ const executeQuery = useCallback((text: string) => { // Cancel any in-flight request abortRef.current?.abort(); abortRef.current = new AbortController(); const activeFilters = filterRegulationType.trim() || undefined; let currentResponse = ''; // Capture the assistant message id so we can attach sources later let assistantMsgId: number | null = null; void ragChat( text, 5, (data) => { if (data.type === 'session' && data.session_id) { setSessionId(data.session_id); } else if (data.type === 'retrieved' && data.docs) { const docs: RetrievalData[] = data.docs.map(d => ({ id: parseInt(d.id.replace('chunk-', ''), 10) || 1, file: d.doc_name, clause: d.clause, score: d.score, content: d.preview, docId: d.doc_id, downloadUrl: d.download_url, })); setRetrievals(docs); // Attach sources to the assistant message once we know its id if (assistantMsgId !== null) { setMessages(prev => prev.map(m => m.id === assistantMsgId ? { ...m, sources: docs } : m )); } } else if (data.type === 'chunk' && data.text) { currentResponse += data.text; setMessages(prev => { const last = prev[prev.length - 1]; if (last?.role === 'assistant' && last.id === assistantMsgId) { return [...prev.slice(0, -1), { ...last, content: currentResponse }]; } // First chunk: create assistant message const newId = nextMessageId(); assistantMsgId = newId; return [...prev, { id: newId, role: 'assistant' as const, content: currentResponse }]; }); } else if (data.type === 'done') { if (data.session_id) setSessionId(data.session_id); setLoading(false); } else if (data.type === 'error') { setLoading(false); setMessages(prev => [ ...prev, { id: nextMessageId(), role: 'assistant' as const, content: '抱歉,生成回答时出错,请稍后再试。' }, ]); } }, (error) => { console.error('RAG chat error:', error); setLoading(false); setMessages(prev => [ ...prev, { id: nextMessageId(), role: 'assistant' as const, content: '抱歉,连接服务器时出错,请稍后再试。' }, ]); }, () => { setLoading(false); }, activeFilters, sessionId, abortRef.current.signal, ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [filterRegulationType, sessionId]); const sendMessage = (text: string) => { if (!text.trim() || loading) return; setMessages(prev => [...prev, { id: nextMessageId(), role: 'user' as const, content: text }]); setInput(''); setLoading(true); setRetrievals([]); setHighlightedSourceIdx(null); executeQuery(text); }; const regenerateLastAnswer = () => { if (loading) return; const lastUserMsg = [...messages].reverse().find(m => m.role === 'user'); if (!lastUserMsg) return; // Remove the last assistant message setMessages(prev => { const lastAssistantIdx = [...prev].reverse().findIndex(m => m.role === 'assistant'); if (lastAssistantIdx === -1) return prev; const idx = prev.length - 1 - lastAssistantIdx; return [...prev.slice(0, idx)]; }); setLoading(true); setRetrievals([]); setHighlightedSourceIdx(null); executeQuery(lastUserMsg.content); }; const clearMessages = () => { abortRef.current?.abort(); setMessages([]); setRetrievals([]); setSessionId(undefined); setShowClearConfirm(false); setLoading(false); }; return (
{/* ── Left: chat panel ─────────────────────────────────── */}
{/* Message list */}
{messages.length === 0 ? (
开始法规对话
选择快捷问题或输入您的问题
) : ( messages.map(msg => (
{msg.role === 'assistant' && (
)}
{msg.role === 'assistant' ? ( { const msgSources = msg.sources ?? retrievals; setRetrievals(msgSources); setHighlightedSourceIdx(idx); const el = document.getElementById(`source-${idx}`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }} /> ) : msg.content} {msg.role === 'assistant' && msg.retrievalIds && msg.retrievalIds.length > 0 && (
{msg.retrievalIds.length} 个法规引用
)}
)) )} {loading && (
检索中...
)} {/* Scroll anchor */}
{/* Input area */}
{/* Filter row */}
法规类型 setFilterRegulationType(e.target.value)} placeholder="如: GB / UN-ECE / IATF(留空不过滤)" style={{ flex: 1, maxWidth: 280, padding: '5px 10px', fontSize: 12, background: theme.bgHover, border: `1px solid ${theme.border}`, borderRadius: 6, color: theme.text, outline: 'none', }} />
{/* Quick questions */}
{quickQuestions.map(q => ( ))}
{/* Send row */}
setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendMessage(input)} placeholder="输入法规问题..." style={{ flex: 1, padding: 12, fontSize: 14, background: theme.bgCard, border: `1px solid ${theme.border}`, borderRadius: 8, color: theme.text, outline: 'none', }} /> {loading && ( )} {!loading && messages.length > 0 && ( )} {!loading && messages.filter(m => m.role === 'assistant').length > 0 && ( )}
{/* ── Right: retrieved sources panel ───────────────────── */}
RETRIEVED FRAGMENTS {retrievals.length > 0 && ( {retrievals.length} )}
{retrievals.length > 0 ? (
{retrievals.map((r, i) => (
setSelectedRetrieval(r)} style={{ padding: 16, background: highlightedSourceIdx === i + 1 ? theme.bgElevated : theme.bgHover, borderRadius: 10, border: `1px solid ${highlightedSourceIdx === i + 1 ? theme.accent : theme.border}`, cursor: 'pointer', position: 'relative', transition: 'border-color 0.2s, background 0.2s', }} >
{r.file}
{r.clause}{r.docId ? ` · ${r.docId}` : ''}
{r.content}
))}
) : (
对话后显示相关法规
)}
{/* ── Clear confirm modal ───────────────────────────────── */} {showClearConfirm && (
确定清空对话?
此操作不可恢复,会话历史将被重置
)} {/* ── Source detail modal ───────────────────────────────── */} {selectedRetrieval && (
setSelectedRetrieval(null)} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, }} >
e.stopPropagation()} style={{ width: 520, maxWidth: '90%', maxHeight: '80%', overflowY: 'auto', padding: 24, background: theme.bgCard, borderRadius: 16, border: `1px solid ${theme.accent}`, boxShadow: '0 8px 32px rgba(0,0,0,0.3)', }} >
{(selectedRetrieval.score * 100).toFixed(0)}%
{selectedRetrieval.file} {selectedRetrieval.downloadUrl && ( 下载关联文档 )}
{selectedRetrieval.clause} {selectedRetrieval.docId && ( {selectedRetrieval.docId} )}
{selectedRetrieval.content}
)}
); };