fix somethings

This commit is contained in:
2026-06-08 11:16:28 +08:00
parent 9fea9c6a53
commit e7963b267e
34 changed files with 5195 additions and 246 deletions

View File

@@ -1,12 +1,14 @@
import './styles/globals.css';
import { ThemeProvider, AuthProvider } from './contexts';
import { ThemeProvider, AuthProvider, PageStateProvider } from './contexts';
import { AppRouter } from './router/AppRouter';
function App() {
return (
<ThemeProvider>
<AuthProvider>
<AppRouter />
<PageStateProvider>
<AppRouter />
</PageStateProvider>
</AuthProvider>
</ThemeProvider>
);

View File

@@ -0,0 +1,211 @@
/**
* PageStateContext — preserves page-level session state across route changes.
*
* When React Router unmounts a page component, all its useState values are lost.
* This context lives above the router and holds the state that must survive
* navigation so users can switch modules and return without losing their work.
*
* Covered pages:
* - RagChat: message history, citation rail, sessionId, input draft
* - Compliance: analysis result (sources, findings, conclusion, meta)
* - Perception: selected signal, filter state, AI analysis output
*/
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
// ── RagChat types ─────────────────────────────────────────────────────────────
export interface RagMessage {
id: string;
role: 'user' | 'assistant';
text: string;
citationRefs?: number[];
}
export interface RagCitation {
index: number;
score: number;
name: string;
clause: string;
snippet: string;
docId?: string;
}
export interface RagChatState {
messages: RagMessage[];
citations: RagCitation[];
sessionId: string | null;
inputDraft: string;
}
const RAG_INIT: RagChatState = {
messages: [
{
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.',
},
],
citations: [],
sessionId: null,
inputDraft: '',
};
// ── Compliance types ──────────────────────────────────────────────────────────
export interface ComplianceSourceEvent {
standard: string;
clause: string;
score: number;
status: string;
full_content: string;
}
export interface ComplianceFindingEvent {
title: string;
desc: string;
status: 'ok' | 'warn' | 'risk';
clause_ref?: string;
}
export interface ComplianceActionItem {
label: string;
value: string;
risk?: boolean;
}
export interface ComplianceDonePayload {
conclusion: string;
actions: ComplianceActionItem[];
risk_score: number;
highlight_terms: string[];
para_text: string;
}
export interface ComplianceMeta {
title: string;
sourceType: 'text' | 'doc' | 'upload';
startedAt: string;
}
export type ComplianceStatus = 'idle' | 'streaming' | 'done' | 'error';
export interface ComplianceState {
status: ComplianceStatus;
stageLabel: string;
stageKey: string;
meta: ComplianceMeta | null;
sources: ComplianceSourceEvent[];
findings: ComplianceFindingEvent[];
done: ComplianceDonePayload | null;
errorText: string;
}
const COMPLIANCE_INIT: ComplianceState = {
status: 'idle',
stageLabel: '',
stageKey: '',
meta: null,
sources: [],
findings: [],
done: null,
errorText: '',
};
// ── Perception types ──────────────────────────────────────────────────────────
export interface PerceptionSignal {
id: string;
source: string;
standard: string;
status: 'ok' | 'warn' | 'risk' | 'info';
title: string;
summary: string;
date: string;
tags: string[];
impact: 'High' | 'Medium' | 'Low';
}
export interface PerceptionPageState {
signals: PerceptionSignal[];
searchQuery: string;
sourceFilter: string;
impactFilter: string;
selectedId: string | null;
aiOutput: string;
detailTab: 'overview' | 'obligations' | 'assessment' | 'diff';
crawlStatus: string;
}
const PERCEPTION_INIT: PerceptionPageState = {
signals: [],
searchQuery: '',
sourceFilter: 'All',
impactFilter: 'All',
selectedId: null,
aiOutput: '',
detailTab: 'overview',
crawlStatus: '',
};
// ── Context value ─────────────────────────────────────────────────────────────
interface PageStateContextValue {
// RagChat
ragState: RagChatState;
setRagState: React.Dispatch<React.SetStateAction<RagChatState>>;
ragStreamingRef: React.MutableRefObject<boolean>;
ragAbortRef: React.MutableRefObject<AbortController | null>;
// Compliance
complianceState: ComplianceState;
setComplianceState: React.Dispatch<React.SetStateAction<ComplianceState>>;
complianceAbortRef: React.MutableRefObject<AbortController | null>;
resetCompliance: () => void;
// Perception
perceptionState: PerceptionPageState;
setPerceptionState: React.Dispatch<React.SetStateAction<PerceptionPageState>>;
perceptionAbortRef: React.MutableRefObject<AbortController | null>;
perceptionCrawlAbortRef: React.MutableRefObject<AbortController | null>;
}
const PageStateContext = createContext<PageStateContextValue | null>(null);
// ── Provider ──────────────────────────────────────────────────────────────────
export function PageStateProvider({ children }: { children: React.ReactNode }) {
const [ragState, setRagState] = useState<RagChatState>(RAG_INIT);
const ragStreamingRef = useRef(false);
const ragAbortRef = useRef<AbortController | null>(null);
const [complianceState, setComplianceState] = useState<ComplianceState>(COMPLIANCE_INIT);
const complianceAbortRef = useRef<AbortController | null>(null);
const resetCompliance = useCallback(() => {
complianceAbortRef.current?.abort();
setComplianceState(COMPLIANCE_INIT);
}, []);
const [perceptionState, setPerceptionState] = useState<PerceptionPageState>(PERCEPTION_INIT);
const perceptionAbortRef = useRef<AbortController | null>(null);
const perceptionCrawlAbortRef = useRef<AbortController | null>(null);
return (
<PageStateContext.Provider value={{
ragState, setRagState, ragStreamingRef, ragAbortRef,
complianceState, setComplianceState, complianceAbortRef, resetCompliance,
perceptionState, setPerceptionState, perceptionAbortRef, perceptionCrawlAbortRef,
}}>
{children}
</PageStateContext.Provider>
);
}
// ── Hook ──────────────────────────────────────────────────────────────────────
export function usePageState() {
const ctx = useContext(PageStateContext);
if (!ctx) throw new Error('usePageState must be used inside PageStateProvider');
return ctx;
}

View File

@@ -1,3 +1,18 @@
export { ThemeProvider, useTheme } from './ThemeContext';
export { AuthProvider, useAuth } from './AuthContext';
export type { AuthUser } from './AuthContext';
export { PageStateProvider, usePageState } from './PageStateContext';
export type {
RagChatState,
RagMessage,
RagCitation,
ComplianceState,
ComplianceStatus,
ComplianceSourceEvent,
ComplianceFindingEvent,
ComplianceDonePayload,
ComplianceMeta,
ComplianceActionItem,
PerceptionPageState,
PerceptionSignal,
} from './PageStateContext';

View File

@@ -1,4 +1,25 @@
import { useState, useCallback, useRef } from 'react';
/**
* useComplianceAnalysis — compliance analysis state wired to PageStateContext.
*
* State is stored in the global context so it persists when the user navigates
* to another module and returns. The `run` and `reset` actions are identical
* to the previous hook API so CompliancePage needs no structural changes.
*/
import { useCallback } from 'react';
import { usePageState } from '../../contexts';
import type {
ComplianceMeta,
ComplianceState,
ComplianceSourceEvent,
ComplianceFindingEvent,
ComplianceDonePayload,
} from '../../contexts';
export type { ComplianceMeta, ComplianceState, ComplianceSourceEvent as SourceEvent, ComplianceFindingEvent as FindingEvent, ComplianceDonePayload as DonePayload };
export type { ComplianceActionItem as ActionItem } from '../../contexts';
export type AnalysisStatus = import('../../contexts').ComplianceStatus;
export type AnalysisMeta = ComplianceMeta;
const TOKEN_KEY = 'auth_token';
function authHeader(): Record<string, string> {
@@ -6,55 +27,7 @@ function authHeader(): Record<string, string> {
return t ? { Authorization: `Bearer ${t}` } : {};
}
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 = {
const INITIAL_STATE: ComplianceState = {
status: 'idle',
stageLabel: '',
stageKey: '',
@@ -66,18 +39,12 @@ const INITIAL_STATE: AnalysisState = {
};
export function useComplianceAnalysis() {
const [state, setState] = useState<AnalysisState>(INITIAL_STATE);
const abortRef = useRef<AbortController | null>(null);
const { complianceState: state, setComplianceState: setState, complianceAbortRef, resetCompliance: reset } = usePageState();
const reset = useCallback(() => {
abortRef.current?.abort();
setState(INITIAL_STATE);
}, []);
const run = useCallback(async (formData: FormData, meta: AnalysisMeta) => {
abortRef.current?.abort();
const run = useCallback(async (formData: FormData, meta: ComplianceMeta) => {
complianceAbortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
complianceAbortRef.current = ctrl;
setState({ ...INITIAL_STATE, status: 'streaming', stageLabel: 'Starting…', meta });
@@ -124,7 +91,7 @@ export function useComplianceAnalysis() {
if (j.type === 'stage') {
setState(s => ({ ...s, stageLabel: j.label ?? '', stageKey: j.stage ?? '' }));
} else if (j.type === 'source') {
const src: SourceEvent = {
const src: ComplianceSourceEvent = {
standard: j.standard ?? '',
clause: j.clause ?? '',
score: j.score ?? 0,
@@ -133,7 +100,7 @@ export function useComplianceAnalysis() {
};
setState(s => ({ ...s, sources: [...s.sources, src] }));
} else if (j.type === 'finding') {
const finding: FindingEvent = {
const finding: ComplianceFindingEvent = {
title: j.title ?? '',
desc: j.desc ?? '',
status: j.status ?? 'info',
@@ -141,7 +108,7 @@ export function useComplianceAnalysis() {
};
setState(s => ({ ...s, findings: [...s.findings, finding] }));
} else if (j.type === 'done') {
const payload: DonePayload = {
const payload: ComplianceDonePayload = {
conclusion: j.conclusion ?? '',
actions: j.actions ?? [],
risk_score: j.risk_score ?? 0,
@@ -162,7 +129,7 @@ export function useComplianceAnalysis() {
if (e instanceof Error && e.name === 'AbortError') return;
setState(s => ({ ...s, status: 'error', errorText: String(e) }));
}
}, []);
}, [setState, complianceAbortRef]);
return { state, run, reset };
}

View File

@@ -1,6 +1,8 @@
import { useState, useEffect, useRef } from 'react';
import { Topbar } from '../../components/layout/Topbar';
import { RefreshCw, Play, Square, ExternalLink } from 'lucide-react';
import { usePageState } from '../../contexts';
import type { PerceptionSignal } from '../../contexts';
const TOKEN_KEY = 'auth_token';
function authHeader(): Record<string, string> {
@@ -8,18 +10,6 @@ function authHeader(): Record<string, string> {
return t ? { Authorization: `Bearer ${t}` } : {};
}
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;
@@ -27,29 +17,17 @@ interface Stats {
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'];
// 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 {
// Backend event → Signal
function mapEvent(e: Record<string, unknown>): PerceptionSignal {
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 ?? ''),
standard: String(e.standard ?? e.standard_code ?? e.regulation_id ?? ''),
status: backendStatus === 'high' || backendStatus === 'urgent' ? 'risk'
: backendStatus === 'medium' || backendStatus === 'draft' ? 'warn'
: backendStatus === 'low' || backendStatus === 'final' ? 'ok'
@@ -62,50 +40,40 @@ function mapEvent(e: Record<string, unknown>): Signal {
};
}
const MOCK_SIGNALS: Signal[] = [
const MOCK_SIGNALS: PerceptionSignal[] = [
{
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'
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'
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'
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 [signals, setSignals] = useState<Signal[]>(MOCK_SIGNALS);
const [searchQuery, setSearchQuery] = useState('');
const [sourceFilter, setSourceFilter] = useState('All');
const [impactFilter, setImpactFilter] = useState('All');
const [selected, setSelected] = useState<Signal | null>(null);
const [streaming, setStreaming] = useState(false);
const [aiOutput, setAiOutput] = useState('');
const abortRef = useRef<AbortController | null>(null);
// Persistent state lives in PageStateContext — survives route changes
const { perceptionState, setPerceptionState, perceptionAbortRef, perceptionCrawlAbortRef } = usePageState();
const { signals, searchQuery, sourceFilter, impactFilter, selectedId, aiOutput, detailTab, crawlStatus } = perceptionState;
// Stats and selectedFull are lightweight to re-fetch on mount
const [stats, setStats] = useState<Stats | null>(null);
const [streaming, setStreaming] = useState(false);
const [crawling, setCrawling] = useState(false);
// Full event detail — re-fetched when selected changes or page mounts with a selection
const [selectedFull, setSelectedFull] = useState<Record<string, unknown> | null>(null);
// Re-fetch stats every time the page mounts
useEffect(() => {
fetch('/api/v1/perception/stats', { headers: authHeader() })
.then(r => r.json())
@@ -113,16 +81,36 @@ export function PerceptionPage() {
.catch(() => setStats({ total: 47, high_impact: 7, medium_impact: 18, last_90_days: 14 }));
}, []);
// Fetch signal list on first mount only (if empty), otherwise preserve context state
useEffect(() => {
if (signals.length > 0) return; // already loaded
fetch('/api/v1/perception/events?limit=100', { headers: authHeader() })
.then(r => r.json())
.then(d => {
if (Array.isArray(d?.events) && d.events.length > 0) {
setSignals(d.events.map(mapEvent));
setPerceptionState(s => ({ ...s, signals: d.events.map(mapEvent) }));
} else {
setPerceptionState(s => ({ ...s, signals: MOCK_SIGNALS }));
}
})
.catch(() => { /* keep mock data on error */ });
}, []);
.catch(() => {
setPerceptionState(s => ({ ...s, signals: s.signals.length > 0 ? s.signals : MOCK_SIGNALS }));
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Re-fetch full event detail when navigating back with a selected signal
useEffect(() => {
if (selectedId) {
fetch(`/api/v1/perception/events/${selectedId}`, { headers: authHeader() })
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setSelectedFull(d); })
.catch(() => {});
} else {
setSelectedFull(null);
}
}, [selectedId]);
const selected = signals.find(s => s.id === selectedId) ?? null;
const filtered = signals.filter(s => {
if (sourceFilter !== 'All' && s.source !== sourceFilter) return false;
@@ -137,13 +125,20 @@ export function PerceptionPage() {
function runAnalysis() {
if (!selected) return;
setStreaming(true);
setAiOutput('');
setPerceptionState(s => ({ ...s, aiOutput: '' }));
const ctrl = new AbortController();
abortRef.current = ctrl;
// Backend: POST /api/v1/perception/events/{id}/analyze → SSE stream
fetch(`/api/v1/perception/events/${selected.id}/analyze`, { method: 'POST', headers: authHeader(), signal: ctrl.signal })
perceptionAbortRef.current = ctrl;
fetch(`/api/v1/perception/events/${selected.id}/analyze`, {
method: 'POST',
headers: authHeader(),
signal: ctrl.signal,
})
.then(async res => {
if (!res.body) { setAiOutput('No stream available.'); setStreaming(false); return; }
if (!res.body) {
setPerceptionState(s => ({ ...s, aiOutput: 'No stream available.' }));
setStreaming(false);
return;
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
@@ -160,30 +155,99 @@ export function PerceptionPage() {
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);
if (j.text) setPerceptionState(s => ({ ...s, aiOutput: s.aiOutput + j.text }));
else if (typeof j === 'string') setPerceptionState(s => ({ ...s, aiOutput: s.aiOutput + j }));
} catch {
setAiOutput(p => p + raw);
setPerceptionState(s => ({ ...s, aiOutput: s.aiOutput + raw }));
}
}
}
setStreaming(false);
})
.catch(e => {
if (e.name !== 'AbortError') setAiOutput('Analysis failed. Check API connection.');
if (e.name !== 'AbortError') setPerceptionState(s => ({ ...s, aiOutput: 'Analysis failed. Check API connection.' }));
setStreaming(false);
});
}
function stopAnalysis() {
abortRef.current?.abort();
perceptionAbortRef.current?.abort();
setStreaming(false);
}
function selectSignal(sig: Signal) {
setSelected(sig);
setAiOutput('');
async function runCrawl() {
setCrawling(true);
setPerceptionState(s => ({ ...s, crawlStatus: '正在连接数据源...' }));
try {
const res = await fetch('/api/v1/perception/crawl', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify({}),
});
if (!res.body) {
setPerceptionState(s => ({ ...s, crawlStatus: 'No stream' }));
setCrawling(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);
const parts = buf.split('\n\n');
buf = parts.pop() ?? '';
for (const block of parts) {
const eventLine = block.split('\n').find(l => l.startsWith('event: '));
const dataLine = block.split('\n').find(l => l.startsWith('data: '));
const evtName = eventLine?.slice(7).trim();
const raw = dataLine?.slice(6).trim();
if (!raw) continue;
try {
const d = JSON.parse(raw);
if (evtName === 'progress') {
setPerceptionState(s => ({
...s,
crawlStatus: `${d.source}: ${d.stage === 'fetching' ? '抓取中...' : d.stage === 'processing' ? `处理 ${d.fetched} 条...` : `完成 +${d.new}`}`,
}));
} else if (evtName === 'done') {
setPerceptionState(s => ({ ...s, crawlStatus: `更新完成 — 新增 ${d.total_new} 条,更新 ${d.total_updated}` }));
fetch('/api/v1/perception/events?limit=100', { headers: authHeader() })
.then(r => r.json())
.then(d2 => {
if (Array.isArray(d2?.events)) {
setPerceptionState(s => ({ ...s, signals: d2.events.map(mapEvent) }));
}
});
} else if (evtName === 'error') {
setPerceptionState(s => ({
...s,
crawlStatus: `错误: ${typeof d === 'string' ? d : d.message}`,
}));
}
} catch { /* ignore */ }
}
}
} catch (e: unknown) {
setPerceptionState(s => ({
...s,
crawlStatus: `连接失败: ${e instanceof Error ? e.message : String(e)}`,
}));
}
setCrawling(false);
}
function selectSignal(sig: PerceptionSignal) {
setPerceptionState(s => ({
...s,
selectedId: sig.id,
aiOutput: '',
detailTab: 'overview',
}));
setSelectedFull(null);
setStreaming(false);
perceptionAbortRef.current?.abort();
}
return (
@@ -197,10 +261,18 @@ export function PerceptionPage() {
<input
placeholder="Search signals..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onChange={e => setPerceptionState(s => ({ ...s, searchQuery: e.target.value }))}
/>
</div>
<button className="btn sm"><RefreshCw size={13} />Refresh</button>
<button className="btn sm primary" onClick={runCrawl} disabled={crawling}>
<RefreshCw size={13} className={crawling ? 'spin' : ''} />
{crawling ? '抓取中...' : '刷新数据源'}
</button>
{crawlStatus && (
<span style={{ fontSize: 12, color: 'var(--text-secondary)', marginLeft: 8 }}>
{crawlStatus}
</span>
)}
</>
}
/>
@@ -227,13 +299,25 @@ export function PerceptionPage() {
<div className="filter-bar">
<div className="chip-group">
{SOURCES.map(s => (
<button key={s} className={`chip${sourceFilter === s ? ' active' : ''}`} onClick={() => setSourceFilter(s)}>{s}</button>
<button
key={s}
className={`chip${sourceFilter === s ? ' active' : ''}`}
onClick={() => setPerceptionState(st => ({ ...st, sourceFilter: 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>
<button
key={i}
className={`chip${impactFilter === i ? ' active' : ''}`}
onClick={() => setPerceptionState(st => ({ ...st, impactFilter: i }))}
>
{i}
</button>
))}
</div>
</div>
@@ -243,7 +327,7 @@ export function PerceptionPage() {
{filtered.map(sig => (
<div
key={sig.id}
className={`ev-card${selected?.id === sig.id ? ' selected' : ''}`}
className={`ev-card${selectedId === sig.id ? ' selected' : ''}`}
onClick={() => selectSignal(sig)}
>
<div className="ev-top">
@@ -277,8 +361,11 @@ export function PerceptionPage() {
<span className="source-tag">{selected.source}</span>
<span className="ev-std">{selected.standard}</span>
<span className={`status ${selected.status}`}>
{selected.status === 'risk' ? 'Urgent' : 'Published'}
{selected.status === 'risk' ? 'Urgent' : selected.status === 'warn' ? 'Draft' : 'Published'}
</span>
{selectedFull?.change_summary && (
<span className="status warn" style={{ marginLeft: 'auto' }}>CHANGED</span>
)}
</div>
<div className="detail-title">{selected.title}</div>
<p className="detail-summary">{selected.summary}</p>
@@ -287,23 +374,160 @@ export function PerceptionPage() {
? <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>
{selected && (
<a
href={(selectedFull?.full_text_url as string) || '#'}
target="_blank"
rel="noopener noreferrer"
className="btn sm"
>
<ExternalLink size={12} />Source
</a>
)}
</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 className="detail-tabs">
{(['overview', 'obligations', 'assessment', 'diff'] as const).map(tab => (
<button
key={tab}
className={`detail-tab${detailTab === tab ? ' active' : ''}${tab === 'diff' && !selectedFull?.change_summary ? ' disabled' : ''}`}
onClick={() => {
if (tab !== 'diff' || selectedFull?.change_summary) {
setPerceptionState(s => ({ ...s, detailTab: tab }));
}
}}
>
{tab === 'overview' ? '概览' : tab === 'obligations' ? '义务条款' : tab === 'assessment' ? '影响评估' : '变更对比'}
</button>
))}
</div>
{detailTab === 'overview' && (
<div className="card">
<div className="card-header">Scope &amp; Summary</div>
<p className="detail-summary" style={{ marginTop: 8 }}>
{(selectedFull?.scope as string) || selected.summary}
</p>
{selectedFull?.penalties && (
<p style={{ fontSize: 13, color: 'var(--danger)', marginTop: 6 }}>
{selectedFull.penalties as string}
</p>
)}
</div>
)}
{detailTab === 'obligations' && (
<div className="card">
<div className="card-header"></div>
{(() => {
const obs = (selectedFull?.obligations as Array<Record<string, string>>) || [];
const deadlines = (selectedFull?.deadlines as Array<Record<string, string>>) || [];
return obs.length === 0 && deadlines.length === 0 ? (
<p className="detail-summary" style={{ marginTop: 8 }}>"Run impact analysis"</p>
) : (
<>
{obs.length > 0 && (
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse', marginTop: 8 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<th style={{ textAlign: 'left', padding: '4px 8px' }}></th>
<th style={{ textAlign: 'left', padding: '4px 8px', width: 80 }}></th>
<th style={{ textAlign: 'left', padding: '4px 8px', width: 60 }}></th>
</tr>
</thead>
<tbody>
{obs.map((ob, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--border-faint)' }}>
<td style={{ padding: '6px 8px' }}>{ob.text}</td>
<td style={{ padding: '6px 8px', color: 'var(--text-secondary)' }}>{ob.subject}</td>
<td style={{ padding: '6px 8px' }}>
<span className={`status ${ob.deontic === 'must' || ob.deontic === 'shall' ? 'risk' : ob.deontic === 'prohibited' ? 'risk' : 'info'}`}>
{ob.deontic}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
{deadlines.length > 0 && (
<div style={{ marginTop: 12 }}>
<div className="card-header"></div>
{deadlines.map((d, i) => (
<div key={i} style={{ fontSize: 13, padding: '4px 0', display: 'flex', gap: 12 }}>
<span style={{ fontWeight: 600, color: 'var(--danger)' }}>{d.date || '待定'}</span>
<span style={{ color: 'var(--text-secondary)' }}>{d.description}</span>
</div>
))}
</div>
)}
</>
);
})()}
</div>
)}
{detailTab === 'assessment' && (
<div className="card docs-card">
<div className="card-header">Affected documents</div>
{(() => {
const docs = (selectedFull?.affected_docs as Array<Record<string, unknown>>);
const displayDocs = docs && docs.length > 0 ? docs : [];
return displayDocs.length === 0
? <p className="detail-summary" style={{ marginTop: 8 }}>No affected documents found.</p>
: displayDocs.map((d, i) => (
<div key={i} className="doc-row">
<span className="doc-score">{Math.round(Number(d.score ?? 0) * 100)}%</span>
<div>
<div className="doc-name">
{String(d.doc_name || '')}
<span className="doc-clause">{String(d.key_clauses || d.clause || '')}</span>
</div>
{d.snippet && <div className="doc-snippet">{String(d.snippet)}</div>}
{d.recommendation && (
<div style={{ fontSize: 12, color: 'var(--accent)', marginTop: 2 }}> {String(d.recommendation)}</div>
)}
</div>
</div>
));
})()}
</div>
)}
{detailTab === 'diff' && selectedFull?.change_summary && (
<div className="card">
<div className="card-header"></div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 8 }}>
{selectedFull.change_summary as string}
</p>
{(() => {
const sections = (selectedFull.changed_sections as Array<Record<string, unknown>>) || [];
return sections.map((s, i) => (
<div key={i} style={{ marginTop: 12, borderTop: '1px solid var(--border)', paddingTop: 10 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 6 }}>
<span className={`status ${s.change_type === 'tightened' || s.change_type === 'added' ? 'risk' : s.change_type === 'removed' ? 'warn' : 'info'}`}>
{String(s.change_type)}
</span>
<span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>cosine: {String(s.similarity)}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 12 }}>
<div style={{ background: 'var(--danger-bg)', padding: 8, borderRadius: 4 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}></div>
{String(s.old_text || '')}
</div>
<div style={{ background: 'var(--success-bg)', padding: 8, borderRadius: 4 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}></div>
{String(s.new_text || '')}
</div>
</div>
{s.summary && <p style={{ fontSize: 12, marginTop: 6, color: 'var(--text-secondary)' }}>{String(s.summary)}</p>}
</div>
));
})()}
</div>
)}
{(aiOutput || streaming) && (
<div className="card ai-card">
<div className="card-header">AI Impact Analysis</div>

View File

@@ -1,6 +1,8 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useRef, useEffect, useCallback, useState } from 'react';
import { Topbar } from '../../components/layout/Topbar';
import { Send, Download } from 'lucide-react';
import { usePageState } from '../../contexts';
import type { RagCitation } from '../../contexts';
const TOKEN_KEY = 'auth_token';
function authHeader(): Record<string, string> {
@@ -8,26 +10,8 @@ function authHeader(): Record<string, string> {
return t ? { Authorization: `Bearer ${t}` } : {};
}
interface Message {
id: string;
role: 'user' | 'assistant';
text: string;
// citation indices mentioned in this assistant message (1-based, matching citations array)
citationRefs?: number[];
}
interface Citation {
index: number; // 1-based, matches [N] markers in text
score: number; // 0100 display percentage
name: string; // doc_name
clause: string; // section_title or clause
snippet: string; // preview text
docId?: string;
}
// Map a raw source doc from the backend "retrieved" event to our Citation shape.
// Backend fields: { id, score(0-1), preview, doc_name, clause, doc_id }
function mapSource(s: Record<string, unknown>, idx: number): Citation {
function mapSource(s: Record<string, unknown>, idx: number): RagCitation {
const rawScore = typeof s.score === 'number' ? s.score : 0;
const displayScore = rawScore <= 1 ? Math.round(rawScore * 100) : Math.round(rawScore);
return {
@@ -73,25 +57,21 @@ const MOCK_QUICK = [
];
export function RagChatPage() {
const [messages, setMessages] = useState<Message[]>([
{
id: 'init', role: 'assistant',
text: 'Hello! I can answer questions about your indexed regulations and compliance documents. Try asking about EU AI Act requirements, MIIT rules, or ISO/SAE 21434 scope.',
}
]);
const [quickPrompts, setQuickPrompts] = useState<string[]>(MOCK_QUICK);
const [input, setInput] = useState('');
const [streaming, setStreaming] = useState(false);
const [citations, setCitations] = useState<Citation[]>([]);
// All persistent state lives in PageStateContext — survives route changes
const { ragState, setRagState, ragStreamingRef, ragAbortRef } = usePageState();
const { messages, citations, sessionId, inputDraft } = ragState;
// Local-only UI state: highlighted citation and streaming indicator
// These are fine to reset on navigation since they're transient UI feedback
const [highlightedCit, setHighlightedCit] = useState<number | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [streaming, setStreaming] = useState(ragStreamingRef.current);
const [quickPrompts, setQuickPrompts] = useState<string[]>(MOCK_QUICK);
const bottomRef = useRef<HTMLDivElement>(null);
const citRailRef = useRef<HTMLDivElement>(null);
const citItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
const abortRef = useRef<AbortController | null>(null);
// Fetch quick questions from backend on mount
// Fetch quick questions from backend on mount (only once per session)
useEffect(() => {
fetch('/api/v1/rag/quick-questions', { headers: authHeader() })
.then(r => r.json())
@@ -115,26 +95,33 @@ export function RagChatPage() {
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// Clear highlight after 3s
setTimeout(() => setHighlightedCit(h => h === n ? null : h), 3000);
}, []);
async function send(text?: string) {
const q = (text ?? input).trim();
if (!q || streaming) return;
setInput('');
const userMsg: Message = { id: Date.now().toString(), role: 'user', text: q };
setMessages(m => [...m, userMsg]);
const q = (text ?? inputDraft).trim();
if (!q || ragStreamingRef.current) return;
setRagState(s => ({ ...s, inputDraft: '' }));
const userMsgId = Date.now().toString();
const assistantId = (Date.now() + 1).toString();
setMessages(m => [...m, { id: assistantId, role: 'assistant', text: '' }]);
setRagState(s => ({
...s,
messages: [
...s.messages,
{ id: userMsgId, role: 'user', text: q },
{ id: assistantId, role: 'assistant', text: '' },
],
citations: [],
}));
ragStreamingRef.current = true;
setStreaming(true);
setCitations([]);
setHighlightedCit(null);
const ctrl = new AbortController();
abortRef.current = ctrl;
ragAbortRef.current = ctrl;
try {
const body: Record<string, unknown> = { query: q, top_k: 5 };
@@ -151,14 +138,13 @@ export function RagChatPage() {
const reader = res.body.getReader();
const dec = new TextDecoder();
let buffer = '';
const newCitations: Citation[] = [];
const newCitations: RagCitation[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += dec.decode(value, { stream: true });
// SSE blocks separated by double newline
const blocks = buffer.split('\n\n');
buffer = blocks.pop() ?? '';
@@ -171,56 +157,62 @@ export function RagChatPage() {
const j = JSON.parse(raw);
if (j.type === 'session') {
// Backend assigned a session_id — persist for next request
if (j.session_id) setSessionId(j.session_id);
if (j.session_id) setRagState(s => ({ ...s, sessionId: j.session_id }));
} else if (j.type === 'retrieved' && Array.isArray(j.docs)) {
// Sources arrive before the answer starts
const mapped = j.docs.map((d: Record<string, unknown>, i: number) => mapSource(d, i + 1));
newCitations.push(...mapped);
setCitations([...mapped]);
setRagState(s => ({ ...s, citations: [...mapped] }));
} else if (j.type === 'chunk' && j.text) {
setMessages(m => m.map(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
setRagState(s => ({
...s,
messages: s.messages.map(msg =>
msg.id === assistantId
? { ...msg, text: msg.text + (j.text as string) }
: msg
),
}));
} 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 };
setRagState(s => ({
...s,
messages: s.messages.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
));
setRagState(s => ({
...s,
messages: s.messages.map(msg =>
msg.id === assistantId
? { ...msg, text: `Error: ${j.text ?? 'Unknown error'}` }
: msg
),
}));
}
} catch { /* malformed JSON chunk, skip */ }
}
}
} catch (e: unknown) {
if (e instanceof Error && e.name !== 'AbortError') {
setMessages(m => m.map(msg =>
msg.id === assistantId
? { ...msg, text: 'Could not reach the RAG API. Please check the backend.' }
: msg
));
setRagState(s => ({
...s,
messages: s.messages.map(msg =>
msg.id === assistantId
? { ...msg, text: 'Could not reach the RAG API. Please check the backend.' }
: msg
),
}));
}
} finally {
ragStreamingRef.current = false;
setStreaming(false);
}
}
@@ -291,15 +283,15 @@ export function RagChatPage() {
<textarea
className="composer-input"
placeholder="Ask about your regulations…"
value={input}
onChange={e => setInput(e.target.value)}
value={inputDraft}
onChange={e => setRagState(s => ({ ...s, inputDraft: e.target.value }))}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
rows={2}
/>
<button
className="btn primary"
onClick={() => send()}
disabled={!input.trim() || streaming}
disabled={!inputDraft.trim() || streaming}
>
<Send size={14} />
</button>

View File

@@ -1108,3 +1108,33 @@ mark.comp-highlight {
transition: color 0.15s;
}
.logout-btn:hover { color: var(--danger); }
/* ── Detail Tabs (Perception) ──────────────────── */
.detail-tabs {
display: flex;
gap: 2px;
margin: 8px 0 0;
border-bottom: 1px solid var(--border);
padding-bottom: 0;
}
.detail-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 6px 14px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.detail-tab:hover { color: var(--text); }
.detail-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 600;
}
.detail-tab.disabled {
opacity: 0.35;
cursor: not-allowed;
}
.spin { animation: spin 1s linear infinite; }