fix somethings
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
211
frontend/src/contexts/PageStateContext.tsx
Normal file
211
frontend/src/contexts/PageStateContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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; // 0–100 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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user