update for 1. 优化 2.中英切换
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
import './styles/globals.css';
|
||||
import { ThemeProvider, AuthProvider, PageStateProvider } from './contexts';
|
||||
import { ThemeProvider, AuthProvider, PageStateProvider, LanguageProvider } from './contexts';
|
||||
import { AppRouter } from './router/AppRouter';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<PageStateProvider>
|
||||
<AppRouter />
|
||||
</PageStateProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<PageStateProvider>
|
||||
<AppRouter />
|
||||
</PageStateProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
@@ -13,21 +14,6 @@ interface NavItem {
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
const mainNav: NavItem[] = [
|
||||
{ to: '/', icon: <LayoutDashboard size={16} />, label: 'Overview' },
|
||||
{ to: '/signals', icon: <Radio size={16} />, label: 'Regulatory Signals' },
|
||||
{ to: '/status', icon: <Monitor size={16} />, label: 'System Status' },
|
||||
];
|
||||
|
||||
const workbenchNav: NavItem[] = [
|
||||
{ to: '/documents', icon: <FileText size={16} />, label: 'Documents' },
|
||||
{ to: '/compliance', icon: <Shield size={16} />, label: 'Compliance Analysis' },
|
||||
];
|
||||
|
||||
const chatNav: NavItem[] = [
|
||||
{ to: '/chat', icon: <MessageSquare size={16} />, label: 'Regulation Q&A' },
|
||||
];
|
||||
|
||||
function NavGroup({ title, items }: { title: string; items: NavItem[] }) {
|
||||
return (
|
||||
<div className="nav-group">
|
||||
@@ -60,6 +46,22 @@ function initials(name: string): string {
|
||||
export function Sidebar() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { user, logout } = useAuth();
|
||||
const { lang, t, toggleLang } = useLanguage();
|
||||
|
||||
const mainNav: NavItem[] = [
|
||||
{ to: '/', icon: <LayoutDashboard size={16} />, label: t.nav.overview },
|
||||
{ to: '/signals', icon: <Radio size={16} />, label: t.nav.signals },
|
||||
{ to: '/status', icon: <Monitor size={16} />, label: t.nav.status },
|
||||
];
|
||||
|
||||
const workbenchNav: NavItem[] = [
|
||||
{ to: '/documents', icon: <FileText size={16} />, label: t.nav.documents },
|
||||
{ to: '/compliance', icon: <Shield size={16} />, label: t.nav.compliance },
|
||||
];
|
||||
|
||||
const chatNav: NavItem[] = [
|
||||
{ to: '/chat', icon: <MessageSquare size={16} />, label: t.nav.chat },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
@@ -72,9 +74,9 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<NavGroup title="Main" items={mainNav} />
|
||||
<NavGroup title="Workbench" items={workbenchNav} />
|
||||
<NavGroup title="Chat" items={chatNav} />
|
||||
<NavGroup title={t.nav.groupMain} items={mainNav} />
|
||||
<NavGroup title={t.nav.groupWorkbench} items={workbenchNav} />
|
||||
<NavGroup title={t.nav.groupChat} items={chatNav} />
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
@@ -92,11 +94,19 @@ export function Sidebar() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button className="theme-btn" onClick={toggleTheme} title="Toggle theme">
|
||||
<button
|
||||
className="theme-btn"
|
||||
onClick={toggleLang}
|
||||
title={t.sidebar.toggleLang}
|
||||
style={{ fontSize: 12, fontWeight: 600 }}
|
||||
>
|
||||
{lang === 'en' ? 'EN' : '中'}
|
||||
</button>
|
||||
<button className="theme-btn" onClick={toggleTheme} title={t.sidebar.toggleTheme}>
|
||||
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
|
||||
</button>
|
||||
{user && (
|
||||
<button className="logout-btn" onClick={logout} title="Sign out">
|
||||
<button className="logout-btn" onClick={logout} title={t.sidebar.signOut}>
|
||||
<LogOut size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
36
frontend/src/contexts/LanguageContext.tsx
Normal file
36
frontend/src/contexts/LanguageContext.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import { en } from '../locales/en';
|
||||
import type { Translations } from '../locales/en';
|
||||
import { zh } from '../locales/zh';
|
||||
|
||||
export type Lang = 'en' | 'zh';
|
||||
|
||||
interface LanguageContextValue {
|
||||
lang: Lang;
|
||||
t: Translations;
|
||||
toggleLang: () => void;
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextValue>({
|
||||
lang: 'en',
|
||||
t: en,
|
||||
toggleLang: () => {},
|
||||
});
|
||||
|
||||
export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [lang, setLang] = useState<Lang>('en');
|
||||
|
||||
const toggleLang = () => setLang(l => (l === 'en' ? 'zh' : 'en'));
|
||||
|
||||
const t = lang === 'en' ? en : zh;
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ lang, t, toggleLang }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
return useContext(LanguageContext);
|
||||
}
|
||||
@@ -99,6 +99,10 @@ export interface ComplianceState {
|
||||
findings: ComplianceFindingEvent[];
|
||||
done: ComplianceDonePayload | null;
|
||||
errorText: string;
|
||||
// Direction B additions:
|
||||
analysisId: string | null;
|
||||
isReadOnly: boolean;
|
||||
activeFindingId: string | null;
|
||||
}
|
||||
|
||||
const COMPLIANCE_INIT: ComplianceState = {
|
||||
@@ -110,6 +114,9 @@ const COMPLIANCE_INIT: ComplianceState = {
|
||||
findings: [],
|
||||
done: null,
|
||||
errorText: '',
|
||||
analysisId: null,
|
||||
isReadOnly: false,
|
||||
activeFindingId: null,
|
||||
};
|
||||
|
||||
// ── Perception types ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,6 +2,8 @@ export { ThemeProvider, useTheme } from './ThemeContext';
|
||||
export { AuthProvider, useAuth } from './AuthContext';
|
||||
export type { AuthUser } from './AuthContext';
|
||||
export { PageStateProvider, usePageState } from './PageStateContext';
|
||||
export { LanguageProvider, useLanguage } from './LanguageContext';
|
||||
export type { Lang } from './LanguageContext';
|
||||
export type {
|
||||
RagChatState,
|
||||
RagMessage,
|
||||
|
||||
460
frontend/src/locales/en.ts
Normal file
460
frontend/src/locales/en.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
// English translations — default language
|
||||
export interface Translations {
|
||||
nav: {
|
||||
groupMain: string;
|
||||
groupWorkbench: string;
|
||||
groupChat: string;
|
||||
overview: string;
|
||||
signals: string;
|
||||
status: string;
|
||||
documents: string;
|
||||
compliance: string;
|
||||
chat: string;
|
||||
};
|
||||
sidebar: {
|
||||
toggleTheme: string;
|
||||
toggleLang: string;
|
||||
signOut: string;
|
||||
};
|
||||
overview: {
|
||||
eyebrow: string;
|
||||
heroTitle: string;
|
||||
heroDesc: string;
|
||||
openDashboard: string;
|
||||
jumpToChat: string;
|
||||
sectionHowItWorks: string;
|
||||
sectionScreens: string;
|
||||
statScreens: string;
|
||||
statFlows: string;
|
||||
statReviewPosture: string;
|
||||
stepUpload: string; stepUploadDesc: string;
|
||||
stepProcess: string; stepProcessDesc: string;
|
||||
stepMonitor: string; stepMonitorDesc: string;
|
||||
stepAnalyze: string; stepAnalyzeDesc: string;
|
||||
stepReview: string; stepReviewDesc: string;
|
||||
stepChat: string; stepChatDesc: string;
|
||||
screenStatus: string; screenStatusDesc: string;
|
||||
screenSignals: string; screenSignalsDesc: string;
|
||||
screenDocuments: string; screenDocumentsDesc: string;
|
||||
screenCompliance: string; screenComplianceDesc: string;
|
||||
screenChat: string; screenChatDesc: string;
|
||||
screenAnalytics: string; screenAnalyticsDesc: string;
|
||||
};
|
||||
signals: {
|
||||
topbarTitle: string;
|
||||
topbarSub: string;
|
||||
searchPlaceholder: string;
|
||||
refreshBtn: string;
|
||||
crawlingBtn: string;
|
||||
statTotal: string;
|
||||
statHigh: string;
|
||||
statMedium: string;
|
||||
statLast90: string;
|
||||
badgeFinal: string;
|
||||
badgeDraft: string;
|
||||
badgeUrgent: string;
|
||||
badgePublished: string;
|
||||
emptySelectSignal: string;
|
||||
runAnalysis: string;
|
||||
stopBtn: string;
|
||||
sourceLink: string;
|
||||
tabOverview: string;
|
||||
tabObligations: string;
|
||||
tabImpact: string;
|
||||
tabChanges: string;
|
||||
cardScopeHeader: string;
|
||||
cardObligationsHeader: string;
|
||||
obligationsEmpty: string;
|
||||
colObligationDesc: string;
|
||||
colSubject: string;
|
||||
colType: string;
|
||||
colDeadline: string;
|
||||
deadlinePending: string;
|
||||
cardAffectedDocs: string;
|
||||
noAffectedDocs: string;
|
||||
cardAIImpact: string;
|
||||
footerText: string;
|
||||
statusConnecting: string;
|
||||
statusNoStream: string;
|
||||
statusCrawling: string;
|
||||
statusProcessing: string;
|
||||
statusComplete: string;
|
||||
statusUpdateComplete: string;
|
||||
statusError: string;
|
||||
statusConnFailed: string;
|
||||
diffOld: string;
|
||||
diffNew: string;
|
||||
diffCardHeader: string;
|
||||
};
|
||||
status: {
|
||||
topbarTitle: string;
|
||||
searchPlaceholder: string;
|
||||
exportBtn: string;
|
||||
refreshBtn: string;
|
||||
newUploadBtn: string;
|
||||
statTotal: string;
|
||||
statIndexed: string;
|
||||
statFailed: string;
|
||||
statChunks: string;
|
||||
statCoverage: string;
|
||||
cardHealth: string;
|
||||
badgeOnline: string;
|
||||
badgeError: string;
|
||||
badgeDegraded: string;
|
||||
badgeUnknown: string;
|
||||
healthEndpointError: string;
|
||||
serviceEnabled: string;
|
||||
serviceDisabled: string;
|
||||
serviceNotLoaded: string;
|
||||
cardConfig: string;
|
||||
labelLLMProvider: string;
|
||||
labelLLMModel: string;
|
||||
labelEmbeddingModel: string;
|
||||
labelEmbeddingDim: string;
|
||||
labelMilvusCollection: string;
|
||||
labelParserBackend: string;
|
||||
labelChunkBackend: string;
|
||||
labelParserFailureMode: string;
|
||||
configLoadError: string;
|
||||
cardBreakdown: string;
|
||||
breakdownIndexed: string;
|
||||
breakdownProcessing: string;
|
||||
breakdownFailed: string;
|
||||
cardRuntime: string;
|
||||
labelActiveSessions: string;
|
||||
labelSessionCapacity: string;
|
||||
labelReranker: string;
|
||||
labelBM25: string;
|
||||
statusActive: string;
|
||||
statusUnavailable: string;
|
||||
footerAllOk: string;
|
||||
footerDegraded: string;
|
||||
footerChecking: string;
|
||||
totalChunks: string;
|
||||
};
|
||||
docs: {
|
||||
topbarTitle: string;
|
||||
searchPlaceholder: string;
|
||||
refreshBtn: string;
|
||||
uploadBtn: string;
|
||||
confirmDeleteTitle: string;
|
||||
cancelBtn: string;
|
||||
deleteBtn: string;
|
||||
filterAll: string;
|
||||
filterReady: string;
|
||||
filterProcessing: string;
|
||||
filterFailed: string;
|
||||
filterPending: string;
|
||||
filterAllTypes: string;
|
||||
deleteSelected: string;
|
||||
colName: string;
|
||||
colStatus: string;
|
||||
colUploaded: string;
|
||||
colChunks: string;
|
||||
colSize: string;
|
||||
colType: string;
|
||||
colActions: string;
|
||||
loading: string;
|
||||
emptyNoDocuments: string;
|
||||
emptyNoMatch: string;
|
||||
titleDownload: string;
|
||||
titleRetry: string;
|
||||
titleDelete: string;
|
||||
};
|
||||
compliance: {
|
||||
topbarTitle: string;
|
||||
searchPlaceholder: string;
|
||||
clearBtn: string;
|
||||
exportBtn: string;
|
||||
exportJSON: string;
|
||||
exportText: string;
|
||||
newAnalysisBtn: string;
|
||||
statusAnalyzing: string;
|
||||
statusComplete: string;
|
||||
statusError: string;
|
||||
emptyTitle: string;
|
||||
emptyDesc: string;
|
||||
retrievingMsg: string;
|
||||
defaultRegulation: string;
|
||||
matchSuffix: string;
|
||||
colParagraph: string;
|
||||
extractingMsg: string;
|
||||
noTextExtracted: string;
|
||||
stagesHeader: string;
|
||||
stageExtraction: string;
|
||||
stageClauseSplit: string;
|
||||
stageRetrieval: string;
|
||||
stageSynthesis: string;
|
||||
gapInProgress: string;
|
||||
askAIBtn: string;
|
||||
chatBtn: string;
|
||||
conclusionHeader: string;
|
||||
riskScoreTooltip: string;
|
||||
statusCovered: string;
|
||||
statusGap: string;
|
||||
statusCritical: string;
|
||||
statusInfo: string;
|
||||
sourceTypePasted: string;
|
||||
sourceTypeIndexed: string;
|
||||
sourceTypeUploaded: string;
|
||||
chatSidebarHeader: string;
|
||||
chatThinking: string;
|
||||
quickQ1: string;
|
||||
quickQ2: string;
|
||||
quickQ3: string;
|
||||
chatPlaceholder: string;
|
||||
sendBtn: string;
|
||||
analysisFailed: string;
|
||||
exportReportHeader: string;
|
||||
exportSectionParagraph: string;
|
||||
exportSectionFindings: string;
|
||||
exportSectionConclusion: string;
|
||||
exportSectionActions: string;
|
||||
historyHeader: string;
|
||||
downloadReport: string;
|
||||
historyEmpty: string;
|
||||
historyDeleteConfirm: string;
|
||||
drawerClose: string;
|
||||
drawerChatEmpty: string;
|
||||
drawerSuggestionsHeader: string;
|
||||
};
|
||||
ragchat: {
|
||||
topbarTitle: string;
|
||||
exportBtn: string;
|
||||
quickPromptsHeader: string;
|
||||
inputPlaceholder: string;
|
||||
citationsHeader: string;
|
||||
citationsEmpty: string;
|
||||
apiError: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const en: Translations = {
|
||||
nav: {
|
||||
groupMain: 'Main',
|
||||
groupWorkbench: 'Workbench',
|
||||
groupChat: 'Chat',
|
||||
overview: 'Overview',
|
||||
signals: 'Regulatory Signals',
|
||||
status: 'System Status',
|
||||
documents: 'Documents',
|
||||
compliance: 'Compliance Analysis',
|
||||
chat: 'Regulation Q&A',
|
||||
},
|
||||
sidebar: {
|
||||
toggleTheme: 'Toggle theme',
|
||||
toggleLang: 'Switch language',
|
||||
signOut: 'Sign out',
|
||||
},
|
||||
overview: {
|
||||
eyebrow: 'T-Systems · AI Regulation Hub',
|
||||
heroTitle: 'AI Compliance,\nAutomated end-to-end',
|
||||
heroDesc: 'Monitor global AI regulations, analyze document compliance gaps, and get cited answers — all in one platform.',
|
||||
openDashboard: 'Open dashboard',
|
||||
jumpToChat: 'Jump to regulation chat',
|
||||
sectionHowItWorks: 'How it works',
|
||||
sectionScreens: 'Screens',
|
||||
statScreens: 'Screens',
|
||||
statFlows: 'Backend-aware flows',
|
||||
statReviewPosture: 'Review posture',
|
||||
stepUpload: 'Upload', stepUploadDesc: 'Ingest regulation documents',
|
||||
stepProcess: 'Process', stepProcessDesc: 'Embed and chunk via vector DB',
|
||||
stepMonitor: 'Monitor', stepMonitorDesc: 'Watch regulatory signal feed',
|
||||
stepAnalyze: 'Analyze', stepAnalyzeDesc: 'Run compliance gap analysis',
|
||||
stepReview: 'Review', stepReviewDesc: 'Inspect findings with AI assist',
|
||||
stepChat: 'Chat', stepChatDesc: 'Ask questions with cited answers',
|
||||
screenStatus: 'System Status', screenStatusDesc: 'Live health and workflow queue',
|
||||
screenSignals: 'Regulatory Signals', screenSignalsDesc: 'AI-detected regulatory changes',
|
||||
screenDocuments: 'Document Management', screenDocumentsDesc: 'Upload and inspect documents',
|
||||
screenCompliance: 'Compliance Analysis', screenComplianceDesc: 'Three-column compliance workspace',
|
||||
screenChat: 'Regulation Q&A', screenChatDesc: 'Chat with cited regulation sources',
|
||||
screenAnalytics: 'Analytics', screenAnalyticsDesc: 'KPIs and coverage metrics',
|
||||
},
|
||||
signals: {
|
||||
topbarTitle: 'Regulatory Signals',
|
||||
topbarSub: 'ai-powered · live feed',
|
||||
searchPlaceholder: 'Search signals...',
|
||||
refreshBtn: 'Refresh Sources',
|
||||
crawlingBtn: 'Crawling...',
|
||||
statTotal: 'Total signals',
|
||||
statHigh: 'High impact',
|
||||
statMedium: 'Medium impact',
|
||||
statLast90: 'Last 90 days',
|
||||
badgeFinal: 'Final',
|
||||
badgeDraft: 'Draft',
|
||||
badgeUrgent: 'Urgent',
|
||||
badgePublished: 'Published',
|
||||
emptySelectSignal: 'Select a signal to run impact analysis',
|
||||
runAnalysis: 'Run impact analysis',
|
||||
stopBtn: 'Stop',
|
||||
sourceLink: 'Source',
|
||||
tabOverview: 'Overview',
|
||||
tabObligations: 'Obligations',
|
||||
tabImpact: 'Impact Assessment',
|
||||
tabChanges: 'Change Comparison',
|
||||
cardScopeHeader: 'Scope & Summary',
|
||||
cardObligationsHeader: 'Obligations',
|
||||
obligationsEmpty: 'No structured data yet. Click "Run impact analysis" to extract.',
|
||||
colObligationDesc: 'Obligation',
|
||||
colSubject: 'Subject',
|
||||
colType: 'Type',
|
||||
colDeadline: 'Deadlines',
|
||||
deadlinePending: 'Pending',
|
||||
cardAffectedDocs: 'Affected documents',
|
||||
noAffectedDocs: 'No affected documents found.',
|
||||
cardAIImpact: 'AI Impact Analysis',
|
||||
footerText: 'Live feed · Regulation Hub',
|
||||
statusConnecting: 'Connecting to data sources...',
|
||||
statusNoStream: 'No stream',
|
||||
statusCrawling: 'Crawling...',
|
||||
statusProcessing: 'Processing {count} items...',
|
||||
statusComplete: 'Done +{count} items',
|
||||
statusUpdateComplete: 'Update complete — {new} added, {updated} updated',
|
||||
statusError: 'Error: {message}',
|
||||
statusConnFailed: 'Connection failed: {message}',
|
||||
diffOld: 'Previous',
|
||||
diffNew: 'Current',
|
||||
diffCardHeader: 'Change Comparison',
|
||||
},
|
||||
status: {
|
||||
topbarTitle: 'System Status',
|
||||
searchPlaceholder: 'Search...',
|
||||
exportBtn: 'Export',
|
||||
refreshBtn: 'Refresh',
|
||||
newUploadBtn: 'New upload',
|
||||
statTotal: 'Documents total',
|
||||
statIndexed: 'Indexed',
|
||||
statFailed: 'Failed',
|
||||
statChunks: 'Vector chunks',
|
||||
statCoverage: 'Index coverage',
|
||||
cardHealth: 'System health',
|
||||
badgeOnline: 'Online',
|
||||
badgeError: 'Error',
|
||||
badgeDegraded: 'Degraded',
|
||||
badgeUnknown: 'Unknown',
|
||||
healthEndpointError: 'Could not reach health endpoint',
|
||||
serviceEnabled: 'Enabled',
|
||||
serviceDisabled: 'Disabled',
|
||||
serviceNotLoaded: 'Not loaded',
|
||||
cardConfig: 'System configuration',
|
||||
labelLLMProvider: 'LLM provider',
|
||||
labelLLMModel: 'LLM model',
|
||||
labelEmbeddingModel: 'Embedding model',
|
||||
labelEmbeddingDim: 'Embedding dim',
|
||||
labelMilvusCollection: 'Milvus collection',
|
||||
labelParserBackend: 'Parser backend',
|
||||
labelChunkBackend: 'Chunk backend',
|
||||
labelParserFailureMode: 'Parser failure mode',
|
||||
configLoadError: 'Could not load config',
|
||||
cardBreakdown: 'Document breakdown',
|
||||
breakdownIndexed: 'Indexed',
|
||||
breakdownProcessing: 'Processing / Parsed',
|
||||
breakdownFailed: 'Failed',
|
||||
cardRuntime: 'Runtime info',
|
||||
labelActiveSessions: 'Active chat sessions',
|
||||
labelSessionCapacity: 'Session capacity',
|
||||
labelReranker: 'Cross-encoder reranker',
|
||||
labelBM25: 'BM25 hybrid retrieval',
|
||||
statusActive: 'Active',
|
||||
statusUnavailable: 'Unavailable',
|
||||
footerAllOk: 'All systems operational',
|
||||
footerDegraded: 'Degraded',
|
||||
footerChecking: 'Checking…',
|
||||
totalChunks: 'Total vector chunks',
|
||||
},
|
||||
docs: {
|
||||
topbarTitle: 'Document Management',
|
||||
searchPlaceholder: 'Search documents...',
|
||||
refreshBtn: 'Refresh',
|
||||
uploadBtn: 'Upload document',
|
||||
confirmDeleteTitle: 'Confirm deletion',
|
||||
cancelBtn: 'Cancel',
|
||||
deleteBtn: 'Delete',
|
||||
filterAll: 'All',
|
||||
filterReady: 'Ready',
|
||||
filterProcessing: 'Processing',
|
||||
filterFailed: 'Failed',
|
||||
filterPending: 'Pending',
|
||||
filterAllTypes: 'All types',
|
||||
deleteSelected: 'Delete selected',
|
||||
colName: 'Document name',
|
||||
colStatus: 'Status',
|
||||
colUploaded: 'Uploaded',
|
||||
colChunks: 'Chunks',
|
||||
colSize: 'Size',
|
||||
colType: 'Type',
|
||||
colActions: 'Actions',
|
||||
loading: 'Loading documents…',
|
||||
emptyNoDocuments: 'No documents yet. Upload a document to get started.',
|
||||
emptyNoMatch: 'No documents match the current filters.',
|
||||
titleDownload: 'Download original file',
|
||||
titleRetry: 'Retry processing',
|
||||
titleDelete: 'Delete document',
|
||||
},
|
||||
compliance: {
|
||||
topbarTitle: 'Compliance Analysis',
|
||||
searchPlaceholder: 'Search analyses...',
|
||||
clearBtn: 'Clear',
|
||||
exportBtn: 'Export',
|
||||
exportJSON: 'Export JSON',
|
||||
exportText: 'Export Text',
|
||||
newAnalysisBtn: 'New analysis',
|
||||
statusAnalyzing: 'Analyzing…',
|
||||
statusComplete: 'Analysis complete',
|
||||
statusError: 'Error',
|
||||
emptyTitle: 'No analysis running',
|
||||
emptyDesc: 'Click New analysis to start a compliance gap review against your indexed regulations.',
|
||||
retrievingMsg: 'Retrieving relevant regulations…',
|
||||
defaultRegulation: 'Regulation',
|
||||
matchSuffix: '% match',
|
||||
colParagraph: 'Paragraph Under Review',
|
||||
extractingMsg: 'Extracting and analyzing text…',
|
||||
noTextExtracted: 'No text extracted',
|
||||
stagesHeader: 'Analysis stages',
|
||||
stageExtraction: 'Text extraction',
|
||||
stageClauseSplit: 'Clause splitting',
|
||||
stageRetrieval: 'Regulation retrieval',
|
||||
stageSynthesis: 'Conclusion synthesis',
|
||||
gapInProgress: 'Gap analysis in progress…',
|
||||
askAIBtn: 'Ask AI',
|
||||
chatBtn: 'Chat',
|
||||
conclusionHeader: 'Conclusion',
|
||||
riskScoreTooltip: 'Risk score (0=safe, 100=critical)',
|
||||
statusCovered: 'Covered',
|
||||
statusGap: 'Gap',
|
||||
statusCritical: 'Critical',
|
||||
statusInfo: 'Info',
|
||||
sourceTypePasted: 'Pasted Text',
|
||||
sourceTypeIndexed: 'Indexed Document',
|
||||
sourceTypeUploaded: 'Uploaded File',
|
||||
chatSidebarHeader: 'AI Compliance Q&A',
|
||||
chatThinking: 'Thinking▋',
|
||||
quickQ1: 'What regulation applies?',
|
||||
quickQ2: 'How to remediate?',
|
||||
quickQ3: 'What is the risk?',
|
||||
chatPlaceholder: 'Ask about this finding…',
|
||||
sendBtn: 'Send',
|
||||
analysisFailed: 'Analysis failed',
|
||||
exportReportHeader: 'COMPLIANCE ANALYSIS REPORT',
|
||||
exportSectionParagraph: '── PARAGRAPH UNDER REVIEW ──',
|
||||
exportSectionFindings: '── FINDINGS ──',
|
||||
exportSectionConclusion: '── CONCLUSION ──',
|
||||
exportSectionActions: '── RECOMMENDED ACTIONS ──',
|
||||
historyHeader: 'History',
|
||||
downloadReport: 'Download report',
|
||||
historyEmpty: 'No analyses yet.',
|
||||
historyDeleteConfirm: 'Delete this analysis record? This cannot be undone.',
|
||||
drawerClose: 'Close',
|
||||
drawerChatEmpty: 'No messages yet. Ask a question below.',
|
||||
drawerSuggestionsHeader: 'Suggested questions',
|
||||
},
|
||||
ragchat: {
|
||||
topbarTitle: 'Regulation Q&A',
|
||||
exportBtn: 'Export chat',
|
||||
quickPromptsHeader: 'Quick prompts',
|
||||
inputPlaceholder: 'Ask about your regulations…',
|
||||
citationsHeader: 'Sources',
|
||||
citationsEmpty: 'Citations will appear here after a response is generated.',
|
||||
apiError: 'Could not reach the RAG API. Please check the backend.',
|
||||
},
|
||||
};
|
||||
231
frontend/src/locales/zh.ts
Normal file
231
frontend/src/locales/zh.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type { Translations } from './en';
|
||||
|
||||
export const zh: Translations = {
|
||||
nav: {
|
||||
groupMain: '主菜单',
|
||||
groupWorkbench: '工作台',
|
||||
groupChat: '对话',
|
||||
overview: '概览',
|
||||
signals: '法规信号',
|
||||
status: '系统状态',
|
||||
documents: '文档管理',
|
||||
compliance: '合规分析',
|
||||
chat: '法规问答',
|
||||
},
|
||||
sidebar: {
|
||||
toggleTheme: '切换主题',
|
||||
toggleLang: '切换语言',
|
||||
signOut: '退出',
|
||||
},
|
||||
overview: {
|
||||
eyebrow: 'T-Systems · AI 法规中心',
|
||||
heroTitle: 'AI 合规,\n端到端自动化',
|
||||
heroDesc: '监控全球 AI 法规,分析文档合规差距,获取有引用来源的回答——一站式平台。',
|
||||
openDashboard: '打开仪表盘',
|
||||
jumpToChat: '跳转到法规对话',
|
||||
sectionHowItWorks: '工作流程',
|
||||
sectionScreens: '功能页面',
|
||||
statScreens: '功能页面',
|
||||
statFlows: '后端感知流程',
|
||||
statReviewPosture: '审查状态',
|
||||
stepUpload: '上传', stepUploadDesc: '导入法规文档',
|
||||
stepProcess: '处理', stepProcessDesc: '向量化与分块',
|
||||
stepMonitor: '监控', stepMonitorDesc: '监控法规信号流',
|
||||
stepAnalyze: '分析', stepAnalyzeDesc: '运行合规差距分析',
|
||||
stepReview: '审查', stepReviewDesc: 'AI 辅助审查发现',
|
||||
stepChat: '对话', stepChatDesc: '带引用来源的问答',
|
||||
screenStatus: '系统状态', screenStatusDesc: '实时健康与任务队列',
|
||||
screenSignals: '法规信号', screenSignalsDesc: 'AI 检测法规变更',
|
||||
screenDocuments: '文档管理', screenDocumentsDesc: '上传与查阅文档',
|
||||
screenCompliance: '合规分析', screenComplianceDesc: '三栏合规工作台',
|
||||
screenChat: '法规问答', screenChatDesc: '带引用来源的法规对话',
|
||||
screenAnalytics: '数据分析', screenAnalyticsDesc: 'KPI 与覆盖指标',
|
||||
},
|
||||
signals: {
|
||||
topbarTitle: '法规信号',
|
||||
topbarSub: 'AI 驱动 · 实时订阅',
|
||||
searchPlaceholder: '搜索信号...',
|
||||
refreshBtn: '刷新数据源',
|
||||
crawlingBtn: '抓取中...',
|
||||
statTotal: '信号总数',
|
||||
statHigh: '高影响',
|
||||
statMedium: '中影响',
|
||||
statLast90: '近 90 天',
|
||||
badgeFinal: '已发布',
|
||||
badgeDraft: '草案',
|
||||
badgeUrgent: '紧急',
|
||||
badgePublished: '已发布',
|
||||
emptySelectSignal: '选择信号以运行影响分析',
|
||||
runAnalysis: '运行影响分析',
|
||||
stopBtn: '停止',
|
||||
sourceLink: '来源',
|
||||
tabOverview: '概览',
|
||||
tabObligations: '义务条款',
|
||||
tabImpact: '影响评估',
|
||||
tabChanges: '变更对比',
|
||||
cardScopeHeader: '范围与摘要',
|
||||
cardObligationsHeader: '义务条款',
|
||||
obligationsEmpty: '暂无结构化数据。点击"运行影响分析"触发提取。',
|
||||
colObligationDesc: '义务描述',
|
||||
colSubject: '主体',
|
||||
colType: '类型',
|
||||
colDeadline: '截止日期',
|
||||
deadlinePending: '待定',
|
||||
cardAffectedDocs: '受影响文档',
|
||||
noAffectedDocs: '未找到受影响文档。',
|
||||
cardAIImpact: 'AI 影响分析',
|
||||
footerText: '实时订阅 · 法规中心',
|
||||
statusConnecting: '正在连接数据源...',
|
||||
statusNoStream: '无数据流',
|
||||
statusCrawling: '抓取中...',
|
||||
statusProcessing: '处理 {count} 条...',
|
||||
statusComplete: '完成 +{count} 条',
|
||||
statusUpdateComplete: '更新完成 — 新增 {new} 条,更新 {updated} 条',
|
||||
statusError: '错误: {message}',
|
||||
statusConnFailed: '连接失败: {message}',
|
||||
diffOld: '旧版',
|
||||
diffNew: '新版',
|
||||
diffCardHeader: '变更对比',
|
||||
},
|
||||
status: {
|
||||
topbarTitle: '系统状态',
|
||||
searchPlaceholder: '搜索...',
|
||||
exportBtn: '导出',
|
||||
refreshBtn: '刷新',
|
||||
newUploadBtn: '上传文档',
|
||||
statTotal: '文档总数',
|
||||
statIndexed: '已索引',
|
||||
statFailed: '失败',
|
||||
statChunks: '向量分块数',
|
||||
statCoverage: '索引覆盖率',
|
||||
cardHealth: '系统健康',
|
||||
badgeOnline: '在线',
|
||||
badgeError: '错误',
|
||||
badgeDegraded: '降级',
|
||||
badgeUnknown: '未知',
|
||||
healthEndpointError: '无法访问健康检查端点',
|
||||
serviceEnabled: '已启用',
|
||||
serviceDisabled: '已禁用',
|
||||
serviceNotLoaded: '未加载',
|
||||
cardConfig: '系统配置',
|
||||
labelLLMProvider: 'LLM 提供商',
|
||||
labelLLMModel: 'LLM 模型',
|
||||
labelEmbeddingModel: '向量模型',
|
||||
labelEmbeddingDim: '向量维度',
|
||||
labelMilvusCollection: 'Milvus 集合',
|
||||
labelParserBackend: '解析后端',
|
||||
labelChunkBackend: '分块后端',
|
||||
labelParserFailureMode: '解析失败模式',
|
||||
configLoadError: '无法加载配置',
|
||||
cardBreakdown: '文档分布',
|
||||
breakdownIndexed: '已索引',
|
||||
breakdownProcessing: '处理中 / 已解析',
|
||||
breakdownFailed: '失败',
|
||||
cardRuntime: '运行时信息',
|
||||
labelActiveSessions: '活跃对话会话',
|
||||
labelSessionCapacity: '会话容量',
|
||||
labelReranker: '交叉编码器重排序',
|
||||
labelBM25: 'BM25 混合检索',
|
||||
statusActive: '活跃',
|
||||
statusUnavailable: '不可用',
|
||||
footerAllOk: '所有系统正常',
|
||||
footerDegraded: '降级运行',
|
||||
footerChecking: '检查中…',
|
||||
totalChunks: '向量分块总数',
|
||||
},
|
||||
docs: {
|
||||
topbarTitle: '文档管理',
|
||||
searchPlaceholder: '搜索文档...',
|
||||
refreshBtn: '刷新',
|
||||
uploadBtn: '上传文档',
|
||||
confirmDeleteTitle: '确认删除',
|
||||
cancelBtn: '取消',
|
||||
deleteBtn: '删除',
|
||||
filterAll: '全部',
|
||||
filterReady: '就绪',
|
||||
filterProcessing: '处理中',
|
||||
filterFailed: '失败',
|
||||
filterPending: '待处理',
|
||||
filterAllTypes: '所有类型',
|
||||
deleteSelected: '删除所选',
|
||||
colName: '文档名称',
|
||||
colStatus: '状态',
|
||||
colUploaded: '上传时间',
|
||||
colChunks: '分块数',
|
||||
colSize: '大小',
|
||||
colType: '类型',
|
||||
colActions: '操作',
|
||||
loading: '加载文档中…',
|
||||
emptyNoDocuments: '暂无文档。请上传文档以开始使用。',
|
||||
emptyNoMatch: '没有文档符合当前筛选条件。',
|
||||
titleDownload: '下载原始文件',
|
||||
titleRetry: '重试处理',
|
||||
titleDelete: '删除文档',
|
||||
},
|
||||
compliance: {
|
||||
topbarTitle: '合规分析',
|
||||
searchPlaceholder: '搜索分析记录...',
|
||||
clearBtn: '清除',
|
||||
exportBtn: '导出',
|
||||
exportJSON: '导出 JSON',
|
||||
exportText: '导出文本',
|
||||
newAnalysisBtn: '新建分析',
|
||||
statusAnalyzing: '分析中…',
|
||||
statusComplete: '分析完成',
|
||||
statusError: '错误',
|
||||
emptyTitle: '暂无分析任务',
|
||||
emptyDesc: '点击"新建分析"对已索引法规进行合规差距审查。',
|
||||
retrievingMsg: '正在检索相关法规…',
|
||||
defaultRegulation: '法规',
|
||||
matchSuffix: '% 匹配',
|
||||
colParagraph: '待审查段落',
|
||||
extractingMsg: '正在提取并分析文本…',
|
||||
noTextExtracted: '未提取到文本',
|
||||
stagesHeader: '分析阶段',
|
||||
stageExtraction: '文本提取',
|
||||
stageClauseSplit: '条款分割',
|
||||
stageRetrieval: '法规检索',
|
||||
stageSynthesis: '结论综合',
|
||||
gapInProgress: '差距分析进行中…',
|
||||
askAIBtn: '问 AI',
|
||||
chatBtn: '对话',
|
||||
conclusionHeader: '结论',
|
||||
riskScoreTooltip: '风险评分(0=安全,100=严重)',
|
||||
statusCovered: '已覆盖',
|
||||
statusGap: '存在差距',
|
||||
statusCritical: '严重',
|
||||
statusInfo: '信息',
|
||||
sourceTypePasted: '粘贴文本',
|
||||
sourceTypeIndexed: '已索引文档',
|
||||
sourceTypeUploaded: '上传文件',
|
||||
chatSidebarHeader: 'AI 合规问答',
|
||||
chatThinking: '思考中▋',
|
||||
quickQ1: '适用哪条法规?',
|
||||
quickQ2: '如何整改?',
|
||||
quickQ3: '风险等级如何?',
|
||||
chatPlaceholder: '针对此发现提问…',
|
||||
sendBtn: '发送',
|
||||
analysisFailed: '分析失败',
|
||||
exportReportHeader: '合规分析报告',
|
||||
exportSectionParagraph: '── 待审查段落 ──',
|
||||
exportSectionFindings: '── 发现 ──',
|
||||
exportSectionConclusion: '── 结论 ──',
|
||||
exportSectionActions: '── 建议行动 ──',
|
||||
historyHeader: '历史记录',
|
||||
downloadReport: '下载报告',
|
||||
historyEmpty: '暂无分析记录。',
|
||||
historyDeleteConfirm: '删除此分析记录?此操作不可撤销。',
|
||||
drawerClose: '关闭',
|
||||
drawerChatEmpty: '暂无消息。请在下方提问。',
|
||||
drawerSuggestionsHeader: '建议问题',
|
||||
},
|
||||
ragchat: {
|
||||
topbarTitle: '法规问答',
|
||||
exportBtn: '导出对话',
|
||||
quickPromptsHeader: '快捷问题',
|
||||
inputPlaceholder: '请输入关于法规的问题…',
|
||||
citationsHeader: '引用来源',
|
||||
citationsEmpty: '生成回答后,引用来源将显示在此处。',
|
||||
apiError: '无法连接到 RAG API,请检查后端服务。',
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { Search, Plus, AlertTriangle, Download, MessageSquare, ChevronDown } from 'lucide-react';
|
||||
import { Topbar } from '../../components/layout/Topbar';
|
||||
import { NewAnalysisModal } from './NewAnalysisModal';
|
||||
import { useComplianceAnalysis } from './useComplianceAnalysis';
|
||||
import { usePageState } from '../../contexts';
|
||||
import { HistoryRail } from './HistoryRail';
|
||||
import { FindingChatDrawer } from './FindingChatDrawer';
|
||||
import type { FindingEvent, SourceEvent, AnalysisMeta } from './useComplianceAnalysis';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
@@ -11,9 +15,6 @@ function authHeader(): Record<string, string> {
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = { ok: 'Covered', warn: 'Gap', risk: 'Critical', info: 'Info' };
|
||||
const SOURCE_TYPE_LABEL: Record<string, string> = { text: 'Pasted Text', doc: 'Indexed Document', upload: 'Uploaded File' };
|
||||
|
||||
function riskClass(score: number) {
|
||||
if (score >= 70) return 'high';
|
||||
if (score >= 40) return 'med';
|
||||
@@ -112,11 +113,93 @@ function useFindingChat() {
|
||||
return { open, findingIdx, messages, input, setInput, loading, openFor, close, send };
|
||||
}
|
||||
|
||||
function _FindingChatDrawerWrapper({
|
||||
analysisId,
|
||||
findingIndex,
|
||||
finding,
|
||||
onClose,
|
||||
}: {
|
||||
analysisId: string;
|
||||
findingIndex: number;
|
||||
finding: { title: string; desc: string; status: string; clause_ref?: string };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [findingId, setFindingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/v1/compliance/history/${analysisId}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}` },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then((data: { findings?: Array<{ seq: number; id: string }> }) => {
|
||||
const f = (data.findings ?? []).find(f => f.seq === findingIndex);
|
||||
if (f?.id) setFindingId(f.id);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [analysisId, findingIndex]);
|
||||
|
||||
if (!findingId) return null;
|
||||
return (
|
||||
<FindingChatDrawer
|
||||
analysisId={analysisId}
|
||||
findingId={findingId}
|
||||
finding={finding}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompliancePage() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||
const { state, run, reset } = useComplianceAnalysis();
|
||||
const chat = useFindingChat();
|
||||
const [drawerFindingIdx, setDrawerFindingIdx] = useState<number | null>(null);
|
||||
|
||||
const { setComplianceState } = usePageState();
|
||||
const { t } = useLanguage();
|
||||
const STATUS_LABEL: Record<string, string> = { ok: t.compliance.statusCovered, warn: t.compliance.statusGap, risk: t.compliance.statusCritical, info: t.compliance.statusInfo };
|
||||
const SOURCE_TYPE_LABEL: Record<string, string> = { text: t.compliance.sourceTypePasted, doc: t.compliance.sourceTypeIndexed, upload: t.compliance.sourceTypeUploaded };
|
||||
|
||||
const [historyRefresh, setHistoryRefresh] = useState(0);
|
||||
const prevAnalysisIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.analysisId && state.analysisId !== prevAnalysisIdRef.current) {
|
||||
prevAnalysisIdRef.current = state.analysisId;
|
||||
setHistoryRefresh(n => n + 1);
|
||||
}
|
||||
}, [state.analysisId]);
|
||||
|
||||
async function handleSelectHistory(id: string) {
|
||||
const res = await fetch(`/api/v1/compliance/history/${id}`, { headers: authHeader() });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setComplianceState({
|
||||
status: 'done',
|
||||
stageLabel: 'Complete',
|
||||
stageKey: 'concluding',
|
||||
meta: { title: data.doc_name, sourceType: 'doc', startedAt: data.created_at },
|
||||
sources: [],
|
||||
findings: (data.findings || []).map((f: Record<string, unknown>) => ({
|
||||
title: String(f.title ?? ''),
|
||||
desc: String(f.description ?? ''),
|
||||
status: String(f.status ?? 'ok'),
|
||||
clause_ref: f.clause_ref ? String(f.clause_ref) : undefined,
|
||||
})),
|
||||
done: {
|
||||
conclusion: String(data.conclusion ?? ''),
|
||||
actions: data.actions ?? [],
|
||||
risk_score: Number(data.risk_score ?? 0),
|
||||
highlight_terms: data.highlight_terms ?? [],
|
||||
para_text: String(data.para_text ?? ''),
|
||||
},
|
||||
errorText: '',
|
||||
analysisId: data.id,
|
||||
isReadOnly: true,
|
||||
activeFindingId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const isIdle = state.status === 'idle';
|
||||
const isStreaming = state.status === 'streaming';
|
||||
@@ -147,24 +230,24 @@ export function CompliancePage() {
|
||||
|
||||
function exportText() {
|
||||
const lines: string[] = [
|
||||
`COMPLIANCE ANALYSIS REPORT`,
|
||||
t.compliance.exportReportHeader,
|
||||
`Title: ${state.meta?.title ?? 'Untitled'}`,
|
||||
`Date: ${state.meta?.startedAt ? formatTs(state.meta.startedAt) : ''}`,
|
||||
`Source: ${SOURCE_TYPE_LABEL[state.meta?.sourceType ?? 'text']}`,
|
||||
`Risk Score: ${state.done?.risk_score ?? 'N/A'} / 100`,
|
||||
'',
|
||||
'── PARAGRAPH UNDER REVIEW ──',
|
||||
t.compliance.exportSectionParagraph,
|
||||
state.done?.para_text ?? '',
|
||||
'',
|
||||
'── FINDINGS ──',
|
||||
t.compliance.exportSectionFindings,
|
||||
...state.findings.map((f, i) =>
|
||||
`[${i + 1}] [${f.status.toUpperCase()}] ${f.title}\n ${f.desc}${f.clause_ref ? `\n Ref: ${f.clause_ref}` : ''}`
|
||||
),
|
||||
'',
|
||||
'── CONCLUSION ──',
|
||||
t.compliance.exportSectionConclusion,
|
||||
state.done?.conclusion ?? '',
|
||||
'',
|
||||
'── RECOMMENDED ACTIONS ──',
|
||||
t.compliance.exportSectionActions,
|
||||
...(state.done?.actions ?? []).map(a => `• ${a.label}: ${a.value}`),
|
||||
];
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
|
||||
@@ -184,15 +267,15 @@ export function CompliancePage() {
|
||||
return (
|
||||
<div className="compliance-page" style={{ position: 'relative' }}>
|
||||
<Topbar
|
||||
title="Compliance Analysis"
|
||||
title={t.compliance.topbarTitle}
|
||||
actions={
|
||||
<>
|
||||
<div className="search-box">
|
||||
<Search size={13} />
|
||||
<input placeholder="Search analyses..." />
|
||||
<input placeholder={t.compliance.searchPlaceholder} />
|
||||
</div>
|
||||
{isStreaming || isDone || isError ? (
|
||||
<button className="btn sm" onClick={reset}>Clear</button>
|
||||
<button className="btn sm" onClick={reset}>{t.compliance.clearBtn}</button>
|
||||
) : null}
|
||||
{isDone && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -200,7 +283,7 @@ export function CompliancePage() {
|
||||
className="btn sm"
|
||||
onClick={() => setShowExportMenu(v => !v)}
|
||||
>
|
||||
<Download size={13} />Export<ChevronDown size={11} />
|
||||
<Download size={13} />{t.compliance.exportBtn}<ChevronDown size={11} />
|
||||
</button>
|
||||
{showExportMenu && (
|
||||
<div style={{
|
||||
@@ -212,17 +295,17 @@ export function CompliancePage() {
|
||||
<button onClick={exportJSON} style={{ display: 'block', width: '100%', padding: '9px 14px', textAlign: 'left', fontSize: 13, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--fg)' }}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
|
||||
>Export JSON</button>
|
||||
>{t.compliance.exportJSON}</button>
|
||||
<button onClick={exportText} style={{ display: 'block', width: '100%', padding: '9px 14px', textAlign: 'left', fontSize: 13, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--fg)' }}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
|
||||
>Export Text</button>
|
||||
>{t.compliance.exportText}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button className="btn sm primary" onClick={() => setShowModal(true)}>
|
||||
<Plus size={13} />New analysis
|
||||
<Plus size={13} />{t.compliance.newAnalysisBtn}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
@@ -241,7 +324,7 @@ export function CompliancePage() {
|
||||
<div className={`compliance-status-bar ${state.status}`}>
|
||||
<div className="status-dot" />
|
||||
<span className="status-bar-label">
|
||||
{isStreaming ? 'Analyzing…' : isDone ? 'Analysis complete' : 'Error'}
|
||||
{isStreaming ? t.compliance.statusAnalyzing : isDone ? t.compliance.statusComplete : t.compliance.statusError}
|
||||
</span>
|
||||
<span className="status-bar-sub">{state.stageLabel}</span>
|
||||
</div>
|
||||
@@ -252,8 +335,8 @@ export function CompliancePage() {
|
||||
{isIdle && (
|
||||
<div className="analysis-empty">
|
||||
<div className="analysis-empty-icon"><Plus size={24} /></div>
|
||||
<h3>No analysis running</h3>
|
||||
<p>Click <strong>New analysis</strong> to start a compliance gap review against your indexed regulations.</p>
|
||||
<h3>{t.compliance.emptyTitle}</h3>
|
||||
<p>{t.compliance.emptyDesc}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -285,22 +368,29 @@ export function CompliancePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="compliance-workspace" style={{ position: 'relative' }}>
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
<HistoryRail
|
||||
refreshTrigger={historyRefresh}
|
||||
onSelect={handleSelectHistory}
|
||||
selectedId={state.analysisId}
|
||||
/>
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="compliance-workspace" style={{ position: 'relative' }}>
|
||||
|
||||
{/* Column 1: Retrieved Regulations */}
|
||||
<div className="comp-col source-col">
|
||||
<div className="col-header">
|
||||
Retrieved Regulations {state.sources.length > 0 && `(${state.sources.length})`}
|
||||
{t.compliance.stageRetrieval} {state.sources.length > 0 && `(${state.sources.length})`}
|
||||
</div>
|
||||
{state.sources.length === 0 && isStreaming && (
|
||||
<div style={{ padding: '20px 16px', color: 'var(--muted)', fontSize: 12 }}>
|
||||
Retrieving relevant regulations…
|
||||
{t.compliance.retrievingMsg}
|
||||
</div>
|
||||
)}
|
||||
{state.sources.map((s: SourceEvent, i: number) => (
|
||||
<div key={i} className="source-item card">
|
||||
<div className="source-top">
|
||||
<span className="source-std">{s.standard || 'Regulation'}</span>
|
||||
<span className="source-std">{s.standard || t.compliance.defaultRegulation}</span>
|
||||
<span className={`status ${s.status === 'retrieved' ? 'ok' : s.status}`}>
|
||||
{STATUS_LABEL[s.status] ?? 'Retrieved'}
|
||||
</span>
|
||||
@@ -309,7 +399,7 @@ export function CompliancePage() {
|
||||
{s.score > 0 && (
|
||||
<div className="source-scores">
|
||||
<span className="score-pill">
|
||||
{s.score <= 1 ? Math.round(s.score * 100) : Math.round(s.score)}% match
|
||||
{s.score <= 1 ? Math.round(s.score * 100) : Math.round(s.score)}{t.compliance.matchSuffix}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -324,7 +414,7 @@ export function CompliancePage() {
|
||||
|
||||
{/* Column 2: Paragraph Under Review + Stages */}
|
||||
<div className="comp-col review-col">
|
||||
<div className="col-header">Paragraph Under Review</div>
|
||||
<div className="col-header">{t.compliance.colParagraph}</div>
|
||||
|
||||
<div className="card para-card">
|
||||
{isDone && state.done?.para_text ? (
|
||||
@@ -333,16 +423,16 @@ export function CompliancePage() {
|
||||
</p>
|
||||
) : (
|
||||
<p className="para-text" style={{ color: 'var(--muted)' }}>
|
||||
{isStreaming ? 'Extracting and analyzing text…' : 'No text extracted'}
|
||||
{isStreaming ? t.compliance.extractingMsg : t.compliance.noTextExtracted}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card stages-card">
|
||||
<div className="card-header">Analysis stages</div>
|
||||
<div className="card-header">{t.compliance.stagesHeader}</div>
|
||||
{(() => {
|
||||
const STAGE_KEYS = ['extracting', 'splitting', 'analyzing', 'concluding'];
|
||||
const STAGE_LABELS = ['Text extraction', 'Clause splitting', 'Regulation retrieval', 'Conclusion synthesis'];
|
||||
const STAGE_LABELS = [t.compliance.stageExtraction, t.compliance.stageClauseSplit, t.compliance.stageRetrieval, t.compliance.stageSynthesis];
|
||||
const curIdx = STAGE_KEYS.indexOf(state.stageKey);
|
||||
return STAGE_KEYS.map((key, idx) => {
|
||||
const pct = isDone ? 100 : idx < curIdx ? 100 : idx === curIdx ? 60 : 0;
|
||||
@@ -371,7 +461,7 @@ export function CompliancePage() {
|
||||
|
||||
{state.findings.length === 0 && isStreaming && (
|
||||
<div style={{ padding: '20px 16px', color: 'var(--muted)', fontSize: 12 }}>
|
||||
Gap analysis in progress…
|
||||
{t.compliance.gapInProgress}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -391,8 +481,17 @@ export function CompliancePage() {
|
||||
style={{ marginLeft: 'auto', fontSize: 11, padding: '3px 8px', gap: 4 }}
|
||||
onClick={() => chat.openFor(i, f)}
|
||||
>
|
||||
<MessageSquare size={11} />Ask AI
|
||||
<MessageSquare size={11} />{t.compliance.askAIBtn}
|
||||
</button>
|
||||
{state.analysisId && (
|
||||
<button
|
||||
className="btn sm"
|
||||
onClick={() => setDrawerFindingIdx(i)}
|
||||
style={{ marginTop: 6 }}
|
||||
>
|
||||
💬 {t.compliance.chatBtn}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -401,10 +500,10 @@ export function CompliancePage() {
|
||||
{isDone && state.done && (
|
||||
<div className="card conclusion-box">
|
||||
<div className="card-header" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span>Conclusion</span>
|
||||
<span>{t.compliance.conclusionHeader}</span>
|
||||
<div
|
||||
className={`risk-score-badge ${riskClass(state.done.risk_score)}`}
|
||||
title="Risk score (0=safe, 100=critical)"
|
||||
title={t.compliance.riskScoreTooltip}
|
||||
>
|
||||
{state.done.risk_score}
|
||||
</div>
|
||||
@@ -431,12 +530,14 @@ export function CompliancePage() {
|
||||
{isError && (
|
||||
<div className="card" style={{ borderColor: 'var(--danger)', padding: '14px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--danger)', fontSize: 13, fontWeight: 600 }}>
|
||||
<AlertTriangle size={14} /> Analysis failed
|
||||
<AlertTriangle size={14} /> {t.compliance.analysisFailed}
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--muted)', marginTop: 6 }}>{state.errorText}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Finding Chat Side Panel ────────────────────────────────── */}
|
||||
@@ -450,7 +551,7 @@ export function CompliancePage() {
|
||||
{/* Header */}
|
||||
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>AI Compliance Q&A</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{t.compliance.chatSidebarHeader}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
|
||||
Finding #{(chat.findingIdx ?? 0) + 1} · {activeFinding?.title}
|
||||
</div>
|
||||
@@ -480,7 +581,7 @@ export function CompliancePage() {
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 8, background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 11, color: '#fff', fontWeight: 700 }}>AI</div>
|
||||
<div style={{ padding: '10px 14px', borderRadius: 10, border: '1px solid var(--border)', background: 'var(--bg)', fontSize: 13, color: 'var(--muted)' }}>
|
||||
Thinking<span className="blink-cursor">▋</span>
|
||||
{t.compliance.chatThinking}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -488,7 +589,7 @@ export function CompliancePage() {
|
||||
|
||||
{/* Quick questions */}
|
||||
<div style={{ padding: '8px 20px', display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{['What regulation applies?', 'How to remediate?', 'What is the risk?'].map(q => (
|
||||
{[t.compliance.quickQ1, t.compliance.quickQ2, t.compliance.quickQ3].map(q => (
|
||||
<button key={q} onClick={() => chat.setInput(q)}
|
||||
style={{ padding: '4px 10px', fontSize: 11, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', color: 'var(--muted)' }}>
|
||||
{q}
|
||||
@@ -502,7 +603,7 @@ export function CompliancePage() {
|
||||
value={chat.input}
|
||||
onChange={e => chat.setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chat.send(chatContext); } }}
|
||||
placeholder="Ask about this finding…"
|
||||
placeholder={t.compliance.chatPlaceholder}
|
||||
style={{ flex: 1, padding: '9px 12px', fontSize: 13, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, color: 'var(--fg)', outline: 'none' }}
|
||||
/>
|
||||
<button
|
||||
@@ -510,10 +611,23 @@ export function CompliancePage() {
|
||||
onClick={() => chat.send(chatContext)}
|
||||
disabled={!chat.input.trim() || chat.loading}
|
||||
style={{ padding: '9px 14px' }}
|
||||
>Send</button>
|
||||
>{t.compliance.sendBtn}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{drawerFindingIdx !== null && state.analysisId && (
|
||||
<_FindingChatDrawerWrapper
|
||||
analysisId={state.analysisId}
|
||||
findingIndex={drawerFindingIdx}
|
||||
finding={{
|
||||
title: state.findings[drawerFindingIdx]?.title ?? '',
|
||||
desc: state.findings[drawerFindingIdx]?.desc ?? '',
|
||||
status: state.findings[drawerFindingIdx]?.status ?? 'ok',
|
||||
clause_ref: state.findings[drawerFindingIdx]?.clause_ref,
|
||||
}}
|
||||
onClose={() => setDrawerFindingIdx(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
237
frontend/src/pages/Compliance/FindingChatDrawer.tsx
Normal file
237
frontend/src/pages/Compliance/FindingChatDrawer.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
// frontend/src/pages/Compliance/FindingChatDrawer.tsx
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X, Send } from 'lucide-react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
const t = localStorage.getItem(TOKEN_KEY);
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface FindingInfo {
|
||||
title: string;
|
||||
desc: string;
|
||||
status: string;
|
||||
clause_ref?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
analysisId: string;
|
||||
findingId: string;
|
||||
finding: FindingInfo;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FindingChatDrawer({ analysisId, findingId, finding, onClose }: Props) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingHistory, setLoadingHistory] = useState(true);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const { t } = useLanguage();
|
||||
|
||||
// Load history + suggestions on open
|
||||
useEffect(() => {
|
||||
setLoadingHistory(true);
|
||||
fetch(`/api/v1/compliance/analyses/${analysisId}/findings/${findingId}/chat`, {
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then((data: Message[]) => {
|
||||
setMessages(Array.isArray(data) ? data.map(m => ({ id: m.id, role: m.role, content: m.content })) : []);
|
||||
setLoadingHistory(false);
|
||||
if (!data.length) {
|
||||
fetch(
|
||||
`/api/v1/compliance/analyses/${analysisId}/findings/${findingId}/suggestions`,
|
||||
{ method: 'POST', headers: authHeader() }
|
||||
)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (Array.isArray(d?.questions)) setSuggestions(d.questions); })
|
||||
.catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => setLoadingHistory(false));
|
||||
|
||||
return () => { abortRef.current?.abort(); };
|
||||
}, [analysisId, findingId]);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
async function send(text?: string) {
|
||||
const q = (text ?? input).trim();
|
||||
if (!q || loading) return;
|
||||
setInput('');
|
||||
setSuggestions([]); // hide chips after first message
|
||||
|
||||
const assistantId = `ast-${Date.now()}`;
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ id: `usr-${Date.now()}`, role: 'user', content: q },
|
||||
{ id: assistantId, role: 'assistant', content: '' },
|
||||
]);
|
||||
setLoading(true);
|
||||
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/compliance/analyses/${analysisId}/findings/${findingId}/chat`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify({ query: q }),
|
||||
signal: ctrl.signal,
|
||||
}
|
||||
);
|
||||
if (!res.body) { setLoading(false); return; }
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
const blocks = buf.split('\n\n');
|
||||
buf = blocks.pop() ?? '';
|
||||
for (const block of blocks) {
|
||||
const dl = block.split('\n').find(l => l.startsWith('data: '));
|
||||
if (!dl) continue;
|
||||
try {
|
||||
const j = JSON.parse(dl.slice(6));
|
||||
if (j.type === 'chunk' && j.text) {
|
||||
setMessages(prev =>
|
||||
prev.map(m => m.id === assistantId ? { ...m, content: m.content + (j.text as string) } : m)
|
||||
);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error && e.name !== 'AbortError') {
|
||||
setMessages(prev =>
|
||||
prev.map(m => m.id === assistantId ? { ...m, content: 'Error reaching server.' } : m)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
risk: 'var(--danger, #dc143c)',
|
||||
warn: 'var(--warning, #ff8c00)',
|
||||
ok: 'var(--success, #228b22)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', right: 0, top: 0, bottom: 0, width: 420,
|
||||
background: 'var(--surface)', borderLeft: '1px solid var(--border)',
|
||||
display: 'flex', flexDirection: 'column', zIndex: 200,
|
||||
boxShadow: '-4px 0 16px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '14px 16px', borderBottom: '1px solid var(--border)',
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, color: STATUS_COLOR[finding.status] ?? 'var(--muted)', fontWeight: 600, marginBottom: 2 }}>
|
||||
{finding.status.toUpperCase()}
|
||||
{finding.clause_ref && (
|
||||
<span style={{ fontWeight: 400, marginLeft: 6, color: 'var(--muted)' }}>
|
||||
{finding.clause_ref}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{finding.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2, lineHeight: 1.4 }}>
|
||||
{finding.desc.length > 100 ? finding.desc.slice(0, 100) + '…' : finding.desc}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn icon-btn" onClick={onClose} style={{ flexShrink: 0 }} title={t.compliance.drawerClose}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Suggestion chips */}
|
||||
{suggestions.length > 0 && (
|
||||
<div style={{ padding: '10px 16px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 6 }}>{t.compliance.drawerSuggestionsHeader}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{suggestions.map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="chip"
|
||||
style={{ textAlign: 'left', whiteSpace: 'normal', height: 'auto', padding: '6px 10px' }}
|
||||
onClick={() => send(q)}
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{loadingHistory && (
|
||||
<p style={{ fontSize: 12, color: 'var(--muted)', textAlign: 'center' }}>Loading history…</p>
|
||||
)}
|
||||
{!loadingHistory && messages.length === 0 && (
|
||||
<p style={{ fontSize: 12, color: 'var(--muted)', textAlign: 'center' }}>{t.compliance.drawerChatEmpty}</p>
|
||||
)}
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} className={`message msg-${msg.role}`} style={{ maxWidth: '100%' }}>
|
||||
{msg.role === 'assistant' && <div className="msg-avatar">AI</div>}
|
||||
<div className="msg-bubble" style={{ fontSize: 13, whiteSpace: 'pre-wrap' }}>
|
||||
{msg.content || (loading ? '…' : '')}
|
||||
</div>
|
||||
{msg.role === 'user' && <div className="msg-avatar user-av">You</div>}
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--border)', display: 'flex', gap: 8 }}>
|
||||
<textarea
|
||||
className="composer-input"
|
||||
placeholder={t.compliance.chatPlaceholder}
|
||||
value={input}
|
||||
rows={2}
|
||||
style={{ flex: 1, fontSize: 13 }}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send(); } }}
|
||||
/>
|
||||
<button
|
||||
className="btn primary"
|
||||
disabled={!input.trim() || loading}
|
||||
onClick={() => void send()}
|
||||
style={{ alignSelf: 'flex-end' }}
|
||||
title={t.compliance.sendBtn}
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
frontend/src/pages/Compliance/HistoryRail.tsx
Normal file
142
frontend/src/pages/Compliance/HistoryRail.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
// frontend/src/pages/Compliance/HistoryRail.tsx
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Download, Trash2 } from 'lucide-react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
const t = localStorage.getItem(TOKEN_KEY);
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
id: string;
|
||||
created_at: string;
|
||||
doc_name: string;
|
||||
standard_name: string;
|
||||
risk_score: number;
|
||||
finding_count: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
refreshTrigger: number;
|
||||
onSelect: (id: string) => void;
|
||||
selectedId: string | null;
|
||||
}
|
||||
|
||||
function riskClass(score: number): string {
|
||||
if (score >= 70) return 'risk-high';
|
||||
if (score >= 40) return 'risk-medium';
|
||||
return 'risk-low';
|
||||
}
|
||||
|
||||
export function HistoryRail({ refreshTrigger, onSelect, selectedId }: Props) {
|
||||
const [items, setItems] = useState<HistoryItem[]>([]);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const { t } = useLanguage();
|
||||
|
||||
const fetchHistory = useCallback(() => {
|
||||
fetch('/api/v1/compliance/history?limit=30', { headers: authHeader() })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (Array.isArray(data)) setItems(data);
|
||||
})
|
||||
.catch(() => {/* backend may not have postgres configured */});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchHistory(); }, [fetchHistory, refreshTrigger]);
|
||||
|
||||
function handleDownload(e: React.MouseEvent, item: HistoryItem) {
|
||||
e.stopPropagation();
|
||||
fetch(`/api/v1/compliance/history/${item.id}/download`, { headers: authHeader() })
|
||||
.then(r => r.blob())
|
||||
.then(blob => {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.download = `compliance-${item.doc_name.slice(0, 30)}.docx`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(e: React.MouseEvent, item: HistoryItem) {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm(t.compliance.historyDeleteConfirm)) return;
|
||||
setDeletingId(item.id);
|
||||
fetch(`/api/v1/compliance/history/${item.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then(() => {
|
||||
setItems(prev => prev.filter(i => i.id !== item.id));
|
||||
setDeletingId(null);
|
||||
})
|
||||
.catch(() => setDeletingId(null));
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="history-pane" style={{ minWidth: 200, maxWidth: 220 }}>
|
||||
<div className="history-header">{t.compliance.historyHeader}</div>
|
||||
<p style={{ padding: '12px 16px', fontSize: 12, color: 'var(--muted)', lineHeight: 1.5 }}>
|
||||
{t.compliance.historyEmpty}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="history-pane" style={{ minWidth: 200, maxWidth: 220, overflowY: 'auto' }}>
|
||||
<div className="history-header">{t.compliance.historyHeader}</div>
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`quick-item${selectedId === item.id ? ' active' : ''}`}
|
||||
onClick={() => onSelect(item.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 2 }}>
|
||||
{formatDate(item.created_at)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 500, marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.doc_name || 'Untitled'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className={`risk-badge ${riskClass(item.risk_score)}`} style={{ fontSize: 10 }}>
|
||||
{item.risk_score}
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)', flex: 1 }}>
|
||||
{item.finding_count} finding{item.finding_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<button
|
||||
className="btn icon-btn"
|
||||
title={t.compliance.downloadReport}
|
||||
onClick={e => handleDownload(e, item)}
|
||||
style={{ padding: '2px 4px' }}
|
||||
>
|
||||
<Download size={11} />
|
||||
</button>
|
||||
<button
|
||||
className="btn icon-btn danger"
|
||||
title="Delete"
|
||||
disabled={deletingId === item.id}
|
||||
onClick={e => handleDelete(e, item)}
|
||||
style={{ padding: '2px 4px' }}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,8 @@ const INITIAL_STATE: ComplianceState = {
|
||||
findings: [],
|
||||
done: null,
|
||||
errorText: '',
|
||||
analysisId: null,
|
||||
isReadOnly: false,
|
||||
};
|
||||
|
||||
export function useComplianceAnalysis() {
|
||||
@@ -116,6 +118,8 @@ export function useComplianceAnalysis() {
|
||||
para_text: j.para_text ?? '',
|
||||
};
|
||||
setState(s => ({ ...s, status: 'done', done: payload, stageKey: 'concluding', stageLabel: 'Complete' }));
|
||||
} else if (j.type === 'saved') {
|
||||
setState(s => ({ ...s, analysisId: j.analysis_id ?? null }));
|
||||
} else if (j.type === 'error') {
|
||||
setState(s => ({ ...s, status: 'error', errorText: j.text ?? 'Unknown error' }));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { Topbar } from '../../components/layout/Topbar';
|
||||
import { Upload, Search, Download, Trash2, RefreshCw, AlertTriangle } from 'lucide-react';
|
||||
import { UploadModal } from './UploadModal';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
@@ -45,6 +46,7 @@ function ConfirmDialog({ message, onConfirm, onCancel }: {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div
|
||||
@@ -53,13 +55,13 @@ function ConfirmDialog({ message, onConfirm, onCancel }: {
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||
<AlertTriangle size={18} color="var(--danger)" />
|
||||
<span style={{ fontWeight: 600, fontSize: 15 }}>Confirm deletion</span>
|
||||
<span style={{ fontWeight: 600, fontSize: 15 }}>{t.docs.confirmDeleteTitle}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.6, marginBottom: 20 }}>{message}</p>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button className="btn sm" onClick={onCancel}>Cancel</button>
|
||||
<button className="btn sm" onClick={onCancel}>{t.docs.cancelBtn}</button>
|
||||
<button className="btn sm" style={{ background: 'var(--danger)', color: '#fff', borderColor: 'var(--danger)' }} onClick={onConfirm}>
|
||||
Delete
|
||||
{t.docs.deleteBtn}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,6 +70,7 @@ function ConfirmDialog({ message, onConfirm, onCancel }: {
|
||||
}
|
||||
|
||||
export function DocsPage() {
|
||||
const { t } = useLanguage();
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusF, setStatusF] = useState('All');
|
||||
const [typeF, setTypeF] = useState('All types');
|
||||
@@ -172,22 +175,22 @@ export function DocsPage() {
|
||||
return (
|
||||
<div className="docs-page">
|
||||
<Topbar
|
||||
title="Document Management"
|
||||
title={t.docs.topbarTitle}
|
||||
actions={
|
||||
<>
|
||||
<div className="search-box">
|
||||
<Search size={13} />
|
||||
<input
|
||||
placeholder="Search documents..."
|
||||
placeholder={t.docs.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
|
||||
<RefreshCw size={13} />Refresh
|
||||
<RefreshCw size={13} />{t.docs.refreshBtn}
|
||||
</button>
|
||||
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
|
||||
<Upload size={13} />Upload document
|
||||
<Upload size={13} />{t.docs.uploadBtn}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
@@ -201,24 +204,37 @@ export function DocsPage() {
|
||||
key={f}
|
||||
className={`chip${statusF === f ? ' active' : ''}`}
|
||||
onClick={() => setStatusF(f)}
|
||||
>{f}</button>
|
||||
>
|
||||
{f === 'All' ? t.docs.filterAll
|
||||
: f === 'Ready' ? t.docs.filterReady
|
||||
: f === 'Processing' ? t.docs.filterProcessing
|
||||
: f === 'Failed' ? t.docs.filterFailed
|
||||
: t.docs.filterPending}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select className="select-input" value={typeF} onChange={e => setTypeF(e.target.value)}>
|
||||
{typeOpts.map(o => <option key={o}>{o}</option>)}
|
||||
{typeOpts.map(o => (
|
||||
<option key={o} value={o}>{o === 'All types' ? t.docs.filterAllTypes : o}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Batch action bar */}
|
||||
{selected.size > 0 && (
|
||||
<div className="batch-bar">
|
||||
<span>{selected.size} document{selected.size > 1 ? 's' : ''} selected</span>
|
||||
<span>
|
||||
{selected.size}{' '}
|
||||
{t.docs.colName === 'Document name'
|
||||
? `document${selected.size > 1 ? 's' : ''} selected`
|
||||
: '份文档已选择'}
|
||||
</span>
|
||||
<button
|
||||
className="btn sm"
|
||||
style={{ color: 'var(--danger)', borderColor: 'rgba(239,68,68,.4)' }}
|
||||
onClick={() => askDelete([...selected])}
|
||||
>
|
||||
<Trash2 size={12} />Delete selected
|
||||
<Trash2 size={12} />{t.docs.deleteSelected}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -231,22 +247,22 @@ export function DocsPage() {
|
||||
checked={selected.size === filtered.length && filtered.length > 0}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
<span>Document name</span>
|
||||
<span>Status</span>
|
||||
<span>Uploaded</span>
|
||||
<span>Chunks</span>
|
||||
<span>Size</span>
|
||||
<span>Type</span>
|
||||
<span>Actions</span>
|
||||
<span>{t.docs.colName}</span>
|
||||
<span>{t.docs.colStatus}</span>
|
||||
<span>{t.docs.colUploaded}</span>
|
||||
<span>{t.docs.colChunks}</span>
|
||||
<span>{t.docs.colSize}</span>
|
||||
<span>{t.docs.colType}</span>
|
||||
<span>{t.docs.colActions}</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '32px 16px', color: 'var(--muted)', fontSize: 13, textAlign: 'center' }}>
|
||||
Loading documents…
|
||||
{t.docs.loading}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div style={{ padding: '40px 16px', color: 'var(--muted)', fontSize: 13, textAlign: 'center' }}>
|
||||
{docs.length === 0 ? 'No documents yet. Upload a document to get started.' : 'No documents match the current filters.'}
|
||||
{docs.length === 0 ? t.docs.emptyNoDocuments : t.docs.emptyNoMatch}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(d => {
|
||||
@@ -276,7 +292,7 @@ export function DocsPage() {
|
||||
{/* Download */}
|
||||
<button
|
||||
className="text-link"
|
||||
title="Download original file"
|
||||
title={t.docs.titleDownload}
|
||||
onClick={() => downloadDoc(d.id, d.name)}
|
||||
>
|
||||
<Download size={12} />
|
||||
@@ -286,7 +302,7 @@ export function DocsPage() {
|
||||
{d.status === 'risk' && (
|
||||
<button
|
||||
className="text-link"
|
||||
title="Retry processing"
|
||||
title={t.docs.titleRetry}
|
||||
disabled={isRetrying}
|
||||
onClick={() => retryDoc(d.id)}
|
||||
style={{ color: 'var(--warn)' }}
|
||||
@@ -298,7 +314,7 @@ export function DocsPage() {
|
||||
{/* Delete */}
|
||||
<button
|
||||
className="text-link danger-link"
|
||||
title="Delete document"
|
||||
title={t.docs.titleDelete}
|
||||
disabled={isDeleting}
|
||||
onClick={() => askDelete([d.id])}
|
||||
>
|
||||
|
||||
@@ -1,89 +1,93 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowRight, BarChart2, Eye, FileText, Shield, MessageSquare, Monitor } from 'lucide-react';
|
||||
|
||||
const SCREENS = [
|
||||
{ id: 'status', label: 'System Status', icon: <Monitor size={20} />, to: '/status', desc: 'Live health and workflow queue' },
|
||||
{ id: 'signals', label: 'Regulatory Signals', icon: <Eye size={20} />, to: '/signals', desc: 'AI-detected regulatory changes' },
|
||||
{ id: 'documents', label: 'Document Management', icon: <FileText size={20} />, to: '/documents', desc: 'Upload and inspect documents' },
|
||||
{ id: 'compliance', label: 'Compliance Analysis', icon: <Shield size={20} />, to: '/compliance', desc: 'Three-column compliance workspace' },
|
||||
{ id: 'chat', label: 'Regulation Q&A', icon: <MessageSquare size={20} />, to: '/chat', desc: 'Chat with cited regulation sources' },
|
||||
{ id: 'analytics', label: 'Analytics', icon: <BarChart2 size={20} />, to: '/status', desc: 'KPIs and coverage metrics' },
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ num: '01', label: 'Upload', desc: 'Ingest regulation documents' },
|
||||
{ num: '02', label: 'Process', desc: 'Embed and chunk via vector DB' },
|
||||
{ num: '03', label: 'Monitor', desc: 'Watch regulatory signal feed' },
|
||||
{ num: '04', label: 'Analyze', desc: 'Run compliance gap analysis' },
|
||||
{ num: '05', label: 'Review', desc: 'Inspect findings with AI assist' },
|
||||
{ num: '06', label: 'Chat', desc: 'Ask questions with cited answers' },
|
||||
];
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
export function OverviewPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLanguage();
|
||||
|
||||
const SCREENS = [
|
||||
{ id: 'status', label: t.overview.screenStatus, icon: <Monitor size={20} />, to: '/status', desc: t.overview.screenStatusDesc },
|
||||
{ id: 'signals', label: t.overview.screenSignals, icon: <Eye size={20} />, to: '/signals', desc: t.overview.screenSignalsDesc },
|
||||
{ id: 'documents', label: t.overview.screenDocuments, icon: <FileText size={20} />, to: '/documents', desc: t.overview.screenDocumentsDesc },
|
||||
{ id: 'compliance', label: t.overview.screenCompliance, icon: <Shield size={20} />, to: '/compliance', desc: t.overview.screenComplianceDesc },
|
||||
{ id: 'chat', label: t.overview.screenChat, icon: <MessageSquare size={20} />, to: '/chat', desc: t.overview.screenChatDesc },
|
||||
{ id: 'analytics', label: t.overview.screenAnalytics, icon: <BarChart2 size={20} />, to: '/status', desc: t.overview.screenAnalyticsDesc },
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ num: '01', label: t.overview.stepUpload, desc: t.overview.stepUploadDesc },
|
||||
{ num: '02', label: t.overview.stepProcess, desc: t.overview.stepProcessDesc },
|
||||
{ num: '03', label: t.overview.stepMonitor, desc: t.overview.stepMonitorDesc },
|
||||
{ num: '04', label: t.overview.stepAnalyze, desc: t.overview.stepAnalyzeDesc },
|
||||
{ num: '05', label: t.overview.stepReview, desc: t.overview.stepReviewDesc },
|
||||
{ num: '06', label: t.overview.stepChat, desc: t.overview.stepChatDesc },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="overview-scroll-wrapper">
|
||||
<div className="overview-page">
|
||||
<section className="overview-hero">
|
||||
<p className="hero-eyebrow">T-Systems · AI Regulation Hub</p>
|
||||
<h1 className="hero-title">AI Compliance,<br />Automated end-to-end</h1>
|
||||
<p className="hero-desc">
|
||||
Monitor global AI regulations, analyze document compliance gaps,
|
||||
and get cited answers — all in one platform.
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<button className="btn primary" onClick={() => navigate('/status')}>
|
||||
Open dashboard <ArrowRight size={14} />
|
||||
</button>
|
||||
<button className="btn" onClick={() => navigate('/chat')}>
|
||||
Jump to regulation chat
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="overview-summary card">
|
||||
<div className="summary-item">
|
||||
<span className="summary-num">6</span>
|
||||
<span className="summary-label">Screens</span>
|
||||
</div>
|
||||
<div className="summary-divider" />
|
||||
<div className="summary-item">
|
||||
<span className="summary-num">5</span>
|
||||
<span className="summary-label">Backend-aware flows</span>
|
||||
</div>
|
||||
<div className="summary-divider" />
|
||||
<div className="summary-item">
|
||||
<span className="summary-num">AI</span>
|
||||
<span className="summary-label">Review posture</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="overview-workflow">
|
||||
<h2 className="section-title">How it works</h2>
|
||||
<div className="workflow-steps">
|
||||
{STEPS.map(s => (
|
||||
<div key={s.num} className="workflow-step">
|
||||
<div className="step-num">{s.num}</div>
|
||||
<div className="step-label">{s.label}</div>
|
||||
<div className="step-desc">{s.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="overview-screens">
|
||||
<h2 className="section-title">Screens</h2>
|
||||
<div className="screen-grid">
|
||||
{SCREENS.map(s => (
|
||||
<button key={s.id} className="screen-card card" onClick={() => navigate(s.to)}>
|
||||
<div className="screen-icon">{s.icon}</div>
|
||||
<div className="screen-label">{s.label}</div>
|
||||
<div className="screen-desc">{s.desc}</div>
|
||||
<section className="overview-hero">
|
||||
<p className="hero-eyebrow">{t.overview.eyebrow}</p>
|
||||
<h1 className="hero-title">
|
||||
{t.overview.heroTitle.split('\n').map((line, i) => (
|
||||
<span key={i}>{line}{i === 0 && <br />}</span>
|
||||
))}
|
||||
</h1>
|
||||
<p className="hero-desc">{t.overview.heroDesc}</p>
|
||||
<div className="hero-actions">
|
||||
<button className="btn primary" onClick={() => navigate('/status')}>
|
||||
{t.overview.openDashboard} <ArrowRight size={14} />
|
||||
</button>
|
||||
))}
|
||||
<button className="btn" onClick={() => navigate('/chat')}>
|
||||
{t.overview.jumpToChat}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="overview-summary card">
|
||||
<div className="summary-item">
|
||||
<span className="summary-num">6</span>
|
||||
<span className="summary-label">{t.overview.statScreens}</span>
|
||||
</div>
|
||||
<div className="summary-divider" />
|
||||
<div className="summary-item">
|
||||
<span className="summary-num">5</span>
|
||||
<span className="summary-label">{t.overview.statFlows}</span>
|
||||
</div>
|
||||
<div className="summary-divider" />
|
||||
<div className="summary-item">
|
||||
<span className="summary-num">AI</span>
|
||||
<span className="summary-label">{t.overview.statReviewPosture}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="overview-workflow">
|
||||
<h2 className="section-title">{t.overview.sectionHowItWorks}</h2>
|
||||
<div className="workflow-steps">
|
||||
{STEPS.map(s => (
|
||||
<div key={s.num} className="workflow-step">
|
||||
<div className="step-num">{s.num}</div>
|
||||
<div className="step-label">{s.label}</div>
|
||||
<div className="step-desc">{s.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="overview-screens">
|
||||
<h2 className="section-title">{t.overview.sectionScreens}</h2>
|
||||
<div className="screen-grid">
|
||||
{SCREENS.map(s => (
|
||||
<button key={s.id} className="screen-card card" onClick={() => navigate(s.to)}>
|
||||
<div className="screen-icon">{s.icon}</div>
|
||||
<div className="screen-label">{s.label}</div>
|
||||
<div className="screen-desc">{s.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Topbar } from '../../components/layout/Topbar';
|
||||
import { RefreshCw, Play, Square, ExternalLink } from 'lucide-react';
|
||||
import { usePageState } from '../../contexts';
|
||||
import type { PerceptionSignal } from '../../contexts';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
@@ -62,6 +63,7 @@ const MOCK_SIGNALS: PerceptionSignal[] = [
|
||||
];
|
||||
|
||||
export function PerceptionPage() {
|
||||
const { t } = useLanguage();
|
||||
// Persistent state lives in PageStateContext — survives route changes
|
||||
const { perceptionState, setPerceptionState, perceptionAbortRef, perceptionCrawlAbortRef } = usePageState();
|
||||
const { signals, searchQuery, sourceFilter, impactFilter, selectedId, aiOutput, detailTab, crawlStatus } = perceptionState;
|
||||
@@ -177,7 +179,7 @@ export function PerceptionPage() {
|
||||
|
||||
async function runCrawl() {
|
||||
setCrawling(true);
|
||||
setPerceptionState(s => ({ ...s, crawlStatus: '正在连接数据源...' }));
|
||||
setPerceptionState(s => ({ ...s, crawlStatus: t.signals.statusConnecting }));
|
||||
try {
|
||||
const res = await fetch('/api/v1/perception/crawl', {
|
||||
method: 'POST',
|
||||
@@ -209,10 +211,10 @@ export function PerceptionPage() {
|
||||
if (evtName === 'progress') {
|
||||
setPerceptionState(s => ({
|
||||
...s,
|
||||
crawlStatus: `${d.source}: ${d.stage === 'fetching' ? '抓取中...' : d.stage === 'processing' ? `处理 ${d.fetched} 条...` : `完成 +${d.new} 条`}`,
|
||||
crawlStatus: `${d.source}: ${d.stage === 'fetching' ? t.signals.statusCrawling : d.stage === 'processing' ? t.signals.statusProcessing.replace('{count}', String(d.fetched)) : t.signals.statusComplete.replace('{count}', String(d.new))}`,
|
||||
}));
|
||||
} else if (evtName === 'done') {
|
||||
setPerceptionState(s => ({ ...s, crawlStatus: `更新完成 — 新增 ${d.total_new} 条,更新 ${d.total_updated} 条` }));
|
||||
setPerceptionState(s => ({ ...s, crawlStatus: t.signals.statusUpdateComplete.replace('{new}', String(d.total_new)).replace('{updated}', String(d.total_updated)) }));
|
||||
fetch('/api/v1/perception/events?limit=100', { headers: authHeader() })
|
||||
.then(r => r.json())
|
||||
.then(d2 => {
|
||||
@@ -223,7 +225,7 @@ export function PerceptionPage() {
|
||||
} else if (evtName === 'error') {
|
||||
setPerceptionState(s => ({
|
||||
...s,
|
||||
crawlStatus: `错误: ${typeof d === 'string' ? d : d.message}`,
|
||||
crawlStatus: t.signals.statusError.replace('{message}', typeof d === 'string' ? d : String(d.message)),
|
||||
}));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
@@ -232,7 +234,7 @@ export function PerceptionPage() {
|
||||
} catch (e: unknown) {
|
||||
setPerceptionState(s => ({
|
||||
...s,
|
||||
crawlStatus: `连接失败: ${e instanceof Error ? e.message : String(e)}`,
|
||||
crawlStatus: t.signals.statusConnFailed.replace('{message}', e instanceof Error ? e.message : String(e)),
|
||||
}));
|
||||
}
|
||||
setCrawling(false);
|
||||
@@ -253,20 +255,20 @@ export function PerceptionPage() {
|
||||
return (
|
||||
<div className="perception-page">
|
||||
<Topbar
|
||||
title="Regulatory Signals"
|
||||
subtitle="ai-powered · live feed"
|
||||
title={t.signals.topbarTitle}
|
||||
subtitle={t.signals.topbarSub}
|
||||
actions={
|
||||
<>
|
||||
<div className="search-box">
|
||||
<input
|
||||
placeholder="Search signals..."
|
||||
placeholder={t.signals.searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={e => setPerceptionState(s => ({ ...s, searchQuery: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn sm primary" onClick={runCrawl} disabled={crawling}>
|
||||
<RefreshCw size={13} className={crawling ? 'spin' : ''} />
|
||||
{crawling ? '抓取中...' : '刷新数据源'}
|
||||
{crawling ? t.signals.crawlingBtn : t.signals.refreshBtn}
|
||||
</button>
|
||||
{crawlStatus && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', marginLeft: 8 }}>
|
||||
@@ -280,19 +282,19 @@ export function PerceptionPage() {
|
||||
<div className="stats-bar">
|
||||
<div className="sbar-cell">
|
||||
<span className="sbar-val">{stats?.total ?? '—'}</span>
|
||||
<span className="sbar-lbl">Total signals</span>
|
||||
<span className="sbar-lbl">{t.signals.statTotal}</span>
|
||||
</div>
|
||||
<div className="sbar-cell danger">
|
||||
<span className="sbar-val">{stats?.high_impact ?? '—'}</span>
|
||||
<span className="sbar-lbl">High impact</span>
|
||||
<span className="sbar-lbl">{t.signals.statHigh}</span>
|
||||
</div>
|
||||
<div className="sbar-cell warn">
|
||||
<span className="sbar-val">{stats?.medium_impact ?? '—'}</span>
|
||||
<span className="sbar-lbl">Medium impact</span>
|
||||
<span className="sbar-lbl">{t.signals.statMedium}</span>
|
||||
</div>
|
||||
<div className="sbar-cell accent">
|
||||
<span className="sbar-val">{stats?.last_90_days ?? '—'}</span>
|
||||
<span className="sbar-lbl">Last 90 days</span>
|
||||
<span className="sbar-lbl">{t.signals.statLast90}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -334,14 +336,14 @@ export function PerceptionPage() {
|
||||
<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'}
|
||||
{sig.status === 'ok' ? t.signals.badgeFinal : sig.status === 'warn' ? t.signals.badgeDraft : sig.status === 'risk' ? t.signals.badgeUrgent : t.signals.badgePublished}
|
||||
</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>
|
||||
<div className="ev-tags">{sig.tags.map(tag => <span key={tag} className="ev-tag">{tag}</span>)}</div>
|
||||
<span className={`impact-dot impact-${sig.impact.toLowerCase()}`}>{sig.impact}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,7 +354,7 @@ export function PerceptionPage() {
|
||||
{!selected ? (
|
||||
<div className="analysis-empty">
|
||||
<div className="empty-ring" />
|
||||
<p>Select a signal to run impact analysis</p>
|
||||
<p>{t.signals.emptySelectSignal}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -361,7 +363,7 @@ 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' : selected.status === 'warn' ? 'Draft' : 'Published'}
|
||||
{selected.status === 'risk' ? t.signals.badgeUrgent : selected.status === 'warn' ? t.signals.badgeDraft : t.signals.badgePublished}
|
||||
</span>
|
||||
{selectedFull?.change_summary && (
|
||||
<span className="status warn" style={{ marginLeft: 'auto' }}>CHANGED</span>
|
||||
@@ -371,8 +373,8 @@ export function PerceptionPage() {
|
||||
<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 primary" onClick={runAnalysis}><Play size={12} />{t.signals.runAnalysis}</button>
|
||||
: <button className="btn sm" onClick={stopAnalysis}><Square size={12} />{t.signals.stopBtn}</button>
|
||||
}
|
||||
{selected && (
|
||||
<a
|
||||
@@ -381,7 +383,7 @@ export function PerceptionPage() {
|
||||
rel="noopener noreferrer"
|
||||
className="btn sm"
|
||||
>
|
||||
<ExternalLink size={12} />Source
|
||||
<ExternalLink size={12} />{t.signals.sourceLink}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
@@ -398,14 +400,14 @@ export function PerceptionPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tab === 'overview' ? '概览' : tab === 'obligations' ? '义务条款' : tab === 'assessment' ? '影响评估' : '变更对比'}
|
||||
{tab === 'overview' ? t.signals.tabOverview : tab === 'obligations' ? t.signals.tabObligations : tab === 'assessment' ? t.signals.tabImpact : t.signals.tabChanges}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{detailTab === 'overview' && (
|
||||
<div className="card">
|
||||
<div className="card-header">Scope & Summary</div>
|
||||
<div className="card-header">{t.signals.cardScopeHeader}</div>
|
||||
<p className="detail-summary" style={{ marginTop: 8 }}>
|
||||
{(selectedFull?.scope as string) || selected.summary}
|
||||
</p>
|
||||
@@ -419,21 +421,21 @@ export function PerceptionPage() {
|
||||
|
||||
{detailTab === 'obligations' && (
|
||||
<div className="card">
|
||||
<div className="card-header">义务条款</div>
|
||||
<div className="card-header">{t.signals.cardObligationsHeader}</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>
|
||||
<p className="detail-summary" style={{ marginTop: 8 }}>{t.signals.obligationsEmpty}</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>
|
||||
<th style={{ textAlign: 'left', padding: '4px 8px' }}>{t.signals.colObligationDesc}</th>
|
||||
<th style={{ textAlign: 'left', padding: '4px 8px', width: 80 }}>{t.signals.colSubject}</th>
|
||||
<th style={{ textAlign: 'left', padding: '4px 8px', width: 60 }}>{t.signals.colType}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -453,10 +455,10 @@ export function PerceptionPage() {
|
||||
)}
|
||||
{deadlines.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className="card-header">截止日期</div>
|
||||
<div className="card-header">{t.signals.colDeadline}</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={{ fontWeight: 600, color: 'var(--danger)' }}>{d.date || t.signals.deadlinePending}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{d.description}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -470,12 +472,12 @@ export function PerceptionPage() {
|
||||
|
||||
{detailTab === 'assessment' && (
|
||||
<div className="card docs-card">
|
||||
<div className="card-header">Affected documents</div>
|
||||
<div className="card-header">{t.signals.cardAffectedDocs}</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>
|
||||
? <p className="detail-summary" style={{ marginTop: 8 }}>{t.signals.noAffectedDocs}</p>
|
||||
: displayDocs.map((d, i) => (
|
||||
<div key={i} className="doc-row">
|
||||
<span className="doc-score">{Math.round(Number(d.score ?? 0) * 100)}%</span>
|
||||
@@ -497,7 +499,7 @@ export function PerceptionPage() {
|
||||
|
||||
{detailTab === 'diff' && selectedFull?.change_summary && (
|
||||
<div className="card">
|
||||
<div className="card-header">变更对比</div>
|
||||
<div className="card-header">{t.signals.diffCardHeader}</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 8 }}>
|
||||
{selectedFull.change_summary as string}
|
||||
</p>
|
||||
@@ -513,11 +515,11 @@ export function PerceptionPage() {
|
||||
</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>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{t.signals.diffOld}</div>
|
||||
{String(s.old_text || '')}
|
||||
</div>
|
||||
<div style={{ background: 'var(--success-bg)', padding: 8, borderRadius: 4 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>新版</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{t.signals.diffNew}</div>
|
||||
{String(s.new_text || '')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -530,7 +532,7 @@ export function PerceptionPage() {
|
||||
|
||||
{(aiOutput || streaming) && (
|
||||
<div className="card ai-card">
|
||||
<div className="card-header">AI Impact Analysis</div>
|
||||
<div className="card-header">{t.signals.cardAIImpact}</div>
|
||||
<div className="ai-output">
|
||||
{aiOutput}
|
||||
{streaming && <span className="blink-cursor">▋</span>}
|
||||
@@ -544,7 +546,7 @@ export function PerceptionPage() {
|
||||
|
||||
<footer className="page-footer">
|
||||
<div className="live-dot" />
|
||||
<span>Live feed · Regulation Hub</span>
|
||||
<span>{t.signals.footerText}</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Topbar } from '../../components/layout/Topbar';
|
||||
import { Send, Download } from 'lucide-react';
|
||||
import { usePageState } from '../../contexts';
|
||||
import type { RagCitation } from '../../contexts';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
@@ -59,6 +60,7 @@ const MOCK_QUICK = [
|
||||
export function RagChatPage() {
|
||||
// All persistent state lives in PageStateContext — survives route changes
|
||||
const { ragState, setRagState, ragStreamingRef, ragAbortRef } = usePageState();
|
||||
const { t } = useLanguage();
|
||||
const { messages, citations, sessionId, inputDraft } = ragState;
|
||||
|
||||
// Local-only UI state: highlighted citation and streaming indicator
|
||||
@@ -206,7 +208,7 @@ export function RagChatPage() {
|
||||
...s,
|
||||
messages: s.messages.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: 'Could not reach the RAG API. Please check the backend.' }
|
||||
? { ...msg, text: t.ragchat.apiError }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
@@ -222,7 +224,7 @@ export function RagChatPage() {
|
||||
return (
|
||||
<div className="chat-page">
|
||||
<Topbar
|
||||
title="Regulation Q&A"
|
||||
title={t.ragchat.topbarTitle}
|
||||
actions={
|
||||
<button
|
||||
className="btn sm"
|
||||
@@ -234,7 +236,7 @@ export function RagChatPage() {
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>
|
||||
<Download size={13} />Export chat
|
||||
<Download size={13} />{t.ragchat.exportBtn}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
@@ -242,7 +244,7 @@ export function RagChatPage() {
|
||||
<div className="chat-body">
|
||||
{/* ── History pane ── */}
|
||||
<div className="history-pane">
|
||||
<div className="history-header">Quick prompts</div>
|
||||
<div className="history-header">{t.ragchat.quickPromptsHeader}</div>
|
||||
{quickPrompts.map(q => (
|
||||
<button key={q} className="quick-item" onClick={() => send(q)}>
|
||||
{q}
|
||||
@@ -282,7 +284,7 @@ export function RagChatPage() {
|
||||
<div className="composer-row">
|
||||
<textarea
|
||||
className="composer-input"
|
||||
placeholder="Ask about your regulations…"
|
||||
placeholder={t.ragchat.inputPlaceholder}
|
||||
value={inputDraft}
|
||||
onChange={e => setRagState(s => ({ ...s, inputDraft: e.target.value }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
|
||||
@@ -302,11 +304,11 @@ export function RagChatPage() {
|
||||
{/* ── Citation rail ── */}
|
||||
<div className="citation-rail" ref={citRailRef}>
|
||||
<div className="citation-header">
|
||||
Sources {citations.length > 0 && `(${citations.length})`}
|
||||
{t.ragchat.citationsHeader}{citations.length > 0 && ` (${citations.length})`}
|
||||
</div>
|
||||
{citations.length === 0 && (
|
||||
<p style={{ padding: '12px 16px', fontSize: 12, color: 'var(--muted)', lineHeight: 1.5 }}>
|
||||
Citations will appear here after a response is generated.
|
||||
{t.ragchat.citationsEmpty}
|
||||
</p>
|
||||
)}
|
||||
{citations.map(c => (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Topbar } from '../../components/layout/Topbar';
|
||||
import { Search, Upload, Download, RefreshCw, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react';
|
||||
import { UploadModal } from '../Docs/UploadModal';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
@@ -48,13 +49,14 @@ function StatusIcon({ status }: { status: 'ok' | 'error' | 'warn' | 'info' }) {
|
||||
}
|
||||
|
||||
function ServiceRow({ name, status, detail }: { name: string; status: 'ok' | 'error' | 'warn' | 'info'; detail?: string }) {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<div className="service-row">
|
||||
<StatusIcon status={status} />
|
||||
<span className="service-name" style={{ marginLeft: 8 }}>{name}</span>
|
||||
{detail && <span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 6 }}>{detail}</span>}
|
||||
<span className={`status ${status}`} style={{ marginLeft: 'auto' }}>
|
||||
{status === 'ok' ? 'Online' : status === 'error' ? 'Error' : status === 'warn' ? 'Degraded' : 'Unknown'}
|
||||
{status === 'ok' ? t.status.badgeOnline : status === 'error' ? t.status.badgeError : status === 'warn' ? t.status.badgeDegraded : t.status.badgeUnknown}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -73,6 +75,7 @@ function ConfigRow({ label, value }: { label: string; value: string | number | n
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────────
|
||||
export function StatusPage() {
|
||||
const { t } = useLanguage();
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [health, setHealth] = useState<Health | null>(null);
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
@@ -136,21 +139,21 @@ export function StatusPage() {
|
||||
return (
|
||||
<div className="status-page">
|
||||
<Topbar
|
||||
title="System Status"
|
||||
title={t.status.topbarTitle}
|
||||
actions={
|
||||
<>
|
||||
<div className="search-box">
|
||||
<Search size={13} />
|
||||
<input placeholder="Search..." />
|
||||
<input placeholder={t.status.searchPlaceholder} />
|
||||
</div>
|
||||
<button className="btn sm" onClick={handleExport}>
|
||||
<Download size={13} />Export
|
||||
<Download size={13} />{t.status.exportBtn}
|
||||
</button>
|
||||
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
|
||||
<RefreshCw size={13} />Refresh
|
||||
<RefreshCw size={13} />{t.status.refreshBtn}
|
||||
</button>
|
||||
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
|
||||
<Upload size={13} />New upload
|
||||
<Upload size={13} />{t.status.newUploadBtn}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
@@ -162,26 +165,26 @@ export function StatusPage() {
|
||||
<div className="stats-grid">
|
||||
<div className="stat-cell">
|
||||
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_total ?? '—'}</div>}
|
||||
<div className="stat-label">Documents total</div>
|
||||
<div className="stat-label">{t.status.statTotal}</div>
|
||||
</div>
|
||||
<div className="stat-cell">
|
||||
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_indexed ?? '—'}</div>}
|
||||
<div className="stat-label">Indexed</div>
|
||||
<div className="stat-label">{t.status.statIndexed}</div>
|
||||
</div>
|
||||
<div className="stat-cell danger">
|
||||
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_failed ?? '—'}</div>}
|
||||
<div className="stat-label">Failed</div>
|
||||
<div className="stat-label">{t.status.statFailed}</div>
|
||||
</div>
|
||||
<div className="stat-cell">
|
||||
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.chunks_total?.toLocaleString() ?? '—'}</div>}
|
||||
<div className="stat-label">Vector chunks</div>
|
||||
<div className="stat-label">{t.status.statChunks}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indexed progress bar */}
|
||||
{!loading && stats && stats.documents_total > 0 && (
|
||||
<div style={{ padding: '0 0 20px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)', whiteSpace: 'nowrap' }}>Index coverage</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)', whiteSpace: 'nowrap' }}>{t.status.statCoverage}</span>
|
||||
<div style={{ flex: 1, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 3,
|
||||
@@ -203,7 +206,7 @@ export function StatusPage() {
|
||||
{/* System health */}
|
||||
<div className="card">
|
||||
<div className="card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span>System health</span>
|
||||
<span>{t.status.cardHealth}</span>
|
||||
{lastRefresh && (
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', fontWeight: 400 }}>
|
||||
Updated {lastRefresh.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
@@ -231,12 +234,12 @@ export function StatusPage() {
|
||||
<ServiceRow
|
||||
name="BM25 keyword retriever"
|
||||
status={health.bm25.available ? 'ok' : 'warn'}
|
||||
detail={health.bm25.available ? undefined : 'Not loaded'}
|
||||
detail={health.bm25.available ? undefined : t.status.serviceNotLoaded}
|
||||
/>
|
||||
<ServiceRow
|
||||
name={`Reranker${health.reranker.model ? ` (${health.reranker.model})` : ''}`}
|
||||
status={health.reranker.enabled ? 'ok' : 'info'}
|
||||
detail={health.reranker.enabled ? 'Enabled' : 'Disabled'}
|
||||
detail={health.reranker.enabled ? t.status.serviceEnabled : t.status.serviceDisabled}
|
||||
/>
|
||||
<ServiceRow
|
||||
name="Active sessions"
|
||||
@@ -246,7 +249,7 @@ export function StatusPage() {
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: '12px 0', color: 'var(--muted)', fontSize: 13 }}>
|
||||
Could not reach health endpoint
|
||||
{t.status.healthEndpointError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -257,7 +260,7 @@ export function StatusPage() {
|
||||
onClick={() => setConfigOpen(v => !v)}
|
||||
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
|
||||
>
|
||||
<div className="card-header" style={{ margin: 0, padding: 0, flex: 1, textAlign: 'left' }}>System configuration</div>
|
||||
<div className="card-header" style={{ margin: 0, padding: 0, flex: 1, textAlign: 'left' }}>{t.status.cardConfig}</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', transform: configOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>▾</span>
|
||||
</button>
|
||||
|
||||
@@ -265,17 +268,17 @@ export function StatusPage() {
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{config ? (
|
||||
<>
|
||||
<ConfigRow label="LLM provider" value={config.llm_provider} />
|
||||
<ConfigRow label="LLM model" value={config.llm_model} />
|
||||
<ConfigRow label="Embedding model" value={config.embedding_model} />
|
||||
<ConfigRow label="Embedding dim" value={config.embedding_dim} />
|
||||
<ConfigRow label="Milvus collection" value={config.milvus_collection} />
|
||||
<ConfigRow label="Parser backend" value={config.parser_backend} />
|
||||
<ConfigRow label="Chunk backend" value={config.chunk_backend} />
|
||||
<ConfigRow label="Parser failure mode" value={config.parser_failure_mode} />
|
||||
<ConfigRow label={t.status.labelLLMProvider} value={config.llm_provider} />
|
||||
<ConfigRow label={t.status.labelLLMModel} value={config.llm_model} />
|
||||
<ConfigRow label={t.status.labelEmbeddingModel} value={config.embedding_model} />
|
||||
<ConfigRow label={t.status.labelEmbeddingDim} value={config.embedding_dim} />
|
||||
<ConfigRow label={t.status.labelMilvusCollection} value={config.milvus_collection} />
|
||||
<ConfigRow label={t.status.labelParserBackend} value={config.parser_backend} />
|
||||
<ConfigRow label={t.status.labelChunkBackend} value={config.chunk_backend} />
|
||||
<ConfigRow label={t.status.labelParserFailureMode} value={config.parser_failure_mode} />
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: 'var(--muted)', fontSize: 13 }}>Could not load config</div>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 13 }}>{t.status.configLoadError}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -286,7 +289,7 @@ export function StatusPage() {
|
||||
|
||||
{/* Document breakdown */}
|
||||
<div className="card">
|
||||
<div className="card-header">Document breakdown</div>
|
||||
<div className="card-header">{t.status.cardBreakdown}</div>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{[1, 2, 3].map(i => <div key={i} className="loading-shimmer" style={{ height: 24, borderRadius: 4 }} />)}
|
||||
@@ -294,9 +297,9 @@ export function StatusPage() {
|
||||
) : stats ? (
|
||||
<>
|
||||
{[
|
||||
{ label: 'Indexed', value: stats.documents_indexed, total: stats.documents_total, color: 'var(--ok)' },
|
||||
{ label: 'Processing / Parsed', value: stats.documents_total - stats.documents_indexed - stats.documents_failed, total: stats.documents_total, color: 'var(--warn)' },
|
||||
{ label: 'Failed', value: stats.documents_failed, total: stats.documents_total, color: 'var(--danger)' },
|
||||
{ label: t.status.breakdownIndexed, value: stats.documents_indexed, total: stats.documents_total, color: 'var(--ok)' },
|
||||
{ label: t.status.breakdownProcessing, value: stats.documents_total - stats.documents_indexed - stats.documents_failed, total: stats.documents_total, color: 'var(--warn)' },
|
||||
{ label: t.status.breakdownFailed, value: stats.documents_failed, total: stats.documents_total, color: 'var(--danger)' },
|
||||
].map(row => {
|
||||
const pct = stats.documents_total > 0 ? Math.round((Math.max(0, row.value) / stats.documents_total) * 100) : 0;
|
||||
return (
|
||||
@@ -312,7 +315,7 @@ export function StatusPage() {
|
||||
);
|
||||
})}
|
||||
<div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Total vector chunks</span>
|
||||
<span style={{ color: 'var(--muted)' }}>{t.status.totalChunks}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{stats.chunks_total.toLocaleString()}</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -322,26 +325,26 @@ export function StatusPage() {
|
||||
{/* Sessions & reranker quick facts */}
|
||||
{health && (
|
||||
<div className="card">
|
||||
<div className="card-header">Runtime info</div>
|
||||
<div className="card-header">{t.status.cardRuntime}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Active chat sessions</span>
|
||||
<span style={{ color: 'var(--muted)' }}>{t.status.labelActiveSessions}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{health.sessions.active}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Session capacity</span>
|
||||
<span style={{ color: 'var(--muted)' }}>{t.status.labelSessionCapacity}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{health.sessions.max}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Cross-encoder reranker</span>
|
||||
<span style={{ color: 'var(--muted)' }}>{t.status.labelReranker}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: health.reranker.enabled ? 'var(--ok)' : 'var(--muted)' }}>
|
||||
{health.reranker.enabled ? (health.reranker.model ?? 'Enabled') : 'Disabled'}
|
||||
{health.reranker.enabled ? (health.reranker.model ?? t.status.serviceEnabled) : t.status.serviceDisabled}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--muted)' }}>BM25 hybrid retrieval</span>
|
||||
<span style={{ color: 'var(--muted)' }}>{t.status.labelBM25}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: health.bm25.available ? 'var(--ok)' : 'var(--muted)' }}>
|
||||
{health.bm25.available ? 'Active' : 'Unavailable'}
|
||||
{health.bm25.available ? t.status.statusActive : t.status.statusUnavailable}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,7 +356,7 @@ export function StatusPage() {
|
||||
|
||||
<footer className="page-footer">
|
||||
<div className="live-dot" />
|
||||
<span>Regulation Hub · T-Systems AI · {health ? (health.milvus.status === 'ok' && health.minio.connected ? 'All systems operational' : 'Degraded') : 'Checking…'}</span>
|
||||
<span>Regulation Hub · T-Systems AI · {health ? (health.milvus.status === 'ok' && health.minio.connected ? t.status.footerAllOk : t.status.footerDegraded) : t.status.footerChecking}</span>
|
||||
</footer>
|
||||
|
||||
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
|
||||
|
||||
Reference in New Issue
Block a user