From 3dc12b0bfeb2601ef420682c9dda6a6cb8ea23af Mon Sep 17 00:00:00 2001 From: wangwei Date: Wed, 3 Jun 2026 17:16:00 +0800 Subject: [PATCH] feat: add AppShell + Topbar + 6-route AppRouter with stub pages Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/layout/AppShell.tsx | 23 +- frontend/src/components/layout/Topbar.tsx | 17 + .../src/pages/Compliance/CompliancePage.tsx | 1922 +---------------- frontend/src/pages/Docs/DocsPage.tsx | 559 +---- frontend/src/pages/Overview/OverviewPage.tsx | 3 + .../src/pages/Perception/PerceptionPage.tsx | 149 +- frontend/src/pages/RagChat/RagChatPage.tsx | 582 +---- frontend/src/pages/Status/StatusPage.tsx | 394 +--- frontend/src/router/AppRouter.tsx | 23 +- 9 files changed, 55 insertions(+), 3617 deletions(-) create mode 100644 frontend/src/components/layout/Topbar.tsx create mode 100644 frontend/src/pages/Overview/OverviewPage.tsx diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx index 2603c39..de18c77 100644 --- a/frontend/src/components/layout/AppShell.tsx +++ b/frontend/src/components/layout/AppShell.tsx @@ -1,22 +1,13 @@ -import { useLocation } from 'react-router-dom'; - -import { FooterLayout } from './FooterLayout'; -import { HeaderLayout } from './HeaderLayout'; -import { ContentLayout } from './ContentLayout'; -import { KeepAliveViewport } from './KeepAliveViewport'; -import { getTabByPath } from '../../router/tabs'; +import { Outlet } from 'react-router-dom'; +import { Sidebar } from './Sidebar'; export function AppShell() { - const location = useLocation(); - const activeTab = getTabByPath(location.pathname); - return ( -
- - - - - +
+ +
+ +
); } diff --git a/frontend/src/components/layout/Topbar.tsx b/frontend/src/components/layout/Topbar.tsx new file mode 100644 index 0000000..982d8ff --- /dev/null +++ b/frontend/src/components/layout/Topbar.tsx @@ -0,0 +1,17 @@ +interface TopbarProps { + title: string; + subtitle?: string; + actions?: React.ReactNode; +} + +export function Topbar({ title, subtitle, actions }: TopbarProps) { + return ( +
+
+

{title}

+ {subtitle && {subtitle}} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/frontend/src/pages/Compliance/CompliancePage.tsx b/frontend/src/pages/Compliance/CompliancePage.tsx index 5b37862..a0177f7 100644 --- a/frontend/src/pages/Compliance/CompliancePage.tsx +++ b/frontend/src/pages/Compliance/CompliancePage.tsx @@ -1,1919 +1,3 @@ -import React, { useRef, useState } from 'react'; -import { useTheme } from '../../contexts'; -import type { UploadedDoc, ComplianceChunk, Regulation, SegmentRisk, RiskDashboardData } from '../../types'; -import { - mockComplianceChunks, - mockAIResponses, - mockPriorityActions, - fullDocumentContent, -} from '../../data'; -import { analyzeDocument, getComplianceResult, complianceChat } from '../../api/compliance'; -import { ChatPanel } from './ChatPanel'; -import { TPattern } from '../../components/common/TPattern'; - -// Risk calculation function -const calculateRiskDashboard = (chunks: ComplianceChunk[]): RiskDashboardData | null => { - if (!chunks || chunks.length === 0) return null; - - const segmentRisks: SegmentRisk[] = chunks.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; - const lowScoreHighRegs = highRegs.filter(r => r.score < 0.9).length; - - let level: 'high' | 'medium' | 'low' = 'low'; - let score = Math.round(avgHighScore * 100); - - if (lowScoreHighRegs >= 1 || avgHighScore < 0.85) { - level = 'high'; - score = Math.min(score, 72); - } else if (avgHighScore < 0.92 || regs.filter(r => r.category === 'medium').length >= 2) { - level = 'medium'; - score = Math.min(score, 85); - } - - return { - chunkId: chunk.id, - level, - score, - highRegsCount: highRegs.length, - riskRegs: lowScoreHighRegs, - }; - }); - - const totalScore = Math.round( - segmentRisks.reduce((sum, s) => sum + s.score, 0) / segmentRisks.length - ); - const highRiskCount = segmentRisks.filter(s => s.level === 'high').length; - const mediumRiskCount = segmentRisks.filter(s => s.level === 'medium').length; - const needFixSegments = segmentRisks.filter(s => s.riskRegs > 0).length; - - let status: 'pass' | 'warning' | 'fail' = 'pass'; - let statusLabel = '合规通过'; - if (totalScore < 70) { - status = 'fail'; - statusLabel = '不合规'; - } else if (totalScore < 85 || highRiskCount > 0) { - status = 'warning'; - statusLabel = '需优化'; - } - - return { - score: totalScore, - highRiskCount, - mediumRiskCount, - lowRiskCount: segmentRisks.filter(s => s.level === 'low').length, - needFixSegments, - status, - statusLabel, - segmentRisks, - }; -}; - -// Get regulations by category -const getRegsByCategory = (regulations: Regulation[]) => { - const high = regulations.filter(r => r.category === 'high'); - const medium = regulations.filter(r => r.category === 'medium'); - const low = regulations.filter(r => r.category === 'low'); - return { high, medium, low }; -}; - -export const CompliancePage: React.FC = () => { - const { theme, isDark } = useTheme(); - const nextMessageIdRef = useRef(1); - - // Upload & Analysis States - const [uploadedDoc, setUploadedDoc] = useState(null); - const [uploadedFile, setUploadedFile] = useState(null); - const [isAnalyzing, setIsAnalyzing] = useState(false); - const [analyzeStep, setAnalyzeStep] = useState(0); - const [analyzePercent, setAnalyzePercent] = useState(0); - const [analyzeAction, setAnalyzeAction] = useState(''); - const [chunks, setChunks] = useState([]); - - // Interaction States - const [activeChunkId, setActiveChunkId] = useState(null); - const [expandedRegulationId, setExpandedRegulationId] = useState(null); - const [chatPanelOpen, setChatPanelOpen] = useState(false); - const [chatMessages, setChatMessages] = useState>>({}); - const [chatInput, setChatInput] = useState(''); - const [chatLoading, setChatLoading] = useState(false); - const [dashboardExpanded, setDashboardExpanded] = useState(false); - - const nextMessageId = () => { - const currentId = nextMessageIdRef.current; - nextMessageIdRef.current += 1; - return currentId; - }; - - // Handlers - const handleUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - setUploadedFile(file); - setUploadedDoc({ - name: file.name, - size: `${(file.size / 1024 / 1024).toFixed(2)}MB`, - }); - setChunks([]); - } - }; - - const startAnalysis = async () => { - if (!uploadedDoc || !uploadedFile) return; - - setIsAnalyzing(true); - setAnalyzeStep(1); - setAnalyzePercent(0); - - try { - const uploadRes = await analyzeDocument(uploadedFile); - - // Simulate progress - setTimeout(() => { - setAnalyzePercent(30); - setAnalyzeAction('正在解析文档结构...'); - }, 500); - - 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); - setChunks(mockComplianceChunks); - } - - setAnalyzePercent(100); - setAnalyzeAction('分析完成'); - setIsAnalyzing(false); - }, 4500); - } catch (error) { - console.error('Failed to analyze document:', error); - setIsAnalyzing(false); - setTimeout(() => { - setChunks(mockComplianceChunks); - }, 4500); - } - }; - - const openChat = (chunkId: number) => { - setActiveChunkId(chunkId); - setChatPanelOpen(true); - if (!chatMessages[chunkId]) { - const chunk = chunks.find(c => c.id === chunkId); - setChatMessages(prev => ({ - ...prev, - [chunkId]: [{ - id: nextMessageId(), - role: 'assistant', - content: `您好!我是法规合规分析助手。当前段落涉及 ${chunk?.regulations.length} 条相关法规,您可以询问合规性评估、法规解读或修改建议。`, - }] - })); - } - }; - - const closeChat = () => { - setChatPanelOpen(false); - }; - - const sendChatMessage = () => { - if (!chatInput.trim() || !activeChunkId) return; - - const chunk = chunks.find(c => c.id === activeChunkId); - if (!chunk) return; - - const userMsg = { id: nextMessageId(), role: 'user' as const, content: chatInput }; - setChatMessages(prev => ({ - ...prev, - [activeChunkId]: [...(prev[activeChunkId] || []), userMsg], - })); - setChatInput(''); - setChatLoading(true); - - const segmentContext = [ - `意图:${chunk.intent}`, - `内容:${chunk.content.slice(0, 300)}`, - chunk.regulations.length > 0 - ? `相关法规:${chunk.regulations.slice(0, 3).map(r => `${r.name}${r.clause ? ' ' + r.clause : ''}(相关性 ${Math.round(r.score * 100)}%)`).join(';')}` - : '', - ].filter(Boolean).join('\n'); - - let currentResponse = ''; - - complianceChat( - activeChunkId, - chatInput, - segmentContext, - (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: nextMessageId(), 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: nextMessageId(), role: 'assistant', content: response }], - })); - }, - () => { - setChatLoading(false); - } - ); - }; - - const dashboard = calculateRiskDashboard(chunks); - const segmentRiskMap = dashboard?.segmentRisks?.reduce((map: Record, s) => { - map[s.chunkId] = s; - return map; - }, {}) || {}; - - // Render document with segment blocks - const renderDocumentWithSegmentBlocks = () => { - if (!fullDocumentContent || chunks.length === 0) return null; - - const sortedChunks = [...chunks].sort((a, b) => a.startPos - b.startPos); - const result: React.ReactNode[] = []; - let lastPos = 0; - - sortedChunks.forEach((chunk, idx) => { - const isActive = activeChunkId === chunk.id; - const segmentRisk = segmentRiskMap[chunk.id]; - const riskLevel = segmentRisk?.level || 'low'; - const riskColor = riskLevel === 'high' ? '#ff4444' : riskLevel === 'medium' ? theme.orange : theme.green; - - // Add normal text before this chunk - if (chunk.startPos > lastPos) { - result.push( -
- {renderStructuredDocument(fullDocumentContent.slice(lastPos, chunk.startPos))} -
- ); - } - - // Add segment block - result.push( -
setActiveChunkId(isActive ? null : chunk.id)} - style={{ - padding: '20px 24px', - marginBottom: 20, - background: isActive - ? 'linear-gradient(135deg, rgba(226,0,116,0.12), rgba(190,0,96,0.08))' - : riskLevel === 'high' - ? 'linear-gradient(135deg, rgba(255,68,68,0.06), rgba(255,68,68,0.02))' - : riskLevel === 'medium' - ? 'linear-gradient(135deg, rgba(255,136,0,0.05), rgba(255,136,0,0.02))' - : theme.bgHover, - borderRadius: 12, - border: isActive - ? `2px solid ${theme.accent}` - : riskLevel === 'high' - ? '2px solid rgba(255,68,68,0.4)' - : riskLevel === 'medium' - ? '2px solid rgba(255,136,0,0.3)' - : 'transparent', - cursor: 'pointer', - position: 'relative', - transition: 'all 0.3s ease', - boxShadow: isActive - ? '0 4px 20px rgba(226,0,116,0.15), inset 0 1px 0 rgba(226,0,116,0.1)' - : riskLevel === 'high' - ? '0 2px 10px rgba(255,68,68,0.1)' - : 'none', - }} - > - {/* Intent label with risk indicator */} -
-
- {chunk.index} -
- {chunk.intent} - {/* Risk level badge */} - {riskLevel !== 'low' && ( -
-
- {riskLevel === 'high' ? '高风险' : '中风险'} -
- )} - - {chunk.regulations.length} 条法规 - -
- - {/* Segment content */} -
- {fullDocumentContent.slice(chunk.startPos, chunk.endPos)} -
- - {/* Regulation distribution mini bar */} -
- {(() => { - const regs = getRegsByCategory(chunk.regulations); - return ( - <> - {regs.high.length > 0 && ( -
-
- {regs.high.length} -
- )} - {regs.medium.length > 0 && ( -
-
- {regs.medium.length} -
- )} - {regs.low.length > 0 && ( -
-
- {regs.low.length} -
- )} - - ); - })()} -
-
- ); - - lastPos = chunk.endPos; - }); - - // Add remaining text after all chunks - if (lastPos < fullDocumentContent.length) { - result.push( -
- {renderStructuredDocument(fullDocumentContent.slice(lastPos))} -
- ); - } - - return result; - }; - - const quickQuestions = [ - '这个设计是否合规?', - '需要修改哪些内容?', - '法规的具体要求是什么?', - ]; - - // Render structured document — parses section headers and formats them - const renderStructuredDocument = (content: string) => { - if (!content) return null; - const lines = content.split('\n'); - const elements: React.ReactNode[] = []; - let currentParagraph: string[] = []; - - lines.forEach((line) => { - const sectionMatch = line.match(/^([一二三四五六七八九十]+[、..]|第[一二三四五六七八九十]+[章节部篇]|[0-9]+[、..])/); - - if (sectionMatch || (line.trim() && currentParagraph.length > 0 && line.trim().length < 20 && !line.trim().endsWith('。'))) { - if (currentParagraph.length > 0) { - elements.push( -

{currentParagraph.join('')}

- ); - currentParagraph = []; - } - elements.push( -

{line.trim()}

- ); - } else if (line.trim()) { - currentParagraph.push(line.trim()); - } else { - if (currentParagraph.length > 0) { - elements.push( -

{currentParagraph.join('')}

- ); - currentParagraph = []; - } - } - }); - - if (currentParagraph.length > 0) { - elements.push( -

{currentParagraph.join('')}

- ); - } - - return elements; - }; - - return ( -
- {/* Main Content Area */} -
- - - {/* Upload Section */} - {!uploadedDoc && ( -
-

UPLOAD DESIGN DOCUMENT

-
- -
- - - - -
-
拖拽文件或点击上传
-
PDF · DOCX · TXT · MAX 50MB
-
-
- )} - - {/* Document Preview Section */} - {uploadedDoc && !isAnalyzing && chunks.length === 0 && ( -
-
-

DOCUMENT PREVIEW

- -
- -
-
- - - - - {uploadedDoc.name} - {uploadedDoc.size} -
-
- -
-
-
- - - - - {uploadedDoc.name} - {uploadedDoc.size} -
-
-
-
{renderStructuredDocument(fullDocumentContent)}
-
-
- - -
- )} - - {/* Analysis Progress Section */} - {isAnalyzing && ( -
-

ANALYZING...

- -
- {/* Steps */} -
- {[ - { name: '文档解析', desc: '提取文档内容' }, - { name: 'AI语义分段', desc: '识别设计意图' }, - { name: '法规匹配标注', desc: '关联法规条款' }, - ].map((step, i) => ( -
-
i + 1 ? theme.green : (analyzeStep === i + 1 ? theme.gradientAccent : theme.bgHover), - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }}> - {analyzeStep > i + 1 ? ( - - - - ) : analyzeStep === i + 1 ? ( -
- ) : ( - {i + 1} - )} -
-
-
{step.name}
-
{step.desc}
-
- {analyzeStep === i + 1 && ( - 进行中 - )} -
- ))} -
- - {/* Progress Bar */} -
-
-
-
-
- -
{analyzeAction}
-
-
- )} - - {/* Analysis Results - Dashboard + Split Layout */} - {chunks.length > 0 && dashboard && ( - <> - {/* Risk Dashboard Section — Collapsible */} -
- {/* Collapsed: Compact Summary */} - {!dashboardExpanded && ( -
setDashboardExpanded(true)} - style={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - cursor: 'pointer', - }} - > -
- - - - - 合规风险仪表盘 -
-
- {dashboard.statusLabel} -
-
-
- {dashboard.score} - -
- {dashboard.highRiskCount > 0 && ( -
-
- {dashboard.highRiskCount} 高风险 -
- )} - {dashboard.needFixSegments > 0 && ( -
-
- {dashboard.needFixSegments} 待修改 -
- )} -
-
-
- 展开详情 - - - -
-
- )} - - {/* Expanded: Full Dashboard */} - {dashboardExpanded && ( -
- {/* Dashboard Header with Collapse */} -
-
- - - - - 合规风险仪表盘 -
-
setDashboardExpanded(false)} - style={{ - display: 'flex', - alignItems: 'center', - gap: 8, - padding: '6px 14px', - background: theme.bgHover, - borderRadius: 6, - border: `1px solid ${theme.border}`, - cursor: 'pointer', - }} - > - 收起 - - - -
-
- - {/* Metrics Grid */} -
- {/* Compliance Score */} -
-
合规评分
-
- {dashboard.score} -
-
-
-
-
- - {/* High Risk Items */} -
0 - ? 'linear-gradient(135deg, rgba(255,68,68,0.12), rgba(255,68,68,0.04))' - : theme.bgElevated, - borderRadius: 10, - border: `1px solid ${dashboard.highRiskCount > 0 ? '#ff4444' : theme.border}`, - textAlign: 'center', - }}> -
高风险项
-
0 ? '#ff4444' : theme.green, - marginBottom: 4, - }}> - {dashboard.highRiskCount} -
-
0 ? '#ff6666' : theme.text3, - }}> - {dashboard.highRiskCount > 0 ? '需立即处理' : '无高风险'} -
-
- - {/* Need Fix Segments */} -
0 - ? 'linear-gradient(135deg, rgba(255,136,0,0.12), rgba(255,136,0,0.04))' - : theme.bgElevated, - borderRadius: 10, - border: `1px solid ${dashboard.needFixSegments > 0 ? theme.orange : theme.border}`, - textAlign: 'center', - }}> -
待修改段落
-
0 ? theme.orange : theme.green, - marginBottom: 4, - }}> - {dashboard.needFixSegments} -
-
0 ? '#ff9944' : theme.text3, - }}> - {dashboard.needFixSegments > 0 ? '需补充材料' : '无需修改'} -
-
- - {/* Overall Status */} -
-
整体状态
-
- {dashboard.status === 'pass' ? ( - - - - - ) : dashboard.status === 'warning' ? ( - - - - - ) : ( - - - - - )} - {dashboard.statusLabel} -
-
-
- - {/* Priority Actions */} -
-
- - - - 优先行动建议 - {mockPriorityActions.length} 条 -
- -
- {mockPriorityActions.map((action, idx) => ( -
setActiveChunkId(action.chunkId)} - style={{ - display: 'flex', - alignItems: 'flex-start', - gap: 12, - padding: '12px 16px', - marginBottom: idx < mockPriorityActions.length - 1 ? 8 : 0, - background: theme.bgHover, - borderRadius: 8, - border: `1px solid ${theme.border}`, - cursor: 'pointer', - transition: 'all 0.2s ease', - }} - > -
- {idx + 1} -
-
-
- {action.regulation} - {action.issue} -
-
{action.suggestion}
-
- - - -
- ))} -
-
-
- )} -
- - {/* Split Layout: Document Left, Regulations Right */} -
- {/* Left: Full Document with Marginalia Markers */} -
- {/* Marginalia Column - Segment Markers */} -
- {chunks.map((chunk, idx) => { - const isActive = activeChunkId === chunk.id; - const segmentRisk = segmentRiskMap[chunk.id]; - const riskLevel = segmentRisk?.level || 'low'; - const riskColor = riskLevel === 'high' ? '#ff4444' : riskLevel === 'medium' ? theme.orange : theme.green; - const topPos = 60 + idx * 280; - - return ( -
setActiveChunkId(chunk.id)} - style={{ - position: 'absolute', - top: topPos, - left: 12, - width: 24, - height: 24, - borderRadius: '50%', - background: isActive ? theme.gradientAccent : theme.bgHover, - border: isActive ? 'none' : `2px solid ${riskColor}`, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - cursor: 'pointer', - boxShadow: isActive - ? '0 0 12px rgba(226,0,116,0.5), 0 0 24px rgba(226,0,116,0.2)' - : riskLevel === 'high' - ? '0 0 8px rgba(255,68,68,0.3)' - : riskLevel === 'medium' - ? '0 0 6px rgba(255,136,0,0.2)' - : '0 0 4px rgba(0,212,170,0.15)', - transition: 'all 0.3s ease', - animation: isActive ? 'pulse-glow 2s infinite' : 'none', - zIndex: 10, - }} - > - {chunk.index} -
- ); - })} -
- - {/* Document Content Column */} -
-

- - - - - 原文文档 -

- - {/* Render document with segment blocks — document-style layout */} -
- {renderDocumentWithSegmentBlocks()} -
-
-
- - {/* Right: Regulations Panel */} -
- {/* Header */} -
-
- - - - - 法规标注 -
- {activeChunkId && ( -
-
- - #{chunks.find(c => c.id === activeChunkId)?.index} - -
- - {chunks.find(c => c.id === activeChunkId)?.intent} - -
- )} -
- - {/* Content */} -
- {activeChunkId === null ? ( - /* Default: All segments with regulation counts */ -
- {chunks.map(chunk => { - const regs = getRegsByCategory(chunk.regulations); - const totalRegs = chunk.regulations.length; - - return ( -
setActiveChunkId(chunk.id)} - style={{ - padding: '16px 20px', - background: theme.bgHover, - borderRadius: 10, - border: `1px solid ${theme.border}`, - cursor: 'pointer', - transition: 'all 0.25s ease', - }} - > -
-
- - {chunk.index} - -
-
-
- {chunk.intent} -
-
- {chunk.content.substring(0, 50)}... -
-
-
-
{totalRegs}
-
条法规
-
-
- - {/* Mini regulation distribution bar */} -
- {regs.high.length > 0 && ( -
- )} - {regs.medium.length > 0 && ( -
- )} - {regs.low.length > 0 && ( -
- )} -
-
- ); - })} -
- ) : ( - /* Active: Detailed regulations with expand/collapse */ -
- {/* Active chunk brief */} -
-
- {chunks.find(c => c.id === activeChunkId)?.content.substring(0, 120)}... -
- -
- - {/* Regulations by category */} - {(() => { - const activeChunk = chunks.find(c => c.id === activeChunkId); - if (!activeChunk) return null; - const regs = getRegsByCategory(activeChunk.regulations); - - return ( -
- {/* High Relevance */} - {regs.high.length > 0 && ( -
-
-
- 高度相关 - {regs.high.length} -
- - {regs.high.map((reg) => { - const isExpanded = expandedRegulationId === reg.id; - return ( -
setExpandedRegulationId(isExpanded ? null : reg.id)} - style={{ - padding: isExpanded ? '18px 20px' : '12px 16px', - background: isExpanded ? 'linear-gradient(135deg, rgba(0,212,170,0.08), rgba(0,212,170,0.02))' : theme.bgHover, - borderRadius: 8, - marginBottom: 8, - border: `1px solid ${isExpanded ? theme.green : theme.border}`, - cursor: 'pointer', - transition: 'all 0.25s ease', - }} - > -
-
-
- {reg.name} - {reg.clause} -
-
- {Math.round(reg.score * 100)}% - - - -
-
- - {isExpanded && ( -
-
- 匹配关键词 - {reg.matchKeyword} -
-
- {reg.fullContent} -
-
- )} -
- ); - })} -
- )} - - {/* Medium Relevance */} - {regs.medium.length > 0 && ( -
-
-
- 中度相关 - {regs.medium.length} -
- - {regs.medium.map((reg) => { - const isExpanded = expandedRegulationId === reg.id; - return ( -
setExpandedRegulationId(isExpanded ? null : reg.id)} - style={{ - padding: isExpanded ? '16px 18px' : '12px 16px', - background: isExpanded ? 'linear-gradient(135deg, rgba(255,136,0,0.08), rgba(255,136,0,0.02))' : theme.bgHover, - borderRadius: 8, - marginBottom: 6, - border: `1px solid ${isExpanded ? theme.orange : theme.border}`, - cursor: 'pointer', - transition: 'all 0.25s ease', - }} - > -
-
-
- {reg.name} - {reg.clause} -
-
- {Math.round(reg.score * 100)}% - - - -
-
- - {isExpanded && ( -
-
- 匹配关键词 - {reg.matchKeyword} -
-
- {reg.fullContent} -
-
- )} -
- ); - })} -
- )} - - {/* Low Relevance */} - {regs.low.length > 0 && ( -
-
-
- 低度相关 - {regs.low.length} -
- -
- {regs.low.map(reg => { - const isExpanded = expandedRegulationId === reg.id; - return ( -
setExpandedRegulationId(isExpanded ? null : reg.id)} - style={{ - padding: isExpanded ? '10px 14px' : '6px 12px', - background: isExpanded ? theme.bgElevated : theme.bgHover, - borderRadius: 6, - fontSize: 12, - color: theme.text2, - border: `1px solid ${isExpanded ? theme.borderLight : theme.border}`, - cursor: 'pointer', - transition: 'all 0.2s ease', - }} - > -
- {reg.name} - {isExpanded && ( - - - - )} -
- {isExpanded && ( -
-
{reg.clause}
-
{reg.matchKeyword}
-
{reg.fullContent.substring(0, 60)}...
-
- )} -
- ); - })} -
-
- )} - - {/* Chat Button */} - -
- ); - })()} -
- )} -
-
-
- - )} -
- - {/* Chat Panel */} - {chatPanelOpen && activeChunkId && ( - - )} -
- ); -}; +export function CompliancePage() { + return

Compliance

; +} diff --git a/frontend/src/pages/Docs/DocsPage.tsx b/frontend/src/pages/Docs/DocsPage.tsx index ad10d30..1db5c29 100644 --- a/frontend/src/pages/Docs/DocsPage.tsx +++ b/frontend/src/pages/Docs/DocsPage.tsx @@ -1,558 +1,3 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { useTheme } from '../../contexts'; -import { TPattern } from '../../components/common/TPattern'; -import { getDocumentList, getDocumentStatus, searchRegulations, uploadDocument, deleteDocument, retryDocument, type RegulationSearchItem } from '../../api/docs'; -import type { Doc } from '../../types'; - -type PipelineStatus = 'idle' | 'running' | 'completed' | 'error'; - -const PIPELINE_STEPS = [ - { name: 'LOAD' }, - { name: 'PARSE' }, - { name: 'CHUNK' }, - { name: 'EMBED' }, - { name: 'STORE' }, -]; - -const REGULATION_TYPES = ['', '国家标准', '行业标准', '地方标准', '企业标准', '法律法规', '监管规定']; - -const STEP_DURATION_MS = 700; -const INITIAL_SEARCH_QUERY = '新能源汽车电池安全要求'; - -function wait(ms: number) { - return new Promise((resolve) => { window.setTimeout(resolve, ms); }); +export function DocsPage() { + return

Documents

; } - -export const DocsPage: React.FC = () => { - const { theme, isDark } = useTheme(); - const fileInputRef = useRef(null); - const pipelineRunIdRef = useRef(0); - - const [activeStep, setActiveStep] = useState(-1); - const [completedSteps, setCompletedSteps] = useState([]); - const [pipelineStatus, setPipelineStatus] = useState('idle'); - const [docs, setDocs] = useState([]); - const [uploading, setUploading] = useState(false); - const [uploadFileName, setUploadFileName] = useState(''); - const [loading, setLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState(INITIAL_SEARCH_QUERY); - const [searchResults, setSearchResults] = useState([]); - const [searchLoading, setSearchLoading] = useState(false); - const [searchError, setSearchError] = useState(''); - const [batchQueueLength, setBatchQueueLength] = useState(0); - - // Upload metadata - const [regulationType, setRegulationType] = useState(''); - const [version, setVersion] = useState(''); - - // Batch queue: files waiting to be uploaded after the current one finishes - const batchQueueRef = useRef([]); - - const setBatchQueue = (files: File[]) => { - batchQueueRef.current = files; - setBatchQueueLength(files.length); - }; - - async function loadDocuments() { - setLoading(true); - try { - const response = await getDocumentList(); - const apiDocs: Doc[] = response.docs.map((doc, index) => ({ - id: Number.parseInt(String(doc.id).replace('doc-', ''), 10) || -(index + 1), - name: doc.name, - chunks: doc.chunks, - size: doc.updated_at ? new Date(doc.updated_at).toLocaleString() : 'Indexed document', - status: doc.status === 'indexed' ? 'indexed' : doc.status === 'failed' ? 'failed' : 'parsing', - docId: doc.id, - downloadUrl: doc.download_url, - updatedAt: doc.updated_at, - summary: doc.summary, - regulationType: doc.regulation_type, - version: doc.version, - })); - setDocs(apiDocs); - } catch (error) { - console.error('Failed to load documents:', error); - setDocs([]); - } finally { - setLoading(false); - } - } - - async function runSearch(query: string) { - if (!query.trim()) return; - setSearchLoading(true); - setSearchError(''); - try { - const response = await searchRegulations(query.trim(), 8); - setSearchResults(response.results); - } catch (error) { - console.error('Failed to search regulations:', error); - setSearchError(error instanceof Error ? error.message : '检索失败'); - setSearchResults([]); - } finally { - setSearchLoading(false); - } - } - - useEffect(() => { - const timerId = window.setTimeout(() => { void loadDocuments(); }, 0); - return () => window.clearTimeout(timerId); - }, []); - - useEffect(() => { - const timerId = window.setTimeout(() => { void runSearch(INITIAL_SEARCH_QUERY); }, 0); - return () => window.clearTimeout(timerId); - }, []); - - useEffect(() => { - return () => { pipelineRunIdRef.current += 1; }; - }, []); - - useEffect(() => { - const parsingDocs = docs.filter( - (doc) => doc.status === 'parsing' && doc.docId && !doc.docId.startsWith('pending-') - ); - if (parsingDocs.length === 0) return; - - const timerId = window.setInterval(() => { - parsingDocs.forEach((doc) => { - void getDocumentStatus(doc.docId!).then((res) => { - if (res.status === 'indexed' || res.status === 'failed') { - setDocs((prev) => - prev.map((d) => - d.docId === doc.docId - ? { - ...d, - status: res.status === 'indexed' ? 'indexed' : 'failed', - chunks: res.num_chunks ?? d.chunks, - summary: res.summary ?? d.summary, - regulationType: res.regulation_type ?? d.regulationType, - version: res.version ?? d.version, - } - : d - ) - ); - } - }).catch(() => {}); - }); - }, 5000); - - return () => window.clearInterval(timerId); - }, [docs]); - - const runPipelineFlow = async (runId: number, uploadPromise: Promise>>) => { - const guard = (fn: () => void) => { if (pipelineRunIdRef.current !== runId) return false; fn(); return true; }; - - for (let i = 0; i < PIPELINE_STEPS.length - 1; i++) { - if (!guard(() => setActiveStep(i))) return; - await wait(STEP_DURATION_MS); - if (!guard(() => setCompletedSteps((p) => p.includes(i) ? p : [...p, i]))) return; - } - - if (!guard(() => setActiveStep(PIPELINE_STEPS.length - 1))) return; - await uploadPromise; - if (!guard(() => setCompletedSteps((p) => { const last = PIPELINE_STEPS.length - 1; return p.includes(last) ? p : [...p, last]; }))) return; - - await wait(240); - if (pipelineRunIdRef.current !== runId) return; - setActiveStep(-1); - setPipelineStatus('completed'); - }; - - const uploadSingleFile = async (file: File, runId: number) => { - setUploading(true); - setUploadFileName(file.name); - setActiveStep(-1); - setCompletedSteps([]); - setPipelineStatus('running'); - - const fileSizeMB = (file.size / (1024 * 1024)).toFixed(1); - const tempDocId = `pending-${Date.now()}`; - const newDoc: Doc = { - id: Date.now(), - name: file.name, - chunks: 0, - size: `${fileSizeMB}MB`, - status: 'parsing', - docId: tempDocId, - regulationType: regulationType || undefined, - version: version || undefined, - }; - - setDocs((prev) => [newDoc, ...prev]); - - const uploadPromise = uploadDocument(file, { - regulationType: regulationType || undefined, - version: version || undefined, - }); - void runPipelineFlow(runId, uploadPromise); - - try { - const uploadRes = await uploadPromise; - if (pipelineRunIdRef.current !== runId) return; - - setDocs((prev) => - prev.map((doc) => - doc.id === newDoc.id - ? { ...doc, status: 'indexed', docId: uploadRes.doc_id, chunks: uploadRes.num_chunks || doc.chunks, summary: uploadRes.summary } - : doc - ) - ); - void loadDocuments(); - } catch (error) { - console.error('Upload failed:', error); - if (pipelineRunIdRef.current !== runId) return; - setDocs((prev) => prev.filter((doc) => doc.id !== newDoc.id)); - setPipelineStatus('error'); - setActiveStep(-1); - setCompletedSteps([]); - } finally { - setUploading(false); - setUploadFileName(''); - if (fileInputRef.current) fileInputRef.current.value = ''; - - // Process next file in batch queue - const next = batchQueueRef.current.shift(); - setBatchQueueLength(batchQueueRef.current.length); - if (next) { - const nextRunId = pipelineRunIdRef.current + 1; - pipelineRunIdRef.current = nextRunId; - void uploadSingleFile(next, nextRunId); - } - } - }; - - const handleFileSelect = async (event: React.ChangeEvent) => { - const files = Array.from(event.target.files ?? []); - if (files.length === 0 || uploading) return; - - const [first, ...rest] = files; - setBatchQueue(rest); - - const runId = pipelineRunIdRef.current + 1; - pipelineRunIdRef.current = runId; - await uploadSingleFile(first, runId); - }; - - const handleDelete = async (docId: string) => { - try { - await deleteDocument(docId); - setDocs((prev) => prev.filter((doc) => doc.docId !== docId)); - } catch (error) { - console.error('Delete failed:', error); - } - }; - - const handleRetry = async (docId: string) => { - setDocs((prev) => prev.map((doc) => doc.docId === docId ? { ...doc, status: 'parsing' } : doc)); - try { - const result = await retryDocument(docId); - setDocs((prev) => - prev.map((doc) => doc.docId === docId ? { ...doc, status: 'indexed', chunks: result.num_chunks || doc.chunks } : doc) - ); - } catch (error) { - console.error('Retry failed:', error); - setDocs((prev) => prev.map((doc) => doc.docId === docId ? { ...doc, status: 'failed' } : doc)); - } - }; - - const triggerFileUpload = () => { if (uploading) return; fileInputRef.current?.click(); }; - - const handleDragOver = (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); }; - - const handleDrop = (event: React.DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - const files = Array.from(event.dataTransfer.files); - if (files.length === 0 || uploading) return; - - const [first, ...rest] = files; - setBatchQueue(rest); - const runId = pipelineRunIdRef.current + 1; - pipelineRunIdRef.current = runId; - void uploadSingleFile(first, runId); - }; - - const getStepStyle = (index: number) => { - if (activeStep === index) return { background: theme.bgCard, border: `2px solid ${theme.accent}`, boxShadow: `0 0 12px ${theme.accent}40` }; - if (completedSteps.includes(index)) return { background: theme.bgCard, border: `1px solid ${theme.green}` }; - return { background: theme.bgCard, border: `1px solid ${theme.border}` }; - }; - - const getCheckStyle = (index: number) => { - if (activeStep === index) return { background: theme.gradientAccent, color: '#fff', animation: 'pulse 0.6s infinite' }; - if (completedSteps.includes(index)) return { background: theme.green, color: '#fff' }; - return { background: theme.bgHover, color: theme.text3 }; - }; - - const getPipelineHint = () => { - if (pipelineStatus === 'running') { - const suffix = batchQueueLength > 0 ? ` (+${batchQueueLength} 待上传)` : ''; - return `${activeStep >= 0 ? PIPELINE_STEPS[activeStep].name : 'LOAD'} · ${uploadFileName}${suffix}`; - } - if (pipelineStatus === 'completed') return 'PIPELINE COMPLETE'; - if (pipelineStatus === 'error') return 'PIPELINE FAILED'; - return 'WAITING FOR UPLOAD'; - }; - - const getDocKey = (doc: Doc) => { - // Prefer the backend document identifier because the numeric display id is not guaranteed unique. - return doc.docId ?? `local-${doc.id}-${doc.name}`; - }; - - const inputStyle: React.CSSProperties = { - padding: '8px 12px', - fontSize: 13, - background: theme.bgCard, - border: `1px solid ${theme.border}`, - borderRadius: 8, - color: theme.text, - outline: 'none', - }; - - return ( -
- - -
-

- UPLOAD -

- - - - {/* Metadata row */} -
- - setVersion(e.target.value)} - placeholder="版本号(可选,如 2024)" - style={{ ...inputStyle, flex: 1 }} - /> -
- -
-
- {uploading ? ( -
- - - -
- ) : ( - - - - - )} -
-
- {uploading ? '正在上传并启动处理链路...' : '拖拽文件或点击上传(支持多选)'} -
-
- {uploading ? uploadFileName : 'PDF · DOCX · DOC · MAX 100MB · 支持批量'} -
-
-
- -
-

- PROCESSING PIPELINE -

- -
- {getPipelineHint()} -
- -
- {PIPELINE_STEPS.map((step, index) => { - const isCompleted = completedSteps.includes(index); - const isActive = activeStep === index; - const arrowActive = activeStep > index || isCompleted; - - return ( -
-
- {isActive ? step.name : isCompleted ? '✓' : step.name} -
-
{step.name}
-
- {isCompleted ? 'DONE' : isActive ? 'RUNNING' : 'PENDING'} -
- {index < PIPELINE_STEPS.length - 1 && ( -
- → -
- )} -
- ); - })} -
-
- -
-
-

- 文档管理清单 ({loading ? '...' : docs.length}) -

- -
- {docs.map((doc) => ( -
-
-
- - - - -
- -
-
{doc.name}
-
- {doc.updatedAt ? new Date(doc.updatedAt).toLocaleString() : doc.size} - {doc.docId ? ` · ${doc.docId}` : ''} -
- {/* Tags row */} - {(doc.regulationType || doc.version) && ( -
- {doc.regulationType && ( - - {doc.regulationType} - - )} - {doc.version && ( - - v{doc.version} - - )} -
- )} - {doc.summary && ( -
- {doc.summary} -
- )} -
-
- -
- {doc.status === 'failed' && doc.docId && !doc.docId.startsWith('pending-') && ( - - )} - {doc.downloadUrl && doc.status === 'indexed' && ( - - 下载 - - )} -
- {doc.status === 'parsing' ? '处理中...' : doc.status === 'failed' ? '处理失败' : `${doc.chunks} chunks`} -
- {doc.docId && !doc.docId.startsWith('pending-') && ( - - )} -
-
- ))} -
-
- -
-

- 文档管理内法规检索 -

- -
- setSearchQuery(event.target.value)} - onKeyDown={(event) => { if (event.key === 'Enter') void runSearch(searchQuery); }} - placeholder="输入法规关键词、条款或制度主题" - style={{ flex: 1, padding: 12, fontSize: 14, background: theme.bgCard, border: `1px solid ${theme.border}`, borderRadius: 8, color: theme.text, outline: 'none' }} - /> - -
- - {searchError &&
{searchError}
} - -
- {searchResults.map((item) => ( -
-
-
{item.file}
-
{(item.score * 100).toFixed(1)}%
-
-
- {item.clause}{item.tags.length > 0 ? ` · ${item.tags.join(' · ')}` : ''} -
-
{item.content}
-
- ))} - - {!searchLoading && searchResults.length === 0 && ( -
- 暂无检索结果 -
- )} -
-
-
-
- ); -}; diff --git a/frontend/src/pages/Overview/OverviewPage.tsx b/frontend/src/pages/Overview/OverviewPage.tsx new file mode 100644 index 0000000..c34b8a1 --- /dev/null +++ b/frontend/src/pages/Overview/OverviewPage.tsx @@ -0,0 +1,3 @@ +export function OverviewPage() { + return

Overview

; +} diff --git a/frontend/src/pages/Perception/PerceptionPage.tsx b/frontend/src/pages/Perception/PerceptionPage.tsx index 21fb744..7336eb3 100644 --- a/frontend/src/pages/Perception/PerceptionPage.tsx +++ b/frontend/src/pages/Perception/PerceptionPage.tsx @@ -1,146 +1,3 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useTheme } from '../../contexts'; -import { TPattern } from '../../components/common/TPattern'; -import { - listEvents, - getPerceptionStats, - analyzeEvent, - type RegulationEvent, - type PerceptionStats, - type AffectedDoc, -} from '../../api/perception'; -import { EventFeed } from './EventFeed'; -import { AnalysisPanel } from './AnalysisPanel'; - -export const PerceptionPage: React.FC = () => { - const { theme } = useTheme(); - - // Feed state - const [events, setEvents] = useState([]); - const [stats, setStats] = useState(null); - const [feedLoading, setFeedLoading] = useState(true); - const [filterSource, setFilterSource] = useState(''); - const [filterImpact, setFilterImpact] = useState(''); - - // Selected event - const [selectedId, setSelectedId] = useState(null); - const selectedEvent = events.find(e => e.id === selectedId) ?? null; - - // Analysis state - const [analyzing, setAnalyzing] = useState(false); - const [analysisText, setAnalysisText] = useState(''); - const [affectedDocs, setAffectedDocs] = useState([]); - const abortRef = useRef(null); - - // Load events + stats - const loadFeed = useCallback(async () => { - setFeedLoading(true); - try { - const [evtRes, statsRes] = await Promise.all([ - listEvents({ - source: filterSource || undefined, - impact_level: filterImpact || undefined, - }), - getPerceptionStats(), - ]); - setEvents(evtRes.events); - setStats(statsRes); - } catch { - // silent - } finally { - setFeedLoading(false); - } - }, [filterSource, filterImpact]); - - useEffect(() => { - const timerId = window.setTimeout(() => { void loadFeed(); }, 0); - return () => window.clearTimeout(timerId); - }, [loadFeed]); - - // When selecting a new event, clear previous analysis - const handleSelectEvent = (id: string) => { - if (id === selectedId) return; - abortRef.current?.abort(); - setSelectedId(id); - setAnalysisText(''); - setAffectedDocs([]); - setAnalyzing(false); - }; - - const handleAnalyze = useCallback(() => { - if (!selectedId || analyzing) return; - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - setAnalysisText(''); - setAffectedDocs([]); - setAnalyzing(true); - - void analyzeEvent( - selectedId, - (msg) => { - if (msg.type === 'sources' && msg.docs) { - setAffectedDocs(msg.docs); - } else if (msg.type === 'content' && msg.text) { - setAnalysisText(prev => prev + msg.text); - } else if (msg.type === 'error') { - setAnalysisText(prev => prev + `\n\n⚠ 分析出错:${msg.text ?? '未知错误'}`); - } - }, - () => setAnalyzing(false), - ctrl.signal, - ); - }, [selectedId, analyzing]); - - const handleAbort = () => { - abortRef.current?.abort(); - setAnalyzing(false); - }; - - return ( -
- - - - {/* Page header */} -
-

智能感知

- 法规动态实时追踪 · 知识库影响分析 -
- - {/* Split layout */} -
- {/* Left: Event feed */} - - - {/* Right: Analysis panel */} - -
-
- ); -}; +export function PerceptionPage() { + return

Signals

; +} diff --git a/frontend/src/pages/RagChat/RagChatPage.tsx b/frontend/src/pages/RagChat/RagChatPage.tsx index ced081d..7a649d4 100644 --- a/frontend/src/pages/RagChat/RagChatPage.tsx +++ b/frontend/src/pages/RagChat/RagChatPage.tsx @@ -1,579 +1,3 @@ -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, - ); - }, [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', - }} - > -
-
-
- #{i + 1} - -
-
{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} -
-
-
- )} -
- ); -}; +export function RagChatPage() { + return

Chat

; +} diff --git a/frontend/src/pages/Status/StatusPage.tsx b/frontend/src/pages/Status/StatusPage.tsx index 5db809c..a91dbb8 100644 --- a/frontend/src/pages/Status/StatusPage.tsx +++ b/frontend/src/pages/Status/StatusPage.tsx @@ -1,391 +1,3 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { useTheme } from '../../contexts'; -import { TPattern } from '../../components/common/TPattern'; -import { getSystemStats, getSystemConfig, getSystemHealth, type SystemStats, type SystemConfig, type SystemHealth } from '../../api/status'; -import { getDocumentList, type DocInfo } from '../../api/docs'; - -const StatsCard = ({ label, value, accent = false }: { - label: string; - value: number; - accent?: boolean; -}) => { - const { theme, isDark } = useTheme(); - - return ( -
-
-
{label}
-
{value}
-
- ); -}; - -const ServiceBadge = ({ - label, - status, - detail, -}: { - label: string; - status: 'ok' | 'error' | 'unknown' | boolean; - detail?: string; -}) => { - const { theme } = useTheme(); - const isOk = status === 'ok' || status === true; - const isUnknown = status === 'unknown'; - const color = isUnknown ? theme.text3 : isOk ? theme.green : '#d64545'; - return ( -
-
- - {label} -
- - {detail ?? (isUnknown ? '—' : isOk ? 'OK' : 'ERROR')} - -
- ); -}; - -export const StatusPage: React.FC = () => { - const { theme, isDark } = useTheme(); - const [stats, setStats] = useState({ - documents_total: 0, - documents_indexed: 0, - documents_failed: 0, - chunks_total: 0, - }); - const [config, setConfig] = useState(null); - const [docs, setDocs] = useState([]); - const [health, setHealth] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const loadData = useCallback(async () => { - setLoading(true); - setError(null); - try { - const [statsRes, configRes, docsRes, healthRes] = await Promise.all([ - getSystemStats(), - getSystemConfig(), - getDocumentList(), - getSystemHealth(), - ]); - setStats(statsRes); - setConfig(configRes); - setDocs(docsRes.docs); - setHealth(healthRes); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load status data'); - } finally { - setLoading(false); - } - }, []); - - // Initial load - useEffect(() => { - const timerId = window.setTimeout(() => { void loadData(); }, 0); - return () => window.clearTimeout(timerId); - }, [loadData]); - - // Auto-poll every 5 s while any document is still processing - useEffect(() => { - const hasProcessing = docs.some(d => d.status === 'parsing' || d.status === 'pending'); - if (!hasProcessing) return; - const id = window.setInterval(() => void loadData(), 5000); - return () => window.clearInterval(id); - }, [docs, loadData]); - - return ( -
- - - - {/* Loading indicator */} - {loading && ( -
- - LOADING... -
- )} - - {/* Error banner */} - {error && ( -
- {error} - -
- )} - - {/* Stats section */} -
-
-

- DOCUMENT STATISTICS -

- -
-
- - - - -
-
- - {/* Service health section */} -
-

- SERVICE HEALTH -

-
- - - -
-
- - - -
-
- - {/* System configuration section */} -
-

SYSTEM CONFIGURATION

- -
-
MODELS
-
- {[ - ['LLM Provider', config?.llm_provider || '-'], - ['LLM Model', config?.llm_model || '-'], - ['Embedding Model', config?.embedding_model || '-'], - ['Embedding Dim', String(config?.embedding_dim || 0)], - ].map(([k, v]) => ( -
- {k} - - {v} - -
- ))} -
-
- -
-
STORAGE AND PATHS
-
- {[ - ['Milvus Collection', config?.milvus_collection || '-'], - ['Metadata Path', config?.document_metadata_path || '-'], - ['Embedding Base URL', config?.embedding_base_url || '-'], - ].map(([k, v]) => ( -
- {k} - - {v} - -
- ))} -
-
-
- - {/* Document index section */} -
-

DOCUMENT INDEX

- {docs.map(d => ( -
-
- {d.name} - - {d.updated_at ? new Date(d.updated_at).toLocaleString() : d.status} - -
-
- {d.chunks} chunks -
- - {d.status === 'parsing' ? '⟳ ' : ''}{d.status.toUpperCase()} - -
-
-
- ))} -
-
- ); -}; +export function StatusPage() { + return

Status

; +} diff --git a/frontend/src/router/AppRouter.tsx b/frontend/src/router/AppRouter.tsx index c4494f0..c4dae25 100644 --- a/frontend/src/router/AppRouter.tsx +++ b/frontend/src/router/AppRouter.tsx @@ -1,18 +1,23 @@ -import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; - +import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { AppShell } from '../components/layout/AppShell'; -import { appTabs, defaultTab } from './tabs'; +import { OverviewPage } from '../pages/Overview/OverviewPage'; +import { StatusPage } from '../pages/Status/StatusPage'; +import { PerceptionPage } from '../pages/Perception/PerceptionPage'; +import { DocsPage } from '../pages/Docs/DocsPage'; +import { CompliancePage } from '../pages/Compliance/CompliancePage'; +import { RagChatPage } from '../pages/RagChat/RagChatPage'; export function AppRouter() { return ( - }> - } /> - {appTabs.map((tab) => ( - - ))} - } /> + }> + } /> + } /> + } /> + } /> + } /> + } />