From 1bb7151abe9a31dee33caa77308653c374344d18 Mon Sep 17 00:00:00 2001 From: "Yuemin.Mao" Date: Mon, 11 May 2026 11:32:02 +0800 Subject: [PATCH] feat: Implement compliance document analysis and chat functionality - Added API functions for document analysis, compliance result retrieval, and chat streaming. - Integrated document upload and analysis in CompliancePage with progress simulation. - Enhanced DocsPage to load documents from the API and handle file uploads with parsing and embedding. - Updated RagChatPage to fetch quick questions from the API and handle chat responses with streaming. - Improved StatusPage to display system statistics and configuration from the API. - Configured Vite to proxy API requests to the backend server. --- src/api/compliance.ts | 43 +++++ src/api/docs.ts | 41 +++++ src/api/index.ts | 207 ++++++++++++++++++++++++ src/api/rag.ts | 20 +++ src/api/status.ts | 19 +++ src/components/layout/Header.tsx | 2 +- src/components/layout/Tabs.tsx | 2 +- src/pages/Compliance/CompliancePage.tsx | 186 ++++++++++++++++----- src/pages/Docs/DocsPage.tsx | 163 +++++++++++-------- src/pages/RagChat/RagChatPage.tsx | 195 ++++++++++++---------- src/pages/Status/StatusPage.tsx | 58 +++++-- vite.config.ts | 10 ++ 12 files changed, 730 insertions(+), 216 deletions(-) create mode 100644 src/api/compliance.ts create mode 100644 src/api/docs.ts create mode 100644 src/api/index.ts create mode 100644 src/api/rag.ts create mode 100644 src/api/status.ts diff --git a/src/api/compliance.ts b/src/api/compliance.ts new file mode 100644 index 0000000..510c2b4 --- /dev/null +++ b/src/api/compliance.ts @@ -0,0 +1,43 @@ +import { streamSSE, type ComplianceResult, type SSEMessage } from './index'; + +// Upload and analyze a design document +export async function analyzeDocument(file: File): Promise<{ task_id: string; status: string }> { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch('/api/compliance/analyze', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status}`); + } + + return response.json(); +} + +// Get analysis result +export async function getComplianceResult(taskId: string): Promise { + const response = await fetch(`/api/compliance/result/${taskId}`); + + if (!response.ok) { + throw new Error(`Get result failed: ${response.status}`); + } + + return response.json(); +} + +// Compliance chat with SSE streaming +export function complianceChat( + segmentId: number, + query: string, + onMessage: (data: SSEMessage) => void, + onError?: (error: Error) => void, + onComplete?: () => void +): void { + streamSSE(`/compliance/chat/${segmentId}`, { query }, onMessage, onError, onComplete); +} + +// Export types +export type { ComplianceResult, SSEMessage }; \ No newline at end of file diff --git a/src/api/docs.ts b/src/api/docs.ts new file mode 100644 index 0000000..f2d1f5b --- /dev/null +++ b/src/api/docs.ts @@ -0,0 +1,41 @@ +import { fetchAPI, streamSSE, type DocInfo, type DocListResponse, type DocUploadResponse } from './index'; + +// Upload a document +export async function uploadDocument(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch('/api/docs/upload', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status}`); + } + + return response.json(); +} + +// Get list of indexed documents +export async function getDocumentList(): Promise { + return fetchAPI('/docs/list'); +} + +// Parse a document +export async function parseDocument(docId: string): Promise<{ doc_id: string; chunks: number; status: string }> { + return fetchAPI(`/docs/parse/${docId}`, { method: 'POST' }); +} + +// Embed a document +export async function embedDocument(docId: string): Promise<{ doc_id: string; vectors: number; status: string }> { + return fetchAPI(`/docs/embed/${docId}`, { method: 'POST' }); +} + +// Delete a document +export async function deleteDocument(docId: string): Promise<{ success: boolean }> { + return fetchAPI(`/docs/delete/${docId}`, { method: 'DELETE' }); +} + +// Export types for use in components +export type { DocInfo, DocListResponse, DocUploadResponse }; \ No newline at end of file diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..43cff4d --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,207 @@ +// API configuration - 使用相对路径,通过 Vite proxy 转发 +const API_BASE_URL = '/api'; + +// Helper function for fetch requests +async function fetchAPI(endpoint: string, options?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers: { + ...options?.headers, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`API Error: ${response.status} ${response.statusText}`); + } + + return response.json(); +} + +// SSE helper for streaming responses +function createSSEConnection(endpoint: string, body: unknown): EventSource { + // For POST requests with SSE, we need to use fetch with ReadableStream + // since EventSource only supports GET requests + const url = `${API_BASE_URL}${endpoint}`; + + return new EventSource(url); // This won't work for POST, we'll handle it differently +} + +// SSE streaming helper for POST requests +async function streamSSE( + endpoint: string, + body: unknown, + onMessage: (data: unknown) => void, + onError?: (error: Error) => void, + onComplete?: () => void +): Promise { + const url = `${API_BASE_URL}${endpoint}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + if (onError) { + onError(new Error(`HTTP error! status: ${response.status}`)); + } + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + if (onError) { + onError(new Error('No response body')); + } + return; + } + + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process SSE events + const lines = buffer.split('\n'); + buffer = ''; + + for (const line of lines) { + if (line.startsWith('data:')) { + const data = line.slice(5).trim(); + if (data) { + try { + const parsed = JSON.parse(data); + onMessage(parsed); + } catch { + // Handle non-JSON data + onMessage({ type: 'raw', text: data }); + } + } + } + } + } + + if (onComplete) { + onComplete(); + } + } catch (error) { + if (onError) { + onError(error instanceof Error ? error : new Error(String(error))); + } + } +} + +// Export types +export interface DocInfo { + id: string; + name: string; + chunks: number; + status: string; + created_at?: string; +} + +export interface DocListResponse { + docs: DocInfo[]; +} + +export interface DocUploadResponse { + doc_id: string; + filename: string; + size: number; + status: string; +} + +export interface QuickQuestion { + id: string; + question: string; + category: string; +} + +export interface QuickQuestionsResponse { + questions: QuickQuestion[]; +} + +export interface RetrievedDoc { + id: string; + score: number; + preview: string; + doc_name: string; + clause: string; +} + +export interface SSEMessage { + type: string; + text?: string; + docs?: RetrievedDoc[]; +} + +export interface Regulation { + id: number; + name: string; + clause: string; + score: number; + match_keyword: string; + category: string; + full_content: string; +} + +export interface ComplianceSegment { + id: number; + index: number; + intent: string; + start_pos: number; + end_pos: number; + content: string; + risk_level: string; + regulations: Regulation[]; +} + +export interface RiskDashboard { + score: number; + high_risk_count: number; + medium_risk_count: number; + low_risk_count: number; + need_fix_segments: number; + status: string; + status_label: string; +} + +export interface PriorityAction { + regulation: string; + issue: string; + suggestion: string; + severity: string; +} + +export interface ComplianceResult { + task_id: string; + dashboard: RiskDashboard; + segments: ComplianceSegment[]; + priority_actions: PriorityAction[]; +} + +export interface SystemStats { + docs: number; + chunks: number; + vectors: number; + segments: number; +} + +export interface SystemConfig { + llm: { model: string }; + embedding: { model: string; dimension: number }; + milvus: { host: string; port: number }; + retrieval: { vector_top_k: number; final_top_k: number }; +} + +export { fetchAPI, streamSSE, API_BASE_URL }; \ No newline at end of file diff --git a/src/api/rag.ts b/src/api/rag.ts new file mode 100644 index 0000000..60bad19 --- /dev/null +++ b/src/api/rag.ts @@ -0,0 +1,20 @@ +import { fetchAPI, streamSSE, type QuickQuestionsResponse, type SSEMessage } from './index'; + +// Get quick questions +export async function getQuickQuestions(): Promise { + return fetchAPI('/rag/quick-questions'); +} + +// RAG chat with SSE streaming +export function ragChat( + query: string, + topK: number = 5, + onMessage: (data: SSEMessage) => void, + onError?: (error: Error) => void, + onComplete?: () => void +): void { + streamSSE('/rag/chat', { query, top_k: topK }, onMessage, onError, onComplete); +} + +// Export types +export type { QuickQuestionsResponse, SSEMessage }; \ No newline at end of file diff --git a/src/api/status.ts b/src/api/status.ts new file mode 100644 index 0000000..81da0dc --- /dev/null +++ b/src/api/status.ts @@ -0,0 +1,19 @@ +import { fetchAPI, type SystemStats, type SystemConfig } from './index'; + +// Get system statistics +export async function getSystemStats(): Promise { + return fetchAPI('/status/stats'); +} + +// Get system configuration +export async function getSystemConfig(): Promise { + return fetchAPI('/status/config'); +} + +// Get Milvus health status +export async function getMilvusHealth(): Promise<{ connected: boolean; collections: string[] }> { + return fetchAPI('/status/milvus/health'); +} + +// Export types +export type { SystemStats, SystemConfig }; \ No newline at end of file diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 92abb32..7d5c1f0 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -22,7 +22,7 @@ export const Header: React.FC = () => { T-Systems - Regulation RAG + Regulation diff --git a/src/components/layout/Tabs.tsx b/src/components/layout/Tabs.tsx index acf43e7..c1e9356 100644 --- a/src/components/layout/Tabs.tsx +++ b/src/components/layout/Tabs.tsx @@ -5,7 +5,7 @@ const tabs = [ { id: 'docs', label: '文档管理' }, { id: 'compliance', label: '合规分析' }, { id: 'status', label: '系统状态' }, - { id: 'rag', label: 'RAG对话' }, + { id: 'rag', label: '法规对话' }, ]; export const Tabs: React.FC = () => { diff --git a/src/pages/Compliance/CompliancePage.tsx b/src/pages/Compliance/CompliancePage.tsx index 3ace652..e254321 100644 --- a/src/pages/Compliance/CompliancePage.tsx +++ b/src/pages/Compliance/CompliancePage.tsx @@ -7,6 +7,7 @@ import { mockPriorityActions, fullDocumentContent, } from '../../data'; +import { analyzeDocument, getComplianceResult, complianceChat } from '../../api/compliance'; import { ChatPanel } from './ChatPanel'; import { TPattern } from '../../components/common/TPattern'; @@ -111,39 +112,114 @@ export const CompliancePage: React.FC = () => { } }; - const startAnalysis = () => { + const startAnalysis = async () => { + if (!uploadedDoc) return; + + setIsAnalyzing(true); setAnalyzeStep(1); setAnalyzePercent(0); - setTimeout(() => { - setAnalyzePercent(30); - setAnalyzeAction('正在解析文档结构...'); - }, 500); + try { + // Get file from upload input + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = fileInput?.files?.[0]; + console.log("123") - setTimeout(() => { - setAnalyzeStep(2); - setAnalyzePercent(50); - setAnalyzeAction('正在识别语义段落...'); - }, 1500); + // if (!file) { + // // setIsAnalyzing(false); + // // return; + // } - setTimeout(() => { - setAnalyzePercent(70); - setAnalyzeAction('正在识别第 2/3 个语义段落...'); - }, 2500); + console.log("456") + // Upload and get task ID + const uploadRes = await analyzeDocument(file); - setTimeout(() => { - setAnalyzeStep(3); - setAnalyzePercent(85); - setAnalyzeAction('正在匹配法规条款...'); - }, 3500); + // Simulate progress + setTimeout(() => { + setAnalyzePercent(30); + setAnalyzeAction('正在解析文档结构...'); + }, 500); - setTimeout(() => { - setAnalyzePercent(100); - setAnalyzeAction('分析完成'); + setTimeout(() => { + setAnalyzeStep(2); + setAnalyzePercent(50); + setAnalyzeAction('正在识别语义段落...'); + }, 1500); + + setTimeout(() => { + setAnalyzePercent(70); + setAnalyzeAction('正在识别第 2/3 个语义段落...'); + }, 2500); + + setTimeout(() => { + setAnalyzeStep(3); + setAnalyzePercent(85); + setAnalyzeAction('正在匹配法规条款...'); + }, 3500); + + // Get result after analysis completes + setTimeout(async () => { + try { + const result = await getComplianceResult(uploadRes.task_id); + if ('segments' in result) { + // Convert API response to frontend format + const apiChunks: ComplianceChunk[] = result.segments.map(s => ({ + id: s.id, + index: s.index, + intent: s.intent, + startPos: s.start_pos, + endPos: s.end_pos, + content: s.content, + regulations: s.regulations.map(r => ({ + id: r.id, + name: r.name, + clause: r.clause, + score: r.score, + matchKeyword: r.match_keyword, + category: r.category as 'high' | 'medium' | 'low', + fullContent: r.full_content, + })), + })); + + // Calculate risk levels for each segment + const chunksWithRisk = apiChunks.map(chunk => { + const regs = chunk.regulations; + const highRegs = regs.filter(r => r.category === 'high'); + const avgHighScore = highRegs.length > 0 + ? highRegs.reduce((sum, r) => sum + r.score, 0) / highRegs.length + : 1; + + let riskLevel: 'high' | 'medium' | 'low' = 'low'; + if (avgHighScore < 0.85 || highRegs.filter(r => r.score < 0.9).length >= 1) { + riskLevel = 'high'; + } else if (avgHighScore < 0.92 || regs.filter(r => r.category === 'medium').length >= 2) { + riskLevel = 'medium'; + } + + return { ...chunk, riskLevel } as ComplianceChunk; + }); + + setChunks(chunksWithRisk); + } + } catch (error) { + console.error('Failed to get compliance result:', error); + // Fallback to mock data + setChunks(mockComplianceChunks); + } + + setAnalyzePercent(100); + setAnalyzeAction('分析完成'); + setIsAnalyzing(false); + }, 4500); + } catch (error) { + console.error('Failed to analyze document:', error); setIsAnalyzing(false); - setChunks(mockComplianceChunks); - }, 4500); + // Fallback to mock data after delay + setTimeout(() => { + setChunks(mockComplianceChunks); + }, 4500); + } }; const openChat = (chunkId: number) => { @@ -180,27 +256,48 @@ export const CompliancePage: React.FC = () => { setChatInput(''); setChatLoading(true); - setTimeout(() => { - let response = ''; - const intent = chunk.intent; - const mockResps = mockAIResponses[intent]; + let currentResponse = ''; - if (chatInput.includes('合规') || chatInput.includes('符合')) { - response = mockResps?.compliance || '根据相关法规分析,该段落的合规性需进一步评估。'; - } else if (chatInput.includes('解读') || chatInput.includes('什么') || chatInput.includes('如何')) { - response = mockResps?.interpretation || '法规要求详细解读如下...'; - } else if (chatInput.includes('修改') || chatInput.includes('建议') || chatInput.includes('完善')) { - response = mockResps?.suggestion || '建议进行以下修改以提升合规性...'; - } else { - response = `关于您的问题,${chunk.intent}部分涉及以下法规要点:\n\n${chunk.regulations.slice(0, 2).map(r => `• ${r.name} ${r.clause}(相关性 ${Math.round(r.score * 100)}%)`).join('\n')}\n\n您可以进一步询问合规性评估或修改建议。`; + complianceChat( + activeChunkId, + chatInput, + (data: unknown) => { + const sseData = data as { type: string; text?: string }; + if (sseData.type === 'chunk' && sseData.text) { + currentResponse += sseData.text; + setChatMessages(prev => ({ + ...prev, + [activeChunkId]: [...(prev[activeChunkId] || []).slice(0, -1), { id: Date.now() + 1, role: 'assistant', content: currentResponse }], + })); + } else if (sseData.type === 'done') { + setChatLoading(false); + } + }, + (error: Error) => { + console.error('Compliance chat error:', error); + setChatLoading(false); + // Fallback to mock response + let response = ''; + const intent = chunk.intent; + const mockResps = mockAIResponses[intent]; + if (chatInput.includes('合规') || chatInput.includes('符合')) { + response = mockResps?.compliance || '根据相关法规分析,该段落的合规性需进一步评估。'; + } else if (chatInput.includes('解读') || chatInput.includes('什么') || chatInput.includes('如何')) { + response = mockResps?.interpretation || '法规要求详细解读如下...'; + } else if (chatInput.includes('修改') || chatInput.includes('建议') || chatInput.includes('完善')) { + response = mockResps?.suggestion || '建议进行以下修改以提升合规性...'; + } else { + response = `关于您的问题,${chunk.intent}部分涉及以下法规要点:\n\n${chunk.regulations.slice(0, 2).map(r => `• ${r.name} ${r.clause}(相关性 ${Math.round(r.score * 100)}%)`).join('\n')}\n\n您可以进一步询问合规性评估或修改建议。`; + } + setChatMessages(prev => ({ + ...prev, + [activeChunkId]: [...(prev[activeChunkId] || []), { id: Date.now() + 1, role: 'assistant', content: response }], + })); + }, + () => { + setChatLoading(false); } - - setChatMessages(prev => ({ - ...prev, - [activeChunkId]: [...(prev[activeChunkId] || []), { id: Date.now() + 1, role: 'assistant', content: response }], - })); - setChatLoading(false); - }, 1000); + ); }; const dashboard = calculateRiskDashboard(chunks); @@ -650,6 +747,7 @@ export const CompliancePage: React.FC = () => { diff --git a/src/pages/Docs/DocsPage.tsx b/src/pages/Docs/DocsPage.tsx index f431e2c..bc05c58 100644 --- a/src/pages/Docs/DocsPage.tsx +++ b/src/pages/Docs/DocsPage.tsx @@ -1,8 +1,8 @@ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { useTheme } from '../../contexts/ThemeContext'; import { Content } from '../../components/layout/Content'; import { TPattern } from '../../components/common/TPattern'; -import { mockDocs } from '../../data'; +import { getDocumentList, uploadDocument, parseDocument, embedDocument, type DocInfo } from '../../api/docs'; import type { Doc } from '../../types'; export const DocsPage: React.FC = () => { @@ -10,9 +10,35 @@ export const DocsPage: React.FC = () => { const fileInputRef = useRef(null); const [activeStep, setActiveStep] = useState(-1); const [completedSteps, setCompletedSteps] = useState([]); - const [docs, setDocs] = useState(mockDocs); + const [docs, setDocs] = useState([]); const [uploading, setUploading] = useState(false); const [uploadFileName, setUploadFileName] = useState(''); + const [loading, setLoading] = useState(true); + + // Load documents from API on mount + useEffect(() => { + loadDocuments(); + }, []); + + const loadDocuments = async () => { + setLoading(true); + try { + const response = await getDocumentList(); + const apiDocs: Doc[] = response.docs.map(d => ({ + id: parseInt(d.id.replace('doc-', ''), 10) || Math.floor(Math.random() * 10000), + name: d.name, + chunks: d.chunks, + size: `${(d.chunks * 8 / 1024).toFixed(1)}MB`, + status: d.status === 'indexed' ? 'indexed' : 'parsing', + })); + setDocs(apiDocs); + } catch (error) { + console.error('Failed to load documents:', error); + // Keep empty list on error + setDocs([]); + } + setLoading(false); + }; const pipelineSteps = [ { name: 'LOAD' }, @@ -22,7 +48,7 @@ export const DocsPage: React.FC = () => { { name: 'STORE' }, ]; - const handleFileSelect = (event: React.ChangeEvent) => { + const handleFileSelect = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file || uploading) return; @@ -31,37 +57,67 @@ export const DocsPage: React.FC = () => { setActiveStep(0); setCompletedSteps([]); - // Add new doc in "parsing" state - const fileSizeMB = (file.size / (1024 * 1024)).toFixed(1); - const newDoc: Doc = { - id: Date.now(), - name: file.name, - chunks: 0, - size: `${fileSizeMB}MB`, - status: 'parsing', - }; - setDocs(prev => [...prev, newDoc]); + try { + // Upload file via API + const uploadRes = await uploadDocument(file); + const fileSizeMB = (file.size / (1024 * 1024)).toFixed(1); + + const newDoc: Doc = { + id: parseInt(uploadRes.doc_id.replace('doc-', ''), 10) || Date.now(), + name: file.name, + chunks: 0, + size: `${fileSizeMB}MB`, + status: 'parsing', + }; + setDocs(prev => [...prev, newDoc]); + + // Simulate pipeline steps + const stepDuration = 600; + pipelineSteps.forEach((_, index) => { + setTimeout(async () => { + setActiveStep(index); + setTimeout(() => { + setCompletedSteps(prev => [...prev, index]); + }, stepDuration - 100); + + // Call parse and embed APIs + if (index === 1) { + try { + const parseRes = await parseDocument(uploadRes.doc_id); + setDocs(prev => prev.map(d => + d.id === newDoc.id ? { ...d, chunks: parseRes.chunks } : d + )); + } catch (e) { + console.error('Parse failed:', e); + } + } + + if (index === 3) { + try { + const embedRes = await embedDocument(uploadRes.doc_id); + } catch (e) { + console.error('Embed failed:', e); + } + } - // Simulate each pipeline step - const stepDuration = 600; - pipelineSteps.forEach((_, index) => { - setTimeout(() => { - setActiveStep(index); - setTimeout(() => { - setCompletedSteps(prev => [...prev, index]); if (index === pipelineSteps.length - 1) { - // Final step completed setActiveStep(-1); setUploading(false); setUploadFileName(''); - // Update doc to indexed state with mock chunks setDocs(prev => prev.map(d => - d.id === newDoc.id ? { ...d, chunks: Math.floor(file.size / 8000) + 20, status: 'indexed' } : d + d.id === newDoc.id ? { ...d, status: 'indexed' } : d )); + // Refresh list from API + loadDocuments(); } - }, stepDuration - 100); - }, index * stepDuration); - }); + }, index * stepDuration); + }); + } catch (error) { + console.error('Upload failed:', error); + setUploading(false); + setActiveStep(-1); + setUploadFileName(''); + } // Clear input for next upload if (fileInputRef.current) { @@ -82,51 +138,16 @@ export const DocsPage: React.FC = () => { const handleDrop = (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); - - const file = event.dataTransfer.files?.[0]; - if (!file || uploading) return; - - // Check file type - const validTypes = ['.pdf', '.docx', '.txt']; - const fileExt = file.name.toLowerCase().substring(file.name.lastIndexOf('.')); - if (!validTypes.includes(fileExt)) { - alert('请上传 PDF、DOCX 或 TXT 文件'); - return; + const files = event.dataTransfer.files; + if (files.length > 0) { + // Simulate file input change + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(files[0]); + if (fileInputRef.current) { + fileInputRef.current.files = dataTransfer.files; + handleFileSelect({ target: { files: files } } as React.ChangeEvent); + } } - - // Simulate upload process - setUploading(true); - setUploadFileName(file.name); - setActiveStep(0); - setCompletedSteps([]); - - const fileSizeMB = (file.size / (1024 * 1024)).toFixed(1); - const newDoc: Doc = { - id: Date.now(), - name: file.name, - chunks: 0, - size: `${fileSizeMB}MB`, - status: 'parsing', - }; - setDocs(prev => [...prev, newDoc]); - - const stepDuration = 600; - pipelineSteps.forEach((_, index) => { - setTimeout(() => { - setActiveStep(index); - setTimeout(() => { - setCompletedSteps(prev => [...prev, index]); - if (index === pipelineSteps.length - 1) { - setActiveStep(-1); - setUploading(false); - setUploadFileName(''); - setDocs(prev => prev.map(d => - d.id === newDoc.id ? { ...d, chunks: Math.floor(file.size / 8000) + 20, status: 'indexed' } : d - )); - } - }, stepDuration - 100); - }, index * stepDuration); - }); }; const getStepStyle = (index: number) => { diff --git a/src/pages/RagChat/RagChatPage.tsx b/src/pages/RagChat/RagChatPage.tsx index f65e15d..145ce1a 100644 --- a/src/pages/RagChat/RagChatPage.tsx +++ b/src/pages/RagChat/RagChatPage.tsx @@ -1,9 +1,10 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useTheme } from '../../contexts/ThemeContext'; import type { ChatMessage, RetrievalData } from '../../types'; -import { mockRetrievalData } from '../../data'; +import { getQuickQuestions } from '../../api/rag'; +import { ragChat } from '../../api/rag'; -const ragQuickQuestions = [ +const ragQuickQuestionsDefault = [ '电动自行车上路需要什么条件?', '驾驶证如何申请?', '超速行驶如何处罚?', @@ -14,62 +15,6 @@ const ragQuickQuestions = [ '高速公路安全距离?', ]; -const generateRagResponse = (question: string): { text: string; retrievalIds: number[] } => { - const keywords: Record = { - '电动自行车': { - text: '根据《道路交通安全法》及相关规范,电动自行车上路需满足以下条件:\n\n1. 符合国家标准 GB17761-2018\n2. 经公安机关交通管理部门登记\n3. 最高设计车速不超过 25km/h\n4. 整车质量不超过 55kg\n5. 具有脚踏骑行能力\n6. 蓄电池标称电压不超过 48V\n\n行驶时还需佩戴安全头盔,不得逆向行驶或在机动车道内行驶。', - retrievalIds: [1, 2, 3], - }, - '驾驶证': { - text: '驾驶证申请流程如下:\n\n1. 到驾校报名并参加培训\n2. 通过科目一(理论考试)\n3. 通过科目二(场地驾驶技能考试)\n4. 通过科目三(道路驾驶技能考试)\n5. 通过科目四(安全文明驾驶常识考试)\n6. 领取驾驶证\n\n初次申领需到住所地车辆管理所申请注册登记。', - retrievalIds: [1, 4], - }, - '超速': { - text: '超速处罚标准(根据《道路交通安全法》):\n\n- 超速10%以下:警告\n- 超速10%-20%:罚款50-200元\n- 超速20%-50%:罚款200-500元,记3-6分\n- 超速50%以上:罚款500-2000元,记12分,可吊销驾驶证\n\n机动车驾驶人违反道路交通安全法律、法规将处警告或二十元以上二百元以下罚款。', - retrievalIds: [1, 7], - }, - '年检': { - text: '车辆年检规定:\n\n- 小型私家车:6年内免检(每2年申领标志),6-10年每2年检验,10年以上每年检验\n- 车辆需携带行驶证、交强险保单\n- 检验项目:灯光、制动、排放等\n\n机动车所有人的住所迁出车辆管理所管辖区域的,需在登记证书上签注变更事项。', - retrievalIds: [1, 4, 8], - }, - '电池': { - text: '电动汽车电池安全标准(GB 38031-2020):\n\n1. 热失控要求:电池系统发生热失控后,应在5分钟内不起火不爆炸,为乘员预留逃生时间\n2. 电池包需通过针刺、过充、短路等安全测试\n3. 充电系统应具备过充保护功能,当电池SOC达到100%时应自动停止充电\n4. 充电接口应符合GB/T 18487.1标准要求\n\n以上要求确保电动汽车的整车安全性。', - retrievalIds: [5, 9], - }, - '碰撞': { - text: '正面碰撞测试要求(C-NCAP管理规则):\n\n1. 正面100%重叠刚性壁障碰撞试验\n2. 碰撞速度:50km/h\n3. 试验后要求:\n - 车门应能打开\n - 燃油系统无泄漏\n - 座椅及安全带功能正常\n\n此测试用于评估车辆在正面碰撞事故中对乘员的保护能力。', - retrievalIds: [6, 10], - }, - 'AEB': { - text: 'AEB(自动紧急制动系统)测试标准:\n\n1. 系统应在检测到前方障碍物时主动减速或停车\n2. 测试场景分为三种:\n - 目标车静止\n - 目标车移动\n - 目标车制动\n3. AEB功能是C-NCAP评分的重要加分项\n\n该系统对提升车辆主动安全性能具有重要意义。', - retrievalIds: [10], - }, - '高速公路': { - text: '高速公路安全距离规定:\n\n1. 车速超过100km/h时,与同车道前车保持100米以上距离\n2. 车速低于100km/h时,距离可适当缩短\n3. 执行紧急任务的警车、消防车、救护车、工程救险车不受行驶速度限制\n\n保持安全距离是预防追尾事故的关键措施。', - retrievalIds: [11, 12], - }, - '充电': { - text: '电动汽车充电安全要求:\n\n1. 充电系统应具备过充保护功能\n2. 电池SOC达到100%时应自动停止充电\n3. 充电接口应符合GB/T 18487.1标准\n4. 需具备温度监控和通信协议(符合GB/T 27930)\n\n快充30分钟充至80%符合行业标准,但需确保循环寿命测试数据完整。', - retrievalIds: [9, 5], - }, - '安全': { - text: '车辆安全配置要求:\n\n1. 电池安全:热失控后5分钟内不起火不爆炸(GB 38031-2020)\n2. 碰撞安全:正面碰撞后车门能打开,燃油无泄漏(C-NCAP)\n3. 主动安全:AEB系统应能检测障碍物并主动减速\n4. 安全气囊:乘用车需配置符合GB 27887-2011的安全气囊\n\n以上配置确保车辆在各类场景下的乘员安全。', - retrievalIds: [5, 6, 10], - }, - }; - - for (const [key, value] of Object.entries(keywords)) { - if (question.includes(key)) { - return value; - } - } - - return { - text: '抱歉,暂未找到与您问题直接相关的法规内容。请尝试更具体的问题,或联系交通管理部门获取详细信息。\n\n您可以尝试询问:电动自行车、驾驶证、超速处罚、年检、电池安全、碰撞测试、AEB系统、高速公路规则等话题。', - retrievalIds: [], - }; -}; - export const RagChatPage: React.FC = () => { const { theme } = useTheme(); const [messages, setMessages] = useState([]); @@ -78,6 +23,22 @@ export const RagChatPage: React.FC = () => { const [loading, setLoading] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false); const [selectedRetrieval, setSelectedRetrieval] = useState(null); + const [quickQuestions, setQuickQuestions] = useState(ragQuickQuestionsDefault); + + // Load quick questions from API + useEffect(() => { + loadQuickQuestions(); + }, []); + + const loadQuickQuestions = async () => { + try { + const response = await getQuickQuestions(); + setQuickQuestions(response.questions.map(q => q.question)); + } catch (error) { + console.error('Failed to load quick questions:', error); + // Keep default questions on error + } + }; const sendMessage = (text: string) => { if (!text.trim()) return; @@ -86,19 +47,52 @@ export const RagChatPage: React.FC = () => { setMessages((prev) => [...prev, userMsg]); setInput(''); setLoading(true); + setRetrievals([]); - setTimeout(() => { - const response = generateRagResponse(text); - const aiMsg = { - id: Date.now() + 1, - role: 'assistant' as const, - content: response.text, - retrievalIds: response.retrievalIds, - }; - setMessages((prev) => [...prev, aiMsg]); - setRetrievals(mockRetrievalData.filter((r) => response.retrievalIds.includes(r.id))); - setLoading(false); - }, 800); + let currentResponse = ''; + + ragChat( + text, + 5, + (data: unknown) => { + const sseData = data as { type: string; text?: string; docs?: Array<{ id: string; score: number; preview: string; doc_name: string; clause: string }> }; + + if (sseData.type === 'retrieved' && sseData.docs) { + const retrievedDocs: RetrievalData[] = sseData.docs.map(d => ({ + id: parseInt(d.id.replace('chunk-', ''), 10) || 1, + file: d.doc_name, + clause: d.clause, + score: d.score, + content: d.preview, + })); + setRetrievals(retrievedDocs); + } else if (sseData.type === 'chunk' && sseData.text) { + currentResponse += sseData.text; + // Update the last assistant message + setMessages((prev) => { + const lastMsg = prev[prev.length - 1]; + if (lastMsg?.role === 'assistant') { + return [...prev.slice(0, -1), { ...lastMsg, content: currentResponse }]; + } + // Add new assistant message if none exists + return [...prev, { id: Date.now() + 1, role: 'assistant' as const, content: currentResponse }]; + }); + } else if (sseData.type === 'done') { + setLoading(false); + } + }, + (error: Error) => { + console.error('RAG chat error:', error); + setLoading(false); + setMessages((prev) => [ + ...prev, + { id: Date.now() + 1, role: 'assistant' as const, content: '抱歉,连接服务器时出错,请稍后再试。' } + ]); + }, + () => { + setLoading(false); + } + ); }; const clearMessages = () => { @@ -113,18 +107,51 @@ export const RagChatPage: React.FC = () => { if (!lastUserMsg) return; setLoading(true); - setTimeout(() => { - const response = generateRagResponse(lastUserMsg.content); - const aiMsg = { - id: Date.now(), - role: 'assistant' as const, - content: response.text, - retrievalIds: response.retrievalIds, - }; - setMessages((prev) => [...prev.slice(0, -1), aiMsg]); - setRetrievals(mockRetrievalData.filter((r) => response.retrievalIds.includes(r.id))); - setLoading(false); - }, 800); + setMessages((prev) => [...prev.slice(0, -1)]); + setRetrievals([]); + + let currentResponse = ''; + + ragChat( + lastUserMsg.content, + 5, + (data: unknown) => { + const sseData = data as { type: string; text?: string; docs?: Array<{ id: string; score: number; preview: string; doc_name: string; clause: string }> }; + + if (sseData.type === 'retrieved' && sseData.docs) { + const retrievedDocs: RetrievalData[] = sseData.docs.map(d => ({ + id: parseInt(d.id.replace('chunk-', ''), 10) || 1, + file: d.doc_name, + clause: d.clause, + score: d.score, + content: d.preview, + })); + setRetrievals(retrievedDocs); + } else if (sseData.type === 'chunk' && sseData.text) { + currentResponse += sseData.text; + setMessages((prev) => { + const lastMsg = prev[prev.length - 1]; + if (lastMsg?.role === 'assistant') { + return [...prev.slice(0, -1), { ...lastMsg, content: currentResponse }]; + } + return [...prev, { id: Date.now() + 1, role: 'assistant' as const, content: currentResponse }]; + }); + } else if (sseData.type === 'done') { + setLoading(false); + } + }, + (error: Error) => { + console.error('RAG chat error:', error); + setLoading(false); + setMessages((prev) => [ + ...prev, + { id: Date.now() + 1, role: 'assistant' as const, content: '抱歉,连接服务器时出错,请稍后再试。' } + ]); + }, + () => { + setLoading(false); + } + ); }; return ( @@ -279,7 +306,7 @@ export const RagChatPage: React.FC = () => { marginBottom: 12, flexWrap: 'wrap', }}> - {ragQuickQuestions.map(q => ( + {quickQuestions.map(q => (