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.
This commit is contained in:
2026-05-11 11:32:02 +08:00
parent 41f9c122a9
commit 1bb7151abe
12 changed files with 730 additions and 216 deletions

43
src/api/compliance.ts Normal file
View File

@@ -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<ComplianceResult | { status: string; message: string }> {
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 };

41
src/api/docs.ts Normal file
View File

@@ -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<DocUploadResponse> {
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<DocListResponse> {
return fetchAPI<DocListResponse>('/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 };

207
src/api/index.ts Normal file
View File

@@ -0,0 +1,207 @@
// API configuration - 使用相对路径,通过 Vite proxy 转发
const API_BASE_URL = '/api';
// Helper function for fetch requests
async function fetchAPI<T>(endpoint: string, options?: RequestInit): Promise<T> {
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<void> {
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 };

20
src/api/rag.ts Normal file
View File

@@ -0,0 +1,20 @@
import { fetchAPI, streamSSE, type QuickQuestionsResponse, type SSEMessage } from './index';
// Get quick questions
export async function getQuickQuestions(): Promise<QuickQuestionsResponse> {
return fetchAPI<QuickQuestionsResponse>('/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 };

19
src/api/status.ts Normal file
View File

@@ -0,0 +1,19 @@
import { fetchAPI, type SystemStats, type SystemConfig } from './index';
// Get system statistics
export async function getSystemStats(): Promise<SystemStats> {
return fetchAPI<SystemStats>('/status/stats');
}
// Get system configuration
export async function getSystemConfig(): Promise<SystemConfig> {
return fetchAPI<SystemConfig>('/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 };

View File

@@ -22,7 +22,7 @@ export const Header: React.FC = () => {
T-Systems
</span>
<span style={{ fontWeight: 300, fontSize: 16, color: theme.text2 }}>
Regulation RAG
Regulation
</span>
</div>
</div>

View File

@@ -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 = () => {

View File

@@ -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 = () => {
<button
onClick={startAnalysis}
className="t-btn"
type="button"
style={{
padding: '20px 48px',
fontSize: 16,
@@ -658,6 +756,8 @@ export const CompliancePage: React.FC = () => {
border: 'none',
borderRadius: 12,
cursor: 'pointer',
pointerEvents: 'auto',
zIndex: 10,
}}
></button>
</section>

View File

@@ -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<HTMLInputElement>(null);
const [activeStep, setActiveStep] = useState<number>(-1);
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
const [docs, setDocs] = useState<Doc[]>(mockDocs);
const [docs, setDocs] = useState<Doc[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadFileName, setUploadFileName] = useState<string>('');
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<HTMLInputElement>) => {
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>);
}
}
// 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) => {

View File

@@ -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<string, { text: string; retrievalIds: number[] }> = {
'电动自行车': {
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<ChatMessage[]>([]);
@@ -78,6 +23,22 @@ export const RagChatPage: React.FC = () => {
const [loading, setLoading] = useState<boolean>(false);
const [showClearConfirm, setShowClearConfirm] = useState<boolean>(false);
const [selectedRetrieval, setSelectedRetrieval] = useState<RetrievalData | null>(null);
const [quickQuestions, setQuickQuestions] = useState<string[]>(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 => (
<button
key={q}
onClick={() => sendMessage(q)}

View File

@@ -1,8 +1,9 @@
import React from 'react';
import React, { useState, 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 { getSystemStats, getSystemConfig, type SystemStats, type SystemConfig } from '../../api/status';
import { getDocumentList, type DocInfo } from '../../api/docs';
const StatsCard = ({ label, value, accent = false }: {
label: string;
@@ -37,9 +38,34 @@ const StatsCard = ({ label, value, accent = false }: {
export const StatusPage: React.FC = () => {
const { theme, isDark } = useTheme();
const [stats, setStats] = useState<SystemStats>({ docs: 0, chunks: 0, vectors: 0, segments: 0 });
const [config, setConfig] = useState<SystemConfig | null>(null);
const [docs, setDocs] = useState<DocInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const [statsRes, configRes, docsRes] = await Promise.all([
getSystemStats(),
getSystemConfig(),
getDocumentList(),
]);
setStats(statsRes);
setConfig(configRes);
setDocs(docsRes.docs);
} catch (error) {
console.error('Failed to load status data:', error);
}
setLoading(false);
};
// 计算总chunks
const totalChunks = mockDocs.reduce((sum, d) => sum + d.chunks, 0);
const totalChunks = docs.reduce((sum, d) => sum + d.chunks, 0);
return (
<Content>
@@ -51,10 +77,10 @@ export const StatusPage: React.FC = () => {
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 16,
}}>
<StatsCard label="DOCUMENTS" value={mockDocs.length} />
<StatsCard label="CHUNKS" value={totalChunks} />
<StatsCard label="DIMENSIONS" value={1536} />
<StatsCard label="CLAUSES" value={totalChunks} accent />
<StatsCard label="DOCUMENTS" value={stats.docs} />
<StatsCard label="CHUNKS" value={stats.chunks} />
<StatsCard label="DIMENSIONS" value={config?.embedding.dimension || 1536} />
<StatsCard label="CLAUSES" value={stats.vectors} accent />
</div>
</section>
@@ -78,8 +104,8 @@ export const StatusPage: React.FC = () => {
}}>
{[
['Vector DB', 'Milvus'],
['Host', 'localhost'],
['Port', '19530'],
['Host', config?.milvus.host || 'localhost'],
['Port', String(config?.milvus.port || 19530)],
].map(([k, v]) => (
<div key={k} style={{
display: 'flex',
@@ -107,9 +133,9 @@ export const StatusPage: React.FC = () => {
gap: 12,
}}>
{[
['LLM Model', 'qwen-max'],
['Embedding Model', 'text-embedding-v3'],
['Embedding Dim', '1536'],
['LLM Model', config?.llm.model || 'qwen-max'],
['Embedding Model', config?.embedding.model || 'text-embedding-v3'],
['Embedding Dim', String(config?.embedding.dimension || 1536)],
['Temperature', '0.1'],
].map(([k, v]) => (
<div key={k} style={{
@@ -138,9 +164,9 @@ export const StatusPage: React.FC = () => {
gap: 12,
}}>
{[
['Vector Top-K', '10'],
['Vector Top-K', String(config?.retrieval.vector_top_k || 10)],
['BM25 Top-K', '10'],
['Final Top-K', '5'],
['Final Top-K', String(config?.retrieval.final_top_k || 5)],
].map(([k, v]) => (
<div key={k} style={{
display: 'flex',
@@ -198,7 +224,7 @@ export const StatusPage: React.FC = () => {
marginBottom: 20,
letterSpacing: '1px',
}}>DOCUMENT INDEX</h2>
{mockDocs.map(d => (
{docs.map(d => (
<div key={d.id} style={{
display: 'flex',
alignItems: 'center',
@@ -211,7 +237,7 @@ export const StatusPage: React.FC = () => {
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 14 }}>{d.name}</span>
<span className="mono" style={{ fontSize: 11, color: theme.text3 }}>{d.size}</span>
<span className="mono" style={{ fontSize: 11, color: theme.text3 }}>{(d.chunks * 8 / 1024).toFixed(1)}MB</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<span className="mono" style={{ fontSize: 12, color: theme.text2 }}>{d.chunks} chunks</span>

View File

@@ -4,4 +4,14 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})