fix
This commit is contained in:
@@ -34,6 +34,7 @@ def _document_response(result: DocumentProcessResult) -> DocumentUploadResponse:
|
|||||||
@router.post("/upload", response_model=DocumentUploadResponse)
|
@router.post("/upload", response_model=DocumentUploadResponse)
|
||||||
async def upload_document(
|
async def upload_document(
|
||||||
file: UploadFile = File(..., description="上传的文档文件"),
|
file: UploadFile = File(..., description="上传的文档文件"),
|
||||||
|
doc_id: str | None = Form(None, description="客户端预分配的文档ID,不传则自动生成"),
|
||||||
doc_name: str | None = Form(None, description="文档名称"),
|
doc_name: str | None = Form(None, description="文档名称"),
|
||||||
regulation_type: str | None = Form(None, description="法规类型"),
|
regulation_type: str | None = Form(None, description="法规类型"),
|
||||||
version: str | None = Form(None, description="文档版本"),
|
version: str | None = Form(None, description="文档版本"),
|
||||||
@@ -48,6 +49,7 @@ async def upload_document(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = get_document_command_service().upload_and_process(
|
result = get_document_command_service().upload_and_process(
|
||||||
|
doc_id=doc_id,
|
||||||
file_name=file.filename,
|
file_name=file.filename,
|
||||||
content=content,
|
content=content,
|
||||||
content_type=file.content_type or "application/octet-stream",
|
content_type=file.content_type or "application/octet-stream",
|
||||||
|
|||||||
BIN
frontend/09e5e346-5238-4c7e-9c5e-db1d6ef1225f.ico
Normal file
BIN
frontend/09e5e346-5238-4c7e-9c5e-db1d6ef1225f.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/x-icon" href="/company-logo.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>T-Systems Regulation Hub</title>
|
<title>T-Systems Regulation Hub</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
BIN
frontend/public/company-logo.ico
Normal file
BIN
frontend/public/company-logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
@@ -54,10 +54,10 @@ export function Sidebar() {
|
|||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="sidebar-brand">
|
<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-text">
|
||||||
<div className="brand-name">Regulation Hub</div>
|
<div className="brand-name">T-Systems</div>
|
||||||
<div className="brand-sub">T-Systems AI</div>
|
<div className="brand-sub">Regulation Hub</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Topbar } from '../../components/layout/Topbar';
|
import { Topbar } from '../../components/layout/Topbar';
|
||||||
import { Upload, Search } from 'lucide-react';
|
import { Upload, Search } from 'lucide-react';
|
||||||
|
import { UploadModal } from './UploadModal';
|
||||||
|
|
||||||
interface Doc {
|
interface Doc {
|
||||||
id: string;
|
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' },
|
{ 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' };
|
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() {
|
export function DocsPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusF, setStatusF] = useState('All');
|
const [statusF, setStatusF] = useState('All');
|
||||||
const [typeF, setTypeF] = useState('All types');
|
const [typeF, setTypeF] = useState('All types');
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [docs, setDocs] = useState<Doc[]>(MOCK_DOCS);
|
const [docs, setDocs] = useState<Doc[]>(MOCK_DOCS);
|
||||||
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/v1/documents')
|
fetch('/api/v1/documents/management-list')
|
||||||
.then(r => r.json())
|
.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(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -73,7 +93,9 @@ export function DocsPage() {
|
|||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
|
||||||
</div>
|
</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 SOURCES = ['All', 'MIIT', 'UN-ECE', 'ISO', 'GB Comm.', 'EUR-Lex', 'IATF'];
|
||||||
const IMPACTS = ['All', 'High', 'Medium', 'Low'];
|
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[] = [
|
const MOCK_SIGNALS: Signal[] = [
|
||||||
{
|
{
|
||||||
id: '1', source: 'EUR-Lex', standard: 'EU/2024/1689', status: 'risk',
|
id: '1', source: 'EUR-Lex', standard: 'EU/2024/1689', status: 'risk',
|
||||||
@@ -66,6 +91,8 @@ const MOCK_DOCS: DocResult[] = [
|
|||||||
|
|
||||||
export function PerceptionPage() {
|
export function PerceptionPage() {
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [signals, setSignals] = useState<Signal[]>(MOCK_SIGNALS);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [sourceFilter, setSourceFilter] = useState('All');
|
const [sourceFilter, setSourceFilter] = useState('All');
|
||||||
const [impactFilter, setImpactFilter] = useState('All');
|
const [impactFilter, setImpactFilter] = useState('All');
|
||||||
const [selected, setSelected] = useState<Signal | null>(null);
|
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 }));
|
.catch(() => setStats({ total: 47, high_impact: 7, medium_impact: 18, last_90_days: 14 }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filtered = MOCK_SIGNALS.filter(s =>
|
useEffect(() => {
|
||||||
(sourceFilter === 'All' || s.source === sourceFilter) &&
|
fetch('/api/v1/perception/events?limit=100')
|
||||||
(impactFilter === 'All' || s.impact === impactFilter)
|
.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() {
|
function runAnalysis() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
@@ -91,21 +134,30 @@ export function PerceptionPage() {
|
|||||||
setAiOutput('');
|
setAiOutput('');
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortRef.current = ctrl;
|
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 => {
|
.then(async res => {
|
||||||
if (!res.body) { setAiOutput('No stream available.'); setStreaming(false); return; }
|
if (!res.body) { setAiOutput('No stream available.'); setStreaming(false); return; }
|
||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
const dec = new TextDecoder();
|
const dec = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
const chunk = dec.decode(value);
|
buf += dec.decode(value);
|
||||||
for (const line of chunk.split('\n')) {
|
const parts = buf.split('\n\n');
|
||||||
if (line.startsWith('data: ')) {
|
buf = parts.pop() ?? '';
|
||||||
const data = line.slice(6);
|
for (const block of parts) {
|
||||||
if (data === '[DONE]') break;
|
const dataLine = block.split('\n').find(l => l.startsWith('data: '));
|
||||||
try { const j = JSON.parse(data); setAiOutput(p => p + (j.text || '')); }
|
if (!dataLine) continue;
|
||||||
catch { setAiOutput(p => p + data); }
|
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={
|
actions={
|
||||||
<>
|
<>
|
||||||
<div className="search-box">
|
<div className="search-box">
|
||||||
<input placeholder="Search signals..." />
|
<input
|
||||||
|
placeholder="Search signals..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn sm"><RefreshCw size={13} />Refresh</button>
|
<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 { Topbar } from '../../components/layout/Topbar';
|
||||||
import { Send, Download } from 'lucide-react';
|
import { Send, Download } from 'lucide-react';
|
||||||
|
|
||||||
@@ -6,79 +6,138 @@ interface Message {
|
|||||||
id: string;
|
id: string;
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
text: string;
|
text: string;
|
||||||
|
// citation indices mentioned in this assistant message (1-based, matching citations array)
|
||||||
|
citationRefs?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Citation {
|
interface Citation {
|
||||||
score: number;
|
index: number; // 1-based, matches [N] markers in text
|
||||||
name: string;
|
score: number; // 0–100 display percentage
|
||||||
clause: string;
|
name: string; // doc_name
|
||||||
snippet: string;
|
clause: string; // section_title or clause
|
||||||
|
snippet: string; // preview text
|
||||||
|
docId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HISTORY = [
|
// Map a raw source doc from the backend "retrieved" event to our Citation shape.
|
||||||
{ id: 'h1', title: 'EU AI Act Article 9 scope', date: '2025-11-18' },
|
// Backend fields: { id, score(0-1), preview, doc_name, clause, doc_id }
|
||||||
{ id: 'h2', title: 'MIIT training data requirements', date: '2025-11-15' },
|
function mapSource(s: Record<string, unknown>, idx: number): Citation {
|
||||||
{ id: 'h3', title: 'ISO 21434 CSMS audit scope', date: '2025-11-10' },
|
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?',
|
'What does EU AI Act Art. 9 require for risk management?',
|
||||||
'Which documents need CSMS certification?',
|
'Which documents need CSMS certification?',
|
||||||
'Summarize MIIT training data rules',
|
'Summarize MIIT training data rules',
|
||||||
'What are high-risk AI categories under Annex III?',
|
'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() {
|
export function RagChatPage() {
|
||||||
const [messages, setMessages] = useState<Message[]>([
|
const [messages, setMessages] = useState<Message[]>([
|
||||||
{
|
{
|
||||||
id: 'init', role: 'assistant',
|
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 [input, setInput] = useState('');
|
||||||
const [streaming, setStreaming] = useState(false);
|
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 bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const citRailRef = useRef<HTMLDivElement>(null);
|
||||||
|
const citItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||||
const abortRef = useRef<AbortController | null>(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(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [messages]);
|
}, [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) {
|
async function send(text?: string) {
|
||||||
const q = (text ?? input).trim();
|
const q = (text ?? input).trim();
|
||||||
if (!q || streaming) return;
|
if (!q || streaming) return;
|
||||||
setInput('');
|
setInput('');
|
||||||
|
|
||||||
const userMsg: Message = { id: Date.now().toString(), role: 'user', text: q };
|
const userMsg: Message = { id: Date.now().toString(), role: 'user', text: q };
|
||||||
setMessages(m => [...m, userMsg]);
|
setMessages(m => [...m, userMsg]);
|
||||||
|
|
||||||
const assistantId = (Date.now() + 1).toString();
|
const assistantId = (Date.now() + 1).toString();
|
||||||
setMessages(m => [...m, { id: assistantId, role: 'assistant', text: '' }]);
|
setMessages(m => [...m, { id: assistantId, role: 'assistant', text: '' }]);
|
||||||
setStreaming(true);
|
setStreaming(true);
|
||||||
|
setCitations([]);
|
||||||
|
setHighlightedCit(null);
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortRef.current = ctrl;
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
try {
|
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', {
|
const res = await fetch('/api/v1/rag/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ question: q }),
|
body: JSON.stringify(body),
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,29 +145,65 @@ export function RagChatPage() {
|
|||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
const dec = new TextDecoder();
|
const dec = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
|
const newCitations: Citation[] = [];
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
buffer += dec.decode(value);
|
buffer += dec.decode(value, { stream: true });
|
||||||
const lines = buffer.split('\n');
|
|
||||||
buffer = lines.pop() ?? '';
|
// SSE blocks separated by double newline
|
||||||
for (const line of lines) {
|
const blocks = buffer.split('\n\n');
|
||||||
if (line.startsWith('data: ')) {
|
buffer = blocks.pop() ?? '';
|
||||||
const data = line.slice(6);
|
|
||||||
if (data === '[DONE]') break;
|
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 {
|
try {
|
||||||
const j = JSON.parse(data);
|
const j = JSON.parse(raw);
|
||||||
if (j.text) setMessages(m => m.map(msg =>
|
|
||||||
msg.id === assistantId ? { ...msg, text: msg.text + j.text } : msg
|
if (j.type === 'session') {
|
||||||
));
|
// Backend assigned a session_id — persist for next request
|
||||||
if (j.citations) setCitations(j.citations);
|
if (j.session_id) setSessionId(j.session_id);
|
||||||
} catch {
|
|
||||||
|
} 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 =>
|
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) {
|
} catch (e: unknown) {
|
||||||
@@ -130,30 +225,44 @@ export function RagChatPage() {
|
|||||||
<div className="chat-page">
|
<div className="chat-page">
|
||||||
<Topbar
|
<Topbar
|
||||||
title="Regulation Q&A"
|
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">
|
<div className="chat-body">
|
||||||
|
{/* ── History pane ── */}
|
||||||
<div className="history-pane">
|
<div className="history-pane">
|
||||||
<div className="history-header">Chat history</div>
|
<div className="history-header">Quick prompts</div>
|
||||||
{HISTORY.map(h => (
|
{quickPrompts.map(q => (
|
||||||
<div key={h.id} className="history-item">
|
<button key={q} className="quick-item" onClick={() => send(q)}>
|
||||||
<div className="history-title">{h.title}</div>
|
{q}
|
||||||
<div className="history-date">{h.date}</div>
|
</button>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="quick-header">Quick prompts</div>
|
|
||||||
{QUICK.map(q => (
|
|
||||||
<button key={q} className="quick-item" onClick={() => send(q)}>{q}</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Chat main ── */}
|
||||||
<div className="chat-main">
|
<div className="chat-main">
|
||||||
<div className="messages">
|
<div className="messages">
|
||||||
{messages.map(msg => (
|
{messages.map(msg => (
|
||||||
<div key={msg.id} className={`message msg-${msg.role}`}>
|
<div key={msg.id} className={`message msg-${msg.role}`}>
|
||||||
{msg.role === 'assistant' && <div className="msg-avatar">AI</div>}
|
{msg.role === 'assistant' && <div className="msg-avatar">AI</div>}
|
||||||
<div className="msg-bubble">
|
<div className="msg-bubble">
|
||||||
{msg.text}
|
{msg.role === 'assistant'
|
||||||
|
? renderWithCitations(msg.text, jumpToCitation)
|
||||||
|
: msg.text
|
||||||
|
}
|
||||||
{streaming && msg.id === lastAssistantId && (
|
{streaming && msg.id === lastAssistantId && (
|
||||||
<span className="blink-cursor">▋</span>
|
<span className="blink-cursor">▋</span>
|
||||||
)}
|
)}
|
||||||
@@ -166,7 +275,7 @@ export function RagChatPage() {
|
|||||||
|
|
||||||
<div className="composer">
|
<div className="composer">
|
||||||
<div className="quick-chips">
|
<div className="quick-chips">
|
||||||
{QUICK.slice(0, 3).map(q => (
|
{quickPrompts.slice(0, 3).map(q => (
|
||||||
<button key={q} className="chip" onClick={() => send(q)}>
|
<button key={q} className="chip" onClick={() => send(q)}>
|
||||||
{q.length > 42 ? q.slice(0, 42) + '…' : q}
|
{q.length > 42 ? q.slice(0, 42) + '…' : q}
|
||||||
</button>
|
</button>
|
||||||
@@ -175,7 +284,7 @@ export function RagChatPage() {
|
|||||||
<div className="composer-row">
|
<div className="composer-row">
|
||||||
<textarea
|
<textarea
|
||||||
className="composer-input"
|
className="composer-input"
|
||||||
placeholder="Ask about your regulations..."
|
placeholder="Ask about your regulations…"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={e => setInput(e.target.value)}
|
onChange={e => setInput(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
|
||||||
@@ -192,13 +301,29 @@ export function RagChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="citation-rail">
|
{/* ── Citation rail ── */}
|
||||||
<div className="citation-header">Sources</div>
|
<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 => (
|
{citations.map(c => (
|
||||||
<div key={`${c.name}-${c.clause}`} className="citation-item">
|
<div
|
||||||
<div className="cit-score">{c.score}%</div>
|
key={c.index}
|
||||||
<div>
|
ref={el => { citItemRefs.current[c.index] = el; }}
|
||||||
<div className="cit-name">{c.name} <span className="cit-clause">{c.clause}</span></div>
|
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 className="cit-snippet">{c.snippet}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Topbar } from '../../components/layout/Topbar';
|
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 = [
|
const TASKS = [
|
||||||
{ name: 'EU AI Act — Article 13 check', status: 'ok', progress: 88, cta: 'View report' },
|
{ 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() {
|
export function StatusPage() {
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/v1/perception/stats')
|
setLoading(true);
|
||||||
|
fetch('/api/v1/status/stats')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => setStats(d))
|
.then(d => { setStats(d); setLoading(false); })
|
||||||
.catch(() => setStats({ total_documents: 42, vector_chunks: 3841, high_impact: 7, last_90_days: 14 }));
|
.catch(() => {
|
||||||
}, []);
|
setStats({ documents_total: 42, documents_indexed: 38, documents_failed: 1, chunks_total: 3841 });
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [refreshKey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="status-page">
|
<div className="status-page">
|
||||||
@@ -58,28 +68,44 @@ export function StatusPage() {
|
|||||||
<Search size={13} />
|
<Search size={13} />
|
||||||
<input placeholder="Search..." />
|
<input placeholder="Search..." />
|
||||||
</div>
|
</div>
|
||||||
<button className="btn sm"><Download size={13} />Export status</button>
|
<button
|
||||||
<button className="btn sm primary"><Upload size={13} />New upload</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="page-content">
|
||||||
<div className="stats-grid">
|
<div className="stats-grid">
|
||||||
<div className="stat-cell">
|
<div className="stat-cell">
|
||||||
<div className="stat-value">{stats?.total_documents ?? '—'}</div>
|
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_total ?? '—'}</div>}
|
||||||
<div className="stat-label">Documents indexed</div>
|
<div className="stat-label">Documents total</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-cell">
|
<div className="stat-cell">
|
||||||
<div className="stat-value">{stats?.vector_chunks?.toLocaleString() ?? '—'}</div>
|
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_indexed ?? '—'}</div>}
|
||||||
<div className="stat-label">Vector chunks</div>
|
<div className="stat-label">Indexed</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-cell danger">
|
<div className="stat-cell danger">
|
||||||
<div className="stat-value">{stats?.high_impact ?? '—'}</div>
|
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_failed ?? '—'}</div>}
|
||||||
<div className="stat-label">High-impact signals</div>
|
<div className="stat-label">Failed</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-cell">
|
<div className="stat-cell">
|
||||||
<div className="stat-value">{stats?.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">Last 90 days</div>
|
<div className="stat-label">Vector chunks</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -150,6 +176,8 @@ export function StatusPage() {
|
|||||||
<div className="live-dot" />
|
<div className="live-dot" />
|
||||||
<span>Regulation Hub · T-Systems AI · Online</span>
|
<span>Regulation Hub · T-Systems AI · Online</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/* ── Design Tokens ──────────────────────────────── */
|
/* ── Design Tokens ──────────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
--rail-bg: #ffffff;
|
--rail-bg: #ffffff;
|
||||||
--rail-surface: #f7f8fa;
|
--rail-surface: #f7f8fa;
|
||||||
--rail-fg: #111827;
|
--rail-fg: #111827;
|
||||||
@@ -10,18 +12,18 @@
|
|||||||
|
|
||||||
--bg: #f2f4f7;
|
--bg: #f2f4f7;
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--fg: #111827;
|
--fg: #111111;
|
||||||
--muted: #6b7280;
|
--muted: #6b7280;
|
||||||
--border: #e5e7eb;
|
--border: #e5e5e5;
|
||||||
--border-strong: #d1d5db;
|
--border-strong: #d1d5db;
|
||||||
|
|
||||||
--accent: #e20074;
|
--accent: #e20074;
|
||||||
--accent-dim: rgba(226,0,116,.10);
|
--accent-dim: rgba(226,0,116,.10);
|
||||||
--accent-hover: #c8006a;
|
--accent-hover: #c8006a;
|
||||||
--success: #16a34a;
|
--success: #17a34a;
|
||||||
--success-bg: rgba(22,163,74,.08);
|
--success-bg: rgba(23,163,74,.08);
|
||||||
--warn: #d97706;
|
--warn: #eab308;
|
||||||
--warn-bg: rgba(217,119,6,.08);
|
--warn-bg: rgba(234,179,8,.08);
|
||||||
--danger: #dc2626;
|
--danger: #dc2626;
|
||||||
--danger-bg: rgba(220,38,38,.08);
|
--danger-bg: rgba(220,38,38,.08);
|
||||||
--info: #2563eb;
|
--info: #2563eb;
|
||||||
@@ -31,29 +33,71 @@
|
|||||||
--font-body: "TeleNeoWeb-Regular", "Inter", -apple-system, sans-serif;
|
--font-body: "TeleNeoWeb-Regular", "Inter", -apple-system, sans-serif;
|
||||||
--font-mono: ui-monospace, "JetBrains Mono", Menlo, monospace;
|
--font-mono: ui-monospace, "JetBrains Mono", Menlo, monospace;
|
||||||
|
|
||||||
--sidebar-w: 232px;
|
--sidebar-w: 240px;
|
||||||
--topbar-h: 54px;
|
--topbar-h: 54px;
|
||||||
--radius-sm: 6px;
|
--radius-sm: 8px;
|
||||||
--radius-md: 10px;
|
--radius-md: 12px;
|
||||||
--radius-pill: 9999px;
|
--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);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] {
|
/* Auto dark: matches system preference unless user chose light explicitly */
|
||||||
--rail-bg: #1a1a2e;
|
@media (prefers-color-scheme: dark) {
|
||||||
--rail-surface: #16213e;
|
:root:not([data-theme="light"]) {
|
||||||
--rail-fg: #f0f0f0;
|
color-scheme: dark;
|
||||||
--rail-muted: #8b929e;
|
--rail-bg: #17181d;
|
||||||
--rail-border: #2d3748;
|
--rail-surface: #1d1f26;
|
||||||
|
--rail-fg: #f5f7fb;
|
||||||
|
--rail-muted: #a2a9b8;
|
||||||
|
--rail-border: #2a2d35;
|
||||||
--rail-hover: rgba(255,255,255,.06);
|
--rail-hover: rgba(255,255,255,.06);
|
||||||
--rail-active: rgba(226,0,116,.15);
|
--rail-active: rgba(226,0,116,.15);
|
||||||
|
|
||||||
--bg: #0f0f1a;
|
--bg: #0f1014;
|
||||||
--surface: #1a1a2e;
|
--surface: #17181d;
|
||||||
--fg: #f0f0f0;
|
--fg: #f5f7fb;
|
||||||
--muted: #9ca3af;
|
--muted: #a2a9b8;
|
||||||
--border: #2d3748;
|
--border: #2a2d35;
|
||||||
--border-strong: #4a5568;
|
--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"] {
|
||||||
|
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 light (overrides auto dark) */
|
||||||
|
[data-theme="light"] {
|
||||||
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Base Reset ─────────────────────────────────── */
|
/* ── Base Reset ─────────────────────────────────── */
|
||||||
@@ -165,13 +209,11 @@ body {
|
|||||||
padding: 18px 16px 14px;
|
padding: 18px 16px 14px;
|
||||||
border-bottom: 1px solid var(--rail-border);
|
border-bottom: 1px solid var(--rail-border);
|
||||||
}
|
}
|
||||||
.brand-mark {
|
.brand-logo {
|
||||||
width: 32px; height: 32px;
|
height: 32px;
|
||||||
background: var(--accent);
|
width: auto;
|
||||||
color: #fff;
|
max-width: 48px;
|
||||||
border-radius: 8px;
|
object-fit: contain;
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 11px; font-weight: 700; font-family: var(--font-display);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.brand-name { font-size: 13px; font-weight: 700; font-family: var(--font-display); color: var(--rail-fg); }
|
.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-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-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-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-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-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; }
|
.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