fix
This commit is contained in:
@@ -54,10 +54,10 @@ export function Sidebar() {
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-brand">
|
||||
<div className="brand-mark">TS</div>
|
||||
<img src="/company-logo.ico" alt="T-Systems" className="brand-logo" />
|
||||
<div className="brand-text">
|
||||
<div className="brand-name">Regulation Hub</div>
|
||||
<div className="brand-sub">T-Systems AI</div>
|
||||
<div className="brand-name">T-Systems</div>
|
||||
<div className="brand-sub">Regulation Hub</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
356
frontend/src/pages/Docs/UploadModal.tsx
Normal file
356
frontend/src/pages/Docs/UploadModal.tsx
Normal 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; // 0–100
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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; // 0–100 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/* ── Design Tokens ──────────────────────────────── */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--rail-bg: #ffffff;
|
||||
--rail-surface: #f7f8fa;
|
||||
--rail-fg: #111827;
|
||||
@@ -10,18 +12,18 @@
|
||||
|
||||
--bg: #f2f4f7;
|
||||
--surface: #ffffff;
|
||||
--fg: #111827;
|
||||
--fg: #111111;
|
||||
--muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--border: #e5e5e5;
|
||||
--border-strong: #d1d5db;
|
||||
|
||||
--accent: #e20074;
|
||||
--accent-dim: rgba(226,0,116,.10);
|
||||
--accent-hover: #c8006a;
|
||||
--success: #16a34a;
|
||||
--success-bg: rgba(22,163,74,.08);
|
||||
--warn: #d97706;
|
||||
--warn-bg: rgba(217,119,6,.08);
|
||||
--success: #17a34a;
|
||||
--success-bg: rgba(23,163,74,.08);
|
||||
--warn: #eab308;
|
||||
--warn-bg: rgba(234,179,8,.08);
|
||||
--danger: #dc2626;
|
||||
--danger-bg: rgba(220,38,38,.08);
|
||||
--info: #2563eb;
|
||||
@@ -31,29 +33,71 @@
|
||||
--font-body: "TeleNeoWeb-Regular", "Inter", -apple-system, sans-serif;
|
||||
--font-mono: ui-monospace, "JetBrains Mono", Menlo, monospace;
|
||||
|
||||
--sidebar-w: 232px;
|
||||
--sidebar-w: 240px;
|
||||
--topbar-h: 54px;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-pill: 9999px;
|
||||
--shadow-card: 0 1px 4px rgba(0,0,0,.06), 0 0 0 1px rgba(0,0,0,.04);
|
||||
--shadow-card: 0 2px 8px rgba(0,0,0,.06), 0 0 0 1px rgba(0,0,0,.04);
|
||||
}
|
||||
|
||||
/* Auto dark: matches system preference unless user chose light explicitly */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--rail-bg: #17181d;
|
||||
--rail-surface: #1d1f26;
|
||||
--rail-fg: #f5f7fb;
|
||||
--rail-muted: #a2a9b8;
|
||||
--rail-border: #2a2d35;
|
||||
--rail-hover: rgba(255,255,255,.06);
|
||||
--rail-active: rgba(226,0,116,.15);
|
||||
|
||||
--bg: #0f1014;
|
||||
--surface: #17181d;
|
||||
--fg: #f5f7fb;
|
||||
--muted: #a2a9b8;
|
||||
--border: #2a2d35;
|
||||
--border-strong: #3a3d48;
|
||||
|
||||
--success: #22c55e;
|
||||
--success-bg: rgba(34,197,94,.10);
|
||||
--warn: #facc15;
|
||||
--warn-bg: rgba(250,204,21,.10);
|
||||
--danger: #f87171;
|
||||
--danger-bg: rgba(248,113,113,.10);
|
||||
}
|
||||
}
|
||||
|
||||
/* Explicit dark mode (user toggled) */
|
||||
[data-theme="dark"] {
|
||||
--rail-bg: #1a1a2e;
|
||||
--rail-surface: #16213e;
|
||||
--rail-fg: #f0f0f0;
|
||||
--rail-muted: #8b929e;
|
||||
--rail-border: #2d3748;
|
||||
color-scheme: dark;
|
||||
--rail-bg: #17181d;
|
||||
--rail-surface: #1d1f26;
|
||||
--rail-fg: #f5f7fb;
|
||||
--rail-muted: #a2a9b8;
|
||||
--rail-border: #2a2d35;
|
||||
--rail-hover: rgba(255,255,255,.06);
|
||||
--rail-active: rgba(226,0,116,.15);
|
||||
|
||||
--bg: #0f0f1a;
|
||||
--surface: #1a1a2e;
|
||||
--fg: #f0f0f0;
|
||||
--muted: #9ca3af;
|
||||
--border: #2d3748;
|
||||
--border-strong: #4a5568;
|
||||
--bg: #0f1014;
|
||||
--surface: #17181d;
|
||||
--fg: #f5f7fb;
|
||||
--muted: #a2a9b8;
|
||||
--border: #2a2d35;
|
||||
--border-strong: #3a3d48;
|
||||
|
||||
--success: #22c55e;
|
||||
--success-bg: rgba(34,197,94,.10);
|
||||
--warn: #facc15;
|
||||
--warn-bg: rgba(250,204,21,.10);
|
||||
--danger: #f87171;
|
||||
--danger-bg: rgba(248,113,113,.10);
|
||||
}
|
||||
|
||||
/* Explicit light (overrides auto dark) */
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ── Base Reset ─────────────────────────────────── */
|
||||
@@ -165,13 +209,11 @@ body {
|
||||
padding: 18px 16px 14px;
|
||||
border-bottom: 1px solid var(--rail-border);
|
||||
}
|
||||
.brand-mark {
|
||||
width: 32px; height: 32px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; font-weight: 700; font-family: var(--font-display);
|
||||
.brand-logo {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
max-width: 48px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-name { font-size: 13px; font-weight: 700; font-family: var(--font-display); color: var(--rail-fg); }
|
||||
@@ -538,8 +580,199 @@ body {
|
||||
|
||||
.citation-rail { border-left: 1px solid var(--border); overflow-y: auto; padding: 14px 0; background: var(--surface); }
|
||||
.citation-header { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); padding: 0 16px 8px; }
|
||||
.citation-item { display: flex; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--border); }
|
||||
.citation-item { display: flex; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--border); transition: background 0.15s; }
|
||||
.citation-item.highlighted { background: var(--accent-dim); border-left: 3px solid var(--accent); }
|
||||
.cit-score { font-size: 11px; font-weight: 700; font-family: var(--font-mono); color: var(--success); width: 34px; flex-shrink: 0; padding-top: 1px; }
|
||||
.cit-index { font-size: 10px; font-weight: 700; font-family: var(--font-mono); color: var(--muted); width: 18px; flex-shrink: 0; padding-top: 2px; }
|
||||
.cit-name { font-size: 12px; font-weight: 600; margin-bottom: 3px; }
|
||||
.cit-clause { font-size: 10px; font-family: var(--font-mono); color: var(--muted); margin-left: 5px; }
|
||||
.cit-snippet { font-size: 11px; color: var(--muted); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.5; }
|
||||
|
||||
/* Inline citation badge [N] in message text */
|
||||
.cite-ref {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
margin: 0 1px;
|
||||
border: none;
|
||||
line-height: 1;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.cite-ref:hover { background: var(--accent); color: #fff; }
|
||||
|
||||
/* ── Loading States ─────────────────────────────── */
|
||||
.loading-shimmer {
|
||||
background: linear-gradient(90deg, var(--border) 25%, var(--bg) 50%, var(--border) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s ease-in-out infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
display: inline-block;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.stat-value-loading { height: 32px; width: 64px; }
|
||||
|
||||
/* ── Upload Modal ───────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,.45);
|
||||
backdrop-filter: blur(3px);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.modal-dialog {
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 0.85fr;
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 14px 48px rgba(0,0,0,.22);
|
||||
position: relative;
|
||||
}
|
||||
.modal-panel {
|
||||
padding: 28px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal-panel + .modal-panel {
|
||||
border-left: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--surface) 76%, var(--bg) 24%);
|
||||
}
|
||||
.modal-eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.modal-title { font-size: 22px; font-weight: 700; font-family: var(--font-display); line-height: 1.2; }
|
||||
.modal-lead { font-size: 13px; color: var(--muted); margin-top: 8px; line-height: 1.6; }
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 16px; right: 20px;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
width: 30px; height: 30px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; color: var(--muted);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.modal-close:hover { background: var(--bg); color: var(--fg); }
|
||||
|
||||
.dropzone {
|
||||
margin-top: 20px;
|
||||
border: 1px dashed rgba(226,0,116,.46);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(226,0,116,.04);
|
||||
padding: 28px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
min-height: 180px;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.dropzone.drag-over { background: rgba(226,0,116,.08); border-color: var(--accent); }
|
||||
.drop-icon {
|
||||
width: 52px; height: 52px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(226,0,116,.35);
|
||||
background: var(--surface);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px; font-weight: 700;
|
||||
}
|
||||
.drop-label { font-size: 15px; font-weight: 600; font-family: var(--font-display); }
|
||||
.drop-hint { font-size: 12px; color: var(--muted); }
|
||||
.drop-actions { display: flex; gap: 10px; margin-top: 4px; }
|
||||
|
||||
.staged-files { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; }
|
||||
.file-row {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
padding: 12px 14px;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.file-row-top { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
|
||||
.file-name { font-size: 13px; font-weight: 500; }
|
||||
.file-meta { font-size: 11px; color: var(--muted); font-family: var(--font-mono); }
|
||||
.file-remove { background: none; border: none; cursor: pointer; color: var(--muted); padding: 2px; }
|
||||
.file-remove:hover { color: var(--danger); }
|
||||
.file-progress { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
|
||||
.file-progress-fill { height: 100%; background: var(--accent); border-radius: 2px; }
|
||||
|
||||
.upload-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 18px; }
|
||||
.upload-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.upload-field label { font-size: 12px; color: var(--muted); }
|
||||
.upload-field input,
|
||||
.upload-field select,
|
||||
.upload-field textarea {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
padding: 0 10px;
|
||||
font-size: 13px; color: var(--fg);
|
||||
font-family: var(--font-body);
|
||||
outline: none;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.upload-field input:focus,
|
||||
.upload-field select:focus,
|
||||
.upload-field textarea:focus { border-color: var(--accent); }
|
||||
.upload-field textarea { min-height: 72px; padding: 8px 10px; resize: vertical; }
|
||||
.upload-field.full-width { grid-column: 1 / -1; }
|
||||
.modal-actions { display: flex; gap: 10px; margin-top: 20px; }
|
||||
|
||||
.queue-section { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; }
|
||||
.queue-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
padding: 12px 14px;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.queue-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
|
||||
.queue-name { font-size: 13px; font-weight: 600; }
|
||||
.queue-desc { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||
.queue-progress { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
|
||||
.queue-progress-fill { height: 100%; border-radius: 3px; background: color-mix(in srgb, var(--accent) 74%, white 26%); transition: width 0.4s ease; }
|
||||
|
||||
.summary-cards { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; }
|
||||
.summary-card-sm {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--surface) 88%, var(--bg) 12%);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.summary-card-sm strong { font-size: 13px; font-family: var(--font-mono); }
|
||||
.summary-card-hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
||||
|
||||
Reference in New Issue
Block a user