update for 1. 优化 2.中英切换

This commit is contained in:
2026-06-10 11:10:36 +08:00
parent e7963b267e
commit 9212747e1b
42 changed files with 7866 additions and 278 deletions

View File

@@ -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>
);
}

View File

@@ -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>
)}

View 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);
}

View File

@@ -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 ──────────────────────────────────────────────────────────

View File

@@ -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
View 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
View 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请检查后端服务。',
},
};

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View File

@@ -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' }));
}

View File

@@ -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])}
>

View File

@@ -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>
);
}

View File

@@ -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 &amp; 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>
);

View File

@@ -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 => (

View File

@@ -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)} />}