feat: implement Regulatory Signals page with two-pane split and SSE streaming

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 17:35:37 +08:00
parent 235de65975
commit 7cd7a10bea

View File

@@ -1,3 +1,265 @@
export function PerceptionPage() { import { useState, useEffect, useRef } from 'react';
return <div className="page-content"><p>Signals</p></div>; import { Topbar } from '../../components/layout/Topbar';
import { RefreshCw, Play, Square, ExternalLink } from 'lucide-react';
interface Signal {
id: string;
source: string;
standard: string;
status: 'ok' | 'warn' | 'risk' | 'info';
title: string;
summary: string;
date: string;
tags: string[];
impact: 'High' | 'Medium' | 'Low';
}
interface Stats {
total: number;
high_impact: number;
medium_impact: number;
last_90_days: number;
}
interface DocResult {
score: number;
name: string;
clause: string;
snippet: string;
}
const SOURCES = ['All', 'MIIT', 'UN-ECE', 'ISO', 'GB Comm.', 'EUR-Lex', 'IATF'];
const IMPACTS = ['All', 'High', 'Medium', 'Low'];
const MOCK_SIGNALS: Signal[] = [
{
id: '1', source: 'EUR-Lex', standard: 'EU/2024/1689', status: 'risk',
title: 'EU AI Act — High-risk AI in vehicles',
summary: 'Article 9 mandates risk management systems for automotive AI classifying as high-risk under Annex III point 3.',
date: '2025-11-18', tags: ['automotive', 'GDPR', 'certification'], impact: 'High'
},
{
id: '2', source: 'MIIT', standard: 'Draft-2025-08', status: 'warn',
title: 'MIIT Draft — in-vehicle AI training data',
summary: 'Draft regulation requires OEM data provenance documentation and OTA audit trails for AI systems.',
date: '2025-10-30', tags: ['OTA', 'data-governance', 'China'], impact: 'High'
},
{
id: '3', source: 'ISO', standard: 'ISO/SAE 21434:2021/Amd1', status: 'info',
title: 'ISO/SAE 21434 Amendment 1',
summary: 'Amendment clarifies CSMS scope for software-only updates and vulnerability disclosure timelines.',
date: '2025-10-05', tags: ['cybersecurity', 'CSMS', 'ISO'], impact: 'Medium'
},
{
id: '4', source: 'UN-ECE', standard: 'UNECE WP.29 R155', status: 'ok',
title: 'UNECE R155 Corrigendum',
summary: 'Editorial corrections to cybersecurity management system requirements. No substantive changes.',
date: '2025-09-12', tags: ['type-approval', 'UNECE'], impact: 'Low'
},
];
const MOCK_DOCS: DocResult[] = [
{ score: 94, name: 'Vehicle AI Safety Manual v3.2', clause: '§4.2.1', snippet: 'The risk management process shall identify and evaluate risks arising from AI system decisions in safety-critical scenarios...' },
{ score: 87, name: 'ADAS System Requirements', clause: '§7.1', snippet: 'Automated driving functions must document training data lineage and model performance envelopes prior to deployment.' },
{ score: 71, name: 'Type Approval Documentation', clause: 'Annex B', snippet: 'Cybersecurity management system certification requires third-party audit of AI decision audit logs retention policy.' },
];
export function PerceptionPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [sourceFilter, setSourceFilter] = useState('All');
const [impactFilter, setImpactFilter] = useState('All');
const [selected, setSelected] = useState<Signal | null>(null);
const [streaming, setStreaming] = useState(false);
const [aiOutput, setAiOutput] = useState('');
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
fetch('/api/v1/perception/stats')
.then(r => r.json())
.then(setStats)
.catch(() => setStats({ total: 47, high_impact: 7, medium_impact: 18, last_90_days: 14 }));
}, []);
const filtered = MOCK_SIGNALS.filter(s =>
(sourceFilter === 'All' || s.source === sourceFilter) &&
(impactFilter === 'All' || s.impact === impactFilter)
);
function runAnalysis() {
if (!selected) return;
setStreaming(true);
setAiOutput('');
const ctrl = new AbortController();
abortRef.current = ctrl;
fetch(`/api/v1/perception/analyze?signal_id=${selected.id}`, { signal: ctrl.signal })
.then(async res => {
if (!res.body) { setAiOutput('No stream available.'); setStreaming(false); return; }
const reader = res.body.getReader();
const dec = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = dec.decode(value);
for (const line of chunk.split('\n')) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') break;
try { const j = JSON.parse(data); setAiOutput(p => p + (j.text || '')); }
catch { setAiOutput(p => p + data); }
}
}
}
setStreaming(false);
})
.catch(e => {
if (e.name !== 'AbortError') setAiOutput('Analysis failed. Check API connection.');
setStreaming(false);
});
}
function stopAnalysis() {
abortRef.current?.abort();
setStreaming(false);
}
function selectSignal(sig: Signal) {
setSelected(sig);
setAiOutput('');
setStreaming(false);
}
return (
<div className="perception-page">
<Topbar
title="Regulatory Signals"
subtitle="ai-powered · live feed"
actions={
<>
<div className="search-box">
<input placeholder="Search signals..." />
</div>
<button className="btn sm"><RefreshCw size={13} />Refresh</button>
</>
}
/>
<div className="stats-bar">
<div className="sbar-cell">
<span className="sbar-val">{stats?.total ?? '—'}</span>
<span className="sbar-lbl">Total signals</span>
</div>
<div className="sbar-cell danger">
<span className="sbar-val">{stats?.high_impact ?? '—'}</span>
<span className="sbar-lbl">High impact</span>
</div>
<div className="sbar-cell warn">
<span className="sbar-val">{stats?.medium_impact ?? '—'}</span>
<span className="sbar-lbl">Medium impact</span>
</div>
<div className="sbar-cell accent">
<span className="sbar-val">{stats?.last_90_days ?? '—'}</span>
<span className="sbar-lbl">Last 90 days</span>
</div>
</div>
<div className="filter-bar">
<div className="chip-group">
{SOURCES.map(s => (
<button key={s} className={`chip${sourceFilter === s ? ' active' : ''}`} onClick={() => setSourceFilter(s)}>{s}</button>
))}
</div>
<div className="filter-sep" />
<div className="chip-group">
{IMPACTS.map(i => (
<button key={i} className={`chip${impactFilter === i ? ' active' : ''}`} onClick={() => setImpactFilter(i)}>{i}</button>
))}
</div>
</div>
<div className="perception-split">
<div className="feed-pane">
{filtered.map(sig => (
<div
key={sig.id}
className={`ev-card${selected?.id === sig.id ? ' selected' : ''}`}
onClick={() => selectSignal(sig)}
>
<div className="ev-top">
<span className="source-tag">{sig.source}</span>
<span className="ev-std">{sig.standard}</span>
<span className={`status ${sig.status}`}>
{sig.status === 'ok' ? 'Final' : sig.status === 'warn' ? 'Draft' : sig.status === 'risk' ? 'Urgent' : 'Published'}
</span>
</div>
<div className="ev-title">{sig.title}</div>
<div className="ev-summary">{sig.summary}</div>
<div className="ev-bottom">
<span className="ev-date">{sig.date}</span>
<div className="ev-tags">{sig.tags.map(t => <span key={t} className="ev-tag">{t}</span>)}</div>
<span className={`impact-dot impact-${sig.impact.toLowerCase()}`}>{sig.impact}</span>
</div>
</div>
))}
</div>
<div className="analysis-pane">
{!selected ? (
<div className="analysis-empty">
<div className="empty-ring" />
<p>Select a signal to run impact analysis</p>
</div>
) : (
<>
<div className="card detail-card">
<div className="detail-header">
<span className="source-tag">{selected.source}</span>
<span className="ev-std">{selected.standard}</span>
<span className={`status ${selected.status}`}>
{selected.status === 'risk' ? 'Urgent' : 'Published'}
</span>
</div>
<div className="detail-title">{selected.title}</div>
<p className="detail-summary">{selected.summary}</p>
<div className="detail-actions">
{!streaming
? <button className="btn sm primary" onClick={runAnalysis}><Play size={12} />Run impact analysis</button>
: <button className="btn sm" onClick={stopAnalysis}><Square size={12} />Stop</button>
}
<button className="btn sm"><ExternalLink size={12} />Source</button>
</div>
</div>
<div className="card docs-card">
<div className="card-header">Affected documents</div>
{MOCK_DOCS.map(d => (
<div key={d.name} className="doc-row">
<span className="doc-score">{d.score}%</span>
<div>
<div className="doc-name">{d.name} <span className="doc-clause">{d.clause}</span></div>
<div className="doc-snippet">{d.snippet}</div>
</div>
</div>
))}
</div>
{(aiOutput || streaming) && (
<div className="card ai-card">
<div className="card-header">AI Impact Analysis</div>
<div className="ai-output">
{aiOutput}
{streaming && <span className="blink-cursor"></span>}
</div>
</div>
)}
</>
)}
</div>
</div>
<footer className="page-footer">
<div className="live-dot" />
<span>Live feed · Regulation Hub</span>
</footer>
</div>
);
} }