feat: add AppShell + Topbar + 6-route AppRouter with stub pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<div className="flex min-h-screen flex-col bg-t-bg text-t-text">
|
||||
<HeaderLayout activeTab={activeTab} />
|
||||
<ContentLayout tab={activeTab}>
|
||||
<KeepAliveViewport activeTab={activeTab} />
|
||||
</ContentLayout>
|
||||
<FooterLayout />
|
||||
<div className="app-shell">
|
||||
<Sidebar />
|
||||
<div className="content-area">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
17
frontend/src/components/layout/Topbar.tsx
Normal file
17
frontend/src/components/layout/Topbar.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
interface TopbarProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Topbar({ title, subtitle, actions }: TopbarProps) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
<h1 className="topbar-title">{title}</h1>
|
||||
{subtitle && <span className="topbar-sub">{subtitle}</span>}
|
||||
</div>
|
||||
{actions && <div className="topbar-actions">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<void>((resolve) => { window.setTimeout(resolve, ms); });
|
||||
export function DocsPage() {
|
||||
return <div className="page-content"><p>Documents</p></div>;
|
||||
}
|
||||
|
||||
export const DocsPage: React.FC = () => {
|
||||
const { theme, isDark } = useTheme();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pipelineRunIdRef = useRef(0);
|
||||
|
||||
const [activeStep, setActiveStep] = useState<number>(-1);
|
||||
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
|
||||
const [pipelineStatus, setPipelineStatus] = useState<PipelineStatus>('idle');
|
||||
const [docs, setDocs] = useState<Doc[]>([]);
|
||||
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<RegulationSearchItem[]>([]);
|
||||
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<File[]>([]);
|
||||
|
||||
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<Awaited<ReturnType<typeof uploadDocument>>>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="relative w-full">
|
||||
<TPattern />
|
||||
|
||||
<section style={{ marginBottom: 56 }}>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, marginBottom: 20, letterSpacing: '1px' }}>
|
||||
UPLOAD
|
||||
</h2>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Metadata row */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
|
||||
<select
|
||||
value={regulationType}
|
||||
onChange={(e) => setRegulationType(e.target.value)}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
>
|
||||
{REGULATION_TYPES.map((t) => (
|
||||
<option key={t} value={t}>{t || '法规类型(可选)'}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={version}
|
||||
onChange={(e) => setVersion(e.target.value)}
|
||||
placeholder="版本号(可选,如 2024)"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={triggerFileUpload}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
border: `2px solid ${uploading ? theme.accent : theme.border}`,
|
||||
borderRadius: 16,
|
||||
padding: 64,
|
||||
textAlign: 'center',
|
||||
background: theme.bgCard,
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: uploading ? 'wait' : 'pointer',
|
||||
boxShadow: !isDark ? '0 4px 16px rgba(226,0,116,0.08)' : 'none',
|
||||
opacity: uploading ? 0.78 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 80, height: 80, borderRadius: 20, background: theme.bgHover, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
|
||||
{uploading ? (
|
||||
<div style={{ animation: 'spin 1s linear infinite' }}>
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke={theme.accent} strokeWidth="2" strokeDasharray="60" strokeDashoffset="20" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 4L12 16M12 4L7 9M12 4L17 9" stroke={theme.accent} strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M4 18H20" stroke={theme.accent} strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 500, marginBottom: 8 }}>
|
||||
{uploading ? '正在上传并启动处理链路...' : '拖拽文件或点击上传(支持多选)'}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: theme.text3 }}>
|
||||
{uploading ? uploadFileName : 'PDF · DOCX · DOC · MAX 100MB · 支持批量'}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: 40 }}>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, marginBottom: 20, letterSpacing: '1px' }}>
|
||||
PROCESSING PIPELINE
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: pipelineStatus === 'error' ? '#d64545' : pipelineStatus === 'completed' ? theme.green : theme.text3,
|
||||
letterSpacing: '1px',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{getPipelineHint()}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
{PIPELINE_STEPS.map((step, index) => {
|
||||
const isCompleted = completedSteps.includes(index);
|
||||
const isActive = activeStep === index;
|
||||
const arrowActive = activeStep > index || isCompleted;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.name}
|
||||
style={{ flex: 1, padding: 20, textAlign: 'center', borderRadius: 12, position: 'relative', boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none', transition: 'all 0.3s ease', ...getStepStyle(index) }}
|
||||
>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 12px', fontSize: 16, transition: 'all 0.3s ease', ...getCheckStyle(index) }}>
|
||||
{isActive ? step.name : isCompleted ? '✓' : step.name}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 12, fontWeight: 600 }}>{step.name}</div>
|
||||
<div className="mono" style={{ fontSize: 10, color: theme.text3, marginTop: 8 }}>
|
||||
{isCompleted ? 'DONE' : isActive ? 'RUNNING' : 'PENDING'}
|
||||
</div>
|
||||
{index < PIPELINE_STEPS.length - 1 && (
|
||||
<div style={{ position: 'absolute', right: -8, top: '50%', transform: 'translateY(-50%)', color: arrowActive ? theme.green : theme.borderLight, fontWeight: arrowActive ? 700 : 400, opacity: arrowActive ? 1 : 0.45, transition: 'all 0.3s ease' }}>
|
||||
→
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginBottom: 56 }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, marginBottom: 20, letterSpacing: '1px' }}>
|
||||
文档管理清单 ({loading ? '...' : docs.length})
|
||||
</h2>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{docs.map((doc) => (
|
||||
<div
|
||||
key={getDocKey(doc)}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 20, background: theme.bgCard, borderRadius: 12, border: `1px solid ${doc.status === 'parsing' ? theme.accent : theme.border}`, transition: 'all 0.2s ease', boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
|
||||
<div style={{ width: 44, height: 44, borderRadius: 10, background: theme.bgHover, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M14 2H6C5 2 4 3 4 4V20C4 21 5 22 6 22H18C19 22 20 21 20 20V8L14 2Z" stroke={theme.accent} strokeWidth="1.5" />
|
||||
<path d="M14 2V8H20" stroke={theme.accent} strokeWidth="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 500 }}>{doc.name}</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.text3 }}>
|
||||
{doc.updatedAt ? new Date(doc.updatedAt).toLocaleString() : doc.size}
|
||||
{doc.docId ? ` · ${doc.docId}` : ''}
|
||||
</div>
|
||||
{/* Tags row */}
|
||||
{(doc.regulationType || doc.version) && (
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 5, flexWrap: 'wrap' }}>
|
||||
{doc.regulationType && (
|
||||
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 4, background: `${theme.accent}18`, color: theme.accent, fontWeight: 500 }}>
|
||||
{doc.regulationType}
|
||||
</span>
|
||||
)}
|
||||
{doc.version && (
|
||||
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 4, background: theme.bgHover, color: theme.text2 }}>
|
||||
v{doc.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{doc.summary && (
|
||||
<div style={{ fontSize: 12, color: theme.text2, marginTop: 6, lineHeight: 1.5, maxWidth: 320, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{doc.summary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, flexShrink: 0 }}>
|
||||
{doc.status === 'failed' && doc.docId && !doc.docId.startsWith('pending-') && (
|
||||
<button onClick={() => void handleRetry(doc.docId!)} style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', border: `1px solid ${theme.accent}`, borderRadius: 6, color: theme.accent, cursor: 'pointer' }}>
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
{doc.downloadUrl && doc.status === 'indexed' && (
|
||||
<a href={doc.downloadUrl} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: theme.accent, textDecoration: 'none' }}>
|
||||
下载
|
||||
</a>
|
||||
)}
|
||||
<div className="mono" style={{ fontSize: 12, padding: '6px 12px', background: theme.bgHover, borderRadius: 6, color: doc.status === 'failed' ? '#d64545' : theme.text2 }}>
|
||||
{doc.status === 'parsing' ? '处理中...' : doc.status === 'failed' ? '处理失败' : `${doc.chunks} chunks`}
|
||||
</div>
|
||||
{doc.docId && !doc.docId.startsWith('pending-') && (
|
||||
<button onClick={() => void handleDelete(doc.docId!)} style={{ fontSize: 12, padding: '6px 10px', background: 'transparent', border: `1px solid ${theme.border}`, borderRadius: 6, color: theme.text3, cursor: 'pointer' }}>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, marginBottom: 20, letterSpacing: '1px' }}>
|
||||
文档管理内法规检索
|
||||
</h2>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 16 }}>
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(event) => 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' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => void runSearch(searchQuery)}
|
||||
disabled={searchLoading || !searchQuery.trim()}
|
||||
style={{ padding: '12px 18px', fontSize: 13, fontWeight: 600, background: searchLoading || !searchQuery.trim() ? theme.bgHover : theme.gradientAccent, color: searchLoading || !searchQuery.trim() ? theme.text3 : '#fff', border: 'none', borderRadius: 8, cursor: searchLoading || !searchQuery.trim() ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
检索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{searchError && <div style={{ marginBottom: 12, fontSize: 13, color: '#d64545' }}>{searchError}</div>}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{searchResults.map((item) => (
|
||||
<div key={`${item.id}-${item.file}`} style={{ padding: 18, background: theme.bgCard, borderRadius: 12, border: `1px solid ${theme.border}`, boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 6 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: theme.text }}>{item.file}</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.accent }}>{(item.score * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.text3, marginBottom: 8 }}>
|
||||
{item.clause}{item.tags.length > 0 ? ` · ${item.tags.join(' · ')}` : ''}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: theme.text2, lineHeight: 1.6 }}>{item.content}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!searchLoading && searchResults.length === 0 && (
|
||||
<div style={{ padding: 24, borderRadius: 12, background: theme.bgCard, border: `1px solid ${theme.border}`, textAlign: 'center', color: theme.text3 }}>
|
||||
暂无检索结果
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
3
frontend/src/pages/Overview/OverviewPage.tsx
Normal file
3
frontend/src/pages/Overview/OverviewPage.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function OverviewPage() {
|
||||
return <div className="page-content"><p>Overview</p></div>;
|
||||
}
|
||||
@@ -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<RegulationEvent[]>([]);
|
||||
const [stats, setStats] = useState<PerceptionStats | null>(null);
|
||||
const [feedLoading, setFeedLoading] = useState(true);
|
||||
const [filterSource, setFilterSource] = useState('');
|
||||
const [filterImpact, setFilterImpact] = useState('');
|
||||
|
||||
// Selected event
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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<AffectedDoc[]>([]);
|
||||
const abortRef = useRef<AbortController | null>(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);
|
||||
export function PerceptionPage() {
|
||||
return <div className="page-content"><p>Signals</p></div>;
|
||||
}
|
||||
}, [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 (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<style>{`
|
||||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
|
||||
`}</style>
|
||||
<TPattern />
|
||||
|
||||
{/* Page header */}
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 16, marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, color: theme.text, margin: 0 }}>智能感知</h1>
|
||||
<span style={{ fontSize: 13, color: theme.text3 }}>法规动态实时追踪 · 知识库影响分析</span>
|
||||
</div>
|
||||
|
||||
{/* Split layout */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '400px 1fr',
|
||||
gap: 24,
|
||||
flex: 1,
|
||||
minHeight: 560,
|
||||
}}>
|
||||
{/* Left: Event feed */}
|
||||
<EventFeed
|
||||
events={events}
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelectEvent}
|
||||
filterSource={filterSource}
|
||||
filterImpact={filterImpact}
|
||||
onFilterSource={setFilterSource}
|
||||
onFilterImpact={setFilterImpact}
|
||||
stats={stats}
|
||||
loading={feedLoading}
|
||||
/>
|
||||
|
||||
{/* Right: Analysis panel */}
|
||||
<AnalysisPanel
|
||||
event={selectedEvent}
|
||||
analyzing={analyzing}
|
||||
analysisText={analysisText}
|
||||
affectedDocs={affectedDocs}
|
||||
onAnalyze={handleAnalyze}
|
||||
onAbort={handleAbort}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<ChatMessage[]>([]);
|
||||
// retrievals: right-panel shows sources of the most recent assistant reply
|
||||
const [retrievals, setRetrievals] = useState<RetrievalData[]>([]);
|
||||
const [input, setInput] = useState<string>('');
|
||||
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);
|
||||
const [filterRegulationType, setFilterRegulationType] = useState<string>('');
|
||||
const [highlightedSourceIdx, setHighlightedSourceIdx] = useState<number | null>(null);
|
||||
const [sessionId, setSessionId] = useState<string | undefined>();
|
||||
|
||||
// Auto-scroll ref
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
// AbortController for cancelling in-flight requests
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
function nextMessageId() {
|
||||
const id = nextMessageIdRef.current;
|
||||
nextMessageIdRef.current += 1;
|
||||
return id;
|
||||
export function RagChatPage() {
|
||||
return <div className="page-content"><p>Chat</p></div>;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div style={{ flex: 1, display: 'flex', minHeight: 0, height: '100%' }}>
|
||||
{/* ── Left: chat panel ─────────────────────────────────── */}
|
||||
<div style={{
|
||||
flex: '0 0 60%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRight: `1px solid ${theme.border}`,
|
||||
}}>
|
||||
{/* Message list */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '24px 32px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 20,
|
||||
}}>
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 60, color: theme.text3 }}>
|
||||
<div style={{
|
||||
width: 72, height: 72, borderRadius: 16,
|
||||
background: theme.bgCard, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', margin: '0 auto 20px',
|
||||
border: `1px solid ${theme.border}`,
|
||||
}}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" stroke={theme.accent} strokeWidth="1.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, marginBottom: 6, color: theme.text }}>开始法规对话</div>
|
||||
<div className="mono" style={{ fontSize: 11 }}>选择快捷问题或输入您的问题</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map(msg => (
|
||||
<div key={msg.id} style={{
|
||||
display: 'flex', gap: 12,
|
||||
flexDirection: msg.role === 'user' ? 'row-reverse' : 'row',
|
||||
}}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: theme.gradientAccent, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="6" fill="#fff"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
maxWidth: '80%',
|
||||
padding: msg.role === 'user' ? '12px 18px' : '14px 18px',
|
||||
background: msg.role === 'user' ? theme.gradientAccent : theme.bgCard,
|
||||
borderRadius: 12,
|
||||
color: msg.role === 'user' ? '#fff' : theme.text,
|
||||
fontSize: 14, lineHeight: 1.6, whiteSpace: 'pre-wrap',
|
||||
border: msg.role === 'assistant' ? `1px solid ${theme.border}` : 'none',
|
||||
}}>
|
||||
{msg.role === 'assistant' ? (
|
||||
<CitedAnswer
|
||||
text={msg.content}
|
||||
sources={msg.sources ?? retrievals}
|
||||
onCiteClick={(idx) => {
|
||||
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 && (
|
||||
<div style={{
|
||||
marginTop: 10, paddingTop: 10,
|
||||
borderTop: `1px solid ${theme.border}`,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M14 2H6C5 2 4 3 4 4V20C4 21 5 22 6 22H18C19 22 20 21 20 20V8L14 2Z" stroke={theme.accent} strokeWidth="1.5"/>
|
||||
</svg>
|
||||
<span className="mono" style={{ fontSize: 11, color: theme.accent }}>
|
||||
{msg.retrievalIds.length} 个法规引用
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: theme.gradientAccent, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="6" fill="#fff"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '14px 18px', background: theme.bgCard,
|
||||
borderRadius: 12, border: `1px solid ${theme.border}`,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 6, height: 6, borderRadius: '50%',
|
||||
background: theme.accent, animation: 'pulse 1s infinite',
|
||||
}} />
|
||||
<span style={{ fontSize: 13, color: theme.text2 }}>检索中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Scroll anchor */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div style={{ padding: '16px 32px 20px', background: theme.bg, borderTop: `1px solid ${theme.border}` }}>
|
||||
{/* Filter row */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 10, alignItems: 'center' }}>
|
||||
<span className="mono" style={{ fontSize: 11, color: theme.text3, whiteSpace: 'nowrap' }}>法规类型</span>
|
||||
<input
|
||||
value={filterRegulationType}
|
||||
onChange={e => 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',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick questions */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
{quickQuestions.map(q => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => sendMessage(q)}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '6px 14px', fontSize: 12, background: theme.bgCard,
|
||||
border: `1px solid ${theme.border}`, borderRadius: 6,
|
||||
color: theme.text2, cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>{q}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Send row */}
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<input
|
||||
value={input}
|
||||
onChange={e => 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',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={loading || !input.trim()}
|
||||
style={{
|
||||
padding: '12px 24px', fontSize: 14, fontWeight: 600,
|
||||
background: loading || !input.trim() ? theme.bgHover : theme.gradientAccent,
|
||||
color: loading || !input.trim() ? theme.text3 : '#fff',
|
||||
border: 'none', borderRadius: 8,
|
||||
cursor: loading || !input.trim() ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>发送</button>
|
||||
{loading && (
|
||||
<button
|
||||
onClick={() => { abortRef.current?.abort(); setLoading(false); }}
|
||||
style={{
|
||||
padding: '12px 16px', fontSize: 13, background: theme.bgCard,
|
||||
border: `1px solid ${theme.border}`, borderRadius: 8,
|
||||
color: theme.text2, cursor: 'pointer',
|
||||
}}
|
||||
>停止</button>
|
||||
)}
|
||||
{!loading && messages.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
style={{
|
||||
padding: '12px 16px', fontSize: 13, background: theme.bgCard,
|
||||
border: `1px solid ${theme.border}`, borderRadius: 8,
|
||||
color: theme.text2, cursor: 'pointer',
|
||||
}}
|
||||
>清空</button>
|
||||
)}
|
||||
{!loading && messages.filter(m => m.role === 'assistant').length > 0 && (
|
||||
<button
|
||||
onClick={regenerateLastAnswer}
|
||||
style={{
|
||||
padding: '12px 16px', fontSize: 13, background: theme.bgCard,
|
||||
border: `1px solid ${theme.border}`, borderRadius: 8,
|
||||
color: theme.text2, cursor: 'pointer',
|
||||
}}
|
||||
>重生成</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right: retrieved sources panel ───────────────────── */}
|
||||
<div style={{ flex: '0 0 40%', display: 'flex', flexDirection: 'column', background: theme.bgCard }}>
|
||||
<div style={{
|
||||
padding: '20px 24px', borderBottom: `1px solid ${theme.border}`,
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: 6,
|
||||
background: theme.gradientAccent, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M14 2H6C5 2 4 3 4 4V20C4 21 5 22 6 22H18C19 22 20 21 20 20V8L14 2Z" stroke="#fff" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 12, fontWeight: 600, color: theme.accent, letterSpacing: '1px' }}>
|
||||
RETRIEVED FRAGMENTS
|
||||
</span>
|
||||
{retrievals.length > 0 && (
|
||||
<span className="mono" style={{
|
||||
fontSize: 11, padding: '4px 10px',
|
||||
background: theme.bgHover, borderRadius: 4, color: theme.text3,
|
||||
}}>{retrievals.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
|
||||
{retrievals.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{retrievals.map((r, i) => (
|
||||
<div
|
||||
key={r.id}
|
||||
id={`source-${i + 1}`}
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 16, bottom: 16,
|
||||
width: 3, background: theme.gradientAccent, borderRadius: 2,
|
||||
}} />
|
||||
<div style={{ paddingLeft: 8 }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', marginBottom: 8,
|
||||
}}>
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 700, color: theme.accent }}>#{i + 1}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{r.downloadUrl && (
|
||||
<a
|
||||
href={r.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ fontSize: 11, color: theme.accent, textDecoration: 'none' }}
|
||||
>下载文档</a>
|
||||
)}
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 600, color: theme.accent }}>
|
||||
{(r.score * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4, color: theme.text }}>{r.file}</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.text3, marginBottom: 8 }}>
|
||||
{r.clause}{r.docId ? ` · ${r.docId}` : ''}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 12, color: theme.text2, lineHeight: 1.5,
|
||||
display: '-webkit-box', WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
||||
}}>{r.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: theme.text3 }}>
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: 10,
|
||||
background: theme.bgHover, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', margin: '0 auto 16px',
|
||||
}}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M14 2H6C5 2 4 3 4 4V20C4 21 5 22 6 22H18C19 22 20 21 20 20V8L14 2Z" stroke={theme.text3} strokeWidth="1.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11 }}>对话后显示相关法规</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Clear confirm modal ───────────────────────────────── */}
|
||||
{showClearConfirm && (
|
||||
<div 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,
|
||||
}}>
|
||||
<div style={{
|
||||
padding: 24, background: theme.bgCard, borderRadius: 16,
|
||||
maxWidth: 400, border: `1px solid ${theme.border}`,
|
||||
}}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12, color: theme.text }}>确定清空对话?</div>
|
||||
<div style={{ fontSize: 13, color: theme.text2, marginBottom: 20 }}>此操作不可恢复,会话历史将被重置</div>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowClearConfirm(false)}
|
||||
style={{
|
||||
padding: '10px 18px', fontSize: 13, background: theme.bgHover,
|
||||
border: 'none', borderRadius: 8, color: theme.text2, cursor: 'pointer',
|
||||
}}
|
||||
>取消</button>
|
||||
<button
|
||||
onClick={clearMessages}
|
||||
style={{
|
||||
padding: '10px 18px', fontSize: 13, fontWeight: 600,
|
||||
background: theme.accent, border: 'none', borderRadius: 8,
|
||||
color: '#fff', cursor: 'pointer',
|
||||
}}
|
||||
>确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Source detail modal ───────────────────────────────── */}
|
||||
{selectedRetrieval && (
|
||||
<div
|
||||
onClick={() => 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,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => 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)',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', marginBottom: 16,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ padding: '4px 10px', background: theme.gradientAccent, borderRadius: 6 }}>
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 600, color: '#fff' }}>
|
||||
{(selectedRetrieval.score * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: theme.text }}>{selectedRetrieval.file}</span>
|
||||
{selectedRetrieval.downloadUrl && (
|
||||
<a
|
||||
href={selectedRetrieval.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ fontSize: 12, color: theme.accent, textDecoration: 'none' }}
|
||||
>下载关联文档</a>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedRetrieval(null)}
|
||||
style={{
|
||||
width: 28, height: 28, background: theme.bgHover,
|
||||
border: 'none', borderRadius: 6, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M18 6L6 18M6 6L18 18" stroke={theme.text3} strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: '10px 14px', background: theme.bgHover, borderRadius: 8, marginBottom: 16 }}>
|
||||
<span className="mono" style={{ fontSize: 12, color: theme.accent }}>{selectedRetrieval.clause}</span>
|
||||
{selectedRetrieval.docId && (
|
||||
<span className="mono" style={{ fontSize: 11, color: theme.text3, marginLeft: 8 }}>{selectedRetrieval.docId}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, lineHeight: 1.7, color: theme.text2, whiteSpace: 'pre-wrap' }}>
|
||||
{selectedRetrieval.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
padding: 20,
|
||||
background: theme.bgCard,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${accent ? theme.accent : theme.border}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.06)' : 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
background: theme.gradientAccent,
|
||||
}} />
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.text3, marginBottom: 8, letterSpacing: '1px' }}>{label}</div>
|
||||
<div className="mono" style={{ fontSize: 32, fontWeight: 700, color: accent ? theme.accent : theme.text }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 16px',
|
||||
background: theme.bgCard,
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${theme.border}`,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ color, fontSize: 10 }}>●</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: theme.text2 }}>{label}</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color, fontWeight: 600 }}>
|
||||
{detail ?? (isUnknown ? '—' : isOk ? 'OK' : 'ERROR')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatusPage: React.FC = () => {
|
||||
const { theme, isDark } = useTheme();
|
||||
const [stats, setStats] = useState<SystemStats>({
|
||||
documents_total: 0,
|
||||
documents_indexed: 0,
|
||||
documents_failed: 0,
|
||||
chunks_total: 0,
|
||||
});
|
||||
const [config, setConfig] = useState<SystemConfig | null>(null);
|
||||
const [docs, setDocs] = useState<DocInfo[]>([]);
|
||||
const [health, setHealth] = useState<SystemHealth | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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);
|
||||
export function StatusPage() {
|
||||
return <div className="page-content"><p>Status</p></div>;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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 (
|
||||
<div className="relative w-full">
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
<TPattern />
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, color: theme.text3, fontSize: 13 }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${theme.accent}`,
|
||||
borderTopColor: 'transparent',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}} />
|
||||
<span className="mono">LOADING...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div style={{
|
||||
marginBottom: 16,
|
||||
padding: '12px 16px',
|
||||
background: '#d6454520',
|
||||
border: '1px solid #d64545',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: '#d64545' }}>{error}</span>
|
||||
<button
|
||||
onClick={() => void loadData()}
|
||||
style={{ background: 'none', border: '1px solid #d64545', borderRadius: 6, color: '#d64545', cursor: 'pointer', padding: '4px 10px', fontSize: 12 }}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats section */}
|
||||
<section style={{ marginBottom: 48 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, letterSpacing: '1px', margin: 0 }}>
|
||||
DOCUMENT STATISTICS
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => void loadData()}
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: 6,
|
||||
color: theme.text3,
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
padding: '4px 12px',
|
||||
fontSize: 11,
|
||||
opacity: loading ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
↻ 刷新
|
||||
</button>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: 16,
|
||||
}}>
|
||||
<StatsCard label="DOCUMENTS" value={stats.documents_total} />
|
||||
<StatsCard label="INDEXED" value={stats.documents_indexed} />
|
||||
<StatsCard label="FAILED" value={stats.documents_failed} />
|
||||
<StatsCard label="CHUNKS" value={stats.chunks_total} accent />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Service health section */}
|
||||
<section style={{ marginBottom: 48 }}>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, marginBottom: 20, letterSpacing: '1px' }}>
|
||||
SERVICE HEALTH
|
||||
</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 12 }}>
|
||||
<ServiceBadge
|
||||
label="MILVUS"
|
||||
status={health?.milvus.status ?? 'unknown'}
|
||||
detail={health ? (health.milvus.status === 'ok' ? `${health.milvus.num_entities ?? 0} entities` : 'disconnected') : '—'}
|
||||
/>
|
||||
<ServiceBadge
|
||||
label="MINIO"
|
||||
status={health?.minio.status ?? 'unknown'}
|
||||
detail={health ? (health.minio.status === 'ok' ? 'connected' : 'disconnected') : '—'}
|
||||
/>
|
||||
<ServiceBadge
|
||||
label="BM25 HYBRID"
|
||||
status={health?.bm25.available ?? false}
|
||||
detail={health ? (health.bm25.available ? 'enabled' : 'unavailable') : '—'}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
||||
<ServiceBadge
|
||||
label="RERANKER"
|
||||
status={health?.reranker.enabled ?? false}
|
||||
detail={health ? (health.reranker.enabled ? (health.reranker.model ?? 'enabled') : 'disabled') : '—'}
|
||||
/>
|
||||
<ServiceBadge
|
||||
label="SESSIONS"
|
||||
status="ok"
|
||||
detail={health ? `${health.sessions.active} / ${health.sessions.max}` : '—'}
|
||||
/>
|
||||
<ServiceBadge
|
||||
label="LLM"
|
||||
status="ok"
|
||||
detail={config ? `${config.llm_provider} · ${config.llm_model}` : '—'}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* System configuration section */}
|
||||
<section style={{ marginBottom: 48 }}>
|
||||
<h2 style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: theme.accent,
|
||||
marginBottom: 20,
|
||||
letterSpacing: '1px',
|
||||
}}>SYSTEM CONFIGURATION</h2>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.text3, marginBottom: 12, letterSpacing: '1px' }}>MODELS</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 12,
|
||||
}}>
|
||||
{[
|
||||
['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]) => (
|
||||
<div key={k} style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
background: theme.bgCard,
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${theme.border}`,
|
||||
boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<span className="mono" style={{ fontSize: 12, color: theme.text3, flexShrink: 0 }}>{k}</span>
|
||||
<span
|
||||
title={v}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
maxWidth: 200,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'help',
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.text3, marginBottom: 12, letterSpacing: '1px' }}>STORAGE AND PATHS</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 12,
|
||||
}}>
|
||||
{[
|
||||
['Milvus Collection', config?.milvus_collection || '-'],
|
||||
['Metadata Path', config?.document_metadata_path || '-'],
|
||||
['Embedding Base URL', config?.embedding_base_url || '-'],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
background: theme.bgCard,
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${theme.border}`,
|
||||
boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<span className="mono" style={{ fontSize: 12, color: theme.text3, flexShrink: 0 }}>{k}</span>
|
||||
<span
|
||||
title={v}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
maxWidth: 200,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'help',
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Document index section */}
|
||||
<section>
|
||||
<h2 style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: theme.accent,
|
||||
marginBottom: 20,
|
||||
letterSpacing: '1px',
|
||||
}}>DOCUMENT INDEX</h2>
|
||||
{docs.map(d => (
|
||||
<div key={d.id} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
background: theme.bgCard,
|
||||
borderRadius: 10,
|
||||
marginBottom: 10,
|
||||
border: `1px solid ${
|
||||
d.status === 'failed' ? '#d64545' :
|
||||
d.status === 'parsing' || d.status === 'pending' ? theme.accent + '80' :
|
||||
theme.border
|
||||
}`,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, overflow: 'hidden' }}>
|
||||
<span style={{ fontSize: 14, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.name}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: theme.text3, flexShrink: 0 }}>
|
||||
{d.updated_at ? new Date(d.updated_at).toLocaleString() : d.status}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
|
||||
<span className="mono" style={{ fontSize: 12, color: theme.text2 }}>{d.chunks} chunks</span>
|
||||
<div style={{
|
||||
padding: '4px 12px',
|
||||
background:
|
||||
d.status === 'failed' ? '#d64545' :
|
||||
d.status === 'parsing' || d.status === 'pending' ? theme.accent :
|
||||
theme.green,
|
||||
borderRadius: 6,
|
||||
opacity: d.status === 'parsing' || d.status === 'pending' ? 0.85 : 1,
|
||||
}}>
|
||||
<span className="mono" style={{ fontSize: 10, fontWeight: 600, color: '#fff' }}>
|
||||
{d.status === 'parsing' ? '⟳ ' : ''}{d.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Navigate to={defaultTab.path} replace />} />
|
||||
{appTabs.map((tab) => (
|
||||
<Route key={tab.id} path={tab.path.slice(1)} element={null} />
|
||||
))}
|
||||
<Route path="*" element={<Navigate to={defaultTab.path} replace />} />
|
||||
<Route path="/" element={<AppShell />}>
|
||||
<Route index element={<OverviewPage />} />
|
||||
<Route path="status" element={<StatusPage />} />
|
||||
<Route path="signals" element={<PerceptionPage />} />
|
||||
<Route path="documents" element={<DocsPage />} />
|
||||
<Route path="compliance" element={<CompliancePage />} />
|
||||
<Route path="chat" element={<RagChatPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
Reference in New Issue
Block a user