This commit is contained in:
wangwei
2026-06-05 09:00:36 +08:00
parent 746513cc54
commit 06e0967128
13 changed files with 4560 additions and 239 deletions
+480 -91
View File
@@ -1,32 +1,182 @@
import { useState, useRef } from 'react';
import { Search, Plus, AlertTriangle, Download, MessageSquare, ChevronDown } from 'lucide-react';
import { Topbar } from '../../components/layout/Topbar';
import { Search, Plus } from 'lucide-react';
import { NewAnalysisModal } from './NewAnalysisModal';
import { useComplianceAnalysis } from './useComplianceAnalysis';
import type { FindingEvent, SourceEvent, AnalysisMeta } from './useComplianceAnalysis';
const SOURCES = [
{ standard: 'EU AI Act', helper: 'Art. 9 — Risk management', scores: ['Art. 9.1', 'Art. 9.2'], status: 'risk' },
{ standard: 'MIIT Draft 2025-08', helper: '§3 — Training data provenance', scores: ['§3.1', '§3.4'], status: 'warn' },
{ standard: 'ISO/SAE 21434:2021', helper: 'Clause 9 — CSMS', scores: ['9.3', '9.4'], status: 'ok' },
];
const STATUS_LABEL: Record<string, string> = { ok: 'Covered', warn: 'Gap', risk: 'Critical', info: 'Info' };
const SOURCE_TYPE_LABEL: Record<string, string> = { text: 'Pasted Text', doc: 'Indexed Document', upload: 'Uploaded File' };
const STAGES = [
{ label: 'Clause retrieval', pct: 100, status: 'ok' },
{ label: 'Requirement extraction', pct: 100, status: 'ok' },
{ label: 'Gap analysis', pct: 78, status: 'warn' },
{ label: 'Recommendation synthesis', pct: 30, status: 'info' },
];
function riskClass(score: number) {
if (score >= 70) return 'high';
if (score >= 40) return 'med';
return 'low';
}
const FINDINGS = [
{ title: 'Missing risk management documentation', desc: 'No formal risk management system found for the described AI system scope under Art. 9.', status: 'risk' },
{ title: 'Training data lineage incomplete', desc: 'MIIT §3.1 requires traceable provenance for training datasets. Current documentation lacks data source registry.', status: 'warn' },
{ title: 'CSMS audit trail present', desc: 'ISO 21434 audit log requirements are met. Retention policy documented in Annex B.', status: 'ok' },
];
function highlightText(text: string, terms: string[]): React.ReactNode[] {
if (!terms.length) return [text];
const escaped = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const pattern = new RegExp(`(${escaped.join('|')})`, 'i');
const patternGlobal = new RegExp(`(${escaped.join('|')})`, 'gi');
return text.split(patternGlobal).map((part, i) =>
pattern.test(part)
? <mark key={i} className="comp-highlight">{part}</mark>
: <span key={i}>{part}</span>
);
}
const PARA = `The AI system described in Section 4.2.1 of the Vehicle AI Safety Manual performs real-time classification of driving scenarios to support Level 3 automated driving decisions. The system ingests sensor fusion data from cameras, LIDAR, and radar arrays, processes it through a deep neural network trained on 2.4M annotated driving scenarios, and outputs driving mode recommendations with associated confidence scores. The model was trained using data collected between 2022 and 2024 across European and Chinese road environments.`;
function formatTs(iso: string) {
try {
return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
} catch { return iso; }
}
const STATUS_LABEL: Record<string, string> = { ok: 'Covered', warn: 'Gap', risk: 'Critical' };
// ── Chat state for a single finding ─────────────────────────────────────────
interface ChatMsg { id: number; role: 'user' | 'assistant'; content: string }
function useFindingChat() {
const [open, setOpen] = useState(false);
const [findingIdx, setFindingIdx] = useState<number | null>(null);
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
function openFor(idx: number, finding: FindingEvent) {
setFindingIdx(idx);
setOpen(true);
setMessages([{
id: 0,
role: 'assistant',
content: `I'm reviewing finding: **${finding.title}**\n\n${finding.desc}${finding.clause_ref ? `\n\nRef: ${finding.clause_ref}` : ''}\n\nHow can I help?`,
}]);
setInput('');
}
function close() { setOpen(false); abortRef.current?.abort(); }
async function send(segmentContext: string) {
if (!input.trim() || loading) return;
const q = input.trim();
setInput('');
const userMsg: ChatMsg = { id: Date.now(), role: 'user', content: q };
const assistantId = Date.now() + 1;
setMessages(m => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]);
setLoading(true);
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
const res = await fetch(`/api/v1/compliance/chat/${findingIdx ?? 0}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: q, segment_context: segmentContext }),
signal: ctrl.signal,
});
if (!res.body) { setLoading(false); return; }
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const blocks = buf.split('\n\n');
buf = blocks.pop() ?? '';
for (const block of blocks) {
const dl = block.split('\n').find(l => l.startsWith('data: '));
if (!dl) continue;
try {
const j = JSON.parse(dl.slice(6));
if (j.type === 'chunk' && j.text) {
setMessages(m => m.map(msg => msg.id === assistantId ? { ...msg, content: msg.content + j.text } : msg));
}
} catch { /* skip */ }
}
}
} catch (e: unknown) {
if (e instanceof Error && e.name === 'AbortError') return;
} finally {
setLoading(false);
}
}
return { open, findingIdx, messages, input, setInput, loading, openFor, close, send };
}
export function CompliancePage() {
const [showModal, setShowModal] = useState(false);
const [showExportMenu, setShowExportMenu] = useState(false);
const { state, run, reset } = useComplianceAnalysis();
const chat = useFindingChat();
const isIdle = state.status === 'idle';
const isStreaming = state.status === 'streaming';
const isDone = state.status === 'done';
const isError = state.status === 'error';
// ── Export helpers ────────────────────────────────────────────────────────
function exportJSON() {
const data = {
title: state.meta?.title,
sourceType: state.meta?.sourceType,
startedAt: state.meta?.startedAt,
sources: state.sources,
findings: state.findings,
conclusion: state.done?.conclusion,
actions: state.done?.actions,
risk_score: state.done?.risk_score,
highlight_terms: state.done?.highlight_terms,
para_text: state.done?.para_text,
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url;
a.download = `compliance-report-${Date.now()}.json`; a.click();
URL.revokeObjectURL(url);
setShowExportMenu(false);
}
function exportText() {
const lines: string[] = [
`COMPLIANCE ANALYSIS REPORT`,
`Title: ${state.meta?.title ?? 'Untitled'}`,
`Date: ${state.meta?.startedAt ? formatTs(state.meta.startedAt) : ''}`,
`Source: ${SOURCE_TYPE_LABEL[state.meta?.sourceType ?? 'text']}`,
`Risk Score: ${state.done?.risk_score ?? 'N/A'} / 100`,
'',
'── PARAGRAPH UNDER REVIEW ──',
state.done?.para_text ?? '',
'',
'── FINDINGS ──',
...state.findings.map((f, i) =>
`[${i + 1}] [${f.status.toUpperCase()}] ${f.title}\n ${f.desc}${f.clause_ref ? `\n Ref: ${f.clause_ref}` : ''}`
),
'',
'── CONCLUSION ──',
state.done?.conclusion ?? '',
'',
'── RECOMMENDED ACTIONS ──',
...(state.done?.actions ?? []).map(a => `${a.label}: ${a.value}`),
];
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url;
a.download = `compliance-report-${Date.now()}.txt`; a.click();
URL.revokeObjectURL(url);
setShowExportMenu(false);
}
// ── Chat context (finding desc + clause_ref as segment context) ──────────
const activeFinding = chat.findingIdx !== null ? state.findings[chat.findingIdx] : null;
const chatContext = activeFinding
? `Finding: ${activeFinding.title}\n${activeFinding.desc}${activeFinding.clause_ref ? `\nRef: ${activeFinding.clause_ref}` : ''}`
: '';
return (
<div className="compliance-page">
<div className="compliance-page" style={{ position: 'relative' }}>
<Topbar
title="Compliance Analysis"
actions={
@@ -35,92 +185,331 @@ export function CompliancePage() {
<Search size={13} />
<input placeholder="Search analyses..." />
</div>
<button className="btn sm primary"><Plus size={13} />New analysis</button>
{isStreaming || isDone || isError ? (
<button className="btn sm" onClick={reset}>Clear</button>
) : null}
{isDone && (
<div style={{ position: 'relative' }}>
<button
className="btn sm"
onClick={() => setShowExportMenu(v => !v)}
>
<Download size={13} />Export<ChevronDown size={11} />
</button>
{showExportMenu && (
<div style={{
position: 'absolute', right: 0, top: '100%', marginTop: 4,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 6px 20px rgba(0,0,0,.15)',
zIndex: 50, minWidth: 140, overflow: 'hidden',
}}>
<button onClick={exportJSON} style={{ display: 'block', width: '100%', padding: '9px 14px', textAlign: 'left', fontSize: 13, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--fg)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg)')}
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
>Export JSON</button>
<button onClick={exportText} style={{ display: 'block', width: '100%', padding: '9px 14px', textAlign: 'left', fontSize: 13, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--fg)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg)')}
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
>Export Text</button>
</div>
)}
</div>
)}
<button className="btn sm primary" onClick={() => setShowModal(true)}>
<Plus size={13} />New analysis
</button>
</>
}
/>
<div className="compliance-hero">
<p className="hero-eyebrow">Compliance Workspace</p>
<h2 className="compliance-title">Document Paragraph Review</h2>
<p className="compliance-desc">
Three-column AI-assisted compliance gap analysis with regulation retrieval, paragraph review, and findings synthesis.
</p>
</div>
{showModal && (
<NewAnalysisModal
onClose={() => setShowModal(false)}
onSubmit={(fd, meta) => run(fd, meta)}
/>
)}
<div className="compliance-workspace">
<div className="comp-col source-col">
<div className="col-header">Retrieved Regulations</div>
{SOURCES.map(s => (
<div key={s.standard} className="source-item card">
<div className="source-top">
<span className="source-std">{s.standard}</span>
<span className={`status ${s.status}`}>{STATUS_LABEL[s.status]}</span>
</div>
<div className="source-helper">{s.helper}</div>
<div className="source-scores">
{s.scores.map(sc => <span key={sc} className="score-pill">{sc}</span>)}
</div>
</div>
))}
{/* Status bar */}
{(isStreaming || isDone || isError) && (
<div style={{ padding: '0 24px' }}>
<div className={`compliance-status-bar ${state.status}`}>
<div className="status-dot" />
<span className="status-bar-label">
{isStreaming ? 'Analyzing…' : isDone ? 'Analysis complete' : 'Error'}
</span>
<span className="status-bar-sub">{state.stageLabel}</span>
</div>
</div>
)}
<div className="comp-col review-col">
<div className="col-header">Paragraph Under Review</div>
<div className="card para-card">
<p className="para-text">
{PARA.split(/(AI system)/g).map((part, i) =>
part === 'AI system'
? <mark key={i}>{part}</mark>
: <span key={i}>{part}</span>
{/* Empty state */}
{isIdle && (
<div className="analysis-empty">
<div className="analysis-empty-icon"><Plus size={24} /></div>
<h3>No analysis running</h3>
<p>Click <strong>New analysis</strong> to start a compliance gap review against your indexed regulations.</p>
</div>
)}
{/* Workspace */}
{!isIdle && (
<>
{/* Analysis Header */}
{state.meta && (
<div style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '10px 24px', borderBottom: '1px solid var(--border)',
fontSize: 13,
}}>
<span style={{ fontWeight: 600, color: 'var(--fg)' }}>{state.meta.title}</span>
<span style={{ color: 'var(--muted)', fontSize: 11 }}>·</span>
<span style={{ color: 'var(--muted)', fontSize: 11 }}>{SOURCE_TYPE_LABEL[state.meta.sourceType]}</span>
<span style={{ color: 'var(--muted)', fontSize: 11 }}>·</span>
<span style={{ color: 'var(--muted)', fontSize: 11 }}>{formatTs(state.meta.startedAt)}</span>
{isDone && state.done && (
<>
<span style={{ color: 'var(--muted)', fontSize: 11 }}>·</span>
<span className={`risk-score-badge ${riskClass(state.done.risk_score)}`}
style={{ width: 28, height: 28, fontSize: 11 }}
title="Risk score">
{state.done.risk_score}
</span>
</>
)}
</p>
</div>
<div className="card stages-card">
<div className="card-header">Analysis stages</div>
{STAGES.map(st => (
<div key={st.label} className="stage-row">
<div className="stage-label-row">
<span className="stage-label">{st.label}</span>
<span className="stage-pct">{st.pct}%</span>
</div>
<div className="stage-bar">
<div className={`stage-fill stage-${st.status}`} style={{ width: `${st.pct}%` }} />
</div>
</div>
))}
</div>
</div>
</div>
)}
<div className="comp-col findings-col">
<div className="col-header">Findings</div>
{FINDINGS.map(f => (
<div key={f.title} className="finding-item card">
<div className="finding-top">
<span className="finding-title">{f.title}</span>
<span className={`status ${f.status}`}>{STATUS_LABEL[f.status] ?? f.status}</span>
<div className="compliance-workspace" style={{ position: 'relative' }}>
{/* Column 1: Retrieved Regulations */}
<div className="comp-col source-col">
<div className="col-header">
Retrieved Regulations {state.sources.length > 0 && `(${state.sources.length})`}
</div>
<p className="finding-desc">{f.desc}</p>
{state.sources.length === 0 && isStreaming && (
<div style={{ padding: '20px 16px', color: 'var(--muted)', fontSize: 12 }}>
Retrieving relevant regulations
</div>
)}
{state.sources.map((s: SourceEvent, i: number) => (
<div key={i} className="source-item card">
<div className="source-top">
<span className="source-std">{s.standard || 'Regulation'}</span>
<span className={`status ${s.status === 'retrieved' ? 'ok' : s.status}`}>
{STATUS_LABEL[s.status] ?? 'Retrieved'}
</span>
</div>
{s.clause && <div className="source-helper">{s.clause}</div>}
{s.score > 0 && (
<div className="source-scores">
<span className="score-pill">
{s.score <= 1 ? Math.round(s.score * 100) : Math.round(s.score)}% match
</span>
</div>
)}
{s.full_content && (
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 6, lineHeight: 1.5 }}>
{s.full_content.slice(0, 120)}
</div>
)}
</div>
))}
</div>
))}
<div className="card conclusion-box">
<div className="card-header">Conclusion</div>
<p className="conclusion-text">
The document requires a formal risk management section documenting the AI system classification, risk identification methodology, and mitigation measures per EU AI Act Art. 9 before compliance can be certified.
</p>
<div className="action-items">
<div className="action-item">
<span className="action-label">Next action</span>
<span className="action-value">Draft risk management annex</span>
{/* Column 2: Paragraph Under Review + Stages */}
<div className="comp-col review-col">
<div className="col-header">Paragraph Under Review</div>
<div className="card para-card">
{isDone && state.done?.para_text ? (
<p className="para-text">
{highlightText(state.done.para_text, state.done.highlight_terms ?? [])}
</p>
) : (
<p className="para-text" style={{ color: 'var(--muted)' }}>
{isStreaming ? 'Extracting and analyzing text…' : 'No text extracted'}
</p>
)}
</div>
<div className="action-item">
<span className="action-label">Escalation</span>
<span className="action-value risk-text">Legal review required</span>
<div className="card stages-card">
<div className="card-header">Analysis stages</div>
{(() => {
const STAGE_KEYS = ['extracting', 'splitting', 'analyzing', 'concluding'];
const STAGE_LABELS = ['Text extraction', 'Clause splitting', 'Regulation retrieval', 'Conclusion synthesis'];
const curIdx = STAGE_KEYS.indexOf(state.stageKey);
return STAGE_KEYS.map((key, idx) => {
const pct = isDone ? 100 : idx < curIdx ? 100 : idx === curIdx ? 60 : 0;
const stStatus = pct === 100 ? 'ok' : pct > 0 ? 'running' : 'info';
return (
<div key={key} className={`stage-row${stStatus === 'running' ? ' stage-running' : ''}`}>
<div className="stage-label-row">
<span className="stage-label">{STAGE_LABELS[idx]}</span>
<span className="stage-pct">{pct}%</span>
</div>
<div className="stage-bar">
<div className={`stage-fill stage-${stStatus}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
});
})()}
</div>
</div>
{/* Column 3: Findings + Conclusion */}
<div className="comp-col findings-col">
<div className="col-header">
Findings {state.findings.length > 0 && `(${state.findings.length})`}
</div>
{state.findings.length === 0 && isStreaming && (
<div style={{ padding: '20px 16px', color: 'var(--muted)', fontSize: 12 }}>
Gap analysis in progress
</div>
)}
{state.findings.map((f: FindingEvent, i: number) => (
<div key={i} className="finding-item card">
<div className="finding-top">
<span className="finding-title">{f.title}</span>
<span className={`status ${f.status}`}>{STATUS_LABEL[f.status] ?? f.status}</span>
</div>
<p className="finding-desc">{f.desc}</p>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 6 }}>
{f.clause_ref && (
<div style={{ fontSize: 11, color: 'var(--muted)' }}>Ref: {f.clause_ref}</div>
)}
<button
className="btn sm"
style={{ marginLeft: 'auto', fontSize: 11, padding: '3px 8px', gap: 4 }}
onClick={() => chat.openFor(i, f)}
>
<MessageSquare size={11} />Ask AI
</button>
</div>
</div>
))}
{/* Conclusion */}
{isDone && state.done && (
<div className="card conclusion-box">
<div className="card-header" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span>Conclusion</span>
<div
className={`risk-score-badge ${riskClass(state.done.risk_score)}`}
title="Risk score (0=safe, 100=critical)"
>
{state.done.risk_score}
</div>
</div>
<div className="risk-meter">
<span style={{ fontSize: 11, color: 'var(--muted)', width: 24 }}>0</span>
<div className="risk-bar-track">
<div className="risk-bar-fill" style={{ width: `${state.done.risk_score}%` }} />
</div>
<span style={{ fontSize: 11, color: 'var(--muted)', width: 24, textAlign: 'right' }}>100</span>
</div>
<p className="conclusion-text">{state.done.conclusion}</p>
<div className="action-items">
{state.done.actions.map((a, i) => (
<div key={i} className="action-item">
<span className="action-label">{a.label}</span>
<span className={`action-value${a.risk ? ' risk-text' : ''}`}>{a.value}</span>
</div>
))}
</div>
</div>
)}
{isError && (
<div className="card" style={{ borderColor: 'var(--danger)', padding: '14px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--danger)', fontSize: 13, fontWeight: 600 }}>
<AlertTriangle size={14} /> Analysis failed
</div>
<p style={{ fontSize: 12, color: 'var(--muted)', marginTop: 6 }}>{state.errorText}</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* ── Finding Chat Side Panel ────────────────────────────────── */}
{chat.open && (
<div style={{
position: 'fixed', right: 0, top: 0, bottom: 0, width: 400,
background: 'var(--surface)', borderLeft: '1px solid var(--border)',
display: 'flex', flexDirection: 'column', zIndex: 200,
boxShadow: '-8px 0 32px rgba(0,0,0,.12)',
}}>
{/* Header */}
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>AI Compliance Q&A</div>
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
Finding #{(chat.findingIdx ?? 0) + 1} · {activeFinding?.title}
</div>
</div>
<button
onClick={chat.close}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--muted)', padding: 4 }}
></button>
</div>
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{chat.messages.map(msg => (
<div key={msg.id} style={{ display: 'flex', gap: 10, flexDirection: msg.role === 'user' ? 'row-reverse' : 'row' }}>
{msg.role === 'assistant' && (
<div style={{ width: 28, height: 28, borderRadius: 8, background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 11, color: '#fff', fontWeight: 700 }}>AI</div>
)}
<div style={{
maxWidth: '82%', padding: '10px 14px', borderRadius: 10, fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap',
background: msg.role === 'user' ? 'var(--accent)' : 'var(--bg)',
color: msg.role === 'user' ? '#fff' : 'var(--fg)',
border: msg.role === 'assistant' ? '1px solid var(--border)' : 'none',
}}>{msg.content}</div>
</div>
))}
{chat.loading && (
<div style={{ display: 'flex', gap: 10 }}>
<div style={{ width: 28, height: 28, borderRadius: 8, background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 11, color: '#fff', fontWeight: 700 }}>AI</div>
<div style={{ padding: '10px 14px', borderRadius: 10, border: '1px solid var(--border)', background: 'var(--bg)', fontSize: 13, color: 'var(--muted)' }}>
Thinking<span className="blink-cursor"></span>
</div>
</div>
)}
</div>
{/* Quick questions */}
<div style={{ padding: '8px 20px', display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{['What regulation applies?', 'How to remediate?', 'What is the risk?'].map(q => (
<button key={q} onClick={() => chat.setInput(q)}
style={{ padding: '4px 10px', fontSize: 11, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', color: 'var(--muted)' }}>
{q}
</button>
))}
</div>
{/* Input */}
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border)', display: 'flex', gap: 8 }}>
<input
value={chat.input}
onChange={e => chat.setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chat.send(chatContext); } }}
placeholder="Ask about this finding…"
style={{ flex: 1, padding: '9px 12px', fontSize: 13, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, color: 'var(--fg)', outline: 'none' }}
/>
<button
className="btn primary"
onClick={() => chat.send(chatContext)}
disabled={!chat.input.trim() || chat.loading}
style={{ padding: '9px 14px' }}
>Send</button>
</div>
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,241 @@
import { useState, useRef, useEffect } from 'react';
import { X, Upload, FileText, Database } from 'lucide-react';
interface DocOption {
id: string;
name: string;
type?: string;
}
import type { AnalysisMeta } from './useComplianceAnalysis';
interface Props {
onClose: () => void;
onSubmit: (formData: FormData, meta: AnalysisMeta) => void;
}
const DOMAINS = ['EU AI Act', 'MIIT', 'ISO 21434', 'GDPR', 'NIST AI RMF', 'GB/T'];
export function NewAnalysisModal({ onClose, onSubmit }: Props) {
const [tab, setTab] = useState<'text' | 'doc' | 'upload'>('text');
const [text, setText] = useState('');
const [title, setTitle] = useState('');
const [selectedDomains, setSelectedDomains] = useState<string[]>([]);
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [docs, setDocs] = useState<DocOption[]>([]);
const [file, setFile] = useState<File | null>(null);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
// Fetch indexed docs for "From Document" tab
useEffect(() => {
fetch('/api/v1/documents/management-list')
.then(r => r.json())
.then(d => {
const list: DocOption[] = (d?.documents ?? d ?? []).map((item: Record<string, unknown>) => ({
id: String(item.doc_id ?? item.id ?? ''),
name: String(item.doc_name ?? item.name ?? ''),
type: String(item.regulation_type ?? item.type ?? ''),
}));
setDocs(list);
})
.catch(() => setDocs([]));
}, []);
function toggleDomain(d: string) {
setSelectedDomains(prev =>
prev.includes(d) ? prev.filter(x => x !== d) : [...prev, d]
);
}
function handleFileChange(f: File | null) {
if (!f) return;
setFile(f);
if (!title) setTitle(f.name.replace(/\.[^.]+$/, ''));
}
function handleSubmit() {
const fd = new FormData();
if (title) fd.append('title', title);
if (selectedDomains.length) fd.append('domains', selectedDomains.join(','));
if (tab === 'text') {
if (!text.trim()) return;
fd.append('text', text.trim());
} else if (tab === 'doc') {
if (!selectedDocId) return;
fd.append('doc_id', selectedDocId);
} else {
if (!file) return;
fd.append('file', file);
}
const meta: AnalysisMeta = {
title: title || (tab === 'upload' && file ? file.name.replace(/\.[^.]+$/, '') : 'Untitled Analysis'),
sourceType: tab,
startedAt: new Date().toISOString(),
};
onSubmit(fd, meta);
onClose();
}
const canSubmit =
(tab === 'text' && text.trim().length > 0) ||
(tab === 'doc' && selectedDocId !== null) ||
(tab === 'upload' && file !== null);
return (
<div
className="modal-overlay"
ref={overlayRef}
onClick={e => { if (e.target === overlayRef.current) onClose(); }}
>
<div className="modal-dialog" style={{ maxWidth: 720, gridTemplateColumns: '1fr' }}>
<div className="modal-panel">
{/* Header */}
<div className="modal-header">
<span className="modal-title">New Compliance Analysis</span>
<button className="modal-close" onClick={onClose}><X size={16} /></button>
</div>
{/* Title field */}
<div className="upload-field" style={{ marginBottom: 16 }}>
<label>Analysis title (optional)</label>
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="e.g. Section 4.2.1 AI System Review"
/>
</div>
{/* Tabs */}
<div className="modal-tabs">
<button className={`modal-tab${tab === 'text' ? ' active' : ''}`} onClick={() => setTab('text')}>
<FileText size={12} style={{ marginRight: 5, display: 'inline' }} />Paste Text
</button>
<button className={`modal-tab${tab === 'doc' ? ' active' : ''}`} onClick={() => setTab('doc')}>
<Database size={12} style={{ marginRight: 5, display: 'inline' }} />From Document
</button>
<button className={`modal-tab${tab === 'upload' ? ' active' : ''}`} onClick={() => setTab('upload')}>
<Upload size={12} style={{ marginRight: 5, display: 'inline' }} />Upload File
</button>
</div>
{/* Tab content */}
{tab === 'text' && (
<div className="upload-field full-width">
<label>Document text to analyze</label>
<textarea
style={{ minHeight: 240 }}
placeholder="Paste the document paragraph or clause text here…"
value={text}
onChange={e => setText(e.target.value)}
/>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{text.length} characters</span>
</div>
)}
{tab === 'doc' && (
<div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 6 }}>
Select an indexed document to analyze:
</div>
<div className="doc-select-list" style={{ maxHeight: 340 }}>
{docs.length === 0 && (
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--muted)', fontSize: 13 }}>
No indexed documents found
</div>
)}
{docs.map(doc => (
<div
key={doc.id}
className={`doc-select-item${selectedDocId === doc.id ? ' selected' : ''}`}
onClick={() => setSelectedDocId(doc.id)}
>
<div className="doc-select-check">
{selectedDocId === doc.id && (
<svg width="8" height="8" viewBox="0 0 8 8" fill="white">
<path d="M1 4l2 2 4-4" stroke="white" strokeWidth="1.5" fill="none" strokeLinecap="round" />
</svg>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="doc-select-name">{doc.name}</div>
{doc.type && <div className="doc-select-meta">{doc.type}</div>}
</div>
</div>
))}
</div>
</div>
)}
{tab === 'upload' && (
<div
className={`dropzone${dragOver ? ' drag-over' : ''}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={e => {
e.preventDefault();
setDragOver(false);
handleFileChange(e.dataTransfer.files[0] ?? null);
}}
>
<input
ref={fileInputRef}
type="file"
style={{ display: 'none' }}
accept=".pdf,.docx,.txt,.md"
onChange={e => handleFileChange(e.target.files?.[0] ?? null)}
/>
<div className="drop-icon">PDF</div>
{file ? (
<div>
<div className="drop-label">{file.name}</div>
<div className="drop-hint">{(file.size / 1024).toFixed(0)} KB click to replace</div>
</div>
) : (
<div>
<div className="drop-label">Drop file here or click to browse</div>
<div className="drop-hint">PDF, DOCX, TXT, MD max 20 MB</div>
</div>
)}
</div>
)}
{/* Domain filter */}
<div style={{ marginTop: 18 }}>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 6 }}>
Filter by regulation domain (optional):
</div>
<div className="domain-chips">
{DOMAINS.map(d => (
<button
key={d}
className={`domain-chip${selectedDomains.includes(d) ? ' selected' : ''}`}
onClick={() => toggleDomain(d)}
>
{d}
</button>
))}
</div>
</div>
{/* Actions */}
<div className="modal-actions" style={{ marginTop: 24 }}>
<button className="btn" onClick={onClose}>Cancel</button>
<button
className="btn primary"
disabled={!canSubmit}
onClick={handleSubmit}
>
Start Analysis
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,161 @@
import { useState, useCallback, useRef } from 'react';
export type AnalysisStatus = 'idle' | 'streaming' | 'done' | 'error';
export interface SourceEvent {
standard: string;
clause: string;
score: number;
status: string;
full_content: string;
}
export interface FindingEvent {
title: string;
desc: string;
status: 'ok' | 'warn' | 'risk';
clause_ref?: string;
}
export interface ActionItem {
label: string;
value: string;
risk?: boolean;
}
export interface DonePayload {
conclusion: string;
actions: ActionItem[];
risk_score: number;
highlight_terms: string[];
para_text: string;
}
export interface AnalysisMeta {
title: string;
sourceType: 'text' | 'doc' | 'upload';
startedAt: string; // ISO timestamp
}
export interface AnalysisState {
status: AnalysisStatus;
stageLabel: string;
stageKey: string;
meta: AnalysisMeta | null;
sources: SourceEvent[];
findings: FindingEvent[];
done: DonePayload | null;
errorText: string;
}
const INITIAL_STATE: AnalysisState = {
status: 'idle',
stageLabel: '',
stageKey: '',
meta: null,
sources: [],
findings: [],
done: null,
errorText: '',
};
export function useComplianceAnalysis() {
const [state, setState] = useState<AnalysisState>(INITIAL_STATE);
const abortRef = useRef<AbortController | null>(null);
const reset = useCallback(() => {
abortRef.current?.abort();
setState(INITIAL_STATE);
}, []);
const run = useCallback(async (formData: FormData, meta: AnalysisMeta) => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setState({ ...INITIAL_STATE, status: 'streaming', stageLabel: 'Starting…', meta });
try {
const res = await fetch('/api/v1/compliance/analyze-stream', {
method: 'POST',
body: formData,
signal: ctrl.signal,
});
if (!res.ok) {
const txt = await res.text();
setState(s => ({ ...s, status: 'error', errorText: `HTTP ${res.status}: ${txt}` }));
return;
}
if (!res.body) {
setState(s => ({ ...s, status: 'error', errorText: 'No response stream' }));
return;
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += dec.decode(value, { stream: true });
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 === 'stage') {
setState(s => ({ ...s, stageLabel: j.label ?? '', stageKey: j.stage ?? '' }));
} else if (j.type === 'source') {
const src: SourceEvent = {
standard: j.standard ?? '',
clause: j.clause ?? '',
score: j.score ?? 0,
status: j.status ?? 'retrieved',
full_content: j.full_content ?? '',
};
setState(s => ({ ...s, sources: [...s.sources, src] }));
} else if (j.type === 'finding') {
const finding: FindingEvent = {
title: j.title ?? '',
desc: j.desc ?? '',
status: j.status ?? 'info',
clause_ref: j.clause_ref,
};
setState(s => ({ ...s, findings: [...s.findings, finding] }));
} else if (j.type === 'done') {
const payload: DonePayload = {
conclusion: j.conclusion ?? '',
actions: j.actions ?? [],
risk_score: j.risk_score ?? 0,
highlight_terms: j.highlight_terms ?? [],
para_text: j.para_text ?? '',
};
setState(s => ({ ...s, status: 'done', done: payload, stageKey: 'concluding', stageLabel: 'Complete' }));
} else if (j.type === 'error') {
setState(s => ({ ...s, status: 'error', errorText: j.text ?? 'Unknown error' }));
}
} catch { /* skip malformed */ }
}
}
// Mark done if stream ended without explicit done event
setState(s => s.status === 'streaming' ? { ...s, status: 'done', stageKey: 'concluding', stageLabel: 'Complete' } : s);
} catch (e: unknown) {
if (e instanceof Error && e.name === 'AbortError') return;
setState(s => ({ ...s, status: 'error', errorText: String(e) }));
}
}, []);
return { state, run, reset };
}