This commit is contained in:
2026-06-04 15:43:44 +08:00
parent ac490d851a
commit 746513cc54
11 changed files with 955 additions and 131 deletions

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { Topbar } from '../../components/layout/Topbar';
import { Upload, Search } from 'lucide-react';
import { UploadModal } from './UploadModal';
interface Doc {
id: string;
@@ -24,20 +25,39 @@ const MOCK_DOCS: Doc[] = [
{ id: '7', name: 'GB/T 42118-2022', status: 'risk', uploadedAt: '2025-08-30', chunks: 0, type: 'National Draft' },
];
const STATUS_LABEL: Record<string, string> = { ok: 'Ready', warn: 'Embedding', risk: 'Failed', info: 'Pending' };
const STATUS_LABEL: Record<string, string> = { ok: 'Ready', warn: 'Processing', risk: 'Failed', info: 'Pending' };
const STATUS_MAP: Record<string, string> = { All: 'All', Ready: 'ok', Embedding: 'warn', Failed: 'risk', Pending: 'info' };
// Map backend DocumentStatus enum values to frontend display status
function backendStatus(s: string): Doc['status'] {
if (s === 'indexed') return 'ok';
if (s === 'failed') return 'risk';
if (s === 'parsed') return 'warn'; // chunked, awaiting embedding
return 'info'; // pending / stored
}
export function DocsPage() {
const [search, setSearch] = useState('');
const [statusF, setStatusF] = useState('All');
const [typeF, setTypeF] = useState('All types');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [docs, setDocs] = useState<Doc[]>(MOCK_DOCS);
const [showUpload, setShowUpload] = useState(false);
useEffect(() => {
fetch('/api/v1/documents')
fetch('/api/v1/documents/management-list')
.then(r => r.json())
.then(d => { if (Array.isArray(d?.documents)) setDocs(d.documents); })
.then(d => {
if (!Array.isArray(d?.documents)) return;
setDocs(d.documents.map((item: Record<string, unknown>) => ({
id: item.doc_id as string,
name: item.doc_name as string,
status: backendStatus(item.status as string),
uploadedAt: ((item.updated_at as string) ?? '').slice(0, 10),
chunks: (item.chunk_count as number) ?? 0,
type: (item.regulation_type as string) || '—',
})));
})
.catch(() => {});
}, []);
@@ -73,7 +93,9 @@ export function DocsPage() {
onChange={e => setSearch(e.target.value)}
/>
</div>
<button className="btn sm primary"><Upload size={13} />Upload document</button>
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
<Upload size={13} />Upload document
</button>
</>
}
/>
@@ -136,6 +158,8 @@ export function DocsPage() {
))}
</div>
</div>
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
</div>
);
}

View File

@@ -0,0 +1,356 @@
import { useState, useRef, useCallback } from 'react';
import { X, Upload } from 'lucide-react';
interface Props {
onClose: () => void;
}
const REG_TYPES = ['EU Regulation', 'ISO Standard', 'National Draft', 'Internal Policy'];
// Backend DocumentStatus values and what stage they map to:
// pending → stage 0 (Preflight validation) in progress
// stored → stage 1 (Object storage upload) done, stage 2 starting
// parsed → stage 2 (Parse submission) done, stage 3 starting
// indexed → all stages done
// failed → error
type DocStatus = 'pending' | 'stored' | 'parsed' | 'indexed' | 'failed' | 'idle';
interface StageState {
status: 'waiting' | 'running' | 'done' | 'error';
progress: number; // 0100
}
const STAGE_LABELS = [
{ name: 'Preflight validation', desc: 'File-type validation, duplicate check, metadata' },
{ name: 'Object storage upload', desc: 'File saved to document store' },
{ name: 'Parse submission', desc: 'Aliyun DocMind / local parser — extracting structure' },
{ name: 'Chunking + embedding', desc: 'Semantic blocks → vector chunks → 1024-d embeddings' },
];
function docStatusToStages(status: DocStatus): StageState[] {
const waiting: StageState = { status: 'waiting', progress: 0 };
const done: StageState = { status: 'done', progress: 100 };
const running = (p: number): StageState => ({ status: 'running', progress: p });
switch (status) {
case 'idle': return [waiting, waiting, waiting, waiting];
case 'pending': return [running(40), waiting, waiting, waiting];
case 'stored': return [done, done, running(30), waiting];
case 'parsed': return [done, done, done, running(60)];
case 'indexed': return [done, done, done, done];
case 'failed': return [waiting, waiting, waiting, waiting]; // shown separately
default: return [waiting, waiting, waiting, waiting];
}
}
// Generate a short unique ID client-side (matches backend's 8-char uuid prefix pattern)
function genDocId(): string {
return Math.random().toString(36).slice(2, 10);
}
export function UploadModal({ onClose }: Props) {
const [files, setFiles] = useState<File[]>([]);
const [regType, setRegType] = useState(REG_TYPES[0]);
const [version, setVersion] = useState('');
const [dragging, setDragging] = useState(false);
// Per-file processing state
const [currentFileIdx, setCurrentFileIdx] = useState(-1);
const [docStatus, setDocStatus] = useState<DocStatus>('idle');
const [fileErrors, setFileErrors] = useState<string[]>([]);
// Overall state
const [submitting, setSubmitting] = useState(false);
const [doneCount, setDoneCount] = useState(0);
const [allDone, setAllDone] = useState(false);
const [submitError, setSubmitError] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
function addFiles(newFiles: FileList | null) {
if (!newFiles) return;
setFiles(prev => [...prev, ...Array.from(newFiles)]);
}
function removeFile(index: number) {
setFiles(prev => prev.filter((_, i) => i !== index));
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setDragging(false);
addFiles(e.dataTransfer.files);
}
function stopPolling() {
if (pollTimer.current) {
clearInterval(pollTimer.current);
pollTimer.current = null;
}
}
const pollStatus = useCallback((docId: string, resolve: () => void, reject: (msg: string) => void) => {
let attempts = 0;
const MAX_ATTEMPTS = 120; // 4 minutes at 2s interval
stopPolling();
pollTimer.current = setInterval(async () => {
attempts++;
try {
const res = await fetch(`/api/v1/documents/status/${docId}`);
if (!res.ok) {
if (attempts > MAX_ATTEMPTS) { stopPolling(); reject('Polling timeout'); }
return;
}
const data: { status: string; message?: string } = await res.json();
const status = data.status as DocStatus;
setDocStatus(status);
if (status === 'indexed') {
stopPolling();
resolve();
} else if (status === 'failed') {
stopPolling();
reject(data.message ?? 'Processing failed');
} else if (attempts > MAX_ATTEMPTS) {
stopPolling();
reject('Processing timeout — check Document Management for status');
}
} catch {
// network hiccup — keep polling
}
}, 2000);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function processFile(file: File, idx: number) {
setCurrentFileIdx(idx);
setDocStatus('idle');
const docId = genDocId();
const form = new FormData();
form.append('file', file);
form.append('doc_id', docId);
form.append('doc_name', file.name);
form.append('regulation_type', regType);
if (version) form.append('version', version);
form.append('generate_summary', 'false');
// Fire upload — this is a long-running synchronous call on the backend.
// We start polling immediately so the UI updates as the backend writes status transitions.
const uploadPromise = fetch('/api/v1/documents/upload', { method: 'POST', body: form });
// Start polling after a short delay so the backend has time to create the document record
await new Promise<void>((res, rej) => {
const reject = (msg: string) => rej(new Error(msg));
// Begin polling immediately — backend creates the record synchronously before processing
setTimeout(() => pollStatus(docId, res, reject), 800);
// Also handle the upload response (in case processing finishes before poll catches it)
uploadPromise.then(async httpRes => {
if (!httpRes.ok) {
const detail = await httpRes.text().catch(() => httpRes.statusText);
stopPolling();
reject(`${file.name}: ${httpRes.status} ${detail}`);
}
// Upload succeeded — polling will catch the final status
}).catch(err => {
stopPolling();
reject(err instanceof Error ? err.message : 'Upload error');
});
});
}
async function handleSubmit() {
if (files.length === 0) return;
setSubmitting(true);
setSubmitError('');
setDoneCount(0);
setFileErrors([]);
const errors: string[] = new Array(files.length).fill('');
for (let i = 0; i < files.length; i++) {
try {
await processFile(files[i], i);
setDoneCount(i + 1);
} catch (e: unknown) {
errors[i] = e instanceof Error ? e.message : String(e);
setFileErrors([...errors]);
}
}
setSubmitting(false);
setAllDone(true);
setDocStatus('idle');
setCurrentFileIdx(-1);
}
// Compute queue stage display for the currently-processing file
const stages = docStatusToStages(submitting ? docStatus : 'idle');
const successCount = doneCount - fileErrors.filter(Boolean).length;
if (allDone) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-dialog" style={{ gridTemplateColumns: '1fr', maxWidth: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-panel" style={{ alignItems: 'center', justifyContent: 'center', textAlign: 'center', gap: 16, padding: 48 }}>
<div style={{ width: 52, height: 52, borderRadius: '50%', background: 'var(--success-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--success)', fontSize: 24 }}></div>
<div className="modal-title" style={{ fontSize: 18 }}>Import complete</div>
<p className="modal-lead">
{successCount} of {files.length} file{files.length > 1 ? 's' : ''} indexed successfully.
{fileErrors.some(Boolean) && ' Some files failed — see Document Management for details.'}
</p>
<button className="btn primary" onClick={onClose}>Close</button>
</div>
</div>
</div>
);
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-dialog" onClick={e => e.stopPropagation()}>
<button className="modal-close" onClick={onClose} aria-label="Close" disabled={submitting}><X size={14} /></button>
{/* ── Left panel: upload form ── */}
<div className="modal-panel">
<div className="modal-eyebrow">Upload documents</div>
<div className="modal-title">Stage files for parsing and indexing.</div>
<p className="modal-lead">PDF, DOCX, TXT one per API call, processed sequentially.</p>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.docx,.doc,.txt"
style={{ display: 'none' }}
onChange={e => addFiles(e.target.files)}
/>
<div
className={`dropzone${dragging ? ' drag-over' : ''}`}
onDragOver={e => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => !submitting && fileInputRef.current?.click()}
>
<div className="drop-icon">PDF</div>
<div className="drop-label">Drop files here or browse</div>
<p className="drop-hint">Up to 20 files · 200 MB combined</p>
<div className="drop-actions">
<button className="btn sm primary" onClick={e => { e.stopPropagation(); fileInputRef.current?.click(); }} disabled={submitting}>
<Upload size={12} />Choose files
</button>
</div>
</div>
{files.length > 0 && (
<div className="staged-files">
{files.map((f, i) => {
const isDone = doneCount > i;
const isActive = submitting && currentFileIdx === i;
const hasErr = !!fileErrors[i];
return (
<div key={i} className="file-row">
<div className="file-row-top">
<div>
<div className="file-name">{f.name}</div>
<div className="file-meta">{(f.size / 1024).toFixed(0)} KB · {f.type || 'document'}</div>
</div>
{!submitting && (
<button className="file-remove" onClick={() => removeFile(i)} aria-label="Remove"><X size={13} /></button>
)}
{isDone && !hasErr && <span className="status ok" style={{ flexShrink: 0 }}>Indexed</span>}
{isDone && hasErr && <span className="status risk" style={{ flexShrink: 0 }}>Failed</span>}
{isActive && <span className="status info" style={{ flexShrink: 0 }}>
{docStatus === 'pending' ? 'Storing…'
: docStatus === 'stored' ? 'Parsing…'
: docStatus === 'parsed' ? 'Embedding…'
: 'Processing…'}
</span>}
</div>
<div className="file-progress">
<div className="file-progress-fill" style={{
width: isDone ? '100%'
: isActive ? `${docStatusToStages(docStatus)[3].progress || docStatusToStages(docStatus)[2].progress || docStatusToStages(docStatus)[0].progress || 20}%`
: '0%',
background: hasErr ? 'var(--danger)' : undefined,
transition: 'width 0.4s ease',
}} />
</div>
</div>
);
})}
</div>
)}
<div className="upload-fields">
<div className="upload-field">
<label htmlFor="um-reg-type">Regulation type</label>
<select id="um-reg-type" value={regType} onChange={e => setRegType(e.target.value)} disabled={submitting}>
{REG_TYPES.map(t => <option key={t}>{t}</option>)}
</select>
</div>
<div className="upload-field">
<label htmlFor="um-version">Version / release</label>
<input id="um-version" type="text" placeholder="e.g. 2024 final" value={version} onChange={e => setVersion(e.target.value)} disabled={submitting} />
</div>
</div>
{submitError && <p style={{ color: 'var(--danger)', fontSize: 12, marginTop: 10 }}>{submitError}</p>}
<div className="modal-actions">
<button className="btn" onClick={onClose} disabled={submitting}>Cancel</button>
<button className="btn primary" onClick={handleSubmit} disabled={files.length === 0 || submitting}>
{submitting
? `Processing ${currentFileIdx + 1} / ${files.length}`
: 'Start import queue'}
</button>
</div>
</div>
{/* ── Right panel: live queue stages ── */}
<div className="modal-panel">
<div className="modal-eyebrow">Import queue</div>
<div className="modal-title" style={{ fontSize: 18 }}>Live pipeline progress</div>
<p className="modal-lead">Stages update in real time as each file moves through the processing pipeline.</p>
<div className="summary-cards">
<div className="summary-card-sm">
<strong>{files.length} file{files.length !== 1 ? 's' : ''} staged</strong>
<div className="summary-card-hint">
{submitting ? `File ${currentFileIdx + 1} of ${files.length}${docStatus === 'idle' ? 'starting…' : docStatus}` : 'Ready for submission'}
</div>
</div>
<div className="summary-card-sm">
<strong>text-embedding-v3</strong>
<div className="summary-card-hint">1024-d dense vectors, Milvus collection</div>
</div>
</div>
<div className="queue-section">
{STAGE_LABELS.map((stage, i) => {
const s = stages[i];
const statusCls = s.status === 'done' ? 'ok' : s.status === 'running' ? 'warn' : s.status === 'error' ? 'risk' : 'info';
const label = s.status === 'done' ? 'Done' : s.status === 'running' ? 'Running' : s.status === 'error' ? 'Error' : 'Waiting';
return (
<div key={stage.name} className="queue-card">
<div className="queue-top">
<div>
<div className="queue-name">{stage.name}</div>
<div className="queue-desc">{stage.desc}</div>
</div>
<span className={`status ${statusCls}`}>{label}</span>
</div>
<div className="queue-progress">
<div className="queue-progress-fill" style={{ width: `${s.progress}%`, transition: 'width 0.5s ease' }} />
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}

View File

@@ -31,6 +31,31 @@ interface DocResult {
const SOURCES = ['All', 'MIIT', 'UN-ECE', 'ISO', 'GB Comm.', 'EUR-Lex', 'IATF'];
const IMPACTS = ['All', 'High', 'Medium', 'Low'];
// Backend /api/v1/perception/stats returns:
// { total, high_impact, medium_impact, last_90_days } — field names match, ✓
// Backend /api/v1/perception/events returns:
// { events: [{ id, title, summary, source, standard, impact_level, published_at, tags, status }] }
// Map backend event fields → frontend Signal shape
function mapEvent(e: Record<string, unknown>): Signal {
const impact = String(e.impact_level ?? '').toLowerCase();
const backendStatus = String(e.status ?? '').toLowerCase();
return {
id: String(e.id ?? e.event_id ?? ''),
source: String(e.source ?? ''),
standard: String(e.standard ?? e.regulation_id ?? ''),
status: backendStatus === 'high' || backendStatus === 'urgent' ? 'risk'
: backendStatus === 'medium' || backendStatus === 'draft' ? 'warn'
: backendStatus === 'low' || backendStatus === 'final' ? 'ok'
: 'info',
title: String(e.title ?? ''),
summary: String(e.summary ?? e.description ?? ''),
date: String((e.published_at as string ?? '').slice(0, 10)),
tags: Array.isArray(e.tags) ? e.tags.map(String) : [],
impact: impact === 'high' ? 'High' : impact === 'medium' ? 'Medium' : 'Low',
};
}
const MOCK_SIGNALS: Signal[] = [
{
id: '1', source: 'EUR-Lex', standard: 'EU/2024/1689', status: 'risk',
@@ -66,6 +91,8 @@ const MOCK_DOCS: DocResult[] = [
export function PerceptionPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [signals, setSignals] = useState<Signal[]>(MOCK_SIGNALS);
const [searchQuery, setSearchQuery] = useState('');
const [sourceFilter, setSourceFilter] = useState('All');
const [impactFilter, setImpactFilter] = useState('All');
const [selected, setSelected] = useState<Signal | null>(null);
@@ -80,10 +107,26 @@ export function PerceptionPage() {
.catch(() => setStats({ total: 47, high_impact: 7, medium_impact: 18, last_90_days: 14 }));
}, []);
const filtered = MOCK_SIGNALS.filter(s =>
(sourceFilter === 'All' || s.source === sourceFilter) &&
(impactFilter === 'All' || s.impact === impactFilter)
);
useEffect(() => {
fetch('/api/v1/perception/events?limit=100')
.then(r => r.json())
.then(d => {
if (Array.isArray(d?.events) && d.events.length > 0) {
setSignals(d.events.map(mapEvent));
}
})
.catch(() => { /* keep mock data on error */ });
}, []);
const filtered = signals.filter(s => {
if (sourceFilter !== 'All' && s.source !== sourceFilter) return false;
if (impactFilter !== 'All' && s.impact !== impactFilter) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
if (!s.title.toLowerCase().includes(q) && !s.summary.toLowerCase().includes(q)) return false;
}
return true;
});
function runAnalysis() {
if (!selected) return;
@@ -91,21 +134,30 @@ export function PerceptionPage() {
setAiOutput('');
const ctrl = new AbortController();
abortRef.current = ctrl;
fetch(`/api/v1/perception/analyze?signal_id=${selected.id}`, { signal: ctrl.signal })
// Backend: POST /api/v1/perception/events/{id}/analyze → SSE stream
fetch(`/api/v1/perception/events/${selected.id}/analyze`, { method: 'POST', signal: ctrl.signal })
.then(async res => {
if (!res.body) { setAiOutput('No stream available.'); setStreaming(false); return; }
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = dec.decode(value);
for (const line of chunk.split('\n')) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') break;
try { const j = JSON.parse(data); setAiOutput(p => p + (j.text || '')); }
catch { setAiOutput(p => p + data); }
buf += dec.decode(value);
const parts = buf.split('\n\n');
buf = parts.pop() ?? '';
for (const block of parts) {
const dataLine = block.split('\n').find(l => l.startsWith('data: '));
if (!dataLine) continue;
const raw = dataLine.slice(6).trim();
if (!raw || raw === '[DONE]') continue;
try {
const j = JSON.parse(raw);
if (j.text) setAiOutput(p => p + j.text);
else if (typeof j === 'string') setAiOutput(p => p + j);
} catch {
setAiOutput(p => p + raw);
}
}
}
@@ -136,7 +188,11 @@ export function PerceptionPage() {
actions={
<>
<div className="search-box">
<input placeholder="Search signals..." />
<input
placeholder="Search signals..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
<button className="btn sm"><RefreshCw size={13} />Refresh</button>
</>

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { Topbar } from '../../components/layout/Topbar';
import { Send, Download } from 'lucide-react';
@@ -6,79 +6,138 @@ interface Message {
id: string;
role: 'user' | 'assistant';
text: string;
// citation indices mentioned in this assistant message (1-based, matching citations array)
citationRefs?: number[];
}
interface Citation {
score: number;
name: string;
clause: string;
snippet: string;
index: number; // 1-based, matches [N] markers in text
score: number; // 0100 display percentage
name: string; // doc_name
clause: string; // section_title or clause
snippet: string; // preview text
docId?: string;
}
const HISTORY = [
{ id: 'h1', title: 'EU AI Act Article 9 scope', date: '2025-11-18' },
{ id: 'h2', title: 'MIIT training data requirements', date: '2025-11-15' },
{ id: 'h3', title: 'ISO 21434 CSMS audit scope', date: '2025-11-10' },
];
// Map a raw source doc from the backend "retrieved" event to our Citation shape.
// Backend fields: { id, score(0-1), preview, doc_name, clause, doc_id }
function mapSource(s: Record<string, unknown>, idx: number): Citation {
const rawScore = typeof s.score === 'number' ? s.score : 0;
const displayScore = rawScore <= 1 ? Math.round(rawScore * 100) : Math.round(rawScore);
return {
index: idx,
score: displayScore,
name: String(s.doc_name ?? ''),
clause: String(s.clause ?? s.section_title ?? ''),
snippet: String(s.preview ?? s.text ?? ''),
docId: s.doc_id ? String(s.doc_id) : undefined,
};
}
const QUICK = [
// Parse message text and replace [N] with clickable <button class="cite-ref"> elements.
function renderWithCitations(
text: string,
onCiteClick: (n: number) => void,
): React.ReactNode[] {
const parts = text.split(/(\[\d+\])/g);
return parts.map((part, i) => {
const m = part.match(/^\[(\d+)\]$/);
if (m) {
const n = parseInt(m[1], 10);
return (
<button
key={i}
className="cite-ref"
onClick={() => onCiteClick(n)}
title={`Jump to source [${n}]`}
>
{n}
</button>
);
}
return part;
});
}
const MOCK_QUICK = [
'What does EU AI Act Art. 9 require for risk management?',
'Which documents need CSMS certification?',
'Summarize MIIT training data rules',
'What are high-risk AI categories under Annex III?',
];
const MOCK_CITATIONS: Citation[] = [
{
score: 94, name: 'EU AI Act', clause: 'Art. 9(1)',
snippet: 'Providers of high-risk AI systems shall establish a risk management system consisting of a continuous iterative process run throughout the entire lifecycle.'
},
{
score: 87, name: 'Vehicle AI Safety Manual', clause: '§4.2.1',
snippet: 'All AI systems classified as high-risk must maintain a documented risk register with quarterly review cadence.'
},
{
score: 72, name: 'ISO/SAE 21434', clause: 'Clause 9.3',
snippet: 'The cybersecurity management system shall include AI model update governance procedures and audit log retention policy.'
},
];
export function RagChatPage() {
const [messages, setMessages] = useState<Message[]>([
{
id: 'init', role: 'assistant',
text: 'Hello! I can answer questions about your indexed regulations and compliance documents. Try asking about EU AI Act requirements, MIIT rules, or ISO/SAE 21434 scope.'
text: 'Hello! I can answer questions about your indexed regulations and compliance documents. Try asking about EU AI Act requirements, MIIT rules, or ISO/SAE 21434 scope.',
}
]);
const [quickPrompts, setQuickPrompts] = useState<string[]>(MOCK_QUICK);
const [input, setInput] = useState('');
const [streaming, setStreaming] = useState(false);
const [citations, setCitations] = useState<Citation[]>(MOCK_CITATIONS);
const [citations, setCitations] = useState<Citation[]>([]);
const [highlightedCit, setHighlightedCit] = useState<number | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const citRailRef = useRef<HTMLDivElement>(null);
const citItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
const abortRef = useRef<AbortController | null>(null);
// Fetch quick questions from backend on mount
useEffect(() => {
fetch('/api/v1/rag/quick-questions')
.then(r => r.json())
.then(d => {
if (Array.isArray(d?.questions) && d.questions.length > 0) {
setQuickPrompts(d.questions.map((q: { question: string }) => q.question));
}
})
.catch(() => { /* keep mock */ });
}, []);
// Auto-scroll to latest message
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Jump to citation N in the rail and highlight it
const jumpToCitation = useCallback((n: number) => {
setHighlightedCit(n);
const el = citItemRefs.current[n];
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// Clear highlight after 3s
setTimeout(() => setHighlightedCit(h => h === n ? null : h), 3000);
}, []);
async function send(text?: string) {
const q = (text ?? input).trim();
if (!q || streaming) return;
setInput('');
const userMsg: Message = { id: Date.now().toString(), role: 'user', text: q };
setMessages(m => [...m, userMsg]);
const assistantId = (Date.now() + 1).toString();
setMessages(m => [...m, { id: assistantId, role: 'assistant', text: '' }]);
setStreaming(true);
setCitations([]);
setHighlightedCit(null);
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
const body: Record<string, unknown> = { query: q, top_k: 5 };
if (sessionId) body.session_id = sessionId;
const res = await fetch('/api/v1/rag/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: q }),
body: JSON.stringify(body),
signal: ctrl.signal,
});
@@ -86,29 +145,65 @@ export function RagChatPage() {
const reader = res.body.getReader();
const dec = new TextDecoder();
let buffer = '';
const newCitations: Citation[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += dec.decode(value);
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const j = JSON.parse(data);
if (j.text) setMessages(m => m.map(msg =>
msg.id === assistantId ? { ...msg, text: msg.text + j.text } : msg
));
if (j.citations) setCitations(j.citations);
} catch {
buffer += dec.decode(value, { stream: true });
// SSE blocks separated by double newline
const blocks = buffer.split('\n\n');
buffer = blocks.pop() ?? '';
for (const block of blocks) {
const dataLine = block.split('\n').find(l => l.startsWith('data: '));
if (!dataLine) continue;
const raw = dataLine.slice(6).trim();
if (!raw) continue;
try {
const j = JSON.parse(raw);
if (j.type === 'session') {
// Backend assigned a session_id — persist for next request
if (j.session_id) setSessionId(j.session_id);
} else if (j.type === 'retrieved' && Array.isArray(j.docs)) {
// Sources arrive before the answer starts
const mapped = j.docs.map((d: Record<string, unknown>, i: number) => mapSource(d, i + 1));
newCitations.push(...mapped);
setCitations([...mapped]);
} else if (j.type === 'chunk' && j.text) {
setMessages(m => m.map(msg =>
msg.id === assistantId ? { ...msg, text: msg.text + data } : msg
msg.id === assistantId
? { ...msg, text: msg.text + (j.text as string) }
: msg
));
} else if (j.type === 'status') {
// Status message (e.g. "找到N条相关法规…") — could show in UI if desired
// For now we ignore it to keep the bubble clean
} else if (j.type === 'done') {
// Extract which citation numbers appear in the final answer
setMessages(m => m.map(msg => {
if (msg.id !== assistantId) return msg;
const refs = [...new Set(
[...msg.text.matchAll(/\[(\d+)\]/g)].map(r => parseInt(r[1], 10))
)].filter(n => n >= 1 && n <= newCitations.length);
return { ...msg, citationRefs: refs };
}));
break;
} else if (j.type === 'error') {
setMessages(m => m.map(msg =>
msg.id === assistantId
? { ...msg, text: `Error: ${j.text ?? 'Unknown error'}` }
: msg
));
}
}
} catch { /* malformed JSON chunk, skip */ }
}
}
} catch (e: unknown) {
@@ -130,30 +225,44 @@ export function RagChatPage() {
<div className="chat-page">
<Topbar
title="Regulation Q&A"
actions={<button className="btn sm"><Download size={13} />Export chat</button>}
actions={
<button
className="btn sm"
onClick={() => {
const text = messages.map(m => `${m.role === 'user' ? 'Q' : 'A'}: ${m.text}`).join('\n\n');
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'chat-export.txt'; a.click();
URL.revokeObjectURL(url);
}}
>
<Download size={13} />Export chat
</button>
}
/>
<div className="chat-body">
{/* ── History pane ── */}
<div className="history-pane">
<div className="history-header">Chat history</div>
{HISTORY.map(h => (
<div key={h.id} className="history-item">
<div className="history-title">{h.title}</div>
<div className="history-date">{h.date}</div>
</div>
))}
<div className="quick-header">Quick prompts</div>
{QUICK.map(q => (
<button key={q} className="quick-item" onClick={() => send(q)}>{q}</button>
<div className="history-header">Quick prompts</div>
{quickPrompts.map(q => (
<button key={q} className="quick-item" onClick={() => send(q)}>
{q}
</button>
))}
</div>
{/* ── Chat main ── */}
<div className="chat-main">
<div className="messages">
{messages.map(msg => (
<div key={msg.id} className={`message msg-${msg.role}`}>
{msg.role === 'assistant' && <div className="msg-avatar">AI</div>}
<div className="msg-bubble">
{msg.text}
{msg.role === 'assistant'
? renderWithCitations(msg.text, jumpToCitation)
: msg.text
}
{streaming && msg.id === lastAssistantId && (
<span className="blink-cursor"></span>
)}
@@ -166,7 +275,7 @@ export function RagChatPage() {
<div className="composer">
<div className="quick-chips">
{QUICK.slice(0, 3).map(q => (
{quickPrompts.slice(0, 3).map(q => (
<button key={q} className="chip" onClick={() => send(q)}>
{q.length > 42 ? q.slice(0, 42) + '…' : q}
</button>
@@ -175,7 +284,7 @@ export function RagChatPage() {
<div className="composer-row">
<textarea
className="composer-input"
placeholder="Ask about your regulations..."
placeholder="Ask about your regulations"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
@@ -192,13 +301,29 @@ export function RagChatPage() {
</div>
</div>
<div className="citation-rail">
<div className="citation-header">Sources</div>
{/* ── Citation rail ── */}
<div className="citation-rail" ref={citRailRef}>
<div className="citation-header">
Sources {citations.length > 0 && `(${citations.length})`}
</div>
{citations.length === 0 && (
<p style={{ padding: '12px 16px', fontSize: 12, color: 'var(--muted)', lineHeight: 1.5 }}>
Citations will appear here after a response is generated.
</p>
)}
{citations.map(c => (
<div key={`${c.name}-${c.clause}`} className="citation-item">
<div className="cit-score">{c.score}%</div>
<div>
<div className="cit-name">{c.name} <span className="cit-clause">{c.clause}</span></div>
<div
key={c.index}
ref={el => { citItemRefs.current[c.index] = el; }}
className={`citation-item${highlightedCit === c.index ? ' highlighted' : ''}`}
>
<div className="cit-index">[{c.index}]</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginBottom: 3 }}>
<div className="cit-name">{c.name}</div>
{c.clause && <span className="cit-clause">{c.clause}</span>}
<span className="cit-score" style={{ marginLeft: 'auto' }}>{c.score}%</span>
</div>
<div className="cit-snippet">{c.snippet}</div>
</div>
</div>

View File

@@ -1,8 +1,11 @@
import { useState, useEffect } from 'react';
import { Topbar } from '../../components/layout/Topbar';
import { Search, Upload, Download } from 'lucide-react';
import { Search, Upload, Download, RefreshCw } from 'lucide-react';
import { UploadModal } from '../Docs/UploadModal';
interface Stats { total_documents: number; vector_chunks: number; high_impact: number; last_90_days: number; }
// Backend /api/v1/status/stats returns:
// { documents_total, documents_indexed, documents_failed, chunks_total }
interface Stats { documents_total: number; documents_indexed: number; documents_failed: number; chunks_total: number; }
const TASKS = [
{ name: 'EU AI Act — Article 13 check', status: 'ok', progress: 88, cta: 'View report' },
@@ -40,13 +43,20 @@ const STATUS_LABEL: Record<string, string> = { ok: 'Complete', warn: 'In progres
export function StatusPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const [showUpload, setShowUpload] = useState(false);
useEffect(() => {
fetch('/api/v1/perception/stats')
setLoading(true);
fetch('/api/v1/status/stats')
.then(r => r.json())
.then(d => setStats(d))
.catch(() => setStats({ total_documents: 42, vector_chunks: 3841, high_impact: 7, last_90_days: 14 }));
}, []);
.then(d => { setStats(d); setLoading(false); })
.catch(() => {
setStats({ documents_total: 42, documents_indexed: 38, documents_failed: 1, chunks_total: 3841 });
setLoading(false);
});
}, [refreshKey]);
return (
<div className="status-page">
@@ -58,28 +68,44 @@ export function StatusPage() {
<Search size={13} />
<input placeholder="Search..." />
</div>
<button className="btn sm"><Download size={13} />Export status</button>
<button className="btn sm primary"><Upload size={13} />New upload</button>
<button
className="btn sm"
onClick={() => {
const blob = new Blob([JSON.stringify(stats, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'regulation-hub-status.json'; a.click();
URL.revokeObjectURL(url);
}}
>
<Download size={13} />Export status
</button>
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
<RefreshCw size={13} />Refresh
</button>
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
<Upload size={13} />New upload
</button>
</>
}
/>
<div className="page-content">
<div className="stats-grid">
<div className="stat-cell">
<div className="stat-value">{stats?.total_documents ?? '—'}</div>
<div className="stat-label">Documents indexed</div>
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_total ?? '—'}</div>}
<div className="stat-label">Documents total</div>
</div>
<div className="stat-cell">
<div className="stat-value">{stats?.vector_chunks?.toLocaleString() ?? '—'}</div>
<div className="stat-label">Vector chunks</div>
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_indexed ?? '—'}</div>}
<div className="stat-label">Indexed</div>
</div>
<div className="stat-cell danger">
<div className="stat-value">{stats?.high_impact ?? '—'}</div>
<div className="stat-label">High-impact signals</div>
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_failed ?? '—'}</div>}
<div className="stat-label">Failed</div>
</div>
<div className="stat-cell">
<div className="stat-value">{stats?.last_90_days ?? '—'}</div>
<div className="stat-label">Last 90 days</div>
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.chunks_total?.toLocaleString() ?? '—'}</div>}
<div className="stat-label">Vector chunks</div>
</div>
</div>
@@ -150,6 +176,8 @@ export function StatusPage() {
<div className="live-dot" />
<span>Regulation Hub · T-Systems AI · Online</span>
</footer>
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
</div>
);
}