livrare lot 2

This commit is contained in:
EVOTECH IT SRL 2026-07-10 03:39:53 -07:00
commit 8ecc78e729
763 changed files with 164593 additions and 0 deletions

View file

@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,101 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { ProtectedRoute } from './components/auth/ProtectedRoute';
import { Unauthorized } from './components/auth/Unauthorized';
import { LoginRedirect } from './components/auth/LoginRedirect';
import { AdminLayout } from './components/layout/AdminLayout';
import { ServicesDashboard } from './components/dashboard/ServicesDashboard';
import { FrameworkDashboard } from './pages/FrameworkDashboard';
import { ProgressiveAnalysisTree } from './components/framework/ProgressiveAnalysisTree';
import { UserManagement } from './components/UserManagement';
import { AnalysisHistory } from './components/AnalysisHistory';
import { ProvidersManagement } from './components/ProvidersManagement';
import { LLMComponentsConfig } from './components/LLMComponentsConfig';
import { PipelinesPage } from './components/Pipelines/PipelinesPage';
import { ModerationQueue } from './components/Moderation/ModerationQueue';
import { ModerationDetail } from './components/Moderation/ModerationDetail';
import { ModerationStats } from './components/Moderation/ModerationStats';
const DefaultLanding: React.FC = () => {
const { hasRole } = useAuth();
if (hasRole('admin')) return <ServicesDashboard />;
return <Navigate to="/moderation" replace />;
};
function App() {
return (
<ThemeProvider>
<AuthProvider>
<Router basename="/admin">
<Routes>
<Route path="/login" element={<LoginRedirect />} />
<Route path="/unauthorized" element={<Unauthorized />} />
{/* AdminLayout requires staff role (admin / moderator / senior_moderator).
End users (viewer / free_tier / paid_tier / etc.) get 403. */}
<Route
element={
<ProtectedRoute requiredAnyRole={['admin', 'moderator', 'senior_moderator']}>
<AdminLayout />
</ProtectedRoute>
}
>
<Route index element={<DefaultLanding />} />
<Route path="/dashboard" element={<ProtectedRoute requiredRole="admin"><ServicesDashboard /></ProtectedRoute>} />
{/* Admin-only pages — require 'admin' role */}
<Route path="/framework" element={<ProtectedRoute requiredRole="admin"><FrameworkDashboard /></ProtectedRoute>} />
<Route path="/framework/progressive" element={<ProtectedRoute requiredRole="admin"><ProgressiveAnalysisTree /></ProtectedRoute>} />
<Route path="/users" element={<ProtectedRoute requiredRole="admin"><UserManagement /></ProtectedRoute>} />
<Route path="/providers" element={<ProtectedRoute requiredRole="admin"><ProvidersManagement /></ProtectedRoute>} />
<Route path="/llm-components" element={<ProtectedRoute requiredRole="admin"><LLMComponentsConfig /></ProtectedRoute>} />
<Route path="/pipelines" element={<ProtectedRoute requiredRole="admin"><PipelinesPage /></ProtectedRoute>} />
{/* History — visible to admin + moderators (audit trail) */}
<Route
path="/history"
element={
<ProtectedRoute requiredAnyRole={['admin', 'moderator', 'senior_moderator']}>
<AnalysisHistory />
</ProtectedRoute>
}
/>
{/* Moderation — visible to all staff (admin/moderator/senior_moderator) */}
<Route
path="/moderation"
element={
<ProtectedRoute requiredAnyRole={['admin', 'moderator', 'senior_moderator']}>
<ModerationQueue />
</ProtectedRoute>
}
/>
<Route
path="/moderation/stats"
element={
<ProtectedRoute requiredAnyRole={['admin', 'moderator', 'senior_moderator']}>
<ModerationStats />
</ProtectedRoute>
}
/>
<Route
path="/moderation/:queueId"
element={
<ProtectedRoute requiredAnyRole={['admin', 'moderator', 'senior_moderator']}>
<ModerationDetail />
</ProtectedRoute>
}
/>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Router>
</AuthProvider>
</ThemeProvider>
);
}
export default App;

View file

@ -0,0 +1,84 @@
/**
* AnalysisDetailModal — color maps, cost calc, layout primitives
*/
import React from 'react';
import { Box } from '@mui/material';
import type { ModelPricing } from './types';
export const REDIS_COLOR_MAP: Record<string, string> = {
green: '#22c55e',
lightgreen: '#84cc16',
yellow: '#eab308',
orange: '#f97316',
red: '#ef4444',
darkred: '#7f1d1d',
};
export const getRiskColor = (verdict: any): string => {
if (verdict?.risk_level_color && REDIS_COLOR_MAP[verdict.risk_level_color]) {
return REDIS_COLOR_MAP[verdict.risk_level_color];
}
switch (verdict?.risk_level) {
case 'VERY_LOW':
case 'LOW': return '#22c55e';
case 'MEDIUM': return '#eab308';
case 'HIGH': return '#f97316';
case 'VERY_HIGH': return '#ef4444';
case 'CRITICAL': return '#7f1d1d';
default: return '#9e9e9e';
}
};
export const getCategoryColor = (verdict: any): string => {
if (verdict?.risk_category_color && REDIS_COLOR_MAP[verdict.risk_category_color]) {
return REDIS_COLOR_MAP[verdict.risk_category_color];
}
switch (verdict?.risk_category) {
case 'RELIABLE': return '#22c55e';
case 'MOSTLY_RELIABLE': return '#84cc16';
case 'MIXED': return '#eab308';
case 'QUESTIONABLE': return '#f97316';
case 'UNRELIABLE': return '#ef4444';
case 'DISINFORMATION': return '#7f1d1d';
default: return '#9e9e9e';
}
};
/** Normalize model_key to lookup key used for pricing map. Strips provider prefix. */
export function normalizeModelKey(key: string): string {
if (!key) return '';
// "openrouter:google/gemini-3-flash-preview" → "google/gemini-3-flash-preview"
const colonIdx = key.indexOf(':');
return colonIdx >= 0 ? key.slice(colonIdx + 1) : key;
}
export function computeTokenCost(
promptTokens: number,
completionTokens: number,
pricing: ModelPricing | undefined,
): number {
if (!pricing) return 0;
return (promptTokens / 1_000_000) * pricing.input_per_1m + (completionTokens / 1_000_000) * pricing.output_per_1m;
}
export function formatUsd(amount: number): string {
if (amount === 0) return '$0.00';
if (amount < 0.0001) return `<$0.0001`;
if (amount < 0.01) return `$${amount.toFixed(5)}`;
return `$${amount.toFixed(4)}`;
}
export const formatDate = (dateStr: string | null) =>
dateStr ? new Date(dateStr).toLocaleString() : '-';
export const formatDuration = (ms: number | null | undefined) => {
if (!ms) return '-';
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(2)}s`;
};
export const GridRow: React.FC<{ children: React.ReactNode; columns?: number; sx?: any }> = ({ children, columns = 4, sx = {} }) => (
<Box sx={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 2, ...sx }}>{children}</Box>
);
export const GridCell: React.FC<{ children: React.ReactNode }> = ({ children }) => <Box>{children}</Box>;

View file

@ -0,0 +1,100 @@
import React, { useState, useEffect } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions, Button,
Box, Typography, Chip, CircularProgress, Alert,
} from '@mui/material';
import type { AnalysisDetailModalProps, AnalysisDetail, PricingMap } from './types';
import { getRiskColor } from './helpers';
import { SessionInfoSection, InputContentSection } from './sections/meta';
import { VerdictSection } from './sections/verdict';
import { TechniquesSection, AISection } from './sections/components';
import { ClaimsSection } from './sections/claims';
import { SourceAssessmentSection, LegacyDomainSection } from './sections/source';
import { LLMUsageSection } from './sections/llm-usage';
const API_BASE = '';
export const AnalysisDetailModal: React.FC<AnalysisDetailModalProps> = ({ open, sessionId, onClose }) => {
const [data, setData] = useState<AnalysisDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pricingMap, setPricingMap] = useState<PricingMap>({});
useEffect(() => {
if (!open || !sessionId) return;
const fetchDetails = async () => {
setLoading(true);
setError(null);
try {
const token = localStorage.getItem('keycloak_token');
const [detailRes, modelsRes] = await Promise.all([
fetch(`${API_BASE}/agent-v3/api/v3/pipeline/history/admin/${sessionId}`, {
headers: { Authorization: token ? `Bearer ${token}` : '', 'Content-Type': 'application/json' },
}),
fetch(`${API_BASE}/framework/api/providers/models`, {
headers: { Authorization: token ? `Bearer ${token}` : '', 'Content-Type': 'application/json' },
}),
]);
if (!detailRes.ok) throw new Error('Failed to fetch analysis details');
const result = await detailRes.json();
setData(result.data);
// Build pricing map from model_code → {input_per_1m, output_per_1m}
if (modelsRes.ok) {
const modelsData = await modelsRes.json();
const map: PricingMap = {};
for (const m of modelsData.data || []) {
map[m.model_code] = {
input_per_1m: parseFloat(m.input_cost_per_1m) || 0,
output_per_1m: parseFloat(m.output_cost_per_1m) || 0,
};
}
setPricingMap(map);
}
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchDetails();
}, [open, sessionId]);
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6">Analysis Details</Typography>
{data?.verdict && (
<Chip
label={`Risk: ${data.verdict.risk_score} - ${data.verdict.risk_category}`}
sx={{ bgcolor: getRiskColor(data.verdict), color: 'white', fontWeight: 'bold' }}
/>
)}
</Box>
</DialogTitle>
<DialogContent dividers>
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{data && !loading && (
<Box>
<SessionInfoSection data={data} />
<InputContentSection data={data} />
<VerdictSection data={data} />
<TechniquesSection data={data} />
<AISection data={data} />
<ClaimsSection data={data} />
<SourceAssessmentSection data={data} />
<LegacyDomainSection data={data} />
<LLMUsageSection data={data} pricingMap={pricingMap} />
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
};

View file

@ -0,0 +1,161 @@
import React from 'react';
import {
Box, Typography, Paper, Chip, Accordion, AccordionSummary, AccordionDetails,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
CheckCircle as CheckCircleIcon,
Cancel as CancelIcon,
Help as HelpIcon,
FactCheck as FactCheckIcon,
OpenInNew as OpenInNewIcon,
Search as SearchIcon,
} from '@mui/icons-material';
import type { AnalysisDetail } from '../types';
import { GridRow, REDIS_COLOR_MAP } from '../helpers';
export const ClaimsSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
if (!data.claims) return null;
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<FactCheckIcon sx={{ mr: 1 }} />
<Typography variant="subtitle1" fontWeight="bold">Claims Verification ({data.claims.total_claims} claims)</Typography>
</AccordionSummary>
<AccordionDetails>
<GridRow columns={4}>
<Paper sx={{ p: 1, textAlign: 'center', bgcolor: 'success.light' }}>
<CheckCircleIcon color="success" />
<Typography variant="h6">{data.claims.verified_true}</Typography>
<Typography variant="caption">Verified True</Typography>
</Paper>
<Paper sx={{ p: 1, textAlign: 'center', bgcolor: 'error.light' }}>
<CancelIcon color="error" />
<Typography variant="h6">{data.claims.verified_false}</Typography>
<Typography variant="caption">Verified False</Typography>
</Paper>
<Paper sx={{ p: 1, textAlign: 'center', bgcolor: 'grey.200' }}>
<HelpIcon />
<Typography variant="h6">{data.claims.unverified}</Typography>
<Typography variant="caption">Unverified</Typography>
</Paper>
<Paper sx={{ p: 1, textAlign: 'center' }}>
<Typography variant="h6">{Math.round(Number(data.claims.credibility_score))}%</Typography>
<Typography variant="caption">Credibility</Typography>
</Paper>
</GridRow>
{/* Meta info */}
<Box sx={{ display: 'flex', gap: 2, mt: 2, flexWrap: 'wrap', alignItems: 'center' }}>
{data.claims.interpretation && (
<Chip label={data.claims.interpretation} size="small" variant="outlined" />
)}
{data.claims.web_searches_made != null && (
<Chip icon={<SearchIcon />} label={`${data.claims.web_searches_made} web searches`} size="small" variant="outlined" />
)}
{data.claims.llm_extraction && <Chip label={`Extraction: ${data.claims.llm_extraction}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
{data.claims.llm_verification && <Chip label={`Verification: ${data.claims.llm_verification}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
{data.claims.claims_by_status && (
<Box sx={{ display: 'flex', gap: 0.5 }}>
{Object.entries(data.claims.claims_by_status).map(([status, count]) => (
<Chip key={status} label={`${status}: ${count}`} size="small"
color={status === 'VT' || status === 'LT' ? 'success' : status === 'VF' || status === 'LF' ? 'error' : 'default'} />
))}
</Box>
)}
</Box>
{/* Individual claims */}
{data.claims.claims_verified?.length > 0 && (
<Box sx={{ mt: 2 }}>
{data.claims.claims_verified.map((claim: any, idx: number) => (
<Paper key={idx} sx={{
p: 2, mb: 1.5, borderLeft: 4,
borderColor: claim.status === 'VT' || claim.status === 'LT' ? 'success.main'
: claim.status === 'VF' || claim.status === 'LF' ? 'error.main'
: 'grey.400',
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
<Typography variant="body1" fontWeight="bold" sx={{ flex: 1, mr: 1 }}>
{claim.text || claim.claim}
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
<Chip
label={claim.status_name || claim.status}
size="small"
color={claim.status === 'VT' || claim.status === 'LT' ? 'success' : claim.status === 'VF' || claim.status === 'LF' ? 'error' : 'default'}
sx={claim.status_color ? { bgcolor: REDIS_COLOR_MAP[claim.status_color] || undefined, color: '#fff' } : {}}
/>
{claim.type_name && <Chip label={claim.type_name} size="small" variant="outlined" />}
{claim.priority && <Chip label={claim.priority} size="small" variant="outlined"
color={claim.priority === 'high' ? 'error' : claim.priority === 'medium' ? 'warning' : 'default'} />}
</Box>
</Box>
{claim.context && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1, fontStyle: 'italic' }}>
{claim.context}
</Typography>
)}
{/* Confidence & agreement */}
<Box sx={{ display: 'flex', gap: 2, mb: 1 }}>
{claim.confidence != null && (
<Typography variant="caption" color="text.secondary">
Confidence: <strong>{claim.confidence}%</strong>
</Typography>
)}
{claim.agreement_score != null && (
<Typography variant="caption" color="text.secondary">
Agreement: <strong>{claim.agreement_score}%</strong>
</Typography>
)}
{claim.verification_method && (
<Typography variant="caption" color="text.secondary">
Method: <strong>{claim.verification_method}</strong>
</Typography>
)}
</Box>
{/* Reasoning */}
{claim.reasoning && (
<Paper sx={{ p: 1.5, bgcolor: 'grey.50', mb: 1 }}>
<Typography variant="body2">{claim.reasoning}</Typography>
</Paper>
)}
{/* Sources */}
{claim.sources?.length > 0 && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" fontWeight="bold" color="text.secondary">
Sources ({claim.sources.length}):
</Typography>
{claim.sources.map((src: any, si: number) => (
<Paper key={si} sx={{ p: 1, mt: 0.5, bgcolor: 'grey.50', display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<Chip
label={src.stance}
size="small"
color={src.stance === 'SUPPORTS' ? 'success' : src.stance === 'CONTRADICTS' ? 'error' : 'default'}
sx={{ minWidth: 90, mt: 0.3 }}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: 'primary.main', wordBreak: 'break-all', cursor: 'pointer' }}
component="a" href={src.url} target="_blank" rel="noopener noreferrer">
{src.url}
</Typography>
<OpenInNewIcon sx={{ fontSize: 12, color: 'text.secondary' }} />
{src.reliability && <Chip label={src.reliability} size="small" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
</Box>
{src.relevant_quote && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, fontStyle: 'italic' }}>
"{src.relevant_quote}"
</Typography>
)}
</Box>
</Paper>
))}
</Box>
)}
</Paper>
))}
</Box>
)}
</AccordionDetails>
</Accordion>
);
};

View file

@ -0,0 +1,102 @@
import React from 'react';
import {
Box, Typography, Chip, Alert, Accordion, AccordionSummary, AccordionDetails,
List, ListItem, ListItemText,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
Psychology as PsychologyIcon,
} from '@mui/icons-material';
import type { AnalysisDetail } from '../types';
import { GridRow, GridCell, formatDuration } from '../helpers';
export const TechniquesSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
if (!data.techniques) return null;
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<PsychologyIcon sx={{ mr: 1 }} />
<Typography variant="subtitle1" fontWeight="bold">
Manipulation Techniques ({data.techniques.techniques_count} detected)
</Typography>
</AccordionSummary>
<AccordionDetails>
<GridRow columns={3}>
<GridCell>
<Typography variant="caption" color="text.secondary">Manipulation Score</Typography>
<Typography variant="h6">{Math.round(Number(data.techniques.manipulation_score))}%</Typography>
</GridCell>
<GridCell>
<Typography variant="caption" color="text.secondary">Total Severity</Typography>
<Typography variant="h6">{data.techniques.total_severity}</Typography>
</GridCell>
<GridCell>
<Typography variant="caption" color="text.secondary">Duration</Typography>
<Typography variant="h6">{formatDuration(data.techniques.total_duration_ms || data.techniques.duration_ms?.total)}</Typography>
</GridCell>
</GridRow>
{(data.techniques.llm_screening || data.techniques.llm_deep) && (
<Box sx={{ mt: 1, display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{data.techniques.llm_screening && <Chip label={`Screening: ${data.techniques.llm_screening}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
{data.techniques.llm_deep && <Chip label={`Deep: ${data.techniques.llm_deep}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
</Box>
)}
{data.techniques.dimensions_affected?.length > 0 && (
<Box sx={{ my: 2 }}>
<Typography variant="caption" color="text.secondary">Dimensions Affected</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 0.5 }}>
{data.techniques.dimensions_affected.map((dim: string) => <Chip key={dim} label={dim} size="small" />)}
</Box>
</Box>
)}
{data.techniques.techniques_detected?.length > 0 && (
<List dense>
{data.techniques.techniques_detected.map((tech: any, idx: number) => (
<ListItem key={idx} sx={{ bgcolor: 'grey.50', mb: 0.5, borderRadius: 1 }}>
<ListItemText primary={tech.name || `Technique ${idx + 1}`} secondary={tech.description || tech.evidence} />
{tech.severity && <Chip label={`Severity: ${tech.severity}`} size="small" color={tech.severity > 3 ? 'error' : 'warning'} />}
</ListItem>
))}
</List>
)}
</AccordionDetails>
</Accordion>
);
};
export const AISection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
if (!data.ai_tampered) return null;
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<PsychologyIcon sx={{ mr: 1 }} />
<Typography variant="subtitle1" fontWeight="bold">AI Content Detection - {data.ai_tampered.verdict}</Typography>
</AccordionSummary>
<AccordionDetails>
<GridRow columns={3}>
<GridCell>
<Typography variant="caption" color="text.secondary">AI Probability</Typography>
<Typography variant="h6">{Math.round(Number(data.ai_tampered.ai_probability))}%</Typography>
</GridCell>
<GridCell>
<Typography variant="caption" color="text.secondary">Indicators Found</Typography>
<Typography variant="h6">{data.ai_tampered.indicators_count}</Typography>
</GridCell>
<GridCell>
<Typography variant="caption" color="text.secondary">Duration</Typography>
<Typography variant="h6">{formatDuration(data.ai_tampered.total_duration_ms || data.ai_tampered.duration_ms?.total)}</Typography>
</GridCell>
</GridRow>
{(data.ai_tampered.llm_screening || data.ai_tampered.llm_deep) && (
<Box sx={{ mt: 1, display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{data.ai_tampered.llm_screening && <Chip label={`Screening: ${data.ai_tampered.llm_screening}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
{data.ai_tampered.llm_deep && <Chip label={`Deep: ${data.ai_tampered.llm_deep}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
</Box>
)}
{data.ai_tampered.disclosure_detected && (
<Alert severity="info" sx={{ mt: 2 }}>AI Disclosure detected: {data.ai_tampered.disclosure_text}</Alert>
)}
</AccordionDetails>
</Accordion>
);
};

View file

@ -0,0 +1,119 @@
import React from 'react';
import {
Box, Typography, Paper, Chip, Accordion, AccordionSummary, AccordionDetails,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
GeneratingTokens as TokenIcon,
AttachMoney as MoneyIcon,
} from '@mui/icons-material';
import type { AnalysisDetail, PricingMap } from '../types';
import { GridRow, normalizeModelKey, computeTokenCost, formatUsd } from '../helpers';
interface Props {
data: AnalysisDetail;
pricingMap: PricingMap;
}
export const LLMUsageSection: React.FC<Props> = ({ data, pricingMap }) => {
if (!data.llm_usage) return null;
// Compute cost per component + grand total. We don't have per-model token split
// in by_component, so we charge all tokens at the FIRST model used by that
// component (good enough for single-model primary path).
const byComponent = data.llm_usage.by_component || {};
const componentCosts: Record<string, { cost: number; model: string | null }> = {};
let totalCost = 0;
for (const [comp, usage] of Object.entries(byComponent) as [string, any][]) {
const firstModel = usage.models_used?.[0];
const pricingKey = firstModel ? normalizeModelKey(firstModel) : '';
const pricing = pricingKey ? pricingMap[pricingKey] : undefined;
const cost = computeTokenCost(
usage.prompt_tokens || 0,
usage.completion_tokens || 0,
pricing,
);
componentCosts[comp] = { cost, model: firstModel || null };
totalCost += cost;
}
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<TokenIcon sx={{ mr: 1 }} />
<Typography variant="subtitle1" fontWeight="bold" sx={{ flex: 1 }}>
LLM Usage — {data.llm_usage.total?.total_tokens?.toLocaleString() || 0} tokens ({data.llm_usage.total?.calls || 0} calls)
</Typography>
<Chip
icon={<MoneyIcon />}
label={formatUsd(totalCost)}
size="small"
color={totalCost === 0 ? 'success' : 'warning'}
sx={{ mr: 2 }}
/>
</AccordionSummary>
<AccordionDetails>
<GridRow columns={5}>
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
<Typography variant="h5">{data.llm_usage.total?.calls || 0}</Typography>
<Typography variant="caption">Total Calls</Typography>
</Paper>
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
<Typography variant="h5">{(data.llm_usage.total?.prompt_tokens || 0).toLocaleString()}</Typography>
<Typography variant="caption">Prompt Tokens</Typography>
</Paper>
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
<Typography variant="h5">{(data.llm_usage.total?.completion_tokens || 0).toLocaleString()}</Typography>
<Typography variant="caption">Completion Tokens</Typography>
</Paper>
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
<Typography variant="h5">{(data.llm_usage.total?.total_tokens || 0).toLocaleString()}</Typography>
<Typography variant="caption">Total Tokens</Typography>
</Paper>
<Paper sx={{ p: 1.5, textAlign: 'center', bgcolor: totalCost === 0 ? 'success.50' : 'warning.50' }}>
<Typography variant="h5" color={totalCost === 0 ? 'success.main' : 'warning.main'}>
{formatUsd(totalCost)}
</Typography>
<Typography variant="caption">Est. Cost</Typography>
</Paper>
</GridRow>
{Object.keys(byComponent).length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>Per Component</Typography>
<GridRow columns={Object.keys(byComponent).length}>
{Object.entries(byComponent).map(([comp, usage]: [string, any]) => {
const cc = componentCosts[comp];
return (
<Paper key={comp} sx={{ p: 1.5 }}>
<Typography variant="caption" color="text.secondary" textTransform="capitalize">{comp.replace('_', ' ')}</Typography>
<Typography variant="h6">{(usage.total_tokens || 0).toLocaleString()}</Typography>
<Typography variant="caption" color="text.secondary" display="block">
{usage.calls} calls • {(usage.prompt_tokens || 0).toLocaleString()} in / {(usage.completion_tokens || 0).toLocaleString()} out
</Typography>
<Box sx={{ mt: 0.5 }}>
<Chip
icon={<MoneyIcon />}
label={formatUsd(cc.cost)}
size="small"
color={cc.cost === 0 ? 'success' : 'warning'}
variant="outlined"
sx={{ height: 22, fontSize: 11 }}
/>
</Box>
{usage.models_used?.length > 0 && (
<Box sx={{ mt: 0.5, display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{usage.models_used.map((m: string) => (
<Chip key={m} label={m} size="small" variant="outlined" sx={{ height: 20, fontSize: 10 }} />
))}
</Box>
)}
</Paper>
);
})}
</GridRow>
</Box>
)}
</AccordionDetails>
</Accordion>
);
};

View file

@ -0,0 +1,57 @@
import React from 'react';
import { Typography, Paper, Accordion, AccordionSummary, AccordionDetails } from '@mui/material';
import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material';
import type { AnalysisDetail } from '../types';
import { GridRow, GridCell, formatDate, formatDuration } from '../helpers';
export const SessionInfoSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => (
<Paper sx={{ p: 2, mb: 2 }}>
<GridRow columns={2}>
<GridCell>
<Typography variant="caption" color="text.secondary">Session ID</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{data.session_id}</Typography>
</GridCell>
<GridCell>
<Typography variant="caption" color="text.secondary">User</Typography>
<Typography variant="body2">{data.user_email || data.user_id}</Typography>
</GridCell>
</GridRow>
<GridRow columns={4} sx={{ mt: 2 }}>
<GridCell>
<Typography variant="caption" color="text.secondary">Started</Typography>
<Typography variant="body2">{formatDate(data.started_at)}</Typography>
</GridCell>
<GridCell>
<Typography variant="caption" color="text.secondary">Duration</Typography>
<Typography variant="body2">{formatDuration(data.total_duration_ms)}</Typography>
</GridCell>
<GridCell>
<Typography variant="caption" color="text.secondary">Type</Typography>
<Typography variant="body2">{data.input_type}</Typography>
</GridCell>
<GridCell>
<Typography variant="caption" color="text.secondary">Source</Typography>
<Typography variant="body2">{data.source_app}</Typography>
</GridCell>
</GridRow>
</Paper>
);
export const InputContentSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => (
<Accordion defaultExpanded>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">Input Content</Typography>
</AccordionSummary>
<AccordionDetails>
{data.input_text && (
<Paper sx={{ p: 2, bgcolor: 'grey.100', maxHeight: 200, overflow: 'auto' }}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>
{data.input_text}
</Typography>
</Paper>
)}
{data.input_url && <Typography variant="body2" sx={{ mt: 1 }}><strong>URL:</strong> {data.input_url}</Typography>}
{data.input_media_url && <Typography variant="body2" sx={{ mt: 1 }}><strong>Media:</strong> {data.input_media_url}</Typography>}
</AccordionDetails>
</Accordion>
);

View file

@ -0,0 +1,161 @@
import React from 'react';
import {
Box, Typography, Paper, Chip, Alert, Accordion, AccordionSummary, AccordionDetails,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
Language as LanguageIcon,
Search as SearchIcon,
} from '@mui/icons-material';
import type { AnalysisDetail } from '../types';
import { GridRow, formatDuration } from '../helpers';
export const SourceAssessmentSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
if (!data.source_assessment) return null;
const sa = data.source_assessment;
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<LanguageIcon sx={{ mr: 1 }} />
<Typography variant="subtitle1" fontWeight="bold">Source Assessment</Typography>
</AccordionSummary>
<AccordionDetails>
<GridRow columns={3}>
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
<Typography variant="h4" sx={{ color: sa.trust_score >= 60 ? '#22c55e' : sa.trust_score >= 30 ? '#f97316' : '#ef4444' }}>
{sa.trust_score}
</Typography>
<Typography variant="caption">Trust Score</Typography>
</Paper>
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
<Typography variant="h5" sx={{
color: sa.verdict === 'TRUSTED' ? '#22c55e' : sa.verdict === 'NEUTRAL' ? '#eab308'
: sa.verdict === 'SUSPICIOUS' ? '#f97316' : '#ef4444',
}}>{sa.verdict}</Typography>
<Typography variant="caption">Verdict</Typography>
</Paper>
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
<Typography variant="body1">{sa.risk_level}</Typography>
<Typography variant="caption">Risk Level</Typography>
</Paper>
</GridRow>
{/* 4 Axes */}
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1 }}>Assessment Axes</Typography>
<GridRow columns={4}>
{sa.publication && (
<Paper sx={{ p: 1.5 }}>
<Typography variant="caption" color="text.secondary">Publication ({sa.formula?.publication_weight ? `${Math.round(sa.formula.publication_weight * 100)}%` : '35%'})</Typography>
<Typography variant="h6">{sa.publication.score}</Typography>
<Typography variant="body2" noWrap>{sa.publication.name || '-'}</Typography>
<Typography variant="caption" color="text.secondary">{sa.publication.source_type}</Typography>
{sa.publication.confirmed && <Chip label="Confirmed" size="small" color="success" sx={{ mt: 0.5, height: 20, fontSize: 10 }} />}
</Paper>
)}
{sa.domain && (
<Paper sx={{ p: 1.5 }}>
<Typography variant="caption" color="text.secondary">Domain ({sa.formula?.domain_weight ? `${Math.round(sa.formula.domain_weight * 100)}%` : '25%'})</Typography>
<Typography variant="h6">{sa.domain.score}</Typography>
<Typography variant="body2" noWrap>{sa.domain.name || '-'}</Typography>
{sa.domain.is_blacklisted && <Chip label="BLACKLISTED" size="small" color="error" sx={{ mt: 0.5, height: 20, fontSize: 10 }} />}
{sa.domain.has_ssl != null && (
<Chip label={sa.domain.has_ssl ? 'SSL' : 'No SSL'} size="small" variant="outlined" sx={{ mt: 0.5, height: 20, fontSize: 10 }} />
)}
</Paper>
)}
{sa.author && (
<Paper sx={{ p: 1.5 }}>
<Typography variant="caption" color="text.secondary">Author ({sa.formula?.author_weight ? `${Math.round(sa.formula.author_weight * 100)}%` : '25%'})</Typography>
<Typography variant="h6">{sa.author.score}</Typography>
<Typography variant="body2" noWrap>{sa.author.name || '-'}</Typography>
<Typography variant="caption" color="text.secondary">{sa.author.classification}</Typography>
{sa.author.confirmed && <Chip label="Confirmed" size="small" color="success" sx={{ mt: 0.5, height: 20, fontSize: 10 }} />}
</Paper>
)}
{sa.platform && (
<Paper sx={{ p: 1.5 }}>
<Typography variant="caption" color="text.secondary">Platform ({sa.formula?.platform_weight ? `${Math.round(sa.formula.platform_weight * 100)}%` : '15%'})</Typography>
<Typography variant="h6">{sa.platform.score}</Typography>
<Typography variant="body2" noWrap>{sa.platform.name || '-'}</Typography>
</Paper>
)}
</GridRow>
{sa.formula?.breakdown && (
<Paper sx={{ p: 1, mt: 1, bgcolor: 'grey.50' }}>
<Typography variant="caption" color="text.secondary">Formula: </Typography>
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{sa.formula.breakdown}</Typography>
</Paper>
)}
{/* Meta info */}
<Box sx={{ display: 'flex', gap: 1, mt: 2, flexWrap: 'wrap' }}>
{sa.llm_model_used && <Chip label={`LLM: ${sa.llm_model_used}`} size="small" variant="outlined" />}
{sa.search_results_count != null && (
<Chip icon={<SearchIcon />} label={`${sa.search_results_count} search results`} size="small" variant="outlined" />
)}
{sa.duration_ms != null && <Chip label={formatDuration(sa.duration_ms)} size="small" variant="outlined" />}
</Box>
{sa.red_flags?.length > 0 && (
<Alert severity="error" sx={{ mt: 2 }}>
<strong>Red Flags:</strong>
<ul style={{ margin: 0, paddingLeft: 20 }}>
{sa.red_flags.map((flag: string, idx: number) => <li key={idx}>{flag}</li>)}
</ul>
</Alert>
)}
{sa.warnings?.length > 0 && (
<Alert severity="warning" sx={{ mt: 2 }}>
<strong>Warnings:</strong>
<ul style={{ margin: 0, paddingLeft: 20 }}>
{sa.warnings.map((w: string, idx: number) => <li key={idx}>{w}</li>)}
</ul>
</Alert>
)}
</AccordionDetails>
</Accordion>
);
};
export const LegacyDomainSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
if (data.source_assessment || !data.domain) return null;
const d = data.domain;
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<LanguageIcon sx={{ mr: 1 }} />
<Typography variant="subtitle1" fontWeight="bold">Domain Analysis - {d.domain}</Typography>
</AccordionSummary>
<AccordionDetails>
<GridRow columns={4}>
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
<Typography variant="h5" sx={{ color: d.trust_score >= 60 ? '#22c55e' : d.trust_score >= 30 ? '#f97316' : '#ef4444' }}>
{d.trust_score}
</Typography>
<Typography variant="caption">Trust Score</Typography>
</Paper>
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
<Typography variant="h6" sx={{
color: d.verdict === 'TRUSTED' ? '#22c55e' : d.verdict === 'NEUTRAL' ? '#eab308'
: d.verdict === 'SUSPICIOUS' ? '#f97316' : '#ef4444',
}}>{d.verdict}</Typography>
<Typography variant="caption">Verdict</Typography>
</Paper>
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
<Typography variant="h6">{d.age_days != null ? `${d.age_days} days` : '-'}</Typography>
<Typography variant="caption">Age</Typography>
</Paper>
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
<Typography variant="h6" sx={{ color: d.has_ssl ? (d.ssl_valid ? '#22c55e' : '#ef4444') : '#9e9e9e' }}>
{d.has_ssl ? (d.ssl_valid ? 'Valid' : 'Invalid') : 'None'}
</Typography>
<Typography variant="caption">SSL</Typography>
</Paper>
</GridRow>
<Box sx={{ display: 'flex', gap: 1, mt: 2, flexWrap: 'wrap' }}>
{d.risk_level && <Chip label={`Risk: ${d.risk_level}`} size="small" variant="outlined" />}
{d.is_blacklisted && <Chip label="BLACKLISTED" size="small" color="error" />}
{d.registrar && <Chip label={`Registrar: ${d.registrar}`} size="small" variant="outlined" />}
{d.country && <Chip label={`Country: ${d.country}`} size="small" variant="outlined" />}
</Box>
</AccordionDetails>
</Accordion>
);
};

View file

@ -0,0 +1,97 @@
import React from 'react';
import {
Box, Typography, Paper, Alert, Accordion, AccordionSummary, AccordionDetails,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
Assessment as AssessmentIcon,
} from '@mui/icons-material';
import type { AnalysisDetail } from '../types';
import { GridRow, getRiskColor, getCategoryColor } from '../helpers';
export const VerdictSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
if (!data.verdict) return null;
return (
<Accordion defaultExpanded>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<AssessmentIcon sx={{ mr: 1 }} />
<Typography variant="subtitle1" fontWeight="bold">Final Verdict</Typography>
</AccordionSummary>
<AccordionDetails>
<GridRow columns={4}>
<Paper sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h3" sx={{ color: getRiskColor(data.verdict) }}>{data.verdict.risk_score}</Typography>
<Typography variant="caption">Risk Score</Typography>
</Paper>
<Paper sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h5" sx={{ color: getCategoryColor(data.verdict) }}>{data.verdict.risk_category}</Typography>
<Typography variant="caption">Category</Typography>
</Paper>
<Paper sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h5">{data.verdict.confidence}%</Typography>
<Typography variant="caption">Confidence ({data.verdict.confidence_level})</Typography>
</Paper>
<Paper sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body1">{data.verdict.recommended_action}</Typography>
<Typography variant="caption">Action</Typography>
</Paper>
</GridRow>
{(data.verdict.score_manipulation != null || data.verdict.score_claims != null || data.verdict.score_ai != null || data.verdict.score_source != null || data.verdict.score_context != null || data.verdict.component_scores) && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>Component Scores</Typography>
<GridRow columns={5}>
{data.verdict.component_scores
? Object.entries(data.verdict.component_scores).map(([key, value]) => (
<Paper key={key} sx={{ p: 1, textAlign: 'center' }}>
<Typography variant="body2" fontWeight="bold">
{value !== null && Number(value) >= 0 ? Math.round(Number(value)) : '-'}
</Typography>
<Typography variant="caption" textTransform="capitalize">{key}</Typography>
</Paper>
))
: [
{ key: 'manipulation', value: data.verdict.score_manipulation },
{ key: 'claims', value: data.verdict.score_claims },
{ key: 'ai', value: data.verdict.score_ai },
{ key: 'source', value: data.verdict.score_source },
{ key: 'context', value: data.verdict.score_context },
].map(({ key, value }) => (
<Paper key={key} sx={{ p: 1, textAlign: 'center' }}>
<Typography variant="body2" fontWeight="bold">
{value !== null && Number(value) >= 0 ? Math.round(Number(value)) : '-'}
</Typography>
<Typography variant="caption" textTransform="capitalize">{key}</Typography>
</Paper>
))
}
</GridRow>
</Box>
)}
{(data.verdict.explanation_ro || data.verdict.explanation_en) && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>Explanation</Typography>
{data.verdict.explanation_ro && (
<Paper sx={{ p: 2, mb: 1, bgcolor: 'grey.50' }}>
<Typography variant="caption" color="text.secondary">RO</Typography>
<Typography variant="body2">{data.verdict.explanation_ro}</Typography>
</Paper>
)}
{data.verdict.explanation_en && (
<Paper sx={{ p: 2, bgcolor: 'grey.50' }}>
<Typography variant="caption" color="text.secondary">EN</Typography>
<Typography variant="body2">{data.verdict.explanation_en}</Typography>
</Paper>
)}
</Box>
)}
{data.verdict.override_applied && (
<Alert severity="warning" sx={{ mt: 2 }}>
<strong>Override Applied:</strong> {data.verdict.override_type}
{data.verdict.override_reason && <> — {data.verdict.override_reason}</>}
{data.verdict.override_adjustment != null && <> (adjustment: {data.verdict.override_adjustment})</>}
</Alert>
)}
</AccordionDetails>
</Accordion>
);
};

View file

@ -0,0 +1,40 @@
/**
* AnalysisDetailModal — types
*/
export interface AnalysisDetailModalProps {
open: boolean;
sessionId: string;
onClose: () => void;
}
export interface AnalysisDetail {
session_id: string;
user_id: string;
user_email: string | null;
input_type: string;
input_text: string | null;
input_url: string | null;
input_media_url: string | null;
status: string;
started_at: string;
completed_at: string | null;
total_duration_ms: number | null;
source_app: string;
components_run: string[] | null;
components_skipped: string[] | null;
verdict: any | null;
techniques: any | null;
ai_tampered: any | null;
claims: any | null;
domain: any | null;
source_assessment: any | null;
llm_usage: any | null;
}
export interface ModelPricing {
input_per_1m: number;
output_per_1m: number;
}
export type PricingMap = Record<string, ModelPricing>;

View file

@ -0,0 +1,297 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Container, Typography, Button, Alert,
Dialog, DialogTitle, DialogContent, DialogActions,
} from '@mui/material';
import { copyToClipboard } from '../../utils/clipboard';
import { AnalysisDetailModal } from './AnalysisDetailModal';
import type { AnalysisSession, PaginationInfo } from './history-types';
import { HistoryFilters } from './HistoryFilters';
import { HistoryTable } from './HistoryTable';
import { SocialPostModal } from './SocialPostModal';
const API_BASE = '';
export const AnalysisHistory: React.FC = () => {
const [analyses, setAnalyses] = useState<AnalysisSession[]>([]);
const [loading, setLoading] = useState(true);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState<PaginationInfo>({
page: 1,
limit: 20,
total: 0,
total_pages: 0,
has_next: false,
has_prev: false,
});
// Filters
const [searchFilter, setSearchFilter] = useState('');
const [riskLevelFilter, setRiskLevelFilter] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [fromDate, setFromDate] = useState<string>('');
const [toDate, setToDate] = useState<string>('');
// UI State
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedSession, setSelectedSession] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [sessionToDelete, setSessionToDelete] = useState<AnalysisSession | null>(null);
const [socialPostOpen, setSocialPostOpen] = useState(false);
const [socialPostSession, setSocialPostSession] = useState<AnalysisSession | null>(null);
const handleCopyId = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
copyToClipboard(id).then(() => { setCopiedId(id); setTimeout(() => setCopiedId(null), 2000); });
};
const getAuthHeader = () => {
const token = localStorage.getItem('keycloak_token');
return token ? { Authorization: `Bearer ${token}` } : {};
};
const fetchAnalyses = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
});
if (searchFilter) params.append('search', searchFilter);
if (riskLevelFilter) params.append('risk_level', riskLevelFilter);
if (statusFilter) params.append('status', statusFilter);
if (fromDate) params.append('from_date', fromDate);
if (toDate) params.append('to_date', toDate);
const response = await fetch(`${API_BASE}/agent-v3/api/v3/pipeline/history/admin?${params}`, {
headers: {
...getAuthHeader() as Record<string, string>,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to fetch analyses');
}
const data = await response.json();
setAnalyses(data.data?.items || []);
setPagination(prev => ({
...prev,
total: data.data?.pagination?.total || 0,
total_pages: data.data?.pagination?.total_pages || 0,
has_next: data.data?.pagination?.has_next || false,
has_prev: data.data?.pagination?.has_prev || false,
}));
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}, [pagination.page, pagination.limit, searchFilter, riskLevelFilter, statusFilter, fromDate, toDate]);
useEffect(() => {
fetchAnalyses();
}, [fetchAnalyses]);
const handlePageChange = (_event: unknown, newPage: number) => {
setPagination(prev => ({ ...prev, page: newPage + 1 }));
};
const handleRowsPerPageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setPagination(prev => ({ ...prev, limit: parseInt(event.target.value, 10), page: 1 }));
};
const handleViewDetails = (sessionId: string) => {
setSelectedSession(sessionId);
setDetailModalOpen(true);
};
const handleDeleteClick = (session: AnalysisSession) => {
setSessionToDelete(session);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
if (!sessionToDelete) return;
try {
const response = await fetch(
`${API_BASE}/agent-v3/api/v3/pipeline/history/admin/${sessionToDelete.session_id}`,
{
method: 'DELETE',
headers: {
...getAuthHeader() as Record<string, string>,
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
throw new Error('Failed to delete analysis');
}
fetchAnalyses();
} catch (err: any) {
setError(err.message);
} finally {
setDeleteDialogOpen(false);
setSessionToDelete(null);
}
};
const handleCancel = async (session: AnalysisSession) => {
try {
const response = await fetch(
`${API_BASE}/agent-v3/api/v3/pipeline/${session.session_id}/cancel`,
{
method: 'POST',
headers: {
...getAuthHeader() as Record<string, string>,
'Content-Type': 'application/json',
},
// user_id sent for staging fallback; JWT identity used when present.
body: JSON.stringify({ user_id: session.user_id }),
}
);
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || 'Failed to cancel analysis');
}
fetchAnalyses();
} catch (err: any) {
setError(err.message);
}
};
const handleResume = async (session: AnalysisSession) => {
try {
const response = await fetch(
`${API_BASE}/agent-v3/api/v3/pipeline/${session.session_id}/resume`,
{
method: 'POST',
headers: {
...getAuthHeader() as Record<string, string>,
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: session.user_id }),
}
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
// 409 = expired payload / already complete — surface the reason.
throw new Error(body.error || 'Failed to resume analysis');
}
fetchAnalyses();
} catch (err: any) {
setError(err.message);
}
};
const handleClearFilters = () => {
setSearchFilter('');
setRiskLevelFilter('');
setStatusFilter('');
setFromDate('');
setToDate('');
setPagination(prev => ({ ...prev, page: 1 }));
};
const hasActiveFilters = searchFilter || riskLevelFilter || statusFilter || fromDate || toDate;
// Filter changes reset page to 1
const onSearchChange = (v: string) => { setSearchFilter(v); setPagination(p => ({ ...p, page: 1 })); };
const onRiskLevelChange = (v: string) => { setRiskLevelFilter(v); setPagination(p => ({ ...p, page: 1 })); };
const onStatusChange = (v: string) => { setStatusFilter(v); setPagination(p => ({ ...p, page: 1 })); };
const onFromDateChange = (v: string) => { setFromDate(v); setPagination(p => ({ ...p, page: 1 })); };
const onToDateChange = (v: string) => { setToDate(v); setPagination(p => ({ ...p, page: 1 })); };
return (
<Container maxWidth="xl" sx={{ py: 3 }}>
<Typography variant="h4" gutterBottom>Analysis History</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Browse and manage analysis sessions
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
<HistoryFilters
searchFilter={searchFilter}
riskLevelFilter={riskLevelFilter}
statusFilter={statusFilter}
fromDate={fromDate}
toDate={toDate}
hasActiveFilters={hasActiveFilters}
onSearchChange={onSearchChange}
onRiskLevelChange={onRiskLevelChange}
onStatusChange={onStatusChange}
onFromDateChange={onFromDateChange}
onToDateChange={onToDateChange}
onRefresh={fetchAnalyses}
onClearFilters={handleClearFilters}
/>
<HistoryTable
loading={loading}
analyses={analyses}
pagination={pagination}
copiedId={copiedId}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
onViewDetails={handleViewDetails}
onDeleteClick={handleDeleteClick}
onCancelClick={handleCancel}
onResumeClick={handleResume}
onSocialPostClick={(s) => { setSocialPostSession(s); setSocialPostOpen(true); }}
onCopyId={handleCopyId}
/>
{/* Detail Modal */}
{selectedSession && (
<AnalysisDetailModal
open={detailModalOpen}
sessionId={selectedSession}
onClose={() => {
setDetailModalOpen(false);
setSelectedSession(null);
}}
/>
)}
{/* Social Post Modal */}
<SocialPostModal
open={socialPostOpen}
session={socialPostSession}
onClose={() => {
setSocialPostOpen(false);
setSocialPostSession(null);
}}
/>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle>Delete Analysis</DialogTitle>
<DialogContent>
<Typography>Are you sure you want to delete this analysis?</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Session: {sessionToDelete?.session_id}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleDeleteConfirm} color="error" variant="contained">
Delete
</Button>
</DialogActions>
</Dialog>
</Container>
);
};

View file

@ -0,0 +1,133 @@
import React from 'react';
import {
Box, Paper, Button, TextField, InputAdornment,
FormControl, InputLabel, Select, MenuItem,
} from '@mui/material';
import {
Search as SearchIcon,
Refresh as RefreshIcon,
FilterAltOff as ClearIcon,
} from '@mui/icons-material';
interface Props {
searchFilter: string;
riskLevelFilter: string;
statusFilter: string;
fromDate: string;
toDate: string;
hasActiveFilters: string | boolean;
onSearchChange: (value: string) => void;
onRiskLevelChange: (value: string) => void;
onStatusChange: (value: string) => void;
onFromDateChange: (value: string) => void;
onToDateChange: (value: string) => void;
onRefresh: () => void;
onClearFilters: () => void;
}
export const HistoryFilters: React.FC<Props> = ({
searchFilter,
riskLevelFilter,
statusFilter,
fromDate,
toDate,
hasActiveFilters,
onSearchChange,
onRiskLevelChange,
onStatusChange,
onFromDateChange,
onToDateChange,
onRefresh,
onClearFilters,
}) => (
<Paper sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<TextField
placeholder="Search by email or user..."
value={searchFilter}
onChange={(e) => onSearchChange(e.target.value)}
size="small"
sx={{ minWidth: 220 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
<FormControl size="small" sx={{ minWidth: 130 }}>
<InputLabel>Risk Level</InputLabel>
<Select
value={riskLevelFilter}
onChange={(e) => onRiskLevelChange(e.target.value)}
label="Risk Level"
>
<MenuItem value="">All</MenuItem>
<MenuItem value="VERY_LOW">Very Low</MenuItem>
<MenuItem value="LOW">Low</MenuItem>
<MenuItem value="MEDIUM">Medium</MenuItem>
<MenuItem value="HIGH">High</MenuItem>
<MenuItem value="VERY_HIGH">Very High</MenuItem>
<MenuItem value="CRITICAL">Critical</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Status</InputLabel>
<Select
value={statusFilter}
onChange={(e) => onStatusChange(e.target.value)}
label="Status"
>
<MenuItem value="">All</MenuItem>
<MenuItem value="completed">Completed</MenuItem>
<MenuItem value="failed">Failed</MenuItem>
<MenuItem value="running">Running</MenuItem>
</Select>
</FormControl>
<TextField
label="From"
type="date"
size="small"
value={fromDate}
onChange={(e) => onFromDateChange(e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ width: 150 }}
/>
<TextField
label="To"
type="date"
size="small"
value={toDate}
onChange={(e) => onToDateChange(e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ width: 150 }}
/>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={onRefresh}
size="medium"
>
Refresh
</Button>
{hasActiveFilters && (
<Button
variant="text"
color="secondary"
startIcon={<ClearIcon />}
onClick={onClearFilters}
size="medium"
>
Clear
</Button>
)}
</Box>
</Paper>
);

View file

@ -0,0 +1,271 @@
import React from 'react';
import {
Box, Paper, Typography, Chip, IconButton, Tooltip, LinearProgress,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination,
} from '@mui/material';
import {
History as HistoryIcon,
Delete as DeleteIcon,
Visibility as VisibilityIcon,
ContentCopy as CopyIcon,
Facebook as FacebookIcon,
Cancel as CancelIcon,
Replay as ResumeIcon,
} from '@mui/icons-material';
import type { AnalysisSession, PaginationInfo } from './history-types';
import { getRiskIcon, getRiskCategoryColor, formatDuration, formatDate } from './history-helpers';
// Sessions still in flight can be canceled (agent-v3 /:id/cancel).
const CANCELABLE = new Set(['pending', 'running', 'processing']);
// Failed/canceled sessions can be resumed from checkpoint (/:id/resume).
const RESUMABLE = new Set(['failed', 'canceled']);
interface Props {
loading: boolean;
analyses: AnalysisSession[];
pagination: PaginationInfo;
copiedId: string | null;
onPageChange: (event: unknown, newPage: number) => void;
onRowsPerPageChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onViewDetails: (sessionId: string) => void;
onDeleteClick: (session: AnalysisSession) => void;
onCopyId: (e: React.MouseEvent, id: string) => void;
onSocialPostClick?: (session: AnalysisSession) => void;
onCancelClick?: (session: AnalysisSession) => void;
onResumeClick?: (session: AnalysisSession) => void;
}
export const HistoryTable: React.FC<Props> = ({
loading,
analyses,
pagination,
copiedId,
onPageChange,
onRowsPerPageChange,
onViewDetails,
onDeleteClick,
onCopyId,
onSocialPostClick,
onCancelClick,
onResumeClick,
}) => (
<Paper>
{loading && <LinearProgress />}
<TableContainer>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: 'action.hover' }}>
<TableCell sx={{ fontWeight: 600 }}>Session ID</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Date</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Email</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Source</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Risk</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Techniques</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>AI %</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Claims</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Duration</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!loading && analyses.length === 0 ? (
<TableRow>
<TableCell colSpan={10} align="center" sx={{ py: 4 }}>
<HistoryIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
<Typography color="text.secondary">No analyses found</Typography>
</TableCell>
</TableRow>
) : (
analyses.map((analysis) => (
<TableRow
key={analysis.session_id}
hover
sx={{ cursor: 'pointer' }}
onClick={() => onViewDetails(analysis.session_id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Box display="flex" alignItems="center" gap={0.5}>
<Tooltip title={analysis.session_id}>
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
{analysis.session_id.substring(0, 8)}...
</Typography>
</Tooltip>
<Tooltip title={copiedId === analysis.session_id ? 'Copied!' : 'Copy'}>
<IconButton size="small" onClick={(e) => onCopyId(e, analysis.session_id)}>
<CopyIcon sx={{ fontSize: 14, color: copiedId === analysis.session_id ? 'success.main' : 'action.active' }} />
</IconButton>
</Tooltip>
</Box>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{formatDate(analysis.started_at)}
</Typography>
<Typography variant="caption" color="text.secondary">
{analysis.input_type}
</Typography>
</TableCell>
<TableCell>
<Tooltip title={`User ID: ${analysis.user_id}`}>
<Typography
variant="body2"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 180,
}}
>
{analysis.user_email || <span style={{ color: '#999' }}>No email</span>}
</Typography>
</Tooltip>
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip
label={analysis.input_type}
size="small"
variant="outlined"
color={analysis.input_type === 'url' ? 'primary' : analysis.input_type === 'text' ? 'default' : 'secondary'}
sx={{ textTransform: 'uppercase', fontSize: 11, height: 22 }}
/>
<Chip label={analysis.source_app} size="small" variant="outlined" sx={{ fontSize: 11, height: 22 }} />
</Box>
{(analysis.source_verdict || analysis.domain) && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.3 }}>
{analysis.source_publication || analysis.domain || ''}
{(analysis.source_verdict || analysis.domain_verdict) && (
<Chip
label={analysis.source_verdict || analysis.domain_verdict}
size="small"
sx={{
ml: 0.5, height: 18, fontSize: 10,
bgcolor: (analysis.source_verdict || analysis.domain_verdict) === 'TRUSTED' ? '#22c55e'
: (analysis.source_verdict || analysis.domain_verdict) === 'SUSPICIOUS' ? '#f97316'
: (analysis.source_verdict || analysis.domain_verdict) === 'UNTRUSTED' ? '#ef4444' : undefined,
color: (analysis.source_verdict || analysis.domain_verdict) !== 'NEUTRAL' ? '#fff' : undefined,
}}
/>
)}
</Typography>
)}
</TableCell>
<TableCell align="center">
{analysis.risk_score !== null ? (
<Tooltip title={`${analysis.risk_category} - ${analysis.risk_level}`}>
<Chip
icon={getRiskIcon(analysis.risk_level)}
label={`${analysis.risk_score} ${analysis.risk_category || ''}`}
size="small"
sx={{
bgcolor: getRiskCategoryColor(analysis.risk_category),
color: '#fff',
fontWeight: 600,
'& .MuiChip-icon': { color: '#fff' },
}}
/>
</Tooltip>
) : (
<Chip label="-" size="small" variant="outlined" />
)}
</TableCell>
<TableCell align="center">
{analysis.techniques_count !== null ? (
<Chip
label={analysis.techniques_count}
size="small"
color={Number(analysis.techniques_count) > 0 ? 'warning' : 'default'}
variant="outlined"
/>
) : '-'}
</TableCell>
<TableCell align="center">
{analysis.ai_probability !== null ? (
<Tooltip title={analysis.ai_verdict || ''}>
<Chip
label={`${Math.round(Number(analysis.ai_probability))}%`}
size="small"
color={Number(analysis.ai_probability) > 50 ? 'warning' : 'default'}
variant="outlined"
/>
</Tooltip>
) : '-'}
</TableCell>
<TableCell align="center">
{analysis.total_claims !== null ? (
<Tooltip title={`True: ${analysis.verified_true}, False: ${analysis.verified_false}`}>
<Typography variant="body2">
{analysis.total_claims}
</Typography>
</Tooltip>
) : '-'}
</TableCell>
<TableCell align="center">
<Typography variant="body2" color="text.secondary">
{formatDuration(analysis.total_duration_ms)}
</Typography>
</TableCell>
<TableCell align="center" onClick={(e) => e.stopPropagation()}>
<IconButton
size="small"
onClick={() => onViewDetails(analysis.session_id)}
title="View details"
>
<VisibilityIcon fontSize="small" />
</IconButton>
{onSocialPostClick && (
<IconButton
size="small"
onClick={() => onSocialPostClick(analysis)}
title="Post to Facebook"
sx={{ color: '#1877F2' }}
>
<FacebookIcon fontSize="small" />
</IconButton>
)}
{onCancelClick && CANCELABLE.has(analysis.status) && (
<IconButton
size="small"
onClick={() => onCancelClick(analysis)}
title="Cancel running analysis"
color="warning"
>
<CancelIcon fontSize="small" />
</IconButton>
)}
{onResumeClick && RESUMABLE.has(analysis.status) && (
<IconButton
size="small"
onClick={() => onResumeClick(analysis)}
title="Resume from checkpoint (re-runs only unfinished components)"
color="primary"
>
<ResumeIcon fontSize="small" />
</IconButton>
)}
<IconButton
size="small"
onClick={() => onDeleteClick(analysis)}
title="Delete"
color="error"
>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={pagination.total}
page={pagination.page - 1}
onPageChange={onPageChange}
rowsPerPage={pagination.limit}
onRowsPerPageChange={onRowsPerPageChange}
rowsPerPageOptions={[10, 20, 50, 100]}
/>
</Paper>
);

View file

@ -0,0 +1,423 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Box, TextField, Button, Alert, Chip, CircularProgress,
Typography, Stack, IconButton, Divider, Tooltip, Paper, Checkbox,
FormControlLabel,
} from '@mui/material';
import {
Facebook as FacebookIcon, Close as CloseIcon,
Send as SendIcon, Schedule as ScheduleIcon, OpenInNew as OpenInNewIcon,
Visibility as PreviewIcon,
} from '@mui/icons-material';
import axios from 'axios';
import type { AnalysisSession } from './history-types';
const FRAMEWORK_BASE = process.env.REACT_APP_FRAMEWORK_URL || '/framework';
const AGENT_BASE = process.env.REACT_APP_AGENT_V3_URL || '/agent-v3';
const SHARE_BASE = 'https://didi365.eu/share';
type Status = 'idle' | 'loading' | 'editing' | 'publishing' | 'published' | 'error';
type Block = {
id: string;
label: string;
enabled: boolean;
content: string;
};
interface Props {
open: boolean;
onClose: () => void;
session: AnalysisSession | null;
onSuccess?: (postId: string, externalUrl: string) => void;
}
const buildBlocks = (detail: any): Block[] => {
const risk = detail.risk_category || 'NEUTRAL';
const score = detail.risk_score ?? 0;
const emoji =
score >= 80 ? '🚨' : score >= 60 ? '⚠️' : score >= 40 ? 'ℹ️' : '✅';
const tech = detail.techniques || {};
const claims = detail.claims || {};
const ai = detail.ai_tampered || {};
const domain = detail.domain || {};
const verdict = detail.verdict || {};
const techNames: string = (tech.techniques_detected || [])
.map((t: any) => t.name_ro || t.name_en || t.name)
.filter(Boolean)
.slice(0, 5)
.join(', ');
const blocks: Block[] = [
{
id: 'header',
label: 'Antet (verdict + scor)',
enabled: true,
content: `${emoji} DiDi · ${risk} · Scor risc ${score}/100`,
},
{
id: 'verdict',
label: 'Explicație verdict',
enabled: !!verdict.explanation_ro,
content: (verdict.explanation_ro || verdict.explanation_en || '').trim(),
},
{
id: 'techniques',
label: 'Tehnici de manipulare detectate',
enabled: (tech.techniques_count || 0) > 0,
content: techNames
? `Tehnici de manipulare detectate (${tech.techniques_count}): ${techNames}\nScor manipulare: ${tech.manipulation_score ?? 0}/100`
: '',
},
{
id: 'claims',
label: 'Afirmații verificate',
enabled: (claims.total_claims || 0) > 0,
content: claims.total_claims
? `Afirmații verificate: ${claims.verified_true || 0} confirmate, ${claims.verified_false || 0} false (credibilitate ${claims.credibility_score ?? 0}/100)`
: '',
},
{
id: 'ai',
label: 'Detectare conținut AI',
enabled: (ai.ai_probability || 0) >= 50,
content:
typeof ai.ai_probability === 'number'
? `Probabilitate generat AI: ${ai.ai_probability}% (${ai.verdict || 'N/A'})`
: '',
},
{
id: 'domain',
label: 'Sursă / Domeniu',
enabled: !!(domain.domain && domain.domain_verdict),
content: domain.domain
? `Sursă: ${domain.domain} · ${domain.domain_verdict || ''} (trust ${domain.domain_trust_score ?? 0}/100)`
: '',
},
{
id: 'hashtags',
label: 'Hashtags',
enabled: true,
content: '#DiDi #Antifake #VerificatDeDiDi #DESI',
},
{
id: 'link',
label: 'Link analiză publică',
enabled: false,
content: detail.session_id
? `Vezi analiza completă: ${SHARE_BASE}/${detail.session_id}`
: '',
},
];
return blocks.filter((b) => b.content || b.id === 'header' || b.id === 'link');
};
const joinBlocks = (blocks: Block[]): string =>
blocks
.filter((b) => b.enabled && b.content.trim())
.map((b) => b.content.trim())
.join('\n\n');
export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuccess }) => {
const [status, setStatus] = useState<Status>('idle');
const [blocks, setBlocks] = useState<Block[]>([]);
const [imageUrl, setImageUrl] = useState('');
const [linkUrl, setLinkUrl] = useState('');
const [scheduledAt, setScheduledAt] = useState('');
const [error, setError] = useState<string | null>(null);
const [postId, setPostId] = useState<string | null>(null);
const [externalUrl, setExternalUrl] = useState<string | null>(null);
useEffect(() => {
if (!open || !session) return;
setStatus('loading');
setBlocks([]);
setImageUrl('');
setLinkUrl(session.session_id ? `${SHARE_BASE}/${session.session_id}` : '');
setScheduledAt('');
setError(null);
setPostId(null);
setExternalUrl(null);
const token = localStorage.getItem('keycloak_token');
axios
.get(`${AGENT_BASE}/api/v3/pipeline/history/admin/${session.session_id}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => {
const detail = res.data?.data || res.data;
if (!detail) throw new Error('Răspuns gol de la API');
setBlocks(buildBlocks(detail));
setStatus('editing');
})
.catch((e) => {
setError(e?.response?.data?.error || e.message);
setStatus('error');
});
}, [open, session]);
const previewText = useMemo(() => joinBlocks(blocks), [blocks]);
const charCount = previewText.length;
const updateBlock = (id: string, patch: Partial<Block>) => {
setBlocks((prev) => prev.map((b) => (b.id === id ? { ...b, ...patch } : b)));
};
const handlePublish = async () => {
if (!previewText.trim()) {
setError('Niciun bloc bifat sau toate sunt goale');
return;
}
setStatus('publishing');
setError(null);
try {
const token = localStorage.getItem('keycloak_token');
const draftRes = await axios.post(
`${FRAMEWORK_BASE}/api/admin/social/draft`,
{
session_id: session?.session_id,
content: previewText,
image_url: imageUrl || undefined,
link_url: linkUrl || undefined,
platform: 'facebook',
},
{ headers: { Authorization: `Bearer ${token}` } },
);
const createdId = draftRes.data?.data?.post_id?.toString();
if (!createdId) throw new Error(draftRes.data?.error || 'Salvare draft eșuată');
setPostId(createdId);
const pubRes = await axios.post(
`${FRAMEWORK_BASE}/api/admin/social/publish/${createdId}`,
scheduledAt ? { scheduled_at: new Date(scheduledAt).toISOString() } : {},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (pubRes.data?.success && pubRes.data.data) {
setExternalUrl(pubRes.data.data.external_url || null);
setStatus('published');
if (onSuccess) onSuccess(pubRes.data.data.post_id, pubRes.data.data.external_url);
} else {
throw new Error(pubRes.data?.error || 'Publishing failed');
}
} catch (e: any) {
setError(e?.response?.data?.error || e?.message || 'Publishing failed');
setStatus('error');
}
};
const minScheduleDate = new Date(Date.now() + 11 * 60 * 1000).toISOString().slice(0, 16);
const enabledCount = blocks.filter((b) => b.enabled && b.content.trim()).length;
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FacebookIcon sx={{ color: '#1877F2' }} />
Post to Facebook
<Box sx={{ flexGrow: 1 }} />
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
{status === 'loading' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 3 }}>
<CircularProgress size={20} />
<Typography>Se încarcă datele analizei...</Typography>
</Box>
)}
{(status === 'editing' || status === 'publishing' || status === 'error') && (
<Stack spacing={2}>
{session && (
<Alert severity="info">
Analiza <code style={{ fontSize: '0.85em' }}>{session.session_id.slice(0, 8)}...</code>
{session.risk_score !== null && session.risk_score !== undefined && (
<> · Risc: <strong>{session.risk_score}</strong> ({session.risk_category})</>
)}
{' '}— bifează blocurile pe care vrei să le incluzi în post și editează textul.
</Alert>
)}
<Typography variant="overline" color="text.secondary">
Blocuri ({enabledCount} active din {blocks.length})
</Typography>
<Stack spacing={1.5}>
{blocks.map((block) => (
<Paper
key={block.id}
variant="outlined"
sx={{
p: 1.5,
bgcolor: block.enabled ? 'background.paper' : 'action.disabledBackground',
opacity: block.enabled ? 1 : 0.6,
}}
>
<FormControlLabel
control={
<Checkbox
checked={block.enabled}
onChange={(e) => updateBlock(block.id, { enabled: e.target.checked })}
disabled={status === 'publishing'}
size="small"
/>
}
label={
<Typography variant="body2" fontWeight={600}>
{block.label}
</Typography>
}
sx={{ mb: 0.5 }}
/>
<TextField
multiline
minRows={2}
maxRows={8}
value={block.content}
onChange={(e) => updateBlock(block.id, { content: e.target.value })}
fullWidth
size="small"
disabled={!block.enabled || status === 'publishing'}
placeholder="(gol — nu va apărea în post)"
/>
</Paper>
))}
</Stack>
<Divider />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PreviewIcon fontSize="small" color="action" />
<Typography variant="overline" color="text.secondary">
Preview post · {charCount} caractere
{charCount > 500 && (
<Chip label="Lung" color="warning" size="small" sx={{ ml: 1, height: 18 }} />
)}
</Typography>
</Box>
<Paper
variant="outlined"
sx={{
p: 2,
bgcolor: '#f5f7fa',
fontFamily: 'system-ui, sans-serif',
whiteSpace: 'pre-wrap',
fontSize: '0.9em',
maxHeight: 240,
overflow: 'auto',
}}
>
{previewText || <em style={{ color: '#999' }}>(post gol — bifează cel puțin un bloc)</em>}
</Paper>
<TextField
label="URL imagine (opțional — atașament foto)"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
fullWidth
size="small"
disabled={status === 'publishing'}
placeholder="https://didi365.eu/share/image.png"
/>
<TextField
label="URL link (opțional — link clickable atașat la post)"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
fullWidth
size="small"
disabled={status === 'publishing'}
placeholder="https://didi365.eu/analyses/..."
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Chip
icon={<ScheduleIcon />}
label={
scheduledAt
? `Programat: ${new Date(scheduledAt).toLocaleString('ro-RO')}`
: 'Publicare imediată'
}
color={scheduledAt ? 'warning' : 'default'}
onDelete={scheduledAt ? () => setScheduledAt('') : undefined}
/>
<Tooltip title="Programează publicarea (min 10 min în viitor)">
<TextField
type="datetime-local"
size="small"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
inputProps={{ min: minScheduleDate }}
disabled={status === 'publishing'}
sx={{ width: 220 }}
/>
</Tooltip>
</Box>
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
</Stack>
)}
{status === 'published' && (
<Stack spacing={2} sx={{ py: 2 }}>
<Alert severity="success">
{scheduledAt ? '✓ Programat cu success pe Facebook!' : '✓ Publicat cu success pe Facebook!'}
</Alert>
{externalUrl && (
<Button
href={externalUrl}
target="_blank"
rel="noopener noreferrer"
startIcon={<OpenInNewIcon />}
variant="outlined"
size="small"
>
Deschide pe Facebook
</Button>
)}
</Stack>
)}
</DialogContent>
<DialogActions>
{status === 'published' ? (
<Button onClick={onClose} variant="contained">
Închide
</Button>
) : (
<>
<Button onClick={onClose} disabled={status === 'publishing'}>
Anulează
</Button>
<Button
variant="contained"
startIcon={
status === 'publishing' ? (
<CircularProgress size={16} color="inherit" />
) : (
<SendIcon />
)
}
onClick={handlePublish}
disabled={status !== 'editing' || !previewText.trim()}
sx={{ bgcolor: '#1877F2', '&:hover': { bgcolor: '#0e5fc8' } }}
>
{status === 'publishing'
? 'Se publică...'
: scheduledAt
? 'Programează'
: 'Publică pe Facebook'}
</Button>
</>
)}
</DialogActions>
</Dialog>
);
};

View file

@ -0,0 +1,73 @@
/**
* AnalysisHistory — color helpers + formatters
*/
import React from 'react';
import {
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
Error as ErrorIcon,
Info as InfoIcon,
} from '@mui/icons-material';
export const getRiskColor = (level: string | null): 'success' | 'warning' | 'error' | 'default' => {
switch (level) {
case 'VERY_LOW':
case 'LOW':
return 'success';
case 'MEDIUM':
case 'HIGH':
return 'warning';
case 'VERY_HIGH':
case 'CRITICAL':
return 'error';
default:
return 'default';
}
};
export const getRiskIcon = (level: string | null): React.ReactElement | undefined => {
switch (level) {
case 'VERY_LOW':
case 'LOW':
return <CheckCircleIcon fontSize="small" />;
case 'MEDIUM':
return <InfoIcon fontSize="small" />;
case 'HIGH':
case 'VERY_HIGH':
return <WarningIcon fontSize="small" />;
case 'CRITICAL':
return <ErrorIcon fontSize="small" />;
default:
return undefined;
}
};
export const getRiskCategoryColor = (category: string | null): string => {
switch (category) {
case 'RELIABLE': return '#22c55e';
case 'MOSTLY_RELIABLE': return '#84cc16';
case 'MIXED': return '#eab308';
case 'QUESTIONABLE': return '#f97316';
case 'UNRELIABLE': return '#ef4444';
case 'DISINFORMATION': return '#7f1d1d';
default: return '#9e9e9e';
}
};
export const formatDuration = (ms: number | null) => {
if (!ms) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
export const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString('ro-RO', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};

View file

@ -0,0 +1,48 @@
/**
* AnalysisHistory — types
*/
export interface AnalysisSession {
session_id: string;
user_id: string;
user_email: string | null;
input_type: string;
input_preview: string | null;
input_url: string | null;
status: string;
started_at: string;
completed_at: string | null;
total_duration_ms: number | null;
source_app: string;
risk_score: number | null;
risk_category: string | null;
risk_level: string | null;
confidence: number | null;
confidence_level: string | null;
techniques_score: string | null;
techniques_count: number | null;
ai_probability: string | null;
ai_verdict: string | null;
total_claims: number | null;
verified_true: number | null;
verified_false: number | null;
claims_score: string | null;
domain: string | null;
domain_verdict: string | null;
domain_trust_score: number | null;
source_trust_score: number | null;
source_verdict: string | null;
source_publication: string | null;
source_author: string | null;
source_platform: string | null;
llm_usage: any | null;
}
export interface PaginationInfo {
page: number;
limit: number;
total: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
}

View file

@ -0,0 +1,2 @@
export { AnalysisHistory } from './AnalysisHistory';
export { AnalysisDetailModal } from './AnalysisDetailModal';

View file

@ -0,0 +1,108 @@
/**
* VerdictConfig — fetch wrappers
*/
import type {
ComponentWeight,
Multiplier,
VerdictCategory,
RiskMapping,
SeverityAssessment,
VerdictConfigData,
} from './types';
const FRAMEWORK_API = '/framework/api';
const AGENT_API = '/agent-v3/api/v3/pipeline';
export async function fetchWeights() {
const res = await fetch(`${FRAMEWORK_API}/weights/all`);
return res.json();
}
export async function fetchVerdicts() {
const res = await fetch(`${FRAMEWORK_API}/verdicts/all`);
return res.json();
}
export async function fetchVerdictConfig(): Promise<{ success: boolean; data: VerdictConfigData | null }> {
const res = await fetch(`${AGENT_API}/verdict-config`);
return res.json();
}
export async function updateComponentWeight(id: number, data: Partial<ComponentWeight>) {
const res = await fetch(`${FRAMEWORK_API}/weights/components/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
export async function createMultiplier(data: Partial<Multiplier>) {
const res = await fetch(`${FRAMEWORK_API}/weights/multipliers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
export async function updateMultiplier(id: number, data: Partial<Multiplier>) {
const res = await fetch(`${FRAMEWORK_API}/weights/multipliers/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
export async function deleteMultiplier(id: number) {
const res = await fetch(`${FRAMEWORK_API}/weights/multipliers/${id}`, { method: 'DELETE' });
return res.json();
}
export async function updateVerdictCategory(id: number, data: Partial<VerdictCategory>) {
const res = await fetch(`${FRAMEWORK_API}/verdicts/categories/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
export async function updateRiskMapping(id: number, data: Partial<RiskMapping>) {
const res = await fetch(`${FRAMEWORK_API}/verdicts/risk/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
export async function updateSeverity(id: string, data: Partial<SeverityAssessment>) {
const res = await fetch(`${FRAMEWORK_API}/verdicts/severity/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
export async function fetchRuntimeConfig(): Promise<{ success: boolean; data: VerdictConfigData | null; error?: string }> {
const res = await fetch(`${FRAMEWORK_API}/verdicts/runtime-config`);
return res.json();
}
export async function updateRuntimeConfig(data: VerdictConfigData) {
const res = await fetch(`${FRAMEWORK_API}/verdicts/runtime-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
export async function syncRedis() {
const res = await fetch(`${FRAMEWORK_API}/sync-redis`, { method: 'POST' });
return res.json();
}

View file

@ -0,0 +1,232 @@
import React from 'react';
import {
Box, Typography, Slider,
Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField,
} from '@mui/material';
import type {
ComponentWeight, Multiplier, VerdictCategory, RiskMapping, SeverityAssessment,
DeleteDialogState,
} from '../types';
type DialogState<T> = { open: boolean; data: T | null };
interface WeightDialogProps {
state: DialogState<ComponentWeight>;
setState: React.Dispatch<React.SetStateAction<DialogState<ComponentWeight>>>;
onSave: () => void;
}
export const WeightDialog: React.FC<WeightDialogProps> = ({ state, setState, onSave }) => (
<Dialog open={state.open} onClose={() => setState({ open: false, data: null })} maxWidth="sm" fullWidth>
<DialogTitle>Edit Component Weight</DialogTitle>
<DialogContent>
{state.data && (
<Box pt={1} display="flex" flexDirection="column" gap={2}>
<TextField label="Component" value={state.data.component_name} disabled fullWidth />
<Box>
<Typography gutterBottom>Weight: {state.data.component_weight}%</Typography>
<Slider
value={state.data.component_weight}
onChange={(_, v) => setState({ ...state, data: { ...state.data!, component_weight: v as number } })}
min={0}
max={100}
step={1}
marks={[{ value: 0, label: '0%' }, { value: 25, label: '25%' }, { value: 50, label: '50%' }, { value: 75, label: '75%' }, { value: 100, label: '100%' }]}
/>
</Box>
<TextField
label="Description"
value={state.data.description}
onChange={(e) => setState({ ...state, data: { ...state.data!, description: e.target.value } })}
multiline
rows={2}
fullWidth
/>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
<Button variant="contained" onClick={onSave}>Save</Button>
</DialogActions>
</Dialog>
);
interface MultiplierDialogProps {
state: DialogState<Partial<Multiplier>>;
setState: React.Dispatch<React.SetStateAction<DialogState<Partial<Multiplier>>>>;
onSave: () => void;
}
export const MultiplierDialog: React.FC<MultiplierDialogProps> = ({ state, setState, onSave }) => (
<Dialog open={state.open} onClose={() => setState({ open: false, data: null })} maxWidth="sm" fullWidth>
<DialogTitle>{state.data?.multiplier_id ? 'Edit Multiplier' : 'Add Topic Multiplier'}</DialogTitle>
<DialogContent>
{state.data && (
<Box pt={1} display="flex" flexDirection="column" gap={2}>
<TextField
label="Name"
value={state.data.multiplier_name || ''}
onChange={(e) => setState({ ...state, data: { ...state.data!, multiplier_name: e.target.value } })}
fullWidth
required
placeholder="e.g. elections, health, geopolitics"
/>
<Box>
<Typography gutterBottom>Multiplier: {(state.data.multiplier || 1).toFixed(2)}x</Typography>
<Slider
value={state.data.multiplier || 1}
onChange={(_, v) => setState({ ...state, data: { ...state.data!, multiplier: v as number } })}
min={0.5}
max={2}
step={0.05}
marks={[{ value: 0.5, label: '0.5x' }, { value: 1, label: '1x' }, { value: 1.5, label: '1.5x' }, { value: 2, label: '2x' }]}
/>
</Box>
<TextField
label="Description"
value={state.data.description || ''}
onChange={(e) => setState({ ...state, data: { ...state.data!, description: e.target.value } })}
multiline
rows={2}
fullWidth
/>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
<Button variant="contained" onClick={onSave} disabled={!state.data?.multiplier_name}>Save</Button>
</DialogActions>
</Dialog>
);
interface CategoryDialogProps {
state: DialogState<VerdictCategory>;
setState: React.Dispatch<React.SetStateAction<DialogState<VerdictCategory>>>;
onSave: () => void;
}
export const CategoryDialog: React.FC<CategoryDialogProps> = ({ state, setState, onSave }) => (
<Dialog open={state.open} onClose={() => setState({ open: false, data: null })} maxWidth="sm" fullWidth>
<DialogTitle>Edit Verdict Category</DialogTitle>
<DialogContent>
{state.data && (
<Box pt={1} display="flex" flexDirection="column" gap={2}>
<TextField label="Code" value={state.data.verdict_category_code} onChange={(e) => setState({ ...state, data: { ...state.data!, verdict_category_code: e.target.value } })} fullWidth />
<TextField label="Description" value={state.data.description} onChange={(e) => setState({ ...state, data: { ...state.data!, description: e.target.value } })} fullWidth />
<Box display="flex" gap={2}>
<TextField label="Start Range" type="number" value={state.data.start_range} onChange={(e) => setState({ ...state, data: { ...state.data!, start_range: Number(e.target.value) } })} sx={{ flex: 1 }} />
<TextField label="End Range" type="number" value={state.data.end_range} onChange={(e) => setState({ ...state, data: { ...state.data!, end_range: Number(e.target.value) } })} sx={{ flex: 1 }} />
</Box>
<TextField label="Color" value={state.data.verdict_category_color} onChange={(e) => setState({ ...state, data: { ...state.data!, verdict_category_color: e.target.value } })} fullWidth InputProps={{ startAdornment: <Box sx={{ width: 24, height: 24, bgcolor: state.data.verdict_category_color, borderRadius: 1, mr: 1 }} /> }} />
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
<Button variant="contained" onClick={onSave}>Save</Button>
</DialogActions>
</Dialog>
);
interface RiskDialogProps {
state: DialogState<RiskMapping>;
setState: React.Dispatch<React.SetStateAction<DialogState<RiskMapping>>>;
onSave: () => void;
}
export const RiskDialog: React.FC<RiskDialogProps> = ({ state, setState, onSave }) => (
<Dialog open={state.open} onClose={() => setState({ open: false, data: null })} maxWidth="sm" fullWidth>
<DialogTitle>Edit Risk Mapping</DialogTitle>
<DialogContent>
{state.data && (
<Box pt={1} display="flex" flexDirection="column" gap={2}>
<TextField label="Risk Level Name" value={state.data.risk_mapping} onChange={(e) => setState({ ...state, data: { ...state.data!, risk_mapping: e.target.value } })} fullWidth />
<TextField label="Level Value" type="number" value={state.data.risk_level} onChange={(e) => setState({ ...state, data: { ...state.data!, risk_level: Number(e.target.value) } })} fullWidth />
<Box display="flex" gap={2}>
<TextField label="Start Range" type="number" value={state.data.start_range} onChange={(e) => setState({ ...state, data: { ...state.data!, start_range: Number(e.target.value) } })} sx={{ flex: 1 }} />
<TextField label="End Range" type="number" value={state.data.end_range} onChange={(e) => setState({ ...state, data: { ...state.data!, end_range: Number(e.target.value) } })} sx={{ flex: 1 }} />
</Box>
<TextField label="Color" value={state.data.risk_color} onChange={(e) => setState({ ...state, data: { ...state.data!, risk_color: e.target.value } })} fullWidth InputProps={{ startAdornment: <Box sx={{ width: 24, height: 24, bgcolor: state.data.risk_color, borderRadius: 1, mr: 1 }} /> }} />
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
<Button variant="contained" onClick={onSave}>Save</Button>
</DialogActions>
</Dialog>
);
interface SeverityDialogProps {
state: DialogState<SeverityAssessment>;
setState: React.Dispatch<React.SetStateAction<DialogState<SeverityAssessment>>>;
onSave: () => void;
}
export const SeverityDialog: React.FC<SeverityDialogProps> = ({ state, setState, onSave }) => (
<Dialog open={state.open} onClose={() => setState({ open: false, data: null })} maxWidth="sm" fullWidth>
<DialogTitle>Edit Severity Level</DialogTitle>
<DialogContent>
{state.data && (
<Box pt={1} display="flex" flexDirection="column" gap={2}>
<TextField label="Severity ID" value={state.data.severity_id} disabled fullWidth helperText="Primary key — cannot be changed" />
<TextField
label="Severity Category"
value={state.data.severity_category}
onChange={(e) => setState({ ...state, data: { ...state.data!, severity_category: e.target.value } })}
fullWidth
helperText="LOW / MEDIUM / HIGH / CRITICAL"
/>
<Box display="flex" gap={2}>
<TextField
label="Start Range"
type="number"
value={state.data.start_range}
onChange={(e) => setState({ ...state, data: { ...state.data!, start_range: Number(e.target.value) } })}
sx={{ flex: 1 }}
/>
<TextField
label="End Range"
type="number"
value={state.data.end_range}
onChange={(e) => setState({ ...state, data: { ...state.data!, end_range: Number(e.target.value) } })}
sx={{ flex: 1 }}
/>
</Box>
<TextField
label="Recommended Action"
value={state.data.recomended_action}
onChange={(e) => setState({ ...state, data: { ...state.data!, recomended_action: e.target.value } })}
fullWidth
helperText="MONITOR / REVIEW / ESCALATE / URGENT"
/>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
<Button variant="contained" onClick={onSave}>Save &amp; Sync</Button>
</DialogActions>
</Dialog>
);
interface DeleteDialogProps {
state: DeleteDialogState | null;
onCancel: () => void;
onConfirm: () => void;
}
export const DeleteDialog: React.FC<DeleteDialogProps> = ({ state, onCancel, onConfirm }) => (
<Dialog open={!!state} onClose={onCancel}>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
<Typography>Are you sure you want to delete <strong>{state?.name}</strong>?</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onCancel}>Cancel</Button>
<Button color="error" variant="contained" onClick={onConfirm}>Delete</Button>
</DialogActions>
</Dialog>
);

View file

@ -0,0 +1,216 @@
import React from 'react';
import {
Box, Typography,
Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField,
FormControl, InputLabel, Select, MenuItem,
} from '@mui/material';
import type { VerdictConfigData } from '../types';
interface Props {
state: { open: boolean; data: VerdictConfigData | null };
setState: React.Dispatch<React.SetStateAction<{ open: boolean; data: VerdictConfigData | null }>>;
updateField: (path: string[], value: unknown) => void;
onSave: () => void;
}
export const RuntimeConfigDialog: React.FC<Props> = ({ state, setState, updateField, onSave }) => (
<Dialog
open={state.open}
onClose={() => setState({ open: false, data: null })}
maxWidth="md"
fullWidth
>
<DialogTitle>Edit Verdict Runtime Config</DialogTitle>
<DialogContent dividers>
{state.data && (
<Box display="flex" flexDirection="column" gap={3}>
<Box>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>Synergy Bonus</Typography>
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
<FormControl size="small">
<InputLabel>Enabled</InputLabel>
<Select
label="Enabled"
value={state.data.synergy.enabled ? 'true' : 'false'}
onChange={(e) => updateField(['synergy', 'enabled'], e.target.value === 'true')}
>
<MenuItem value="true">ON</MenuItem>
<MenuItem value="false">OFF</MenuItem>
</Select>
</FormControl>
<TextField label="Threshold (component score)" type="number" size="small"
value={state.data.synergy.threshold}
onChange={(e) => updateField(['synergy', 'threshold'], Number(e.target.value))} />
<TextField label="Bonus per component" type="number" size="small"
value={state.data.synergy.bonus_per_component}
onChange={(e) => updateField(['synergy', 'bonus_per_component'], Number(e.target.value))} />
<TextField label="Max bonus" type="number" size="small"
value={state.data.synergy.max_bonus}
onChange={(e) => updateField(['synergy', 'max_bonus'], Number(e.target.value))} />
</Box>
</Box>
<Box>
<Typography variant="subtitle2" fontWeight={700} gutterBottom>Override: False Claims</Typography>
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
<FormControl size="small">
<InputLabel>Enabled</InputLabel>
<Select label="Enabled"
value={state.data.overrides.false_claims.enabled ? 'true' : 'false'}
onChange={(e) => updateField(['overrides', 'false_claims', 'enabled'], e.target.value === 'true')}>
<MenuItem value="true">ON</MenuItem>
<MenuItem value="false">OFF</MenuItem>
</Select>
</FormControl>
<TextField label="Threshold" type="number" size="small"
value={state.data.overrides.false_claims.threshold}
onChange={(e) => updateField(['overrides', 'false_claims', 'threshold'], Number(e.target.value))} />
<TextField label="Bonus per claim" type="number" size="small"
value={state.data.overrides.false_claims.bonus_per_claim}
onChange={(e) => updateField(['overrides', 'false_claims', 'bonus_per_claim'], Number(e.target.value))} />
<TextField label="Max bonus" type="number" size="small"
value={state.data.overrides.false_claims.max_bonus}
onChange={(e) => updateField(['overrides', 'false_claims', 'max_bonus'], Number(e.target.value))} />
</Box>
</Box>
<Box>
<Typography variant="subtitle2" fontWeight={700} gutterBottom>Override: Severe Techniques</Typography>
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
<FormControl size="small">
<InputLabel>Enabled</InputLabel>
<Select label="Enabled"
value={state.data.overrides.severe_techniques.enabled ? 'true' : 'false'}
onChange={(e) => updateField(['overrides', 'severe_techniques', 'enabled'], e.target.value === 'true')}>
<MenuItem value="true">ON</MenuItem>
<MenuItem value="false">OFF</MenuItem>
</Select>
</FormControl>
<TextField label="Threshold" type="number" size="small"
value={state.data.overrides.severe_techniques.threshold}
onChange={(e) => updateField(['overrides', 'severe_techniques', 'threshold'], Number(e.target.value))} />
<TextField label="Bonus" type="number" size="small"
value={state.data.overrides.severe_techniques.bonus}
onChange={(e) => updateField(['overrides', 'severe_techniques', 'bonus'], Number(e.target.value))} />
</Box>
</Box>
<Box>
<Typography variant="subtitle2" fontWeight={700} gutterBottom>Override: Undisclosed AI</Typography>
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
<FormControl size="small">
<InputLabel>Enabled</InputLabel>
<Select label="Enabled"
value={state.data.overrides.undisclosed_ai.enabled ? 'true' : 'false'}
onChange={(e) => updateField(['overrides', 'undisclosed_ai', 'enabled'], e.target.value === 'true')}>
<MenuItem value="true">ON</MenuItem>
<MenuItem value="false">OFF</MenuItem>
</Select>
</FormControl>
<TextField label="Bonus" type="number" size="small"
value={state.data.overrides.undisclosed_ai.bonus}
onChange={(e) => updateField(['overrides', 'undisclosed_ai', 'bonus'], Number(e.target.value))} />
</Box>
</Box>
<Box>
<Typography variant="subtitle2" fontWeight={700} gutterBottom>Override: Untrusted Domain</Typography>
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
<FormControl size="small">
<InputLabel>Enabled</InputLabel>
<Select label="Enabled"
value={state.data.overrides.untrusted_domain.enabled ? 'true' : 'false'}
onChange={(e) => updateField(['overrides', 'untrusted_domain', 'enabled'], e.target.value === 'true')}>
<MenuItem value="true">ON</MenuItem>
<MenuItem value="false">OFF</MenuItem>
</Select>
</FormControl>
<TextField label="Untrusted bonus" type="number" size="small"
value={state.data.overrides.untrusted_domain.untrusted_bonus}
onChange={(e) => updateField(['overrides', 'untrusted_domain', 'untrusted_bonus'], Number(e.target.value))} />
<TextField label="Suspicious bonus" type="number" size="small"
value={state.data.overrides.untrusted_domain.suspicious_bonus}
onChange={(e) => updateField(['overrides', 'untrusted_domain', 'suspicious_bonus'], Number(e.target.value))} />
<TextField label="Blacklisted bonus" type="number" size="small"
value={state.data.overrides.untrusted_domain.blacklisted_bonus}
onChange={(e) => updateField(['overrides', 'untrusted_domain', 'blacklisted_bonus'], Number(e.target.value))} />
</Box>
</Box>
<Box>
<Typography variant="subtitle2" fontWeight={700} gutterBottom>Override: Domain Red Flags</Typography>
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
<FormControl size="small">
<InputLabel>Enabled</InputLabel>
<Select label="Enabled"
value={state.data.overrides.domain_red_flags.enabled ? 'true' : 'false'}
onChange={(e) => updateField(['overrides', 'domain_red_flags', 'enabled'], e.target.value === 'true')}>
<MenuItem value="true">ON</MenuItem>
<MenuItem value="false">OFF</MenuItem>
</Select>
</FormControl>
<TextField label="Threshold" type="number" size="small"
value={state.data.overrides.domain_red_flags.threshold}
onChange={(e) => updateField(['overrides', 'domain_red_flags', 'threshold'], Number(e.target.value))} />
<TextField label="Bonus per flag" type="number" size="small"
value={state.data.overrides.domain_red_flags.bonus_per_flag}
onChange={(e) => updateField(['overrides', 'domain_red_flags', 'bonus_per_flag'], Number(e.target.value))} />
<TextField label="Max bonus" type="number" size="small"
value={state.data.overrides.domain_red_flags.max_bonus}
onChange={(e) => updateField(['overrides', 'domain_red_flags', 'max_bonus'], Number(e.target.value))} />
</Box>
</Box>
<Box>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>Confidence Bonuses</Typography>
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
<TextField label="Base per component" type="number" size="small"
value={state.data.confidence.base_per_component}
onChange={(e) => updateField(['confidence', 'base_per_component'], Number(e.target.value))} />
<TextField label="Domain strong signal" type="number" size="small"
value={state.data.confidence.domain_strong_signal_bonus}
onChange={(e) => updateField(['confidence', 'domain_strong_signal_bonus'], Number(e.target.value))} />
<TextField label="Domain weak signal" type="number" size="small"
value={state.data.confidence.domain_weak_signal_bonus}
onChange={(e) => updateField(['confidence', 'domain_weak_signal_bonus'], Number(e.target.value))} />
<TextField label="Techniques bonus max" type="number" size="small"
value={state.data.confidence.techniques_bonus_max}
onChange={(e) => updateField(['confidence', 'techniques_bonus_max'], Number(e.target.value))} />
<TextField label="AI HIGH conf bonus" type="number" size="small"
value={state.data.confidence.ai_high_confidence_bonus}
onChange={(e) => updateField(['confidence', 'ai_high_confidence_bonus'], Number(e.target.value))} />
<TextField label="AI MEDIUM conf bonus" type="number" size="small"
value={state.data.confidence.ai_medium_confidence_bonus}
onChange={(e) => updateField(['confidence', 'ai_medium_confidence_bonus'], Number(e.target.value))} />
<TextField label="AI LOW conf bonus" type="number" size="small"
value={state.data.confidence.ai_low_confidence_bonus}
onChange={(e) => updateField(['confidence', 'ai_low_confidence_bonus'], Number(e.target.value))} />
<TextField label="Claims verified bonus max" type="number" size="small"
value={state.data.confidence.claims_verified_bonus_max}
onChange={(e) => updateField(['confidence', 'claims_verified_bonus_max'], Number(e.target.value))} />
</Box>
</Box>
<Box>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>Confidence Levels (min thresholds)</Typography>
<Box display="grid" gridTemplateColumns="repeat(3, 1fr)" gap={2}>
<TextField label="HIGH min" type="number" size="small"
value={state.data.confidence_levels.HIGH.min}
onChange={(e) => updateField(['confidence_levels', 'HIGH', 'min'], Number(e.target.value))} />
<TextField label="MEDIUM min" type="number" size="small"
value={state.data.confidence_levels.MEDIUM.min}
onChange={(e) => updateField(['confidence_levels', 'MEDIUM', 'min'], Number(e.target.value))} />
<TextField label="LOW min" type="number" size="small"
value={state.data.confidence_levels.LOW.min}
onChange={(e) => updateField(['confidence_levels', 'LOW', 'min'], Number(e.target.value))} />
</Box>
</Box>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
<Button variant="contained" onClick={onSave}>Save &amp; Sync</Button>
</DialogActions>
</Dialog>
);

View file

@ -0,0 +1,64 @@
/**
* VerdictConfig — shared visual helpers
*/
import React from 'react';
import { Box, Typography, Tooltip, Chip } from '@mui/material';
export const SEVERITY_COLORS: Record<string, string> = {
LOW: '#4caf50',
MEDIUM: '#ff9800',
HIGH: '#f44336',
CRITICAL: '#b71c1c',
};
export const PROFILE_COLORS: Record<string, string> = {
text_no_url: '#1976d2',
text_with_url: '#0288d1',
image: '#7b1fa2',
audio: '#e65100',
video: '#c62828',
url: '#2e7d32',
};
export const RangeBar: React.FC<{
items: { start: number; end: number; color: string; label: string }[];
}> = ({ items }) => (
<Box sx={{ display: 'flex', height: 28, borderRadius: 1, overflow: 'hidden', border: '1px solid #444', width: '100%' }}>
{items.map((item, i) => {
const width = item.end - item.start + 1;
return (
<Tooltip key={i} title={`${item.label}: ${item.start}-${item.end}`}>
<Box
sx={{
width: `${width}%`,
bgcolor: item.color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 0,
overflow: 'hidden',
}}
>
<Typography variant="caption" sx={{ color: '#fff', fontWeight: 600, fontSize: '0.65rem', textShadow: '0 1px 2px rgba(0,0,0,0.6)', whiteSpace: 'nowrap' }}>
{width > 8 ? item.label : ''}
</Typography>
</Box>
</Tooltip>
);
})}
</Box>
);
export const ConfigValue: React.FC<{ label: string; value: string | number | boolean }> = ({ label, value }) => (
<Box sx={{ display: 'flex', justifyContent: 'space-between', py: 0.5, borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<Typography variant="body2" color="text.secondary">{label}</Typography>
<Typography variant="body2" fontWeight={600}>
{typeof value === 'boolean' ? (
<Chip label={value ? 'ON' : 'OFF'} size="small" color={value ? 'success' : 'default'} />
) : (
String(value)
)}
</Typography>
</Box>
);

View file

@ -0,0 +1,361 @@
/**
* Verdict Configuration Component
*
* Manages Final Verdict settings across 8 tabs:
* 0. Component Weights (editable)
* 1. Verdict Categories (editable)
* 2. Risk Mappings (editable)
* 3. Severity & Actions (editable, syncs to Redis)
* 4. Overrides & Synergy (editable, from Redis verdict_config)
* 5. Confidence (editable, from Redis verdict_config)
* 6. Topic Multipliers (editable, only type=1 active)
* 7. Input Profiles (editable, per input type)
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, Paper, Typography, Tabs, Tab, Button, Alert, CircularProgress,
} from '@mui/material';
import {
Refresh as RefreshIcon,
Balance as WeightsIcon,
Category as CategoryIcon,
Warning as RiskIcon,
TrendingUp as MultiplierIcon,
Shield as ShieldIcon,
Psychology as PsychologyIcon,
Dashboard as ProfilesIcon,
} from '@mui/icons-material';
import type {
ComponentWeight, Multiplier, VerdictCategory, RiskMapping, SeverityAssessment,
VerdictConfigData, DeleteDialogState,
} from './types';
import {
fetchWeights, fetchVerdicts, fetchVerdictConfig, fetchRuntimeConfig,
updateComponentWeight, createMultiplier, updateMultiplier, deleteMultiplier,
updateVerdictCategory, updateRiskMapping, updateSeverity, updateRuntimeConfig,
syncRedis,
} from './api';
import { WeightsTab } from './tabs/WeightsTab';
import { CategoriesTab } from './tabs/CategoriesTab';
import { RiskTab } from './tabs/RiskTab';
import { SeverityTab } from './tabs/SeverityTab';
import { OverridesTab } from './tabs/OverridesTab';
import { ConfidenceTab } from './tabs/ConfidenceTab';
import { MultipliersTab } from './tabs/MultipliersTab';
import {
WeightDialog, MultiplierDialog, CategoryDialog, RiskDialog, SeverityDialog, DeleteDialog,
} from './dialogs/EntityDialogs';
import { RuntimeConfigDialog } from './dialogs/RuntimeConfigDialog';
import { InputProfilesPanel } from './panels/InputProfilesPanel';
export const VerdictConfig: React.FC = () => {
const [tabValue, setTabValue] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [componentWeights, setComponentWeights] = useState<ComponentWeight[]>([]);
const [multipliers, setMultipliers] = useState<Multiplier[]>([]);
const [verdictCategories, setVerdictCategories] = useState<VerdictCategory[]>([]);
const [riskMappings, setRiskMappings] = useState<RiskMapping[]>([]);
const [severityAssessments, setSeverityAssessments] = useState<SeverityAssessment[]>([]);
const [verdictConfig, setVerdictConfig] = useState<VerdictConfigData | null>(null);
const [weightDialog, setWeightDialog] = useState<{ open: boolean; data: ComponentWeight | null }>({ open: false, data: null });
const [multiplierDialog, setMultiplierDialog] = useState<{ open: boolean; data: Partial<Multiplier> | null }>({ open: false, data: null });
const [categoryDialog, setCategoryDialog] = useState<{ open: boolean; data: VerdictCategory | null }>({ open: false, data: null });
const [riskDialog, setRiskDialog] = useState<{ open: boolean; data: RiskMapping | null }>({ open: false, data: null });
const [severityDialog, setSeverityDialog] = useState<{ open: boolean; data: SeverityAssessment | null }>({ open: false, data: null });
const [runtimeConfigDialog, setRuntimeConfigDialog] = useState<{ open: boolean; data: VerdictConfigData | null }>({ open: false, data: null });
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [weightsRes, verdictsRes, runtimeRes, redisRes] = await Promise.all([
fetchWeights(),
fetchVerdicts(),
fetchRuntimeConfig(),
fetchVerdictConfig(),
]);
if (weightsRes.success) {
setComponentWeights(weightsRes.data.componentWeights || []);
setMultipliers(weightsRes.data.multipliers || []);
}
if (verdictsRes.success) {
setVerdictCategories(verdictsRes.data.verdictCategories || []);
setRiskMappings(verdictsRes.data.riskMappings || []);
const sev = (verdictsRes.data.severityAssessments || []).map((s: SeverityAssessment) => ({
...s,
severity_id: typeof s.severity_id === 'string' ? s.severity_id.trim() : s.severity_id,
severity_category: typeof s.severity_category === 'string' ? s.severity_category.trim() : s.severity_category,
recomended_action: typeof s.recomended_action === 'string' ? s.recomended_action.trim() : s.recomended_action,
}));
setSeverityAssessments(sev);
}
// Prefer runtime-config from PG (source of truth). Fall back to Redis read if PG row missing.
if (runtimeRes.success && runtimeRes.data) {
setVerdictConfig(runtimeRes.data);
} else if (redisRes.success && redisRes.data) {
setVerdictConfig(redisRes.data);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch data');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
const showSuccess = (msg: string) => { setSuccess(msg); setTimeout(() => setSuccess(null), 3000); };
const showError = (msg: string) => { setError(msg); setTimeout(() => setError(null), 5000); };
const handleSaveWeight = async () => {
if (!weightDialog.data) return;
try {
const res = await updateComponentWeight(weightDialog.data.component_weight_id, weightDialog.data);
if (res.success) {
showSuccess('Weight updated successfully');
setWeightDialog({ open: false, data: null });
fetchData();
} else { showError(res.error); }
} catch (err) {
showError(err instanceof Error ? err.message : 'Update failed');
}
};
const handleSaveMultiplier = async () => {
if (!multiplierDialog.data) return;
try {
const isEdit = 'multiplier_id' in multiplierDialog.data && multiplierDialog.data.multiplier_id;
const res = isEdit
? await updateMultiplier(multiplierDialog.data.multiplier_id as number, multiplierDialog.data)
: await createMultiplier(multiplierDialog.data);
if (res.success) {
showSuccess(isEdit ? 'Multiplier updated' : 'Multiplier created');
setMultiplierDialog({ open: false, data: null });
fetchData();
} else { showError(res.error); }
} catch (err) {
showError(err instanceof Error ? err.message : 'Operation failed');
}
};
const handleDeleteMultiplier = async () => {
if (!deleteDialog || deleteDialog.type !== 'multiplier') return;
try {
const res = await deleteMultiplier(deleteDialog.id);
if (res.success) {
showSuccess('Multiplier deleted');
setDeleteDialog(null);
fetchData();
} else { showError(res.error); }
} catch (err) {
showError(err instanceof Error ? err.message : 'Delete failed');
}
};
const handleSaveCategory = async () => {
if (!categoryDialog.data) return;
try {
const res = await updateVerdictCategory(categoryDialog.data.verdict_category_id, categoryDialog.data);
if (res.success) {
showSuccess('Category updated');
setCategoryDialog({ open: false, data: null });
fetchData();
} else { showError(res.error); }
} catch (err) {
showError(err instanceof Error ? err.message : 'Update failed');
}
};
const handleSaveRisk = async () => {
if (!riskDialog.data) return;
try {
const res = await updateRiskMapping(riskDialog.data.risk_mapping_id, riskDialog.data);
if (res.success) {
showSuccess('Risk mapping updated');
setRiskDialog({ open: false, data: null });
fetchData();
} else { showError(res.error); }
} catch (err) {
showError(err instanceof Error ? err.message : 'Update failed');
}
};
const handleSyncRedis = async () => {
try {
const res = await syncRedis();
if (res.success) {
showSuccess('Synced to Redis successfully');
fetchData();
} else { showError(res.error); }
} catch (err) {
showError(err instanceof Error ? err.message : 'Sync failed');
}
};
const handleSaveSeverity = async () => {
if (!severityDialog.data) return;
const { severity_id, severity_category, start_range, end_range, recomended_action } = severityDialog.data;
try {
const res = await updateSeverity(severity_id, { severity_category, start_range, end_range, recomended_action });
if (res.success) {
await syncRedis();
showSuccess('Severity updated and synced to Redis');
setSeverityDialog({ open: false, data: null });
fetchData();
} else { showError(res.error); }
} catch (err) {
showError(err instanceof Error ? err.message : 'Update failed');
}
};
const handleSaveRuntimeConfig = async () => {
if (!runtimeConfigDialog.data) return;
try {
const res = await updateRuntimeConfig(runtimeConfigDialog.data);
if (res.success) {
await syncRedis();
showSuccess('Verdict config updated and synced to Redis');
setRuntimeConfigDialog({ open: false, data: null });
fetchData();
} else { showError(res.error); }
} catch (err) {
showError(err instanceof Error ? err.message : 'Update failed');
}
};
const openRuntimeEditor = () => {
if (!verdictConfig) return;
setRuntimeConfigDialog({ open: true, data: JSON.parse(JSON.stringify(verdictConfig)) });
};
const updateRuntimeField = (path: string[], value: unknown) => {
setRuntimeConfigDialog(prev => {
if (!prev.data) return prev;
const next = JSON.parse(JSON.stringify(prev.data)) as VerdictConfigData;
let cursor: Record<string, unknown> = next as unknown as Record<string, unknown>;
for (let i = 0; i < path.length - 1; i++) {
cursor = cursor[path[i]] as Record<string, unknown>;
}
cursor[path[path.length - 1]] = value;
return { open: true, data: next };
});
};
if (loading) {
return (
<Box display="flex" justifyContent="center" p={4}>
<CircularProgress />
</Box>
);
}
const topicMultipliers = multipliers.filter(m => m.multiplier_type === 1);
const inactiveMultipliers = multipliers.filter(m => m.multiplier_type !== 1);
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h5">Final Verdict Configuration</Typography>
<Box display="flex" gap={1}>
<Button startIcon={<RefreshIcon />} onClick={fetchData}>Refresh</Button>
<Button variant="contained" color="secondary" onClick={handleSyncRedis}>Sync to Redis</Button>
</Box>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
<Paper sx={{ p: 2 }}>
<Tabs
value={tabValue}
onChange={(_, v) => setTabValue(v)}
variant="scrollable"
scrollButtons="auto"
sx={{ borderBottom: 1, borderColor: 'divider' }}
>
<Tab icon={<WeightsIcon />} label={`Weights (${componentWeights.length})`} iconPosition="start" />
<Tab icon={<CategoryIcon />} label={`Categories (${verdictCategories.length})`} iconPosition="start" />
<Tab icon={<RiskIcon />} label={`Risk Levels (${riskMappings.length})`} iconPosition="start" />
<Tab icon={<RiskIcon />} label={`Severity (${severityAssessments.length})`} iconPosition="start" />
<Tab icon={<ShieldIcon />} label="Overrides & Synergy" iconPosition="start" />
<Tab icon={<PsychologyIcon />} label="Confidence" iconPosition="start" />
<Tab icon={<MultiplierIcon />} label={`Multipliers (${topicMultipliers.length})`} iconPosition="start" />
<Tab icon={<ProfilesIcon />} label="Input Profiles" iconPosition="start" />
</Tabs>
{tabValue === 0 && (
<WeightsTab
componentWeights={componentWeights}
onEdit={(w) => setWeightDialog({ open: true, data: w })}
/>
)}
{tabValue === 1 && (
<CategoriesTab
verdictCategories={verdictCategories}
onEdit={(c) => setCategoryDialog({ open: true, data: c })}
/>
)}
{tabValue === 2 && (
<RiskTab
riskMappings={riskMappings}
onEdit={(r) => setRiskDialog({ open: true, data: r })}
/>
)}
{tabValue === 3 && (
<SeverityTab
severityAssessments={severityAssessments}
onEdit={(s) => setSeverityDialog({ open: true, data: s })}
/>
)}
{tabValue === 4 && (
<OverridesTab verdictConfig={verdictConfig} onEdit={openRuntimeEditor} />
)}
{tabValue === 5 && (
<ConfidenceTab verdictConfig={verdictConfig} onEdit={openRuntimeEditor} />
)}
{tabValue === 6 && (
<MultipliersTab
topicMultipliers={topicMultipliers}
inactiveMultipliers={inactiveMultipliers}
onAdd={() => setMultiplierDialog({ open: true, data: { multiplier_type: 1, multiplier_name: '', multiplier: 1, description: '' } })}
onEdit={(m) => setMultiplierDialog({ open: true, data: m })}
onDelete={(m) => setDeleteDialog({ open: true, type: 'multiplier', id: m.multiplier_id, name: m.multiplier_name })}
/>
)}
</Paper>
{tabValue === 7 && (
<InputProfilesPanel onSuccess={showSuccess} onError={showError} />
)}
<WeightDialog state={weightDialog} setState={setWeightDialog} onSave={handleSaveWeight} />
<MultiplierDialog state={multiplierDialog} setState={setMultiplierDialog} onSave={handleSaveMultiplier} />
<CategoryDialog state={categoryDialog} setState={setCategoryDialog} onSave={handleSaveCategory} />
<RiskDialog state={riskDialog} setState={setRiskDialog} onSave={handleSaveRisk} />
<SeverityDialog state={severityDialog} setState={setSeverityDialog} onSave={handleSaveSeverity} />
<RuntimeConfigDialog
state={runtimeConfigDialog}
setState={setRuntimeConfigDialog}
updateField={updateRuntimeField}
onSave={handleSaveRuntimeConfig}
/>
<DeleteDialog
state={deleteDialog}
onCancel={() => setDeleteDialog(null)}
onConfirm={handleDeleteMultiplier}
/>
</Box>
);
};
export default VerdictConfig;

View file

@ -0,0 +1,337 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, Typography, Button, Alert, CircularProgress, Card, CardContent, Chip, Slider,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField,
} from '@mui/material';
import { Dashboard as ProfilesIcon, Save as SaveIcon } from '@mui/icons-material';
import { PROFILE_COLORS } from '../helpers';
interface InputProfilesProps {
onSuccess: (msg: string) => void;
onError: (msg: string) => void;
}
export const InputProfilesPanel: React.FC<InputProfilesProps> = ({ onSuccess, onError }) => {
const [profiles, setProfiles] = useState<any[]>([]);
const [selectedProfile, setSelectedProfile] = useState<string>('text_no_url');
const [editedProfile, setEditedProfile] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const fetchProfiles = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/framework/api/input-profiles');
const data = await res.json();
if (data.success) {
setProfiles(data.data);
const current = data.data.find((p: any) => p.profile_code === selectedProfile) || data.data[0];
setEditedProfile(JSON.parse(JSON.stringify(current)));
}
} catch (err) {
onError((err as Error).message);
} finally {
setLoading(false);
}
}, [onError, selectedProfile]);
useEffect(() => { fetchProfiles(); }, [fetchProfiles]);
const handleSelectProfile = (code: string) => {
setSelectedProfile(code);
const p = profiles.find((pr: any) => pr.profile_code === code);
if (p) setEditedProfile(JSON.parse(JSON.stringify(p)));
};
const handleWeightChange = (field: string, value: number) => {
setEditedProfile((prev: any) => ({ ...prev, [field]: value }));
};
const handleOverrideToggle = (overrideCode: string) => {
setEditedProfile((prev: any) => {
const overrides = [...(prev.overrides || [])];
const idx = overrides.findIndex((o: any) => o.override_code === overrideCode);
if (idx >= 0) overrides[idx] = { ...overrides[idx], enabled: !overrides[idx].enabled };
return { ...prev, overrides };
});
};
const handleOverrideValueChange = (overrideCode: string, field: string, value: number) => {
setEditedProfile((prev: any) => {
const overrides = [...(prev.overrides || [])];
const idx = overrides.findIndex((o: any) => o.override_code === overrideCode);
if (idx >= 0) overrides[idx] = { ...overrides[idx], [field]: value };
return { ...prev, overrides };
});
};
const handleSave = async () => {
if (!editedProfile) return;
setSaving(true);
try {
const total = editedProfile.weight_techniques + editedProfile.weight_claims +
editedProfile.weight_ai_tampered + editedProfile.weight_source;
if (total !== 100) {
onError(`Weights must sum to 100% (currently ${total}%)`);
setSaving(false);
return;
}
const profileRes = await fetch(`/framework/api/input-profiles/${editedProfile.profile_code}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
weight_techniques: editedProfile.weight_techniques,
weight_claims: editedProfile.weight_claims,
weight_ai_tampered: editedProfile.weight_ai_tampered,
weight_source: editedProfile.weight_source,
override_cap: editedProfile.override_cap,
min_components: editedProfile.min_components,
ai_disclosure_multipliers: editedProfile.ai_disclosure_multipliers,
}),
});
const profileData = await profileRes.json();
if (!profileData.success) { onError(profileData.error); setSaving(false); return; }
if (editedProfile.overrides?.length > 0) {
const overridesRes = await fetch(`/framework/api/input-profiles/${editedProfile.profile_code}/overrides`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ overrides: editedProfile.overrides }),
});
const overridesData = await overridesRes.json();
if (!overridesData.success) { onError(overridesData.error); setSaving(false); return; }
}
onSuccess(`Profile "${editedProfile.profile_name}" saved! Sync to Redis to apply.`);
fetchProfiles();
} catch (err) {
onError((err as Error).message);
} finally {
setSaving(false);
}
};
if (loading) return <Box display="flex" justifyContent="center" py={4}><CircularProgress /></Box>;
if (!editedProfile) return <Alert severity="info">No profiles found</Alert>;
const weightTotal = editedProfile.weight_techniques + editedProfile.weight_claims +
editedProfile.weight_ai_tampered + editedProfile.weight_source;
const origProfile = profiles.find((p: any) => p.profile_code === selectedProfile);
const isModified = origProfile && JSON.stringify(origProfile) !== JSON.stringify(editedProfile);
return (
<Box py={2}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">
<ProfilesIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Input Type Profiles — Component weights per input type
</Typography>
<Button
variant="contained"
startIcon={saving ? <CircularProgress size={18} /> : <SaveIcon />}
onClick={handleSave}
disabled={!isModified || saving || weightTotal !== 100}
>
Save Profile
</Button>
</Box>
{isModified && <Alert severity="warning" sx={{ mb: 2 }}>Unsaved changes</Alert>}
{weightTotal !== 100 && <Alert severity="error" sx={{ mb: 2 }}>Weights must sum to 100% (currently {weightTotal}%)</Alert>}
<Box display="flex" gap={1} mb={3} flexWrap="wrap">
{profiles.map((p: any) => (
<Chip
key={p.profile_code}
label={p.profile_name}
onClick={() => handleSelectProfile(p.profile_code)}
variant={selectedProfile === p.profile_code ? 'filled' : 'outlined'}
color={selectedProfile === p.profile_code ? 'primary' : 'default'}
sx={{
borderColor: PROFILE_COLORS[p.profile_code],
...(selectedProfile === p.profile_code && { bgcolor: PROFILE_COLORS[p.profile_code] }),
}}
/>
))}
</Box>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" fontWeight={600} gutterBottom>Component Weights (total must = 100%)</Typography>
{[
{ key: 'weight_techniques', label: 'Techniques', color: '#9c27b0' },
{ key: 'weight_claims', label: 'Claims', color: '#009688' },
{ key: 'weight_ai_tampered', label: 'AI Tampered', color: '#ff5722' },
{ key: 'weight_source', label: 'Source', color: '#2e7d32' },
].map(({ key, label, color }) => (
<Box key={key} display="flex" alignItems="center" gap={2} mb={1}>
<Typography sx={{ width: 120, color }}>{label}</Typography>
<Slider
value={editedProfile[key]}
onChange={(_, v) => handleWeightChange(key, v as number)}
min={0} max={100} step={5}
sx={{ flex: 1, color }}
/>
<Typography sx={{ width: 50, textAlign: 'right', fontWeight: 600 }}>{editedProfile[key]}%</Typography>
</Box>
))}
<Box display="flex" justifyContent="flex-end">
<Chip
label={`Total: ${weightTotal}%`}
color={weightTotal === 100 ? 'success' : 'error'}
size="small"
/>
</Box>
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
<Typography variant="subtitle1" fontWeight={600}>Override Rules</Typography>
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="body2" color="text.secondary">Cap:</Typography>
<TextField
size="small"
type="number"
value={editedProfile.override_cap}
onChange={(e) => setEditedProfile((prev: any) => ({ ...prev, override_cap: Number(e.target.value) }))}
sx={{ width: 80 }}
/>
</Box>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Override</TableCell>
<TableCell width={80}>Active</TableCell>
<TableCell width={90}>Threshold</TableCell>
<TableCell width={90}>Per Unit</TableCell>
<TableCell width={90}>Fixed</TableCell>
<TableCell width={90}>Max</TableCell>
</TableRow>
</TableHead>
<TableBody>
{(editedProfile.overrides || []).map((ov: any) => (
<TableRow key={ov.override_code} sx={{ opacity: ov.enabled ? 1 : 0.5 }}>
<TableCell>
<Typography variant="body2">{ov.override_code.replace(/_/g, ' ')}</Typography>
</TableCell>
<TableCell>
<Chip
label={ov.enabled ? 'ON' : 'OFF'}
size="small"
color={ov.enabled ? 'success' : 'default'}
onClick={() => handleOverrideToggle(ov.override_code)}
sx={{ cursor: 'pointer' }}
/>
</TableCell>
<TableCell>
{ov.threshold != null && (
<TextField size="small" type="number" value={ov.threshold}
onChange={(e) => handleOverrideValueChange(ov.override_code, 'threshold', Number(e.target.value))}
sx={{ width: 70 }} />
)}
</TableCell>
<TableCell>
{ov.bonus_per_unit != null && (
<TextField size="small" type="number" value={ov.bonus_per_unit}
onChange={(e) => handleOverrideValueChange(ov.override_code, 'bonus_per_unit', Number(e.target.value))}
sx={{ width: 70 }} />
)}
</TableCell>
<TableCell>
{ov.bonus_fixed != null && (
<TextField size="small" type="number" value={ov.bonus_fixed}
onChange={(e) => handleOverrideValueChange(ov.override_code, 'bonus_fixed', Number(e.target.value))}
sx={{ width: 70 }} />
)}
</TableCell>
<TableCell>
{ov.max_bonus != null && (
<TextField size="small" type="number" value={ov.max_bonus}
onChange={(e) => handleOverrideValueChange(ov.override_code, 'max_bonus', Number(e.target.value))}
sx={{ width: 70 }} />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" fontWeight={600} gutterBottom>AI Disclosure Multipliers</Typography>
<Typography variant="body2" color="text.secondary" mb={2}>
How much AI detection risk is reduced when AI usage is disclosed. Lower = less risk.
</Typography>
{editedProfile.ai_disclosure_multipliers && Object.entries(editedProfile.ai_disclosure_multipliers).map(([key, val]: [string, any]) => (
<Box key={key} display="flex" alignItems="center" gap={2} mb={1}>
<Typography sx={{ width: 100, textTransform: 'capitalize' }}>{key}</Typography>
<Slider
value={val}
onChange={(_, v) => setEditedProfile((prev: any) => ({
...prev,
ai_disclosure_multipliers: { ...prev.ai_disclosure_multipliers, [key]: v as number }
}))}
min={0} max={1} step={0.05}
sx={{ flex: 1 }}
/>
<Typography sx={{ width: 60, textAlign: 'right', fontWeight: 600 }}>{(val * 100).toFixed(0)}%</Typography>
</Box>
))}
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="subtitle1" fontWeight={600} gutterBottom>INCONCLUSIVE Rules</Typography>
<Box display="flex" gap={3} flexWrap="wrap">
<Box>
<Typography variant="body2" color="text.secondary">Minimum components for verdict</Typography>
<TextField
size="small" type="number"
value={editedProfile.min_components}
onChange={(e) => setEditedProfile((prev: any) => ({ ...prev, min_components: Number(e.target.value) }))}
sx={{ width: 80, mt: 1 }}
inputProps={{ min: 1, max: 4 }}
/>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">Primary components</Typography>
<Box display="flex" gap={0.5} mt={1}>
{['techniques', 'claims', 'ai_tampered', 'source'].map(comp => {
const isPrimary = (editedProfile.primary_components || []).includes(comp);
return (
<Chip
key={comp}
label={comp.replace('_', ' ')}
size="small"
variant={isPrimary ? 'filled' : 'outlined'}
color={isPrimary ? 'primary' : 'default'}
onClick={() => {
setEditedProfile((prev: any) => {
const current = prev.primary_components || [];
const updated = isPrimary
? current.filter((c: string) => c !== comp)
: [...current, comp];
return { ...prev, primary_components: updated };
});
}}
sx={{ cursor: 'pointer' }}
/>
);
})}
</Box>
</Box>
</Box>
</CardContent>
</Card>
</Box>
);
};

View file

@ -0,0 +1,62 @@
import React from 'react';
import {
Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
IconButton, Chip,
} from '@mui/material';
import { Edit as EditIcon } from '@mui/icons-material';
import type { VerdictCategory } from '../types';
import { RangeBar } from '../helpers';
interface Props {
verdictCategories: VerdictCategory[];
onEdit: (cat: VerdictCategory) => void;
}
export const CategoriesTab: React.FC<Props> = ({ verdictCategories, onEdit }) => (
<Box py={2}>
<Typography variant="body2" color="text.secondary" mb={2}>
Verdict categories map risk scores to human-readable labels. Used by <code>mapToVerdictCategory()</code>.
</Typography>
<Box mb={2}>
<RangeBar
items={verdictCategories.map(c => ({
start: c.start_range,
end: c.end_range,
color: c.verdict_category_color,
label: c.verdict_category_code,
}))}
/>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Category</TableCell>
<TableCell>Code</TableCell>
<TableCell>Score Range</TableCell>
<TableCell>Color</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{verdictCategories.map((c) => (
<TableRow key={c.verdict_category_id} hover>
<TableCell><strong>{c.description}</strong></TableCell>
<TableCell><Chip label={c.verdict_category_code} size="small" /></TableCell>
<TableCell>{c.start_range} - {c.end_range}</TableCell>
<TableCell>
<Box display="flex" alignItems="center" gap={1}>
<Box sx={{ width: 24, height: 24, bgcolor: c.verdict_category_color, borderRadius: 1, border: '1px solid #555' }} />
<Typography variant="caption">{c.verdict_category_color}</Typography>
</Box>
</TableCell>
<TableCell>
<IconButton size="small" onClick={() => onEdit(c)}><EditIcon /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);

View file

@ -0,0 +1,137 @@
import React from 'react';
import {
Box, Typography, Button, Alert, Card, CardContent, Chip,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
} from '@mui/material';
import { Edit as EditIcon } from '@mui/icons-material';
import type { VerdictConfigData } from '../types';
interface Props {
verdictConfig: VerdictConfigData | null;
onEdit: () => void;
}
export const ConfidenceTab: React.FC<Props> = ({ verdictConfig, onEdit }) => (
<Box py={2}>
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={2} gap={2}>
<Typography variant="body2" color="text.secondary">
Confidence scoring parameters. Used by <code>calculateConfidence()</code>.
Higher confidence = more data sources produced strong signals.
Stored in <code>component_config(pipeline, verdict_config)</code>; saved values auto-sync to Redis.
</Typography>
<Button
startIcon={<EditIcon />}
variant="contained"
size="small"
disabled={!verdictConfig}
onClick={onEdit}
>
Edit Confidence
</Button>
</Box>
{!verdictConfig ? (
<Alert severity="warning">
verdict_config not found in PostgreSQL or Redis. Re-run sync-redis or seed the row.
</Alert>
) : (
<Box display="flex" flexDirection="column" gap={2}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
Base Scoring
</Typography>
<Typography variant="body2" color="text.secondary">
Each of the 4 components (domain, techniques, ai, claims) adds {verdictConfig.confidence.base_per_component} when available.
Max base = {verdictConfig.confidence.base_per_component * 4}.
</Typography>
</CardContent>
</Card>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
Component Bonuses
</Typography>
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
Additional confidence points based on component signal strength.
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Component</TableCell>
<TableCell>Condition</TableCell>
<TableCell align="right">Bonus</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>Domain</TableCell>
<TableCell>TRUSTED / UNTRUSTED verdict</TableCell>
<TableCell align="right">+{verdictConfig.confidence.domain_strong_signal_bonus}</TableCell>
</TableRow>
<TableRow>
<TableCell>Domain</TableCell>
<TableCell>Other verdict (weak signal)</TableCell>
<TableCell align="right">+{verdictConfig.confidence.domain_weak_signal_bonus}</TableCell>
</TableRow>
<TableRow>
<TableCell>Techniques</TableCell>
<TableCell>Avg technique confidence (max bonus)</TableCell>
<TableCell align="right">+{verdictConfig.confidence.techniques_bonus_max}</TableCell>
</TableRow>
<TableRow>
<TableCell>AI Detection</TableCell>
<TableCell>HIGH confidence</TableCell>
<TableCell align="right">+{verdictConfig.confidence.ai_high_confidence_bonus}</TableCell>
</TableRow>
<TableRow>
<TableCell>AI Detection</TableCell>
<TableCell>MEDIUM confidence</TableCell>
<TableCell align="right">+{verdictConfig.confidence.ai_medium_confidence_bonus}</TableCell>
</TableRow>
<TableRow>
<TableCell>AI Detection</TableCell>
<TableCell>LOW confidence</TableCell>
<TableCell align="right">+{verdictConfig.confidence.ai_low_confidence_bonus}</TableCell>
</TableRow>
<TableRow>
<TableCell>Claims</TableCell>
<TableCell>Verified ratio (max bonus)</TableCell>
<TableCell align="right">+{verdictConfig.confidence.claims_verified_bonus_max}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
Confidence Levels
</Typography>
<Box display="flex" gap={2} mt={1}>
<Chip
label={`HIGH >= ${verdictConfig.confidence_levels.HIGH.min}`}
color="success"
variant="outlined"
/>
<Chip
label={`MEDIUM >= ${verdictConfig.confidence_levels.MEDIUM.min}`}
color="warning"
variant="outlined"
/>
<Chip
label={`LOW >= ${verdictConfig.confidence_levels.LOW.min}`}
color="default"
variant="outlined"
/>
</Box>
</CardContent>
</Card>
</Box>
)}
</Box>
);

View file

@ -0,0 +1,113 @@
import React from 'react';
import {
Box, Typography, Button, Chip, IconButton,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
} from '@mui/material';
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
import type { Multiplier } from '../types';
interface Props {
topicMultipliers: Multiplier[];
inactiveMultipliers: Multiplier[];
onAdd: () => void;
onEdit: (m: Multiplier) => void;
onDelete: (m: Multiplier) => void;
}
export const MultipliersTab: React.FC<Props> = ({
topicMultipliers,
inactiveMultipliers,
onAdd,
onEdit,
onDelete,
}) => (
<Box py={2}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="body2" color="text.secondary">
Topic multipliers adjust the final risk score when <code>options.topic</code> is set.
Only <strong>Topic</strong> multipliers are applied in code.
</Typography>
<Button startIcon={<AddIcon />} variant="contained" onClick={onAdd}>
Add Topic Multiplier
</Button>
</Box>
<Typography variant="subtitle2" fontWeight={700} mb={1}>
Active Topic Multipliers ({topicMultipliers.length})
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Multiplier</TableCell>
<TableCell>Effect</TableCell>
<TableCell>Description</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{topicMultipliers.length === 0 ? (
<TableRow>
<TableCell colSpan={5} align="center">
<Typography variant="body2" color="text.secondary">No topic multipliers configured</Typography>
</TableCell>
</TableRow>
) : topicMultipliers.map((m) => (
<TableRow key={m.multiplier_id} hover>
<TableCell><strong>{m.multiplier_name}</strong></TableCell>
<TableCell>{m.multiplier.toFixed(2)}x</TableCell>
<TableCell>
<Chip
label={m.multiplier > 1 ? `+${((m.multiplier - 1) * 100).toFixed(0)}%` : `${((m.multiplier - 1) * 100).toFixed(0)}%`}
size="small"
color={m.multiplier > 1 ? 'error' : m.multiplier < 1 ? 'success' : 'default'}
/>
</TableCell>
<TableCell>{m.description}</TableCell>
<TableCell>
<IconButton size="small" onClick={() => onEdit(m)}><EditIcon /></IconButton>
<IconButton size="small" color="error" onClick={() => onDelete(m)}><DeleteIcon /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{inactiveMultipliers.length > 0 && (
<Box mt={3}>
<Typography variant="subtitle2" fontWeight={700} mb={1} color="text.secondary">
Inactive Multipliers ({inactiveMultipliers.length})
<Chip label="Not implemented in code" size="small" color="default" sx={{ ml: 1 }} />
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Type</TableCell>
<TableCell>Name</TableCell>
<TableCell>Multiplier</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{inactiveMultipliers.map((m) => (
<TableRow key={m.multiplier_id} sx={{ opacity: 0.5 }}>
<TableCell>
<Chip label={m.multiplier_type === 2 ? 'Temporal' : 'Reach'} size="small" variant="outlined" />
</TableCell>
<TableCell>{m.multiplier_name}</TableCell>
<TableCell>{m.multiplier.toFixed(2)}x</TableCell>
<TableCell>
<Chip label="Not implemented" size="small" color="default" variant="outlined" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)}
</Box>
);

View file

@ -0,0 +1,113 @@
import React from 'react';
import { Box, Typography, Button, Alert, Card, CardContent } from '@mui/material';
import { Edit as EditIcon } from '@mui/icons-material';
import type { VerdictConfigData } from '../types';
import { ConfigValue } from '../helpers';
interface Props {
verdictConfig: VerdictConfigData | null;
onEdit: () => void;
}
export const OverridesTab: React.FC<Props> = ({ verdictConfig, onEdit }) => (
<Box py={2}>
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={2} gap={2}>
<Typography variant="body2" color="text.secondary">
Override rules that adjust the final risk score. Used by <code>applyOverrides()</code> and <code>applySynergyBonus()</code>.
Stored in <code>component_config(pipeline, verdict_config)</code>; saved values auto-sync to Redis.
</Typography>
<Button
startIcon={<EditIcon />}
variant="contained"
size="small"
disabled={!verdictConfig}
onClick={onEdit}
>
Edit Overrides &amp; Synergy
</Button>
</Box>
{!verdictConfig ? (
<Alert severity="warning">
verdict_config not found in PostgreSQL or Redis. Re-run sync-redis or seed the row.
</Alert>
) : (
<Box display="flex" flexDirection="column" gap={2}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
Synergy Bonus
</Typography>
<Typography variant="body2" color="text.secondary" mb={1}>
When multiple components show high risk (above threshold), adds a bonus to the final score.
</Typography>
<ConfigValue label="Enabled" value={verdictConfig.synergy.enabled} />
<ConfigValue label="Threshold (component score)" value={verdictConfig.synergy.threshold} />
<ConfigValue label="Bonus per component" value={`+${verdictConfig.synergy.bonus_per_component}%`} />
<ConfigValue label="Max bonus" value={`+${verdictConfig.synergy.max_bonus}%`} />
</CardContent>
</Card>
<Typography variant="subtitle1" fontWeight={700}>Override Rules</Typography>
<Box display="grid" gridTemplateColumns="repeat(auto-fill, minmax(320px, 1fr))" gap={2}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" fontWeight={700} gutterBottom>
False Claims
</Typography>
<ConfigValue label="Enabled" value={verdictConfig.overrides.false_claims.enabled} />
<ConfigValue label="Threshold (min false claims)" value={verdictConfig.overrides.false_claims.threshold} />
<ConfigValue label="Bonus per false claim" value={`+${verdictConfig.overrides.false_claims.bonus_per_claim}%`} />
<ConfigValue label="Max bonus" value={`+${verdictConfig.overrides.false_claims.max_bonus}%`} />
</CardContent>
</Card>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" fontWeight={700} gutterBottom>
Severe Techniques
</Typography>
<ConfigValue label="Enabled" value={verdictConfig.overrides.severe_techniques.enabled} />
<ConfigValue label="Threshold (min severe count)" value={verdictConfig.overrides.severe_techniques.threshold} />
<ConfigValue label="Bonus" value={`+${verdictConfig.overrides.severe_techniques.bonus}%`} />
</CardContent>
</Card>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" fontWeight={700} gutterBottom>
Undisclosed AI
</Typography>
<ConfigValue label="Enabled" value={verdictConfig.overrides.undisclosed_ai.enabled} />
<ConfigValue label="Bonus" value={`+${verdictConfig.overrides.undisclosed_ai.bonus}%`} />
</CardContent>
</Card>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" fontWeight={700} gutterBottom>
Untrusted Domain
</Typography>
<ConfigValue label="Enabled" value={verdictConfig.overrides.untrusted_domain.enabled} />
<ConfigValue label="Untrusted bonus" value={`+${verdictConfig.overrides.untrusted_domain.untrusted_bonus}%`} />
<ConfigValue label="Suspicious bonus" value={`+${verdictConfig.overrides.untrusted_domain.suspicious_bonus}%`} />
<ConfigValue label="Blacklisted bonus" value={`+${verdictConfig.overrides.untrusted_domain.blacklisted_bonus}%`} />
</CardContent>
</Card>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" fontWeight={700} gutterBottom>
Domain Red Flags
</Typography>
<ConfigValue label="Enabled" value={verdictConfig.overrides.domain_red_flags.enabled} />
<ConfigValue label="Threshold (min flags)" value={verdictConfig.overrides.domain_red_flags.threshold} />
<ConfigValue label="Bonus per flag" value={`+${verdictConfig.overrides.domain_red_flags.bonus_per_flag}%`} />
<ConfigValue label="Max bonus" value={`+${verdictConfig.overrides.domain_red_flags.max_bonus}%`} />
</CardContent>
</Card>
</Box>
</Box>
)}
</Box>
);

View file

@ -0,0 +1,62 @@
import React from 'react';
import {
Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
IconButton, Chip,
} from '@mui/material';
import { Edit as EditIcon } from '@mui/icons-material';
import type { RiskMapping } from '../types';
import { RangeBar } from '../helpers';
interface Props {
riskMappings: RiskMapping[];
onEdit: (risk: RiskMapping) => void;
}
export const RiskTab: React.FC<Props> = ({ riskMappings, onEdit }) => (
<Box py={2}>
<Typography variant="body2" color="text.secondary" mb={2}>
Risk levels provide an alternative categorization focused on risk severity. Used by <code>mapToRiskLevel()</code>.
</Typography>
<Box mb={2}>
<RangeBar
items={riskMappings.map(r => ({
start: r.start_range,
end: r.end_range,
color: r.risk_color,
label: r.risk_mapping,
}))}
/>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Risk Level</TableCell>
<TableCell>Level Value</TableCell>
<TableCell>Score Range</TableCell>
<TableCell>Color</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{riskMappings.map((r) => (
<TableRow key={r.risk_mapping_id} hover>
<TableCell><strong>{r.risk_mapping}</strong></TableCell>
<TableCell><Chip label={r.risk_level} size="small" variant="outlined" /></TableCell>
<TableCell>{r.start_range} - {r.end_range}</TableCell>
<TableCell>
<Box display="flex" alignItems="center" gap={1}>
<Box sx={{ width: 24, height: 24, bgcolor: r.risk_color, borderRadius: 1, border: '1px solid #555' }} />
<Typography variant="caption">{r.risk_color}</Typography>
</Box>
</TableCell>
<TableCell>
<IconButton size="small" onClick={() => onEdit(r)}><EditIcon /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);

View file

@ -0,0 +1,66 @@
import React from 'react';
import {
Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
IconButton, Chip,
} from '@mui/material';
import { Edit as EditIcon } from '@mui/icons-material';
import type { SeverityAssessment } from '../types';
import { RangeBar, SEVERITY_COLORS } from '../helpers';
interface Props {
severityAssessments: SeverityAssessment[];
onEdit: (sev: SeverityAssessment) => void;
}
export const SeverityTab: React.FC<Props> = ({ severityAssessments, onEdit }) => (
<Box py={2}>
<Typography variant="body2" color="text.secondary" mb={2}>
Severity levels determine recommended actions. Used by <code>mapToSeverity()</code>.
Stored in <code>bos_parammgmt.severity_assessment</code>; saved rows auto-sync to Redis.
</Typography>
<Box mb={2}>
<RangeBar
items={severityAssessments.map(s => ({
start: s.start_range,
end: s.end_range,
color: SEVERITY_COLORS[s.severity_category] || '#666',
label: s.severity_category,
}))}
/>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Severity</TableCell>
<TableCell>Score Range</TableCell>
<TableCell>Recommended Action</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{severityAssessments.map((s) => (
<TableRow key={s.severity_id} hover>
<TableCell>
<Chip
label={s.severity_category}
size="small"
sx={{ bgcolor: SEVERITY_COLORS[s.severity_category] || '#666', color: '#fff', fontWeight: 600 }}
/>
</TableCell>
<TableCell>{s.start_range} - {s.end_range}</TableCell>
<TableCell>
<Chip label={s.recomended_action} size="small" variant="outlined" />
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => onEdit({ ...s })}>
<EditIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);

View file

@ -0,0 +1,54 @@
import React from 'react';
import {
Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
IconButton, Chip,
} from '@mui/material';
import { Edit as EditIcon } from '@mui/icons-material';
import type { ComponentWeight } from '../types';
interface Props {
componentWeights: ComponentWeight[];
onEdit: (weight: ComponentWeight) => void;
}
export const WeightsTab: React.FC<Props> = ({ componentWeights, onEdit }) => (
<Box py={2}>
<Typography variant="body2" color="text.secondary" mb={2}>
Component weights determine how much each analysis component contributes to the final verdict score.
Weights should sum to 100%.
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Component</TableCell>
<TableCell>Weight (%)</TableCell>
<TableCell>Description</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{componentWeights.map((w) => (
<TableRow key={w.component_weight_id} hover>
<TableCell><strong>{w.component_name}</strong></TableCell>
<TableCell>
<Chip label={`${w.component_weight}%`} size="small" color="primary" />
</TableCell>
<TableCell>{w.description}</TableCell>
<TableCell>
<IconButton size="small" onClick={() => onEdit(w)}>
<EditIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Box mt={2}>
<Typography variant="body2" color="text.secondary">
Total: {componentWeights.reduce((sum, w) => sum + w.component_weight, 0)}%
</Typography>
</Box>
</Box>
);

View file

@ -0,0 +1,82 @@
/**
* VerdictConfig — shared types
*/
export interface ComponentWeight {
component_weight_id: number;
component_name: string;
component_weight: number;
description: string;
}
export interface Multiplier {
multiplier_id: number;
multiplier_type: number;
multiplier_name: string;
multiplier: number;
description: string;
}
export interface VerdictCategory {
verdict_category_id: number;
verdict_category_code: string;
description: string;
start_range: number;
end_range: number;
verdict_category_color: string;
}
export interface RiskMapping {
risk_mapping_id: number;
risk_mapping: string;
risk_level: number;
start_range: number;
end_range: number;
risk_color: string;
}
export interface SeverityAssessment {
severity_id: string;
severity_category: string;
start_range: number;
end_range: number;
recomended_action: string;
}
export interface VerdictConfigData {
synergy: {
enabled: boolean;
threshold: number;
bonus_per_component: number;
max_bonus: number;
};
overrides: {
false_claims: { enabled: boolean; threshold: number; bonus_per_claim: number; max_bonus: number };
severe_techniques: { enabled: boolean; threshold: number; bonus: number };
undisclosed_ai: { enabled: boolean; bonus: number };
untrusted_domain: { enabled: boolean; untrusted_bonus: number; suspicious_bonus: number; blacklisted_bonus: number };
domain_red_flags: { enabled: boolean; threshold: number; bonus_per_flag: number; max_bonus: number };
};
confidence: {
base_per_component: number;
domain_strong_signal_bonus: number;
domain_weak_signal_bonus: number;
techniques_bonus_max: number;
ai_high_confidence_bonus: number;
ai_medium_confidence_bonus: number;
ai_low_confidence_bonus: number;
claims_verified_bonus_max: number;
};
confidence_levels: {
HIGH: { min: number };
MEDIUM: { min: number };
LOW: { min: number };
};
}
export interface DeleteDialogState {
open: boolean;
type: string;
id: number;
name: string;
}

View file

@ -0,0 +1,155 @@
/**
* API helpers for the LLM Components Config page.
*
* Stage assignments + models live in PG (didiFramework /api/providers/*);
* vision config is still fetched from agent-v3 /config because vision is
* an agent-side concern and isn't mirrored to PG yet.
*
* fetchStageAssignments groups the flat assignment rows by stage_code AND
* tier (free/premium), so each stage exposes both chains for the UI's tier
* toggle. Falls back to free when premium is empty (legacy environments).
*/
import type { ModelConfig, StageAssignment, TierCode } from './types';
export const FRAMEWORK_API = '/framework/api/providers';
export async function fetchConfig(apiBase: string) {
// Vision config still lives on agent-v3 (Redis-backed). Used only for AI Tampered.
const res = await fetch(`${apiBase}/config`);
return res.json();
}
export async function fetchModels(_apiBase: string) {
const res = await fetch(`${FRAMEWORK_API}/models`);
const data = await res.json();
if (!data.success) return data;
// Map PG schema to UI shape.
const models = (data.data || []).map((m: any) => ({
model_key: m.model_code,
provider: m.provider_code || m.provider_name,
model_name: m.model_name,
context_window: m.context_window || 0,
cost_input_1m: m.input_cost_per_1m || 0,
cost_output_1m: m.output_cost_per_1m || 0,
speed_tier: m.context_window >= 128000 ? 'fast' : m.context_window >= 32000 ? 'medium' : 'fast',
quality_tier: m.input_cost_per_1m > 3 ? 'premium' : m.input_cost_per_1m > 0.5 ? 'high' : 'standard',
_model_id: m.model_id,
_provider_id: m.provider_id,
}));
return { success: true, data: { models } };
}
export async function fetchStageAssignments(_apiBase: string) {
const res = await fetch(`${FRAMEWORK_API}/assignments`);
const data = await res.json();
if (!data.success) return data;
const grouped: Record<string, StageAssignment> = {};
for (const a of data.data || []) {
const key = a.stage_code;
const tier: TierCode = a.tier === 'premium' ? 'premium' : 'free';
if (!grouped[key]) {
grouped[key] = {
stage: key,
description: a.stage_name || key,
models: [],
modelsByTier: { free: [], premium: [] },
};
}
const role = a.fallback_order === 1 ? 'primary'
: `fallback_${a.fallback_order - 1}` as ModelConfig['role'];
const entry: ModelConfig = {
order: a.fallback_order,
role,
model_key: a.model_code,
name: a.model_name,
temperature: parseFloat(a.temperature),
max_tokens: a.max_tokens,
timeout_ms: a.timeout_ms,
_stage_id: a.stage_id,
_provider_id: a.provider_id,
_model_id: a.model_id,
_tier: tier,
};
grouped[key].modelsByTier![tier].push(entry);
}
for (const stage of Object.values(grouped)) {
if (stage.modelsByTier) {
stage.modelsByTier.free.sort((a, b) => a.order - b.order);
stage.modelsByTier.premium.sort((a, b) => a.order - b.order);
stage.models = stage.modelsByTier.free;
}
}
return { success: true, data: grouped };
}
export async function updateStageAssignments(_apiBase: string, data: Record<string, StageAssignment>) {
const errors: string[] = [];
const modelsRes = await fetch(`${FRAMEWORK_API}/models`);
const modelsData = await modelsRes.json();
const findModel = (modelKey: string) =>
(modelsData.data || []).find((m: any) => m.model_code === modelKey);
for (const [_stageKey, stage] of Object.entries(data)) {
const tierChains: [TierCode, ModelConfig[]][] = stage.modelsByTier
? (Object.entries(stage.modelsByTier) as [TierCode, ModelConfig[]][])
: [['free', stage.models]];
for (const [_tier, chain] of tierChains) {
for (const model of chain) {
const stageId = model._stage_id;
if (!stageId) continue;
const body: any = {};
if (model.temperature !== undefined) body.temperature = model.temperature;
if (model.max_tokens !== undefined) body.max_tokens = model.max_tokens;
if (model.timeout_ms !== undefined) body.timeout_ms = model.timeout_ms;
if (model.model_key) {
const found = findModel(model.model_key);
if (found) {
body.model_id = found.model_id;
body.provider_id = found.provider_id;
}
}
const res = await fetch(`${FRAMEWORK_API}/assignments/${stageId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const result = await res.json();
if (!result.success) {
errors.push(result.error || `Failed to update stage ${stageId}`);
}
}
}
}
if (errors.length > 0) {
return { success: false, error: errors.join('; ') };
}
return { success: true, message: 'Assignments updated in PostgreSQL' };
}
export async function testModel(apiBase: string, modelKey: string) {
const res = await fetch(`${apiBase}/test-model`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_key: modelKey }),
});
return res.json();
}
export async function syncToRedis(): Promise<{ success: boolean; error?: string; message?: string }> {
const res = await fetch('/framework/api/sync-redis', { method: 'POST' });
return res.json();
}

View file

@ -0,0 +1,129 @@
/**
* Static configuration table for the 6 analysis components.
*
* Each entry defines display metadata (name/icon/color), the agent-v3 API
* base used for /test-model and /analyze, and the 2-stage pipeline shape
* (screening + deep, or extraction + verification, etc.).
*
* `verdict` and `moderation` have empty `stages` because they delegate to
* dedicated components rather than the generic stage-assignment editor.
*/
import React from 'react';
import {
Speed as SpeedIcon, Psychology as PsychologyIcon, Science as ScienceIcon,
SmartToy as SmartToyIcon, FactCheck as FactCheckIcon,
Gavel as VerdictIcon, TravelExplore as SourceIcon, Shield as ModerationIcon,
} from '@mui/icons-material';
import type { ComponentType, ComponentConfig } from './types';
export const COMPONENT_CONFIGS: Record<ComponentType, ComponentConfig> = {
'techniques': {
name: 'Manipulation Techniques',
icon: <ScienceIcon />,
description: '2-Stage Pipeline for detecting manipulation techniques',
color: '#9c27b0',
apiBase: '/agent-v3/api/v3/techniques',
stages: [
{
key: 'techniques_screening',
title: 'Stage 1: SCREENING',
icon: <SpeedIcon />,
description: 'Quick dimension detection (~500 tokens, ~2-5s)',
color: '#4caf50',
},
{
key: 'techniques_deep',
title: 'Stage 2: DEEP ANALYSIS',
icon: <PsychologyIcon />,
description: 'Per-dimension technique detection (~1-2k tokens)',
color: '#2196f3',
},
],
},
'ai-tampered': {
name: 'AI Tampered Detection',
icon: <SmartToyIcon />,
description: '2-Stage Pipeline for detecting AI-generated content',
color: '#ff5722',
apiBase: '/agent-v3/api/v3/ai-tampered',
stages: [
{
key: 'ai_tampered_screening',
title: 'Stage 1: SCREENING',
icon: <SpeedIcon />,
description: 'Quick AI detection (~1k tokens)',
color: '#4caf50',
},
{
key: 'ai_tampered_deep',
title: 'Stage 2: DEEP ANALYSIS',
icon: <PsychologyIcon />,
description: 'Per-category indicator detection',
color: '#2196f3',
},
],
hasVision: true,
},
'claims': {
name: 'Claims Verification',
icon: <FactCheckIcon />,
description: '2-Stage Pipeline for claim extraction and verification',
color: '#009688',
apiBase: '/agent-v3/api/v3/claims',
stages: [
{
key: 'claims_extraction',
title: 'Stage 1: EXTRACTION',
icon: <SpeedIcon />,
description: 'Extract claims from text',
color: '#4caf50',
},
{
key: 'claims_verification',
title: 'Stage 2: VERIFICATION',
icon: <FactCheckIcon />,
description: 'Verify claims against sources',
color: '#2196f3',
},
],
},
'source-assessment': {
name: 'Source Assessment',
icon: <SourceIcon />,
description: '2-Stage Pipeline for source credibility assessment (publication, author, platform, domain)',
color: '#2e7d32',
apiBase: '/agent-v3/api/v3/source-assessment',
stages: [
{
key: 'source_assessment_extraction',
title: 'Stage 1: EXTRACTION',
icon: <SpeedIcon />,
description: 'Extract publication, author, platform from text/transcript',
color: '#4caf50',
},
{
key: 'source_assessment_evaluation',
title: 'Stage 2: EVALUATION',
icon: <PsychologyIcon />,
description: 'Classify source using web evidence and framework categories',
color: '#2196f3',
},
],
},
'verdict': {
name: 'Final Verdict',
icon: <VerdictIcon />,
description: 'Configure verdict weights, categories, risk mappings and multipliers',
color: '#7b1fa2',
apiBase: '/framework/api',
stages: [],
},
'moderation': {
name: 'Moderation',
icon: <ModerationIcon />,
description: 'HIL triage rules, brain client settings, sensitive topics, roles',
color: '#0288d1',
apiBase: '/framework/api',
stages: [],
},
};

View file

@ -0,0 +1,77 @@
/**
* Pure helpers for the Parameters panel — flatten/set nested config objects
* and a curated label/tooltip table for the most common scoring keys.
*
* EXCLUDE_KEYS are scaffolding fields that don't belong in the UI grid
* (legacy or computed at runtime); the panel filters them out before render.
*/
export const PARAM_INFO: Record<string, { label: string; tooltip: string }> = {
count_scaler_divisor: { label: 'Count Scaler Divisor', tooltip: 'How many techniques needed for full score. 1 technique = 1/N of max. Default 3 means 1 tech=33%, 2=67%, 3+=100%.' },
severe_threshold: { label: 'Severe Technique Threshold', tooltip: 'Techniques with severity >= this value are considered "severe" and trigger the severe_techniques override bonus.' },
'bonuses.intensity_bonus.cap': { label: 'Intensity Bonus Cap', tooltip: 'Maximum bonus from technique intensity (0-1 scale). Prevents high-intensity single techniques from dominating.' },
'bonuses.intensity_bonus.coefficient': { label: 'Intensity Bonus Coefficient', tooltip: 'Multiplier for intensity calculation. Higher = more impact from technique intensity.' },
'bonuses.intensity_bonus.type': { label: 'Intensity Bonus Type', tooltip: '"sqrt" = diminishing returns (recommended), "linear" = proportional intensity impact.' },
'bonuses.count_bonus.threshold_3': { label: 'Count Bonus (>= 3 techniques)', tooltip: 'Bonus added to manipulation score when 3+ techniques detected (0-1 scale).' },
'bonuses.count_bonus.threshold_5': { label: 'Count Bonus (>= 5 techniques)', tooltip: 'Bonus added when 5+ techniques detected. Replaces threshold_3 bonus.' },
'bonuses.dimension_bonus.per_dimension': { label: 'Dimension Bonus Per Dimension', tooltip: 'Bonus per unique manipulation dimension detected (D1-D8). Multiple dimensions = broader manipulation.' },
'bonuses.dimension_bonus.max_bonus': { label: 'Dimension Bonus Max', tooltip: 'Maximum total bonus from dimensions, regardless of how many are detected.' },
'blend_weights.screening': { label: 'Blend: Screening Weight', tooltip: 'How much the screening stage contributes to final AI probability (0-1). Screening + Deep should sum to 1.0.' },
'blend_weights.deep': { label: 'Blend: Deep Analysis Weight', tooltip: 'How much the deep analysis stage contributes to final AI probability (0-1).' },
undisclosed_threshold: { label: 'Undisclosed AI Threshold', tooltip: 'AI probability (%) above which undisclosed AI triggers the override bonus. Content with disclosed AI is not affected.' },
'thresholds.LIKELY_AI': { label: 'Verdict: LIKELY AI (%)', tooltip: 'AI probability >= this value produces LIKELY_AI verdict label.' },
'thresholds.POSSIBLY_AI': { label: 'Verdict: POSSIBLY AI (%)', tooltip: 'AI probability >= this value produces POSSIBLY_AI verdict label.' },
'thresholds.MIXED': { label: 'Verdict: MIXED (%)', tooltip: 'AI probability >= this value produces MIXED verdict label.' },
'disclosure_impact.explicit': { label: 'Disclosure Impact: Explicit', tooltip: 'Risk multiplier when AI is explicitly declared (e.g. "Written by ChatGPT"). Lower = less risk. 0.3 means 30% of original risk.' },
'disclosure_impact.partial': { label: 'Disclosure Impact: Partial', tooltip: 'Risk multiplier for partial AI disclosure (e.g. "with AI assistance").' },
'disclosure_impact.implied': { label: 'Disclosure Impact: Implied', tooltip: 'Risk multiplier for implied AI disclosure (e.g. mentioning ChatGPT in context).' },
'disclosure_impact.none': { label: 'Disclosure Impact: None', tooltip: 'Risk multiplier when AI is NOT disclosed. 1.0 = full risk (no reduction).' },
'status_weights.VT': { label: 'Status: Verified True', tooltip: 'Credibility weight for claims verified as true (1.0 = full credibility contribution).' },
'status_weights.LT': { label: 'Status: Likely True', tooltip: 'Credibility weight for claims likely true based on sources.' },
'status_weights.UV': { label: 'Status: Unverified', tooltip: 'Credibility weight for claims that could not be verified. Lower = more suspicious. 0.5 = neutral.' },
'status_weights.OP': { label: 'Status: Opinion as Fact', tooltip: 'Credibility weight for opinions presented as facts. Low value penalizes this practice.' },
'status_weights.LF': { label: 'Status: Likely False', tooltip: 'Credibility weight for claims likely false based on contradicting sources.' },
'status_weights.VF': { label: 'Status: Verified False', tooltip: 'Credibility weight for verified false claims. 0 = zero credibility contribution.' },
all_unverified_credibility: { label: 'All Unverified Default', tooltip: 'Default credibility score (0-1) when ALL claims are unverified with zero web sources. 0.75 = lean credible, 0.5 = neutral, 0.25 = suspicious.' },
all_unverified_behavior: { label: 'All Unverified Behavior', tooltip: '"fixed" = use the default value above. "calculate_per_type" = calculate based on claim types and their individual unverified weights.' },
'axis_weights.publication': { label: 'Axis: Publication', tooltip: 'Weight of publication/outlet credibility in trust score (0-1). All axes should sum to 1.0.' },
'axis_weights.domain': { label: 'Axis: Domain', tooltip: 'Weight of domain analysis (age, SSL, blacklist) in trust score. 0 when no URL available.' },
'axis_weights.author': { label: 'Axis: Author', tooltip: 'Weight of author credibility in trust score.' },
'axis_weights.platform': { label: 'Axis: Platform', tooltip: 'Weight of platform type (news site, social media, blog) in trust score.' },
'verdict_thresholds.TRUSTED': { label: 'Threshold: TRUSTED (>=)', tooltip: 'Trust score >= this value = TRUSTED verdict. Source is considered reliable.' },
'verdict_thresholds.NEUTRAL': { label: 'Threshold: NEUTRAL (>=)', tooltip: 'Trust score >= this value = NEUTRAL verdict. Source cannot be confirmed or denied.' },
'verdict_thresholds.SUSPICIOUS': { label: 'Threshold: SUSPICIOUS (>=)', tooltip: 'Trust score >= this value = SUSPICIOUS verdict. Below this = UNTRUSTED.' },
domain_unavailable_default: { label: 'Domain Unavailable Default', tooltip: 'Default domain axis score (0-100) when no URL is provided. 50 = neutral assumption.' },
};
// Keys to exclude from display (internal / non-scoring).
export const EXCLUDE_KEYS = ['base_calculation', 'manipulation_levels', 'warning_flags_triggers',
'category_weights', 'risk_calculation', 'confidence_levels', 'source_reliability_weights',
'claim_type_weights', 'status_thresholds', 'quick_scores'];
/** Flatten a nested object into dot-notation key-value pairs. */
export function flattenConfig(obj: any, prefix = ''): { key: string; value: any }[] {
const result: { key: string; value: any }[] = [];
for (const [k, v] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) {
result.push(...flattenConfig(v, fullKey));
} else {
result.push({ key: fullKey, value: v });
}
}
return result;
}
/** Set a value in a nested object using dot-notation key. */
export function setNestedValue(obj: any, path: string, value: any): any {
const clone = JSON.parse(JSON.stringify(obj));
const parts = path.split('.');
let current = clone;
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) current[parts[i]] = {};
current = current[parts[i]];
}
current[parts[parts.length - 1]] = value;
return clone;
}

View file

@ -0,0 +1,379 @@
/**
* LLM Components Configuration - Unified UI
*
* Manages stage assignments and models for:
* - Techniques V3 (Manipulation Detection)
* - AI Tampered (AI Content Detection)
* - Claims (Claim Verification)
* - Source Assessment (Source credibility)
* - Verdict (final scoring) — delegated to <VerdictConfig />
* - Moderation — delegated to <ModerationSettings />
*
* Original 1608-line index.tsx split into:
* types.ts — domain types
* component-configs.tsx — COMPONENT_CONFIGS table (icons + stages + apiBase)
* api.ts — fetch / update / test / sync helpers
* helpers.ts — flattenConfig + setNestedValue + PARAM_INFO + EXCLUDE_KEYS
* panels/StageAssignmentsPanel — per-stage model chains with tier toggle
* panels/VisionModelsPanel — vision cascade for AI Tampered
* panels/AvailableModelsPanel — flat models grid + connectivity test
* panels/TestAnalysisPanel — quick /analyze runner
* panels/PromptsPanel — system+user prompt editor
* panels/ParametersPanel — scoring config editor
* index.tsx (this) — page shell + state + render dispatcher
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, Paper, Typography, Tabs, Tab, Button, Alert, CircularProgress, Divider,
ToggleButton, ToggleButtonGroup,
} from '@mui/material';
import {
PlayArrow as PlayIcon, Refresh as RefreshIcon,
Psychology as PsychologyIcon, SwapVert as SwapVertIcon,
Visibility as VisionIcon, Settings as SettingsIcon,
Sync as SyncIcon, Description as PromptsTabIcon,
} from '@mui/icons-material';
import { VerdictConfig } from './VerdictConfig';
import ModerationSettings from '../ModerationSettings';
import {
type ComponentType, type TierCode,
type AvailableModel, type StageAssignment, type ModelConfig, type TestResult,
} from './types';
import { COMPONENT_CONFIGS } from './component-configs';
import { fetchConfig, fetchModels, fetchStageAssignments, updateStageAssignments, testModel, syncToRedis } from './api';
import { StageAssignmentsPanel } from './panels/StageAssignmentsPanel';
import { VisionModelsPanel } from './panels/VisionModelsPanel';
import { AvailableModelsPanel } from './panels/AvailableModelsPanel';
import { TestAnalysisPanel } from './panels/TestAnalysisPanel';
import { PromptsPanel } from './panels/PromptsPanel';
import { ParametersPanel } from './panels/ParametersPanel';
export const LLMComponentsConfig: React.FC = () => {
const [selectedComponent, setSelectedComponent] = useState<ComponentType>('techniques');
const [selectedTier, setSelectedTier] = useState<TierCode>('free');
const [tabValue, setTabValue] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]);
const [stageAssignments, setStageAssignments] = useState<Record<string, StageAssignment>>({});
const [visionModels, setVisionModels] = useState<ModelConfig[]>([]);
const [testResults, setTestResults] = useState<Record<string, TestResult>>({});
const [syncing, setSyncing] = useState(false);
const [syncMessage, setSyncMessage] = useState<string | null>(null);
const [testText, setTestText] = useState('');
const [analysisResult, setAnalysisResult] = useState<any>(null);
const [analyzing, setAnalyzing] = useState(false);
const config = COMPONENT_CONFIGS[selectedComponent];
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
setTestResults({});
try {
const [modelsRes, assignmentsRes, configRes] = await Promise.all([
fetchModels(config.apiBase),
fetchStageAssignments(config.apiBase),
fetchConfig(config.apiBase),
]);
if (modelsRes.success) setAvailableModels(modelsRes.data.models || []);
if (assignmentsRes.success) setStageAssignments(assignmentsRes.data || {});
if (config.hasVision && configRes.success && configRes.data.vision_models) {
setVisionModels(configRes.data.vision_models.models || []);
}
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}, [config.apiBase, config.hasVision]);
useEffect(() => { loadData(); }, [loadData, selectedComponent]);
const handleModelChange = (stageKey: string, modelIndex: number, newModelKey: string) => {
setStageAssignments((prev) => {
const updated = { ...prev };
const stage = { ...updated[stageKey] };
const tierMap = { ...(stage.modelsByTier || { free: [], premium: [] }) } as Record<TierCode, ModelConfig[]>;
const currentTierModels = [...(tierMap[selectedTier] || [])];
if (!currentTierModels[modelIndex]) return prev;
currentTierModels[modelIndex] = { ...currentTierModels[modelIndex], model_key: newModelKey };
tierMap[selectedTier] = currentTierModels;
stage.modelsByTier = tierMap;
stage.models = currentTierModels;
updated[stageKey] = stage;
return updated;
});
};
const handleSave = async () => {
setLoading(true);
setError(null);
try {
const res = await updateStageAssignments(config.apiBase, stageAssignments);
if (res.success) {
setSuccess('Configuration saved successfully!');
setTimeout(() => setSuccess(null), 3000);
} else {
setError(res.error || 'Save failed');
}
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
const handleTestModel = async (modelKey: string) => {
setTestResults((prev) => ({ ...prev, [modelKey]: { model_key: modelKey, status: 'testing' } }));
try {
const res = await testModel(config.apiBase, modelKey);
setTestResults((prev) => ({ ...prev, [modelKey]: res.data }));
} catch (err) {
setTestResults((prev) => ({
...prev,
[modelKey]: { model_key: modelKey, status: 'error', error: (err as Error).message },
}));
}
};
const handleAnalyze = async () => {
if (!testText.trim()) return;
setAnalyzing(true);
setAnalysisResult(null);
try {
const res = await fetch(`${config.apiBase}/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: testText }),
});
setAnalysisResult(await res.json());
} catch (err) {
setAnalysisResult({ success: false, error: (err as Error).message });
} finally {
setAnalyzing(false);
}
};
const handleSyncRedis = async () => {
setSyncing(true);
setSyncMessage(null);
try {
const result = await syncToRedis();
if (result.success) {
setSyncMessage('Redis synced successfully!');
setSuccess('Redis synced from PostgreSQL');
setTimeout(() => { setSyncMessage(null); setSuccess(null); }, 4000);
} else {
setError(result.error || 'Sync failed');
}
} catch (err) {
setError((err as Error).message);
} finally {
setSyncing(false);
}
};
const getSpeedColor = (tier: string) => {
switch (tier) {
case 'ultra_fast': return 'success';
case 'fast': return 'info';
case 'medium': return 'warning';
default: return 'default';
}
};
const getQualityColor = (tier: string) => {
switch (tier) {
case 'premium': return 'secondary';
case 'high': return 'primary';
default: return 'default';
}
};
if (loading && availableModels.length === 0) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
</Box>
);
}
const analysisComponents: ComponentType[] = ['techniques', 'ai-tampered', 'claims', 'source-assessment', 'verdict', 'moderation'];
return (
<Box sx={{ p: 3 }}>
<Box mb={3} display="flex" alignItems="center" flexWrap="wrap" gap={2}>
<ToggleButtonGroup
value={selectedComponent}
exclusive
onChange={(_, val) => val && setSelectedComponent(val)}
size="large"
>
{analysisComponents.map((key) => {
const cfg = COMPONENT_CONFIGS[key];
return (
<ToggleButton
key={key}
value={key}
sx={{
px: 3,
'&.Mui-selected': {
bgcolor: `${cfg.color}22`,
borderColor: cfg.color,
}
}}
>
<Box display="flex" alignItems="center" gap={1}>
{cfg.icon}
<Typography>{cfg.name}</Typography>
</Box>
</ToggleButton>
);
})}
</ToggleButtonGroup>
</Box>
{/* Verdict + Moderation delegated to dedicated components */}
{selectedComponent === 'verdict' ? (
<>
<VerdictConfig />
<Divider sx={{ my: 3 }} />
<Typography variant="h5" gutterBottom sx={{ color: '#7b1fa2' }}>
<PromptsTabIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Verdict Prompts
</Typography>
<PromptsPanel componentCode="pipeline" />
</>
) : selectedComponent === 'moderation' ? (
<ModerationSettings />
) : (
<>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Box>
<Typography variant="h4" gutterBottom sx={{ color: config.color }}>
{config.icon}
<Box component="span" ml={1}>{config.name}</Box>
</Typography>
<Typography variant="body2" color="text.secondary">
{config.description}
</Typography>
</Box>
<Box display="flex" gap={1}>
<Button startIcon={<RefreshIcon />} onClick={loadData} disabled={loading}>
Refresh
</Button>
<Button
variant="outlined"
color="warning"
startIcon={syncing ? <CircularProgress size={18} /> : <SyncIcon />}
onClick={handleSyncRedis}
disabled={syncing}
>
{syncing ? 'Syncing...' : 'Sync to Redis'}
</Button>
<Button variant="contained" onClick={handleSave} disabled={loading} sx={{ bgcolor: config.color }}>
Save Configuration
</Button>
</Box>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
{syncMessage && <Alert severity="info" sx={{ mb: 2 }} onClose={() => setSyncMessage(null)}>{syncMessage}</Alert>}
<Paper sx={{ mb: 3 }}>
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)}>
<Tab label="Stage Assignments" icon={<SwapVertIcon />} iconPosition="start" />
{config.hasVision && <Tab label="Vision Models" icon={<VisionIcon />} iconPosition="start" />}
<Tab label="Available Models" icon={<PsychologyIcon />} iconPosition="start" />
<Tab label="Parameters" icon={<SettingsIcon />} iconPosition="start" />
<Tab label="Prompts" icon={<PromptsTabIcon />} iconPosition="start" />
<Tab label="Test Analysis" icon={<PlayIcon />} iconPosition="start" />
</Tabs>
</Paper>
{tabValue === 0 && (
<>
<Paper sx={{
mb: 2, p: 2, display: 'flex', alignItems: 'center',
justifyContent: 'space-between', gap: 2, flexWrap: 'wrap',
}}>
<Box>
<Typography variant="subtitle2" color="text.secondary">Subscription tier</Typography>
<Typography variant="caption" color="text.secondary">
{selectedTier === 'free'
? 'Plan 1-3 (Freemium / Starter / Basic) — local + cheap fallbacks'
: 'Plan 4-6 (Pro / Business / Enterprise) — premium cloud models'}
</Typography>
</Box>
<ToggleButtonGroup
value={selectedTier}
exclusive
size="small"
onChange={(_, v) => v && setSelectedTier(v as TierCode)}
>
<ToggleButton value="free" sx={{ px: 3 }}>FREE</ToggleButton>
<ToggleButton value="premium" sx={{ px: 3 }}>PREMIUM</ToggleButton>
</ToggleButtonGroup>
</Paper>
<StageAssignmentsPanel
stages={config.stages}
stageAssignments={stageAssignments}
availableModels={availableModels}
testResults={testResults}
selectedTier={selectedTier}
onModelChange={handleModelChange}
onTestModel={handleTestModel}
/>
</>
)}
{config.hasVision && tabValue === 1 && (
<VisionModelsPanel visionModels={visionModels} />
)}
{tabValue === (config.hasVision ? 2 : 1) && (
<AvailableModelsPanel
models={availableModels}
testResults={testResults}
onTestModel={handleTestModel}
getSpeedColor={getSpeedColor}
getQualityColor={getQualityColor}
/>
)}
{tabValue === (config.hasVision ? 3 : 2) && (
<ParametersPanel
componentType={selectedComponent}
apiBase={config.apiBase}
onSuccess={(msg) => { setSuccess(msg); setTimeout(() => setSuccess(null), 4000); }}
onError={setError}
/>
)}
{tabValue === (config.hasVision ? 4 : 3) && (
<PromptsPanel componentCode={selectedComponent} />
)}
{tabValue === (config.hasVision ? 5 : 4) && (
<TestAnalysisPanel
componentType={selectedComponent}
testText={testText}
setTestText={setTestText}
analyzing={analyzing}
analysisResult={analysisResult}
onAnalyze={handleAnalyze}
/>
)}
</>
)}
</Box>
);
};

View file

@ -0,0 +1,80 @@
/**
* Available models panel — flat list of every model defined in PG, with
* test buttons + the latest test result inline.
*/
import React from 'react';
import {
Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
Chip, Typography, Button, Tooltip, CircularProgress,
} from '@mui/material';
import {
PlayArrow as PlayIcon, Check as CheckIcon, Close as CloseIcon,
} from '@mui/icons-material';
import type { AvailableModel, TestResult } from '../types';
interface Props {
models: AvailableModel[];
testResults: Record<string, TestResult>;
onTestModel: (modelKey: string) => void;
getSpeedColor: (tier: string) => any;
getQualityColor: (tier: string) => any;
}
export const AvailableModelsPanel: React.FC<Props> = ({
models, testResults, onTestModel, getSpeedColor, getQualityColor,
}) => (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Model</TableCell>
<TableCell>Provider</TableCell>
<TableCell>Context</TableCell>
<TableCell>Speed</TableCell>
<TableCell>Quality</TableCell>
<TableCell>Cost ($/1M)</TableCell>
<TableCell>Status</TableCell>
<TableCell>Test</TableCell>
</TableRow>
</TableHead>
<TableBody>
{models.map((model) => {
const testResult = testResults[model.model_key];
return (
<TableRow key={model.model_key}>
<TableCell>
<Typography fontWeight="bold">{model.model_name}</Typography>
<Typography variant="caption" color="text.secondary">{model.model_key}</Typography>
</TableCell>
<TableCell><Chip label={model.provider} size="small" /></TableCell>
<TableCell>{(model.context_window / 1000).toFixed(0)}K</TableCell>
<TableCell><Chip label={model.speed_tier} size="small" color={getSpeedColor(model.speed_tier)} /></TableCell>
<TableCell><Chip label={model.quality_tier} size="small" color={getQualityColor(model.quality_tier)} /></TableCell>
<TableCell>
{model.cost_input_1m === 0 ? (
<Chip label="FREE" size="small" color="success" />
) : (
`$${model.cost_input_1m} / $${model.cost_output_1m}`
)}
</TableCell>
<TableCell>
{testResult?.status === 'connected' && (
<Chip icon={<CheckIcon />} label={`${testResult.response_time_ms}ms`} size="small" color="success" />
)}
{testResult?.status === 'error' && (
<Tooltip title={testResult.error}><Chip icon={<CloseIcon />} label="Error" size="small" color="error" /></Tooltip>
)}
{testResult?.status === 'testing' && <CircularProgress size={20} />}
</TableCell>
<TableCell>
<Button size="small" startIcon={<PlayIcon />} onClick={() => onTestModel(model.model_key)} disabled={testResult?.status === 'testing'}>
Test
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);

View file

@ -0,0 +1,181 @@
/**
* Parameters panel — editable scoring config per analysis component.
*
* Reads from `/api/input-profiles/scoring-config/:component` (PG-backed via
* didiFramework, not Redis), flattens nested objects into dot-keys, filters
* out scaffolding (EXCLUDE_KEYS), and renders a row per scalar value.
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, Paper, Typography, Alert, Button, Chip, Tooltip, CircularProgress,
TableContainer, Table, TableHead, TableBody, TableRow, TableCell, TextField,
} from '@mui/material';
import {
Save as SaveIcon, Settings as SettingsIcon,
InfoOutlined as InfoIcon,
} from '@mui/icons-material';
import type { ComponentType } from '../types';
import { PARAM_INFO, EXCLUDE_KEYS, flattenConfig, setNestedValue } from '../helpers';
interface Props {
componentType: ComponentType;
apiBase: string;
onSuccess: (msg: string) => void;
onError: (msg: string) => void;
}
export const ParametersPanel: React.FC<Props> = ({ componentType, apiBase, onSuccess, onError }) => {
void apiBase;
const [config, setConfig] = useState<any>(null);
const [editedConfig, setEditedConfig] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const fetchConfig = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`/framework/api/input-profiles/scoring-config/${componentType}`);
const data = await res.json();
if (data.success && data.data) {
setConfig(data.data);
setEditedConfig(JSON.parse(JSON.stringify(data.data)));
}
} catch (err) {
onError((err as Error).message);
} finally {
setLoading(false);
}
}, [componentType, onError]);
useEffect(() => { fetchConfig(); }, [fetchConfig]);
const handleSave = async () => {
if (!editedConfig) return;
setSaving(true);
try {
const res = await fetch(`/framework/api/input-profiles/scoring-config/${componentType}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editedConfig),
});
const result = await res.json();
if (!result.success) {
onError(result.error || 'Failed to save');
return;
}
setConfig(JSON.parse(JSON.stringify(editedConfig)));
onSuccess('Parameters saved! Sync to Redis to apply.');
} catch (err) {
onError((err as Error).message);
} finally {
setSaving(false);
}
};
if (loading) return <Box display="flex" justifyContent="center" py={4}><CircularProgress /></Box>;
if (!editedConfig) return <Alert severity="info">No scoring config found for {componentType}</Alert>;
const flatParams = flattenConfig(editedConfig)
.filter(p => !EXCLUDE_KEYS.some(ex => p.key === ex || p.key.startsWith(ex + '.')));
const isModified = JSON.stringify(config) !== JSON.stringify(editedConfig);
return (
<Paper sx={{ p: 3 }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">
<SettingsIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Scoring Parameters
</Typography>
<Box display="flex" gap={1}>
{isModified && (
<Button variant="text" onClick={() => setEditedConfig(JSON.parse(JSON.stringify(config)))}>
Discard
</Button>
)}
<Button
variant="contained"
startIcon={saving ? <CircularProgress size={18} /> : <SaveIcon />}
onClick={handleSave}
disabled={!isModified || saving}
>
Save Parameters
</Button>
</Box>
</Box>
{isModified && <Alert severity="warning" sx={{ mb: 2 }}>Unsaved changes. Save and then Sync to Redis to apply.</Alert>}
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 600 }}>Parameter</TableCell>
<TableCell sx={{ fontWeight: 600, width: 200 }}>Value</TableCell>
<TableCell sx={{ fontWeight: 600, width: 120 }}>Type</TableCell>
</TableRow>
</TableHead>
<TableBody>
{flatParams.map(({ key, value }) => {
const info = PARAM_INFO[key];
const label = info?.label || key;
const tooltip = info?.tooltip || '';
const valueType = typeof value;
return (
<TableRow key={key} hover>
<TableCell>
<Box display="flex" alignItems="center" gap={0.5}>
<Typography variant="body2">{label}</Typography>
{tooltip && (
<Tooltip title={tooltip} arrow placement="right">
<InfoIcon sx={{ fontSize: 16, color: 'text.secondary', cursor: 'help' }} />
</Tooltip>
)}
</Box>
<Typography variant="caption" color="text.secondary" fontFamily="monospace">{key}</Typography>
</TableCell>
<TableCell>
{valueType === 'boolean' ? (
<Chip
label={value ? 'ON' : 'OFF'}
size="small"
color={value ? 'success' : 'default'}
onClick={() => setEditedConfig((prev: any) => setNestedValue(prev, key, !value))}
sx={{ cursor: 'pointer' }}
/>
) : valueType === 'string' ? (
<TextField
size="small"
value={value}
onChange={(e) => setEditedConfig((prev: any) => setNestedValue(prev, key, e.target.value))}
sx={{ width: 180 }}
/>
) : (
<TextField
size="small"
type="number"
value={value}
onChange={(e) => {
const v = parseFloat(e.target.value);
if (!isNaN(v)) setEditedConfig((prev: any) => setNestedValue(prev, key, v));
}}
sx={{ width: 120 }}
inputProps={{ step: value < 1 ? 0.01 : 1 }}
/>
)}
</TableCell>
<TableCell>
<Chip label={valueType} size="small" variant="outlined"
color={valueType === 'number' ? 'primary' : valueType === 'boolean' ? 'success' : 'default'} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Paper>
);
};

View file

@ -0,0 +1,213 @@
/**
* Prompts panel — edit system_prompt + user_template per (component, stage).
*
* Fetches the active component's prompts plus the SHARED prompts:
* - vision → shown for techniques/ai-tampered/claims (image OCR + AI detect)
* - pipeline → shown for verdict (final-verdict explanation prompt)
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, Paper, Typography, Alert, Accordion, AccordionSummary, AccordionDetails,
TextField, Button, Chip, CircularProgress,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon, Save as SaveIcon,
Description as PromptsTabIcon,
} from '@mui/icons-material';
import type { PromptComponentCode } from '../types';
interface PromptData {
prompt_id: number;
component_code: string;
stage_code: string;
system_prompt: string;
user_template: string;
description: string;
}
interface Props {
componentCode: PromptComponentCode;
}
export const PromptsPanel: React.FC<Props> = ({ componentCode }) => {
const [prompts, setPrompts] = useState<PromptData[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [editedPrompts, setEditedPrompts] = useState<Record<number, Partial<PromptData>>>({});
const fetchPrompts = useCallback(async () => {
setLoading(true);
setError(null);
try {
const components = [componentCode];
if (['techniques', 'ai-tampered', 'claims'].includes(componentCode)) {
components.push('vision');
}
if (componentCode === 'verdict' || componentCode === 'pipeline') {
components.push('pipeline');
}
const allPrompts: PromptData[] = [];
for (const comp of components) {
const res = await fetch(`/framework/api/providers/prompts?component=${comp}`);
const data = await res.json();
if (data.success && data.data) {
allPrompts.push(...data.data);
}
}
setPrompts(allPrompts);
setEditedPrompts({});
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}, [componentCode]);
useEffect(() => { fetchPrompts(); }, [fetchPrompts]);
const handleFieldChange = (promptId: number, field: 'system_prompt' | 'user_template', value: string) => {
setEditedPrompts(prev => ({
...prev,
[promptId]: { ...prev[promptId], [field]: value },
}));
};
const isEdited = (promptId: number) => editedPrompts[promptId] !== undefined;
const handleSave = async (prompt: PromptData) => {
const edits = editedPrompts[prompt.prompt_id];
if (!edits) return;
setSaving(prompt.prompt_id);
setError(null);
try {
const res = await fetch(`/framework/api/providers/prompts/${prompt.prompt_id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
});
const data = await res.json();
if (data.success) {
setSuccess(`Prompt "${prompt.stage_code}" saved! Remember to Sync to Redis.`);
setEditedPrompts(prev => {
const next = { ...prev };
delete next[prompt.prompt_id];
return next;
});
fetchPrompts();
setTimeout(() => setSuccess(null), 5000);
} else {
setError(data.error || 'Failed to save');
}
} catch (err) {
setError((err as Error).message);
} finally {
setSaving(null);
}
};
if (loading) {
return (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
);
}
return (
<Box>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
{prompts.length === 0 ? (
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography color="text.secondary">
No prompts configured for {componentCode}. Prompts can be added via API.
</Typography>
</Paper>
) : (
prompts.map((prompt) => {
const edited = editedPrompts[prompt.prompt_id];
const currentSystem = edited?.system_prompt ?? prompt.system_prompt;
const currentUser = edited?.user_template ?? prompt.user_template;
return (
<Accordion key={prompt.prompt_id} defaultExpanded>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box display="flex" alignItems="center" gap={2} width="100%">
<PromptsTabIcon color="action" />
<Box flex={1}>
<Typography variant="h6">
{prompt.stage_code}
{prompt.component_code !== componentCode && (
<Chip label={prompt.component_code} size="small" sx={{ ml: 1 }} color="info" variant="outlined" />
)}
</Typography>
<Typography variant="body2" color="text.secondary">{prompt.description}</Typography>
</Box>
{isEdited(prompt.prompt_id) && (
<Chip label="Modified" size="small" color="warning" />
)}
</Box>
</AccordionSummary>
<AccordionDetails>
<Box display="flex" flexDirection="column" gap={2}>
<Box>
<Typography variant="subtitle2" gutterBottom>System Prompt</Typography>
<TextField
fullWidth
multiline
rows={6}
value={currentSystem}
onChange={(e) => handleFieldChange(prompt.prompt_id, 'system_prompt', e.target.value)}
sx={{ fontFamily: 'monospace', '& textarea': { fontFamily: 'monospace', fontSize: '0.85rem' } }}
/>
</Box>
<Box>
<Typography variant="subtitle2" gutterBottom>User Template</Typography>
<TextField
fullWidth
multiline
rows={8}
value={currentUser}
onChange={(e) => handleFieldChange(prompt.prompt_id, 'user_template', e.target.value)}
sx={{ fontFamily: 'monospace', '& textarea': { fontFamily: 'monospace', fontSize: '0.85rem' } }}
/>
</Box>
<Box display="flex" justifyContent="flex-end" gap={1}>
{isEdited(prompt.prompt_id) && (
<Button
variant="text"
onClick={() => {
setEditedPrompts(prev => {
const next = { ...prev };
delete next[prompt.prompt_id];
return next;
});
}}
>
Discard Changes
</Button>
)}
<Button
variant="contained"
startIcon={saving === prompt.prompt_id ? <CircularProgress size={18} /> : <SaveIcon />}
onClick={() => handleSave(prompt)}
disabled={!isEdited(prompt.prompt_id) || saving === prompt.prompt_id}
>
Save Prompt
</Button>
</Box>
</Box>
</AccordionDetails>
</Accordion>
);
})
)}
</Box>
);
};

View file

@ -0,0 +1,153 @@
/**
* Stage assignments table — one accordion per pipeline stage, with the
* tier-specific (free/premium) model chain editable inside.
*
* If the requested tier is empty (e.g. premium not configured yet), falls
* back to the free chain and shows a warning Alert pointing to the API to
* create the missing tier rows.
*/
import React from 'react';
import {
Box, Accordion, AccordionSummary, AccordionDetails, Typography, Chip, Alert,
TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
FormControl, Select, MenuItem, IconButton, Tooltip, CircularProgress,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon, PlayArrow as PlayIcon,
Check as CheckIcon, Close as CloseIcon,
} from '@mui/icons-material';
import type { StageConfig, StageAssignment, AvailableModel, TestResult, TierCode } from '../types';
interface Props {
stages: StageConfig[];
stageAssignments: Record<string, StageAssignment>;
availableModels: AvailableModel[];
testResults: Record<string, TestResult>;
selectedTier: TierCode;
onModelChange: (stageKey: string, modelIndex: number, newModelKey: string) => void;
onTestModel: (modelKey: string) => void;
}
export const StageAssignmentsPanel: React.FC<Props> = ({
stages, stageAssignments, availableModels, testResults, selectedTier, onModelChange, onTestModel,
}) => (
<Box>
{stages.map(({ key, title, icon, description, color }) => {
const stage = stageAssignments[key];
if (!stage) return null;
const tierModels =
stage.modelsByTier?.[selectedTier] && stage.modelsByTier[selectedTier].length > 0
? stage.modelsByTier[selectedTier]
: (stage.modelsByTier?.free && stage.modelsByTier.free.length > 0
? stage.modelsByTier.free
: stage.models);
const tierBadge = selectedTier === 'premium' ? 'PREMIUM' : 'FREE';
const tierBadgeColor: 'success' | 'warning' = selectedTier === 'premium' ? 'warning' : 'success';
const missingPremium = selectedTier === 'premium'
&& (!stage.modelsByTier?.premium || stage.modelsByTier.premium.length === 0);
return (
<Accordion key={`${key}-${selectedTier}`} defaultExpanded>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box display="flex" alignItems="center" gap={2} width="100%">
<Box sx={{ color }}>{icon}</Box>
<Box flex={1}>
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="h6">{title}</Typography>
<Chip label={tierBadge} size="small" color={tierBadgeColor} />
</Box>
<Typography variant="body2" color="text.secondary">{description}</Typography>
</Box>
</Box>
</AccordionSummary>
<AccordionDetails>
{missingPremium && (
<Alert severity="warning" sx={{ mb: 2 }}>
No <strong>premium</strong> assignments configured for this stage — showing <strong>free</strong> chain
as fallback. Create premium rows via POST /api/providers/assignments with <code>tier: "premium"</code>.
</Alert>
)}
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell width={80}>Order</TableCell>
<TableCell width={120}>Role</TableCell>
<TableCell>Model</TableCell>
<TableCell width={100}>Temp</TableCell>
<TableCell width={120}>Max Tokens</TableCell>
<TableCell width={120}>Timeout</TableCell>
<TableCell width={100}>Status</TableCell>
<TableCell width={80}>Test</TableCell>
</TableRow>
</TableHead>
<TableBody>
{tierModels.map((model, index) => {
const modelKey = model.model_key || '';
const testResult = testResults[modelKey];
return (
<TableRow key={model.order}>
<TableCell>
<Chip label={model.order} size="small" color={model.order === 1 ? 'primary' : 'default'} />
</TableCell>
<TableCell>
<Chip
label={model.role.replace('_', ' ').toUpperCase()}
size="small"
variant={model.role === 'primary' ? 'filled' : 'outlined'}
color={model.role === 'primary' ? 'success' : 'default'}
/>
</TableCell>
<TableCell>
<FormControl fullWidth size="small">
<Select
value={modelKey}
onChange={(e) => onModelChange(key, index, e.target.value)}
>
{availableModels.map((m) => (
<MenuItem key={m.model_key} value={m.model_key}>
<Box display="flex" alignItems="center" gap={1}>
<Typography>{m.model_name}</Typography>
<Chip label={m.provider} size="small" variant="outlined" />
{m.cost_input_1m === 0 && <Chip label="FREE" size="small" color="success" />}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</TableCell>
<TableCell>{model.temperature != null ? model.temperature : '-'}</TableCell>
<TableCell>{model.max_tokens != null ? model.max_tokens : '-'}</TableCell>
<TableCell>{model.timeout_ms != null ? `${model.timeout_ms / 1000}s` : '-'}</TableCell>
<TableCell>
{testResult?.status === 'connected' && (
<Tooltip title={`${testResult.response_time_ms}ms`}>
<Chip icon={<CheckIcon />} label="OK" size="small" color="success" />
</Tooltip>
)}
{testResult?.status === 'error' && (
<Tooltip title={testResult.error}>
<Chip icon={<CloseIcon />} label="Error" size="small" color="error" />
</Tooltip>
)}
{testResult?.status === 'testing' && <CircularProgress size={20} />}
</TableCell>
<TableCell>
<IconButton size="small" onClick={() => onTestModel(modelKey)} disabled={testResult?.status === 'testing'}>
<PlayIcon />
</IconButton>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</AccordionDetails>
</Accordion>
);
})}
</Box>
);

View file

@ -0,0 +1,98 @@
/**
* Test Analysis panel — quick-run UI for the active component's /analyze
* endpoint. Per-component sample texts let QA reproduce known-good results
* without copy-pasting from a doc.
*/
import React from 'react';
import {
Box, Paper, Typography, TextField, Button, Alert, CircularProgress,
} from '@mui/material';
import { PlayArrow as PlayIcon } from '@mui/icons-material';
import type { ComponentType } from '../types';
interface Props {
componentType: ComponentType;
testText: string;
setTestText: (text: string) => void;
analyzing: boolean;
analysisResult: any;
onAnalyze: () => void;
}
const SAMPLE_TEXTS: Record<ComponentType, string> = {
'techniques': `URGENT: This is a THREAT to our survival! The government is hiding the truth.
We must ACT NOW before it's too late! They don't want you to know about this conspiracy.
Share this before they delete it! This is being censored everywhere.`,
'ai-tampered': `It is important to note that there are several key considerations to keep in mind.
Furthermore, one must acknowledge the multifaceted nature of this issue.
In conclusion, while there are valid perspectives on both sides, a balanced approach is recommended.`,
'claims': `The Eiffel Tower is 500 meters tall. Romania joined the EU in 2007.
The vaccine has a 95% efficacy rate according to clinical trials.
The GDP of Romania grew by 5% in 2024.`,
'source-assessment': `According to a report by Reuters, the new climate agreement was signed by 50 countries.
The article was written by journalist John Smith and published on fortune.com.
The data comes from the United Nations Environment Programme.`,
'verdict': '',
'moderation': '',
};
export const TestAnalysisPanel: React.FC<Props> = ({
componentType, testText, setTestText, analyzing, analysisResult, onAnalyze,
}) => (
<Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
<Box sx={{ flex: '1 1 45%', minWidth: 400 }}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>Input Text</Typography>
<TextField
fullWidth
multiline
rows={12}
value={testText}
onChange={(e) => setTestText(e.target.value)}
placeholder="Enter text to analyze..."
/>
<Box mt={2} display="flex" gap={1}>
<Button
variant="contained"
startIcon={analyzing ? <CircularProgress size={20} /> : <PlayIcon />}
onClick={onAnalyze}
disabled={analyzing || !testText.trim()}
>
{analyzing ? 'Analyzing...' : 'Run Analysis'}
</Button>
<Button variant="outlined" onClick={() => setTestText(SAMPLE_TEXTS[componentType])}>
Load Sample
</Button>
<Button onClick={() => setTestText('')}>Clear</Button>
</Box>
</Paper>
</Box>
<Box sx={{ flex: '1 1 45%', minWidth: 400 }}>
<Paper sx={{ p: 2, minHeight: 400 }}>
<Typography variant="h6" gutterBottom>Results</Typography>
{!analysisResult && !analyzing && (
<Typography color="text.secondary">Run an analysis to see results.</Typography>
)}
{analyzing && (
<Box display="flex" justifyContent="center" alignItems="center" height={300}>
<CircularProgress />
</Box>
)}
{analysisResult && !analyzing && (
<Box>
{analysisResult.success ? (
<Box component="pre" sx={{ overflow: 'auto', maxHeight: 400, bgcolor: 'action.hover', p: 2, borderRadius: 1 }}>
{JSON.stringify(analysisResult.data, null, 2)}
</Box>
) : (
<Alert severity="error">{analysisResult.error}</Alert>
)}
</Box>
)}
</Paper>
</Box>
</Box>
</Box>
);

View file

@ -0,0 +1,64 @@
/**
* Vision Models panel — displayed only for AI Tampered (the one component
* that runs frame-level analysis through the vision cascade).
*
* Read-only listing; vision config edits go through the agent-v3 admin API
* (not yet PG-backed), so the UI doesn't expose direct row editing here.
*/
import React from 'react';
import {
Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
Chip, Typography,
} from '@mui/material';
import { Visibility as VisionIcon } from '@mui/icons-material';
import type { ModelConfig } from '../types';
interface Props {
visionModels: ModelConfig[];
}
export const VisionModelsPanel: React.FC<Props> = ({ visionModels }) => (
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
<VisionIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Vision Models for Image AI Detection
</Typography>
<Typography variant="body2" color="text.secondary" mb={2}>
These models are used to analyze images for AI-generated content indicators.
</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Order</TableCell>
<TableCell>Role</TableCell>
<TableCell>Model</TableCell>
<TableCell>Name</TableCell>
<TableCell>Timeout</TableCell>
</TableRow>
</TableHead>
<TableBody>
{visionModels.map((model) => (
<TableRow key={model.order}>
<TableCell>
<Chip label={model.order} size="small" color={model.order === 1 ? 'primary' : 'default'} />
</TableCell>
<TableCell>
<Chip
label={(model.role || 'primary').replace('_', ' ').toUpperCase()}
size="small"
color={model.role === 'primary' ? 'success' : 'default'}
/>
</TableCell>
<TableCell>
<Typography fontFamily="monospace">{model.model}</Typography>
</TableCell>
<TableCell>{model.name}</TableCell>
<TableCell>{model.timeout ? `${model.timeout / 1000}s` : '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
);

View file

@ -0,0 +1,71 @@
/**
* Domain types for the LLM Components Configuration page.
*
* The shape mirrors PG-side schemas (assignments / models / configs),
* with helper-only fields prefixed `_` (e.g. `_stage_id`) that the UI
* uses to call PUT endpoints by id without flowing through serializers.
*/
import type React from 'react';
export type ComponentType = 'techniques' | 'ai-tampered' | 'claims' | 'source-assessment' | 'verdict' | 'moderation';
export type PromptComponentCode = ComponentType | 'vision' | 'pipeline';
export type TierCode = 'free' | 'premium';
export interface ModelConfig {
order: number;
role: 'primary' | 'fallback_1' | 'fallback_2' | 'fallback_3';
model_key?: string;
model?: string; // for vision models
name?: string;
temperature?: number;
max_tokens?: number;
timeout_ms?: number;
timeout?: number; // legacy alias used by vision models
_stage_id?: number;
_provider_id?: number;
_model_id?: number;
_tier?: TierCode;
}
export interface StageAssignment {
stage: string;
description: string;
models: ModelConfig[];
modelsByTier?: Record<TierCode, ModelConfig[]>;
}
export interface AvailableModel {
model_key: string;
provider: string;
model_name: string;
context_window: number;
cost_input_1m: number;
cost_output_1m: number;
speed_tier: string;
quality_tier: string;
}
export interface TestResult {
model_key: string;
status: 'connected' | 'error' | 'testing';
response_time_ms?: number;
error?: string;
}
export interface ComponentConfig {
name: string;
icon: React.ReactNode;
description: string;
color: string;
apiBase: string;
stages: StageConfig[];
hasVision?: boolean;
}
export interface StageConfig {
key: string;
title: string;
icon: React.ReactNode;
description: string;
color: string;
}

View file

@ -0,0 +1,343 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box, Typography, Paper, Button, Chip, Alert, CircularProgress, Divider, Card, CardContent, Grid,
Dialog, DialogTitle, DialogContent, DialogActions, ToggleButton, ToggleButtonGroup, TextField,
} from '@mui/material';
import {
ArrowBack as BackIcon, CheckCircle as ApproveIcon, EditNote as EditIcon, Block as RejectIcon,
PersonAdd as ClaimIcon, Refresh as RefreshIcon,
} from '@mui/icons-material';
import {
getQueueEntry, claimQueueEntry, resolveQueueEntry,
priorityLabel, priorityColor, statusColor, ageMinutes,
type QueueEntry, type ResolutionAction,
} from './api';
export const ModerationDetail: React.FC = () => {
const { queueId } = useParams<{ queueId: string }>();
const navigate = useNavigate();
const [entry, setEntry] = useState<QueueEntry | null>(null);
const [session, setSession] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [resolveOpen, setResolveOpen] = useState(false);
const [resolveAction, setResolveAction] = useState<ResolutionAction>('approved');
const [resolveNotes, setResolveNotes] = useState('');
const [resolveCorrections, setResolveCorrections] = useState('{\n \n}');
const [submitting, setSubmitting] = useState(false);
const load = useCallback(async () => {
if (!queueId) return;
setLoading(true);
setError(null);
try {
const res = await getQueueEntry(queueId);
if (res.success && res.data) {
setEntry(res.data.queue);
setSession(res.data.session);
} else {
setError(res.error ?? 'Failed to load');
}
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [queueId]);
useEffect(() => { load(); }, [load]);
const handleClaim = async () => {
if (!queueId) return;
setSubmitting(true);
try {
const res = await claimQueueEntry(queueId);
if (res.success) {
setSuccess('Claimed. You can now resolve.');
load();
} else {
setError(res.error ?? 'Claim failed');
}
} catch (e) {
setError((e as Error).message);
} finally {
setSubmitting(false);
}
};
const openResolve = (action: ResolutionAction) => {
setResolveAction(action);
setResolveNotes('');
setResolveCorrections('{\n \n}');
setResolveOpen(true);
};
const handleResolveSubmit = async () => {
if (!queueId) return;
let corrections: Record<string, unknown> | null = null;
if (resolveAction === 'corrected') {
try {
corrections = JSON.parse(resolveCorrections);
} catch (e) {
setError(`Invalid JSON in corrections: ${(e as Error).message}`);
return;
}
}
setSubmitting(true);
try {
const res = await resolveQueueEntry(queueId, {
action: resolveAction,
corrections,
notes: resolveNotes || null,
user_id: 'admin',
});
if (res.success) {
setSuccess(`Resolved as '${resolveAction}'`);
setResolveOpen(false);
load();
} else {
setError(res.error ?? 'Resolve failed');
}
} catch (e) {
setError((e as Error).message);
} finally {
setSubmitting(false);
}
};
if (loading) {
return <Box display="flex" justifyContent="center" p={4}><CircularProgress /></Box>;
}
if (!entry) {
return (
<Box p={3}>
<Alert severity="error" action={<Button onClick={() => navigate('/moderation')}>Back</Button>}>
{error ?? 'Not found'}
</Alert>
</Box>
);
}
const isActionable = entry.status === 'pending' || entry.status === 'in_review';
const inReview = entry.status === 'in_review';
// Pull common fields from session safely
const s = (session ?? {}) as Record<string, unknown>;
const inputText = (s.input_text as string | null) ?? null;
const inputUrl = (s.input_url as string | null) ?? null;
const inputType = (s.input_type as string | null) ?? null;
const userEmail = (s.user_email as string | null) ?? null;
const riskScore = (s.risk_score as number | null) ?? null;
const riskCategory = (s.risk_category as string | null) ?? null;
const confidence = (s.confidence as number | null) ?? null;
const severity = (s.severity as string | null) ?? null;
return (
<Box sx={{ p: 3 }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Box display="flex" alignItems="center" gap={2}>
<Button startIcon={<BackIcon />} onClick={() => navigate('/moderation')}>
Back to Queue
</Button>
<Typography variant="h5">Queue #{entry.queue_id}</Typography>
<Chip label={entry.status} color={statusColor(entry.status)} size="small" />
<Chip label={priorityLabel(entry.priority)} color={priorityColor(entry.priority)} size="small" />
</Box>
<Button startIcon={<RefreshIcon />} onClick={load}>Refresh</Button>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
{/* Action buttons */}
<Paper sx={{ p: 2, mb: 2, bgcolor: isActionable ? 'background.paper' : 'action.hover' }}>
<Box display="flex" gap={1} flexWrap="wrap">
{entry.status === 'pending' && (
<Button
variant="contained" color="primary" startIcon={<ClaimIcon />}
onClick={handleClaim} disabled={submitting}
>
Claim for Review
</Button>
)}
{inReview && (
<>
<Button variant="contained" color="success" startIcon={<ApproveIcon />} onClick={() => openResolve('approved')} disabled={submitting}>
Approve as is
</Button>
<Button variant="contained" color="info" startIcon={<EditIcon />} onClick={() => openResolve('corrected')} disabled={submitting}>
Resolve with Corrections
</Button>
<Button variant="outlined" color="error" startIcon={<RejectIcon />} onClick={() => openResolve('rejected')} disabled={submitting}>
Reject (low quality input)
</Button>
</>
)}
{!isActionable && (
<Typography variant="body2" color="text.secondary">
Status is <strong>{entry.status}</strong>.
{entry.resolved_by && ` Resolved by ${entry.resolved_by} as '${entry.resolution_action}'.`}
</Typography>
)}
</Box>
</Paper>
<Grid container spacing={2}>
{/* Left: Session input */}
<Grid size={{ xs: 12, md: 6 }}>
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom>Input</Typography>
<Box display="flex" gap={1} mb={1} flexWrap="wrap">
<Chip label={`type: ${inputType ?? '?'}`} size="small" variant="outlined" />
{userEmail && <Chip label={userEmail} size="small" variant="outlined" />}
</Box>
{inputText && (
<Box sx={{ maxHeight: 400, overflow: 'auto', bgcolor: 'background.default', p: 2, borderRadius: 1, mt: 1 }}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{inputText}
</Typography>
</Box>
)}
{inputUrl && (
<Box mt={1}>
<Typography variant="caption" color="text.secondary">URL</Typography>
<Typography variant="body2" sx={{ wordBreak: 'break-all' }}>{inputUrl}</Typography>
</Box>
)}
</CardContent>
</Card>
</Grid>
{/* Right: AI verdict summary */}
<Grid size={{ xs: 12, md: 6 }}>
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom>AI Verdict</Typography>
<Box display="flex" gap={1} flexWrap="wrap" mb={2}>
{riskCategory && <Chip label={riskCategory} color="primary" />}
{severity && <Chip label={`severity: ${severity}`} variant="outlined" />}
</Box>
<Box display="grid" gridTemplateColumns="1fr 1fr" gap={2}>
<Box>
<Typography variant="caption" color="text.secondary">Risk Score</Typography>
<Typography variant="h4">{riskScore ?? '—'}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Confidence</Typography>
<Typography variant="h4">{confidence ?? '—'}</Typography>
</Box>
</Box>
<Divider sx={{ my: 2 }} />
<Typography variant="caption" color="text.secondary">Components</Typography>
<Box display="flex" gap={1} flexWrap="wrap" mt={0.5}>
{(s.components_run as string[] | null)?.map((c) => (
<Chip key={c} label={c} size="small" color="success" variant="outlined" />
))}
{(s.components_skipped as string[] | null)?.map((c) => (
<Chip key={`s-${c}`} label={c} size="small" variant="outlined" />
))}
</Box>
</CardContent>
</Card>
</Grid>
{/* Queue meta */}
<Grid size={12}>
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom>Queue Metadata</Typography>
<Box display="grid" gridTemplateColumns="repeat(4, 1fr)" gap={2}>
<Box>
<Typography variant="caption" color="text.secondary">Reason</Typography>
<Typography variant="body2">{entry.enqueue_reason}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Created</Typography>
<Typography variant="body2">{ageMinutes(entry.created_at)}m ago</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Assigned</Typography>
<Typography variant="body2">{entry.assigned_to ?? '—'}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Resolved</Typography>
<Typography variant="body2">{entry.resolved_by ?? '—'}</Typography>
</Box>
</Box>
{entry.enqueue_meta && (
<Box mt={2}>
<Typography variant="caption" color="text.secondary">Triage meta</Typography>
<pre style={{ background: 'var(--mui-palette-background-default,#111)', color: 'var(--mui-palette-text-primary,#e0e0e0)', padding: 8, borderRadius: 4, fontSize: 12, overflow: 'auto' }}>
{JSON.stringify(entry.enqueue_meta, null, 2)}
</pre>
</Box>
)}
</CardContent>
</Card>
</Grid>
</Grid>
{/* Resolve Dialog */}
<Dialog open={resolveOpen} onClose={() => setResolveOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Resolve Queue Entry #{entry.queue_id}</DialogTitle>
<DialogContent>
<Box display="flex" flexDirection="column" gap={2} pt={1}>
<ToggleButtonGroup
value={resolveAction}
exclusive
onChange={(_, v) => v && setResolveAction(v)}
>
<ToggleButton value="approved">Approved (no change)</ToggleButton>
<ToggleButton value="corrected">Corrected</ToggleButton>
<ToggleButton value="rejected">Rejected</ToggleButton>
</ToggleButtonGroup>
{resolveAction === 'corrected' && (
<>
<Alert severity="info">
Provide corrections as a JSON diff. Example:<br/>
<code style={{ fontSize: 11 }}>
{`{ "verdict": { "risk_score": { "from": 67, "to": 45 } }, "techniques": { "removed": ["false_dilemma"] } }`}
</code>
</Alert>
<TextField
label="Corrections (JSON)"
value={resolveCorrections}
onChange={(e) => setResolveCorrections(e.target.value)}
multiline
minRows={6}
maxRows={20}
sx={{ '& textarea': { fontFamily: 'monospace', fontSize: 13 } }}
fullWidth
/>
</>
)}
<TextField
label="Notes (optional)"
value={resolveNotes}
onChange={(e) => setResolveNotes(e.target.value)}
multiline
minRows={2}
fullWidth
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setResolveOpen(false)} disabled={submitting}>Cancel</Button>
<Button variant="contained" onClick={handleResolveSubmit} disabled={submitting}>
{submitting ? 'Saving…' : 'Confirm Resolve'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default ModerationDetail;

View file

@ -0,0 +1,181 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box, Typography, Paper, Table, TableHead, TableBody, TableRow, TableCell,
Chip, IconButton, ToggleButtonGroup, ToggleButton, CircularProgress, Alert, Button, Pagination,
} from '@mui/material';
import { Refresh as RefreshIcon, OpenInNew as OpenIcon, Shield as ShieldIcon } from '@mui/icons-material';
import { listQueue, priorityLabel, priorityColor, statusColor, ageMinutes, type QueueEntry, type QueueStatus } from './api';
const PAGE_SIZE = 20;
export const ModerationQueue: React.FC = () => {
const navigate = useNavigate();
const [items, setItems] = useState<QueueEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<QueueStatus | 'all'>('pending');
const [priorityFilter, setPriorityFilter] = useState<string>('');
const [page, setPage] = useState(1);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params: { status?: string; priority?: string; limit: number; offset: number } = {
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
};
if (statusFilter !== 'all') params.status = statusFilter;
if (priorityFilter) params.priority = priorityFilter;
const res = await listQueue(params);
if (res.success && res.data) {
setItems(res.data);
setTotal(res.total ?? res.data.length);
} else {
setError(res.error ?? 'Failed to load queue');
}
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [statusFilter, priorityFilter, page]);
useEffect(() => { load(); }, [load]);
// Auto-refresh every 30s when on pending tab
useEffect(() => {
if (statusFilter !== 'pending') return;
const t = setInterval(load, 30_000);
return () => clearInterval(t);
}, [statusFilter, load]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<Box sx={{ p: 3 }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Box>
<Typography variant="h4" gutterBottom>
<ShieldIcon sx={{ mr: 1, verticalAlign: 'middle', color: '#0288d1' }} />
Moderation Queue
</Typography>
<Typography variant="body2" color="text.secondary">
Sessions flagged by triage or user reports for human review.
</Typography>
</Box>
<Box display="flex" gap={1}>
<Button variant="outlined" onClick={() => navigate('/moderation/stats')}>
View Stats
</Button>
<Button startIcon={<RefreshIcon />} onClick={load} disabled={loading}>
Refresh
</Button>
</Box>
</Box>
<Paper sx={{ p: 2, mb: 2 }}>
<Box display="flex" gap={2} flexWrap="wrap" alignItems="center">
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Status</Typography>
<ToggleButtonGroup
size="small"
value={statusFilter}
exclusive
onChange={(_, v) => v && (setPage(1), setStatusFilter(v))}
>
<ToggleButton value="pending">Pending</ToggleButton>
<ToggleButton value="in_review">In Review</ToggleButton>
<ToggleButton value="resolved">Resolved</ToggleButton>
<ToggleButton value="all">All</ToggleButton>
</ToggleButtonGroup>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Priority</Typography>
<ToggleButtonGroup
size="small"
value={priorityFilter}
exclusive
onChange={(_, v) => (setPage(1), setPriorityFilter(v ?? ''))}
>
<ToggleButton value="">Any</ToggleButton>
<ToggleButton value="1">1</ToggleButton>
<ToggleButton value="2">2</ToggleButton>
<ToggleButton value="3">3</ToggleButton>
<ToggleButton value="4">4</ToggleButton>
<ToggleButton value="5">5</ToggleButton>
</ToggleButtonGroup>
</Box>
<Typography variant="caption" sx={{ ml: 'auto' }} color="text.secondary">
{total} total
</Typography>
</Box>
</Paper>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
<Paper>
{loading && items.length === 0 ? (
<Box display="flex" justifyContent="center" p={4}><CircularProgress /></Box>
) : items.length === 0 ? (
<Box p={4} textAlign="center">
<Typography color="text.secondary">No items match these filters.</Typography>
</Box>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Queue ID</TableCell>
<TableCell>Priority</TableCell>
<TableCell>Reason</TableCell>
<TableCell>Status</TableCell>
<TableCell>Assigned</TableCell>
<TableCell>Age</TableCell>
<TableCell>Session</TableCell>
<TableCell align="right">Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((it) => (
<TableRow key={it.queue_id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/moderation/${it.queue_id}`)}>
<TableCell>#{it.queue_id}</TableCell>
<TableCell>
<Chip label={priorityLabel(it.priority)} color={priorityColor(it.priority)} size="small" />
</TableCell>
<TableCell>
<Chip label={it.enqueue_reason} variant="outlined" size="small" />
</TableCell>
<TableCell>
<Chip label={it.status} color={statusColor(it.status)} size="small" />
</TableCell>
<TableCell>{it.assigned_to ?? '—'}</TableCell>
<TableCell>{ageMinutes(it.created_at)}m</TableCell>
<TableCell>
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
{it.session_id.slice(0, 8)}…
</Typography>
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={(e) => { e.stopPropagation(); navigate(`/moderation/${it.queue_id}`); }}>
<OpenIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{totalPages > 1 && (
<Box display="flex" justifyContent="center" p={2}>
<Pagination count={totalPages} page={page} onChange={(_, p) => setPage(p)} size="small" />
</Box>
)}
</Paper>
</Box>
);
};
export default ModerationQueue;

View file

@ -0,0 +1,118 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Typography, Paper, Card, CardContent, Grid, CircularProgress, Alert, Button, Chip } from '@mui/material';
import { Refresh as RefreshIcon, ArrowBack as BackIcon } from '@mui/icons-material';
import { getStats, type QueueStats } from './api';
const STAT_COLORS: Record<string, string> = {
pending: '#ff9800',
in_review: '#0288d1',
resolved_24h: '#2e7d32',
};
export const ModerationStats: React.FC = () => {
const navigate = useNavigate();
const [stats, setStats] = useState<QueueStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await getStats();
if (res.success && res.data) setStats(res.data);
else setError(res.error ?? 'Failed to load stats');
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
const t = setInterval(load, 30_000);
return () => clearInterval(t);
}, [load]);
if (loading && !stats) {
return <Box display="flex" justifyContent="center" p={4}><CircularProgress /></Box>;
}
return (
<Box sx={{ p: 3 }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Box display="flex" alignItems="center" gap={2}>
<Button startIcon={<BackIcon />} onClick={() => navigate('/moderation')}>Queue</Button>
<Typography variant="h4">Moderation Stats</Typography>
</Box>
<Button startIcon={<RefreshIcon />} onClick={load}>Refresh</Button>
</Box>
{error && <Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>{error}</Alert>}
{stats && (
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 4 }}>
<Card variant="outlined" sx={{ borderTop: `4px solid ${STAT_COLORS.pending}` }}>
<CardContent>
<Typography variant="caption" color="text.secondary" textTransform="uppercase">Pending</Typography>
<Typography variant="h2">{stats.pending}</Typography>
<Typography variant="caption" color="text.secondary">Awaiting moderator claim</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Card variant="outlined" sx={{ borderTop: `4px solid ${STAT_COLORS.in_review}` }}>
<CardContent>
<Typography variant="caption" color="text.secondary" textTransform="uppercase">In Review</Typography>
<Typography variant="h2">{stats.in_review}</Typography>
<Typography variant="caption" color="text.secondary">Currently being moderated</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Card variant="outlined" sx={{ borderTop: `4px solid ${STAT_COLORS.resolved_24h}` }}>
<CardContent>
<Typography variant="caption" color="text.secondary" textTransform="uppercase">Resolved (24h)</Typography>
<Typography variant="h2">{stats.resolved_24h}</Typography>
<Typography variant="caption" color="text.secondary">Completed in last 24h</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>Open by Priority</Typography>
<Box display="flex" gap={1} flexWrap="wrap">
{Object.keys(stats.by_priority).length === 0 ? (
<Typography variant="body2" color="text.secondary">No open entries.</Typography>
) : (
Object.entries(stats.by_priority).map(([k, v]) => (
<Chip key={k} label={`${k}: ${v}`} color={k === 'p1' ? 'error' : k === 'p2' ? 'warning' : 'default'} />
))
)}
</Box>
</Paper>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>Avg time in queue</Typography>
<Typography variant="h4">
{stats.avg_time_in_queue_ms != null
? `${(stats.avg_time_in_queue_ms / 1000 / 60).toFixed(1)}m`
: '—'}
</Typography>
<Typography variant="caption" color="text.secondary">
From enqueue to claim. Lower is better; spike means moderator capacity issue.
</Typography>
</Paper>
</Grid>
</Grid>
)}
</Box>
);
};
export default ModerationStats;

View file

@ -0,0 +1,135 @@
/**
* API client for Moderation pages — talks to agent-v3 via /agent-v3/api/v3/moderation/*.
* (Nginx proxies the path; in dev/staging it goes direct.)
*/
const AGENT_API = '/agent-v3/api/v3/moderation';
export type EnqueueReason = 'flagged' | 'low_confidence' | 'sensitive_topic' | 'mixed' | 'none';
export type QueueStatus = 'pending' | 'in_review' | 'resolved' | 'declined' | 'auto_closed';
export type ResolutionAction = 'approved' | 'corrected' | 'rejected';
export interface QueueEntry {
queue_id: number | string;
session_id: string;
priority: number;
enqueue_reason: EnqueueReason;
enqueue_meta: Record<string, unknown> | null;
status: QueueStatus;
assigned_to: string | null;
assigned_at: string | null;
resolved_at: string | null;
resolved_by: string | null;
resolution_action: ResolutionAction | null;
time_in_queue_ms: number | null;
time_in_review_ms: number | null;
created_at: string;
}
export interface QueueDetailResponse {
queue: QueueEntry;
session: Record<string, unknown> | null;
}
export interface QueueStats {
pending: number;
in_review: number;
resolved_24h: number;
by_priority: Record<string, number>;
avg_time_in_queue_ms: number | null;
}
export interface ResolveBody {
action: ResolutionAction;
corrections?: Record<string, unknown> | null;
notes?: string | null;
user_id?: string;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
total?: number;
limit?: number;
offset?: number;
error?: string;
message?: string;
}
async function asJson<T>(res: Response): Promise<ApiResponse<T>> {
try { return (await res.json()) as ApiResponse<T>; }
catch { return { success: false, error: `${res.status} ${res.statusText}` }; }
}
async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
const token = localStorage.getItem('keycloak_token');
const headers: HeadersInit = {
...(init.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
return fetch(input, { ...init, headers });
}
export interface ListQueueParams {
status?: string;
priority?: string;
assigned_to?: string;
limit?: number;
offset?: number;
}
export async function listQueue(params: ListQueueParams = {}): Promise<ApiResponse<QueueEntry[]>> {
const q = new URLSearchParams();
if (params.status) q.set('status', params.status);
if (params.priority) q.set('priority', params.priority);
if (params.assigned_to) q.set('assigned_to', params.assigned_to);
if (params.limit) q.set('limit', String(params.limit));
if (params.offset) q.set('offset', String(params.offset));
const res = await authedFetch(`${AGENT_API}/queue?${q}`);
return asJson<QueueEntry[]>(res);
}
export async function getQueueEntry(queueId: number | string): Promise<ApiResponse<QueueDetailResponse>> {
const res = await authedFetch(`${AGENT_API}/queue/${queueId}`);
return asJson<QueueDetailResponse>(res);
}
export async function claimQueueEntry(queueId: number | string, userId?: string): Promise<ApiResponse<QueueEntry>> {
const res = await authedFetch(`${AGENT_API}/queue/${queueId}/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userId ? { user_id: userId } : {}),
});
return asJson<QueueEntry>(res);
}
export async function resolveQueueEntry(queueId: number | string, body: ResolveBody): Promise<ApiResponse<QueueEntry>> {
const res = await authedFetch(`${AGENT_API}/queue/${queueId}/resolve`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return asJson<QueueEntry>(res);
}
export async function getStats(): Promise<ApiResponse<QueueStats>> {
const res = await authedFetch(`${AGENT_API}/stats`);
return asJson<QueueStats>(res);
}
// Helpers for UI
export function priorityLabel(p: number): string {
return ({ 1: 'Critical (flagged)', 2: 'High', 3: 'Low confidence', 4: 'Sensitive topic', 5: 'Random' } as Record<number, string>)[p] ?? `P${p}`;
}
export function priorityColor(p: number): 'error' | 'warning' | 'info' | 'success' | 'default' {
return ({ 1: 'error', 2: 'warning', 3: 'info', 4: 'info', 5: 'default' } as const)[p as 1 | 2 | 3 | 4 | 5] ?? 'default';
}
export function statusColor(s: QueueStatus): 'warning' | 'info' | 'success' | 'error' | 'default' {
return ({ pending: 'warning', in_review: 'info', resolved: 'success', declined: 'default', auto_closed: 'default' } as const)[s] ?? 'default';
}
export function ageMinutes(createdAt: string): number {
return Math.round((Date.now() - new Date(createdAt).getTime()) / 60000);
}

View file

@ -0,0 +1,163 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent, Typography, Box, Switch, TextField, Slider, Button, FormControlLabel, FormGroup, Checkbox, Alert } from '@mui/material';
import { Save as SaveIcon, Info as InfoIcon } from '@mui/icons-material';
import type { ModerationConfig } from './api';
import { updateModerationConfig } from './api';
interface Props {
config: ModerationConfig;
onSaved: (next: ModerationConfig) => void;
onError: (msg: string) => void;
}
export const BrainClientCard: React.FC<Props> = ({ config, onSaved, onError }) => {
const [draft, setDraft] = useState<ModerationConfig>(config);
const [saving, setSaving] = useState(false);
useEffect(() => { setDraft(config); }, [config]);
const dirty = JSON.stringify(draft) !== JSON.stringify(config);
const handleSave = async () => {
setSaving(true);
try {
const res = await updateModerationConfig({
brain_enabled: draft.brain_enabled,
brain_url: draft.brain_url,
brain_lookup_timeout_ms: draft.brain_lookup_timeout_ms,
brain_write_timeout_ms: draft.brain_write_timeout_ms,
brain_confidence_min_silver: Number(draft.brain_confidence_min_silver),
brain_semantic_threshold: Number(draft.brain_semantic_threshold),
brain_per_component: draft.brain_per_component,
});
if (res.success && res.data) {
onSaved(res.data);
} else {
onError(res.error ?? 'Update failed');
}
} catch (e) {
onError((e as Error).message);
} finally {
setSaving(false);
}
};
return (
<Card variant="outlined">
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={1}>
<Box>
<Typography variant="h6" fontWeight={700}>Brain Client</Typography>
<Typography variant="caption" color="text.secondary">
How didi-backend talks to didi-brain. CLIENT settings only.
</Typography>
</Box>
<FormControlLabel
control={
<Switch
checked={draft.brain_enabled}
onChange={(e) => setDraft({ ...draft, brain_enabled: e.target.checked })}
/>
}
label={draft.brain_enabled ? 'Enabled' : 'Disabled'}
labelPlacement="start"
/>
</Box>
<Alert severity="info" icon={<InfoIcon />} sx={{ mb: 2 }}>
These are <strong>client</strong> settings (how didi calls brain). Brain server config
(TTL, embeddings, eviction) lives in the AI platform dashboard.
</Alert>
{!draft.brain_enabled && (
<Alert severity="warning" sx={{ mb: 2 }}>
Brain is OFF. Executors run LLM normally without checking cache.
</Alert>
)}
<Box display="grid" gridTemplateColumns="2fr 1fr 1fr" gap={2}>
<TextField
size="small" fullWidth label="Brain URL"
value={draft.brain_url}
onChange={(e) => setDraft({ ...draft, brain_url: e.target.value })}
helperText="HTTP(S) endpoint of didi-brain (e.g. http://10.11.10.13:8090)"
/>
<TextField
size="small" type="number" fullWidth label="Lookup timeout (ms)"
value={draft.brain_lookup_timeout_ms}
onChange={(e) => setDraft({ ...draft, brain_lookup_timeout_ms: parseInt(e.target.value, 10) || 0 })}
/>
<TextField
size="small" type="number" fullWidth label="Write timeout (ms)"
value={draft.brain_write_timeout_ms}
onChange={(e) => setDraft({ ...draft, brain_write_timeout_ms: parseInt(e.target.value, 10) || 0 })}
/>
</Box>
<Box display="grid" gridTemplateColumns="1fr 1fr" gap={3} mt={3}>
<Box>
<Typography variant="body2" gutterBottom>Confidence threshold for silver write</Typography>
<Slider
value={Number(draft.brain_confidence_min_silver)}
min={0}
max={100}
step={1}
valueLabelDisplay="auto"
onChange={(_, v) => setDraft({ ...draft, brain_confidence_min_silver: Number(v) })}
/>
<Typography variant="caption" color="text.secondary">
LLM results with confidence &lt; {draft.brain_confidence_min_silver} get bronze (not served), ≥ get silver
</Typography>
</Box>
<Box>
<Typography variant="body2" gutterBottom>Semantic match threshold (cosine distance)</Typography>
<Slider
value={Number(draft.brain_semantic_threshold)}
min={0}
max={0.5}
step={0.005}
valueLabelDisplay="auto"
onChange={(_, v) => setDraft({ ...draft, brain_semantic_threshold: Number(v) })}
/>
<Typography variant="caption" color="text.secondary">
Lower = more strict match. Default 0.08 ≈ similarity 0.92
</Typography>
</Box>
</Box>
<Box mt={3}>
<Typography variant="body2" gutterBottom>Per-component cache opt-in</Typography>
<FormGroup row>
{(['techniques', 'ai_tampered', 'claims'] as const).map((comp) => (
<FormControlLabel
key={comp}
control={
<Checkbox
checked={!!draft.brain_per_component?.[comp]}
onChange={(e) => setDraft({
...draft,
brain_per_component: { ...draft.brain_per_component, [comp]: e.target.checked },
})}
/>
}
label={comp.replace('_', ' ')}
/>
))}
</FormGroup>
</Box>
<Box mt={2} display="flex" justifyContent="flex-end" gap={1}>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={handleSave}
disabled={!dirty || saving}
>
{saving ? 'Saving…' : 'Save Brain Client'}
</Button>
</Box>
</CardContent>
</Card>
);
};

View file

@ -0,0 +1,70 @@
import React from 'react';
import { Card, CardContent, Typography, Box, Switch, Table, TableBody, TableCell, TableHead, TableRow, Chip } from '@mui/material';
import type { ModerationRole } from './api';
import { updateModerationRole } from './api';
interface Props {
roles: ModerationRole[];
onChanged: () => void;
onError: (msg: string) => void;
}
const TOGGLE_FIELDS: Array<keyof ModerationRole> = ['can_resolve', 'can_escalate', 'can_force_gold_brain', 'is_active'];
export const RolesCard: React.FC<Props> = ({ roles, onChanged, onError }) => {
const toggle = async (code: string, field: keyof ModerationRole, value: boolean) => {
try {
const res = await updateModerationRole(code, { [field]: value } as Partial<ModerationRole>);
if (res.success) onChanged();
else onError(res.error ?? 'Update failed');
} catch (e) {
onError((e as Error).message);
}
};
return (
<Card variant="outlined">
<CardContent>
<Typography variant="h6" fontWeight={700}>Roles & Permissions</Typography>
<Typography variant="caption" color="text.secondary">
Maps Keycloak realm roles to HIL actions. Toggle changes auto-sync to Redis.
</Typography>
<Table size="small" sx={{ mt: 2 }}>
<TableHead>
<TableRow>
<TableCell>Role</TableCell>
<TableCell align="center">can_resolve</TableCell>
<TableCell align="center">can_escalate</TableCell>
<TableCell align="center">can_force_gold_brain</TableCell>
<TableCell align="center">active</TableCell>
</TableRow>
</TableHead>
<TableBody>
{roles.map((role) => (
<TableRow key={role.role_code} hover>
<TableCell>
<Box>
<Chip label={role.role_code} size="small" color="primary" variant="outlined" />
<Typography variant="caption" display="block" color="text.secondary" mt={0.5}>
{role.role_label}
</Typography>
</Box>
</TableCell>
{TOGGLE_FIELDS.map((field) => (
<TableCell key={field} align="center">
<Switch
checked={!!role[field]}
onChange={(e) => toggle(role.role_code, field, e.target.checked)}
size="small"
/>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
};

View file

@ -0,0 +1,409 @@
import React, { useState } from 'react';
import {
Card, CardContent, Typography, Box, Chip, TextField, Button, IconButton, Switch, FormControlLabel,
Dialog, DialogTitle, DialogContent, DialogActions, MenuItem, Stack, Alert, Tooltip,
} from '@mui/material';
import {
Add as AddIcon,
Delete as DeleteIcon,
Restore as RestoreIcon,
Tune as TuneIcon,
} from '@mui/icons-material';
import type { SensitiveTopic, Volatility } from './api';
import { createSensitiveTopic, deleteSensitiveTopic, updateSensitiveTopic } from './api';
interface Props {
topics: SensitiveTopic[];
onChanged: () => void;
onError: (msg: string) => void;
}
const VOLATILITY_LABEL: Record<Volatility, string> = {
volatile: 'Volatile',
evolving: 'Evolving',
stable: 'Stable',
};
const VOLATILITY_COLOR: Record<Volatility, 'error' | 'warning' | 'success'> = {
volatile: 'error',
evolving: 'warning',
stable: 'success',
};
// Sensible per-volatility defaults to seed the form when creating new topics
// or switching the volatility in the edit dialog.
const VOLATILITY_DEFAULTS: Record<
Volatility,
{ cache_ttl_hours: number; recency_window_days: number; half_life_days: number }
> = {
volatile: { cache_ttl_hours: 24, recency_window_days: 7, half_life_days: 3 },
evolving: { cache_ttl_hours: 168, recency_window_days: 14, half_life_days: 14 },
stable: { cache_ttl_hours: 720, recency_window_days: 180, half_life_days: 180 },
};
// ──────────────────────────────────────────────────────────────────────────────
// Edit volatility dialog
// ──────────────────────────────────────────────────────────────────────────────
interface EditDialogProps {
topic: SensitiveTopic | null;
onClose: () => void;
onSaved: () => void;
onError: (msg: string) => void;
}
const EditVolatilityDialog: React.FC<EditDialogProps> = ({ topic, onClose, onSaved, onError }) => {
const open = topic !== null;
const initialVol: Volatility = (topic?.volatility ?? 'evolving') as Volatility;
const [volatility, setVolatility] = useState<Volatility>(initialVol);
const [ttl, setTtl] = useState<number>(topic?.cache_ttl_hours ?? 720);
const [window_, setWindow] = useState<number>(topic?.recency_window_days ?? 30);
const [halfLife, setHalfLife] = useState<number>(
Number(topic?.half_life_days ?? 30)
);
const [atomicPath, setAtomicPath] = useState<string>(topic?.atomic_path_prefix ?? '');
const [saving, setSaving] = useState(false);
// When the dialog re-opens for a different topic, reset state.
React.useEffect(() => {
if (topic) {
setVolatility((topic.volatility ?? 'evolving') as Volatility);
setTtl(topic.cache_ttl_hours ?? 720);
setWindow(topic.recency_window_days ?? 30);
setHalfLife(Number(topic.half_life_days ?? 30));
setAtomicPath(topic.atomic_path_prefix ?? '');
}
}, [topic]);
const applyVolatilityDefaults = (v: Volatility) => {
setVolatility(v);
const d = VOLATILITY_DEFAULTS[v];
setTtl(d.cache_ttl_hours);
setWindow(d.recency_window_days);
setHalfLife(d.half_life_days);
};
const handleSave = async () => {
if (!topic) return;
setSaving(true);
try {
const trimmedPath = atomicPath.trim();
const res = await updateSensitiveTopic(topic.topic_id, {
volatility,
cache_ttl_hours: ttl,
recency_window_days: window_,
half_life_days: halfLife,
// Empty string explicitly tells the backend to clear the value (NULL).
atomic_path_prefix: trimmedPath === '' ? '' : trimmedPath,
});
if (res.success) {
onSaved();
onClose();
} else {
onError(res.error ?? 'Update failed');
}
} catch (e) {
onError((e as Error).message);
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>
Volatility config — {topic?.topic_code ?? ''}
</DialogTitle>
<DialogContent>
<Alert severity="info" sx={{ mb: 2 }}>
These values are read by didi-brain to set cache TTL, recency window, and
age-decay half-life for content tagged with this topic. Pick a tier to seed
sensible defaults, then fine-tune if needed.
</Alert>
<Stack spacing={2}>
<TextField
select
size="small"
label="Volatility tier"
value={volatility}
onChange={(e) => applyVolatilityDefaults(e.target.value as Volatility)}
>
<MenuItem value="volatile">Volatile (war, breaking news, daily politics)</MenuItem>
<MenuItem value="evolving">Evolving (economy, health, ongoing trials)</MenuItem>
<MenuItem value="stable">Stable (settled science, history)</MenuItem>
</TextField>
<TextField
size="small"
type="number"
label="Cache TTL (hours)"
value={ttl}
onChange={(e) => setTtl(Number(e.target.value))}
inputProps={{ min: 1, max: 26280 }}
helperText="Hard cap: brain truncates verdicts touching this topic to this many hours"
/>
<TextField
size="small"
type="number"
label="Recency window (days)"
value={window_}
onChange={(e) => setWindow(Number(e.target.value))}
inputProps={{ min: 1, max: 365 }}
helperText="For volatile topics, evidence older than this is dropped from /v1/gather"
/>
<TextField
size="small"
type="number"
label="Half-life (days)"
value={halfLife}
onChange={(e) => setHalfLife(Number(e.target.value))}
inputProps={{ min: 0.1, step: 0.5 }}
helperText="Recency boost decays with this half-life when ranking evidence"
/>
<TextField
size="small"
label="Atomic taxonomy path prefix (optional)"
value={atomicPath}
onChange={(e) => setAtomicPath(e.target.value)}
placeholder="e.g. Topics/Health/ or Topics/Politics/Elections"
helperText={
'Maps this policy topic to a path in the brain knowledge graph. ' +
'Leave empty if no mapping. Used by classifier to also tag atoms ' +
'for retrieval. No leading slash. Trailing slash is allowed.'
}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</Button>
</DialogActions>
</Dialog>
);
};
// ──────────────────────────────────────────────────────────────────────────────
// Main card
// ──────────────────────────────────────────────────────────────────────────────
export const SensitiveTopicsCard: React.FC<Props> = ({ topics, onChanged, onError }) => {
const [showInactive, setShowInactive] = useState(false);
const [newCode, setNewCode] = useState('');
const [newLabel, setNewLabel] = useState('');
const [newVolatility, setNewVolatility] = useState<Volatility>('evolving');
const [newAtomicPath, setNewAtomicPath] = useState('');
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState<SensitiveTopic | null>(null);
const filtered = topics.filter((t) => showInactive || t.is_active);
const handleAdd = async () => {
if (!newCode.trim() || !newLabel.trim()) {
onError('Both topic_code and label are required');
return;
}
setCreating(true);
try {
const defaults = VOLATILITY_DEFAULTS[newVolatility];
const trimmedPath = newAtomicPath.trim();
const res = await createSensitiveTopic({
topic_code: newCode.trim(),
topic_label: newLabel.trim(),
volatility: newVolatility,
...defaults,
...(trimmedPath ? { atomic_path_prefix: trimmedPath } : {}),
});
if (res.success) {
setNewCode('');
setNewLabel('');
setNewVolatility('evolving');
setNewAtomicPath('');
onChanged();
} else {
onError(res.error ?? 'Create failed');
}
} catch (e) {
onError((e as Error).message);
} finally {
setCreating(false);
}
};
const handleDeactivate = async (id: number) => {
try {
const res = await deleteSensitiveTopic(id);
if (res.success) onChanged();
else onError(res.error ?? 'Delete failed');
} catch (e) {
onError((e as Error).message);
}
};
const handleReactivate = async (id: number) => {
try {
const res = await updateSensitiveTopic(id, { is_active: true });
if (res.success) onChanged();
else onError(res.error ?? 'Reactivate failed');
} catch (e) {
onError((e as Error).message);
}
};
return (
<Card variant="outlined">
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
<Box>
<Typography variant="h6" fontWeight={700}>Sensitive Topics</Typography>
<Typography variant="caption" color="text.secondary">
Topics that trigger HIL review when detected, with per-topic brain cache volatility config.
Auto-syncs to Redis on change.
</Typography>
</Box>
<FormControlLabel
control={<Switch checked={showInactive} onChange={(e) => setShowInactive(e.target.checked)} />}
label="Show inactive"
/>
</Box>
<Stack spacing={1} my={2}>
{filtered.length === 0 && (
<Typography variant="body2" color="text.secondary">No topics.</Typography>
)}
{filtered.map((t) => {
const volatility = (t.volatility ?? 'evolving') as Volatility;
return (
<Box
key={t.topic_id}
display="flex"
alignItems="center"
gap={1}
p={1}
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
bgcolor: t.is_active ? 'background.paper' : 'action.hover',
opacity: t.is_active ? 1 : 0.65,
}}
>
<Chip
size="small"
label={VOLATILITY_LABEL[volatility]}
color={VOLATILITY_COLOR[volatility]}
sx={{ minWidth: 80 }}
/>
<Box flex={1} minWidth={0}>
<Typography variant="body2" fontWeight={600}>
{t.topic_code}
</Typography>
<Typography variant="caption" color="text.secondary" component="div">
{t.topic_label}
</Typography>
{t.atomic_path_prefix && (
<Tooltip title="Maps to atomic-server taxonomy path (used by brain classifier)">
<Typography
variant="caption"
component="div"
sx={{
fontFamily: 'monospace',
color: 'primary.main',
fontSize: 11,
mt: 0.25,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
↳ {t.atomic_path_prefix}
</Typography>
</Tooltip>
)}
</Box>
<Tooltip title="Cache TTL (hours)">
<Chip size="small" label={`TTL ${t.cache_ttl_hours ?? '—'}h`} variant="outlined" />
</Tooltip>
<Tooltip title="Recency window (days)">
<Chip size="small" label={`${t.recency_window_days ?? '—'}d window`} variant="outlined" />
</Tooltip>
<Tooltip title="Edit volatility config">
<IconButton size="small" onClick={() => setEditing(t)}>
<TuneIcon fontSize="small" />
</IconButton>
</Tooltip>
{t.is_active ? (
<Tooltip title="Deactivate">
<IconButton size="small" onClick={() => handleDeactivate(t.topic_id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
) : (
<Tooltip title="Reactivate">
<IconButton size="small" onClick={() => handleReactivate(t.topic_id)}>
<RestoreIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
);
})}
</Stack>
<Box display="flex" gap={1} alignItems="flex-start" mt={2} flexWrap="wrap">
<TextField
size="small" label="topic_code"
placeholder="e.g. fraud_scams"
value={newCode}
onChange={(e) => setNewCode(e.target.value.toLowerCase())}
helperText="lowercase, [a-z0-9_]"
sx={{ width: 180 }}
/>
<TextField
size="small" label="topic_label"
placeholder="Human readable name"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
sx={{ minWidth: 180, flex: 1 }}
/>
<TextField
select
size="small"
label="Volatility"
value={newVolatility}
onChange={(e) => setNewVolatility(e.target.value as Volatility)}
sx={{ width: 140 }}
>
<MenuItem value="volatile">Volatile</MenuItem>
<MenuItem value="evolving">Evolving</MenuItem>
<MenuItem value="stable">Stable</MenuItem>
</TextField>
<TextField
size="small"
label="Atomic path (optional)"
placeholder="Topics/Health/"
value={newAtomicPath}
onChange={(e) => setNewAtomicPath(e.target.value)}
helperText="Brain taxonomy bridge"
sx={{ minWidth: 200, flex: 1 }}
/>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleAdd}
disabled={creating}
>
{creating ? 'Adding…' : 'Add'}
</Button>
</Box>
<EditVolatilityDialog
topic={editing}
onClose={() => setEditing(null)}
onSaved={onChanged}
onError={onError}
/>
</CardContent>
</Card>
);
};

View file

@ -0,0 +1,152 @@
import React, { useState } from 'react';
import { Card, CardContent, Typography, Box, Switch, TextField, Slider, Button, FormControlLabel, Alert } from '@mui/material';
import { Save as SaveIcon } from '@mui/icons-material';
import type { ModerationConfig } from './api';
import { updateModerationConfig } from './api';
interface Props {
config: ModerationConfig;
onSaved: (next: ModerationConfig) => void;
onError: (msg: string) => void;
}
export const TriageCard: React.FC<Props> = ({ config, onSaved, onError }) => {
const [draft, setDraft] = useState<ModerationConfig>(config);
const [saving, setSaving] = useState(false);
React.useEffect(() => { setDraft(config); }, [config]);
const dirty = JSON.stringify(draft) !== JSON.stringify(config);
const handleSave = async () => {
setSaving(true);
try {
const res = await updateModerationConfig({
triage_enabled: draft.triage_enabled,
confidence_low: Number(draft.confidence_low),
risk_grey_min: Number(draft.risk_grey_min),
risk_grey_max: Number(draft.risk_grey_max),
queue_relax_at: draft.queue_relax_at,
queue_strict_at: draft.queue_strict_at,
auto_tune_enabled: draft.auto_tune_enabled,
});
if (res.success && res.data) {
onSaved(res.data);
} else {
onError(res.error ?? 'Update failed');
}
} catch (e) {
onError((e as Error).message);
} finally {
setSaving(false);
}
};
return (
<Card variant="outlined">
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={1}>
<Box>
<Typography variant="h6" fontWeight={700}>Triage</Typography>
<Typography variant="caption" color="text.secondary">
Decide which sessions enter the moderation queue. Edits sync to Redis on save.
</Typography>
</Box>
<FormControlLabel
control={
<Switch
checked={draft.triage_enabled}
onChange={(e) => setDraft({ ...draft, triage_enabled: e.target.checked })}
/>
}
label={draft.triage_enabled ? 'Enabled' : 'Disabled'}
labelPlacement="start"
/>
</Box>
{!draft.triage_enabled && (
<Alert severity="info" sx={{ mb: 2 }}>
Triage is OFF. No sessions will enter the moderation queue.
</Alert>
)}
<Box display="grid" gridTemplateColumns="1fr 1fr" gap={3} mt={2}>
<Box>
<Typography variant="body2" gutterBottom>Confidence threshold (low)</Typography>
<Slider
value={Number(draft.confidence_low)}
min={0}
max={100}
step={1}
valueLabelDisplay="auto"
onChange={(_, v) => setDraft({ ...draft, confidence_low: Number(v) })}
/>
<Typography variant="caption" color="text.secondary">
Sessions with confidence &lt; {draft.confidence_low} go to queue
</Typography>
</Box>
<Box>
<Typography variant="body2" gutterBottom>Risk grey zone</Typography>
<Box display="flex" gap={1} alignItems="center">
<TextField
size="small" type="number" label="min"
value={draft.risk_grey_min}
onChange={(e) => setDraft({ ...draft, risk_grey_min: e.target.value })}
/>
<Typography>—</Typography>
<TextField
size="small" type="number" label="max"
value={draft.risk_grey_max}
onChange={(e) => setDraft({ ...draft, risk_grey_max: e.target.value })}
/>
</Box>
<Typography variant="caption" color="text.secondary">
Sensitive topics with risk in this band go to queue
</Typography>
</Box>
<Box>
<TextField
size="small" type="number" fullWidth label="Auto-relax at pending"
helperText="If queue ≥ this many pending, drop topic filter"
value={draft.queue_relax_at}
onChange={(e) => setDraft({ ...draft, queue_relax_at: parseInt(e.target.value, 10) || 0 })}
/>
</Box>
<Box>
<TextField
size="small" type="number" fullWidth label="Auto-strict at pending"
helperText="If queue ≤ this many pending, broaden triage"
value={draft.queue_strict_at}
onChange={(e) => setDraft({ ...draft, queue_strict_at: parseInt(e.target.value, 10) || 0 })}
/>
</Box>
<Box>
<FormControlLabel
control={
<Switch
checked={draft.auto_tune_enabled}
onChange={(e) => setDraft({ ...draft, auto_tune_enabled: e.target.checked })}
/>
}
label="Auto-tune thresholds (cron)"
/>
</Box>
</Box>
<Box mt={2} display="flex" justifyContent="flex-end" gap={1}>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={handleSave}
disabled={!dirty || saving}
>
{saving ? 'Saving…' : 'Save Triage'}
</Button>
</Box>
</CardContent>
</Card>
);
};

View file

@ -0,0 +1,171 @@
/**
* API client for Moderation Settings — talks to didiFramework on /framework/api/*.
* All endpoints from migration 011 + Phase 1.2 routes.
*/
const FRAMEWORK_API = '/framework/api';
export interface ModerationConfig {
config_id: number;
triage_enabled: boolean;
confidence_low: string | number;
risk_grey_min: string | number;
risk_grey_max: string | number;
queue_relax_at: number;
queue_strict_at: number;
auto_tune_enabled: boolean;
brain_enabled: boolean;
brain_url: string;
brain_lookup_timeout_ms: number;
brain_write_timeout_ms: number;
brain_confidence_min_silver: string | number;
brain_semantic_threshold: string | number;
brain_per_component: { techniques: boolean; ai_tampered: boolean; claims: boolean };
updated_by: string | null;
updated_at: string;
}
export type Volatility = 'volatile' | 'evolving' | 'stable';
export interface SensitiveTopic {
topic_id: number;
topic_code: string;
topic_label: string;
is_active: boolean;
// Phase D1 — drives brain cache TTL + recency boost.
// Optional in TS so legacy callers / older API responses still type-check;
// backend always returns them after migration 012.
volatility?: Volatility;
cache_ttl_hours?: number;
recency_window_days?: number;
half_life_days?: number | string; // numeric column comes back as string
// Migration 014 — bridge to atomic-server taxonomy. Optional, NULL when no
// mapping is defined. Format: "Topics/Health/" or "Topics/Politics/Elections".
atomic_path_prefix?: string | null;
created_at: string;
updated_at: string;
}
export interface SensitiveTopicVolatilityPatch {
volatility?: Volatility;
cache_ttl_hours?: number;
recency_window_days?: number;
half_life_days?: number;
// Empty string clears the value (server maps '' → NULL).
atomic_path_prefix?: string | null;
}
export interface ModerationRole {
role_code: string;
role_label: string;
can_resolve: boolean;
can_escalate: boolean;
can_force_gold_brain: boolean;
is_active: boolean;
created_at: string;
updated_at: string;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
async function asJson<T>(res: Response): Promise<ApiResponse<T>> {
try {
return (await res.json()) as ApiResponse<T>;
} catch {
return { success: false, error: `${res.status} ${res.statusText}` };
}
}
async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
const token = localStorage.getItem('keycloak_token');
const headers: HeadersInit = {
...(init.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
return fetch(input, { ...init, headers });
}
// ─── moderation-config ───
export async function getModerationConfig(): Promise<ApiResponse<ModerationConfig>> {
const res = await authedFetch(`${FRAMEWORK_API}/moderation-config`);
return asJson<ModerationConfig>(res);
}
export async function updateModerationConfig(patch: Partial<ModerationConfig>): Promise<ApiResponse<ModerationConfig>> {
const res = await authedFetch(`${FRAMEWORK_API}/moderation-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
return asJson<ModerationConfig>(res);
}
// ─── sensitive-topics ───
export async function listSensitiveTopics(active: 'true' | 'false' | 'all' = 'all'): Promise<ApiResponse<SensitiveTopic[]>> {
const res = await authedFetch(`${FRAMEWORK_API}/sensitive-topics?active=${active}`);
return asJson<SensitiveTopic[]>(res);
}
export async function createSensitiveTopic(
body: {
topic_code: string;
topic_label: string;
is_active?: boolean;
} & SensitiveTopicVolatilityPatch
): Promise<ApiResponse<SensitiveTopic>> {
const res = await authedFetch(`${FRAMEWORK_API}/sensitive-topics`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return asJson<SensitiveTopic>(res);
}
export async function updateSensitiveTopic(
id: number,
patch: {
topic_label?: string;
is_active?: boolean;
} & SensitiveTopicVolatilityPatch
): Promise<ApiResponse<SensitiveTopic>> {
const res = await authedFetch(`${FRAMEWORK_API}/sensitive-topics/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
return asJson<SensitiveTopic>(res);
}
export async function deleteSensitiveTopic(id: number): Promise<ApiResponse<void>> {
const res = await authedFetch(`${FRAMEWORK_API}/sensitive-topics/${id}`, { method: 'DELETE' });
return asJson<void>(res);
}
// ─── moderation-roles ───
export async function listModerationRoles(): Promise<ApiResponse<ModerationRole[]>> {
const res = await authedFetch(`${FRAMEWORK_API}/moderation-roles`);
return asJson<ModerationRole[]>(res);
}
export async function updateModerationRole(code: string, patch: Partial<ModerationRole>): Promise<ApiResponse<ModerationRole>> {
const res = await authedFetch(`${FRAMEWORK_API}/moderation-roles/${encodeURIComponent(code)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
return asJson<ModerationRole>(res);
}
// ─── sync-redis ───
export async function syncRedis(): Promise<ApiResponse<{ keys_written: number }>> {
const res = await authedFetch(`${FRAMEWORK_API}/sync-redis`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return asJson<{ keys_written: number }>(res);
}

View file

@ -0,0 +1,152 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Typography, Alert, CircularProgress, Snackbar, Button } from '@mui/material';
import { Refresh as RefreshIcon, CloudSync as SyncIcon } from '@mui/icons-material';
import {
getModerationConfig,
listSensitiveTopics,
listModerationRoles,
syncRedis,
type ModerationConfig,
type SensitiveTopic,
type ModerationRole,
} from './api';
import { TriageCard } from './TriageCard';
import { BrainClientCard } from './BrainClientCard';
import { SensitiveTopicsCard } from './SensitiveTopicsCard';
import { RolesCard } from './RolesCard';
export const ModerationSettings: React.FC = () => {
const [config, setConfig] = useState<ModerationConfig | null>(null);
const [topics, setTopics] = useState<SensitiveTopic[]>([]);
const [roles, setRoles] = useState<ModerationRole[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const loadAll = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [cfgRes, topRes, rolRes] = await Promise.all([
getModerationConfig(),
listSensitiveTopics('all'),
listModerationRoles(),
]);
if (cfgRes.success && cfgRes.data) setConfig(cfgRes.data);
else setError(cfgRes.error ?? 'Failed to load config');
if (topRes.success && topRes.data) setTopics(topRes.data);
if (rolRes.success && rolRes.data) setRoles(rolRes.data);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadAll(); }, [loadAll]);
const handleSync = async () => {
setSyncing(true);
setError(null);
try {
const res = await syncRedis();
if (res.success) {
setSuccess(`Synced to Redis (${res.data?.keys_written ?? '?'} keys)`);
} else {
setError(res.error ?? 'Sync failed');
}
} catch (e) {
setError((e as Error).message);
} finally {
setSyncing(false);
}
};
const handleConfigSaved = async (next: ModerationConfig) => {
setConfig(next);
setSuccess('Config saved. Syncing to Redis…');
await handleSync();
};
const handleTopicsChanged = async () => {
const res = await listSensitiveTopics('all');
if (res.success && res.data) setTopics(res.data);
setSuccess('Topics updated. Syncing to Redis…');
await handleSync();
};
const handleRolesChanged = async () => {
const res = await listModerationRoles();
if (res.success && res.data) setRoles(res.data);
setSuccess('Roles updated. Syncing to Redis…');
await handleSync();
};
if (loading && !config) {
return (
<Box display="flex" justifyContent="center" p={4}>
<CircularProgress />
</Box>
);
}
if (error && !config) {
return (
<Box p={2}>
<Alert severity="error" action={<Button onClick={loadAll}>Retry</Button>}>
{error}
</Alert>
</Box>
);
}
if (!config) return null;
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Box>
<Typography variant="h4" gutterBottom sx={{ color: '#0288d1' }}>
Moderation Settings
</Typography>
<Typography variant="body2" color="text.secondary">
Triage rules + brain client + sensitive topics + roles. All settings persist in PG and sync to Redis.
</Typography>
</Box>
<Box display="flex" gap={1}>
<Button startIcon={<RefreshIcon />} onClick={loadAll} disabled={loading}>
Refresh
</Button>
<Button
variant="outlined"
color="warning"
startIcon={syncing ? <CircularProgress size={18} /> : <SyncIcon />}
onClick={handleSync}
disabled={syncing}
>
{syncing ? 'Syncing…' : 'Sync to Redis'}
</Button>
</Box>
</Box>
{error && <Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>{error}</Alert>}
<Box display="flex" flexDirection="column" gap={3}>
<TriageCard config={config} onSaved={handleConfigSaved} onError={setError} />
<BrainClientCard config={config} onSaved={handleConfigSaved} onError={setError} />
<SensitiveTopicsCard topics={topics} onChanged={handleTopicsChanged} onError={setError} />
<RolesCard roles={roles} onChanged={handleRolesChanged} onError={setError} />
</Box>
<Snackbar
open={!!success}
autoHideDuration={3000}
onClose={() => setSuccess(null)}
message={success}
/>
</Box>
);
};
export default ModerationSettings;

View file

@ -0,0 +1,176 @@
/**
* Dry-run dialog — resolves & shows the execution plan for a pipeline without
* dispatching (Run Console-lite: "ce ar rula", no credits consumed). Also
* offers a real async run + cancel so the operator can exercise INT-3 live.
*/
import React, { useState } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Typography,
Select, MenuItem, TextField, Chip, CircularProgress, Alert, Paper, Divider,
Stack, Table, TableBody, TableCell, TableRow,
} from '@mui/material';
import { pipelinesApi, DryRunResult, PipelineProfile } from './pipelinesApi';
const MEDIA_TYPES = ['text', 'url', 'image', 'audio', 'video'];
interface Props {
open: boolean;
profile: PipelineProfile | null;
onClose: () => void;
}
// Map a pipeline profile_code to the media_type its dry-run should use.
function defaultMediaType(code: string): string {
if (code.startsWith('text')) return 'text';
if (['image', 'audio', 'video', 'url'].includes(code)) return code;
return 'text';
}
export const DryRunDialog: React.FC<Props> = ({ open, profile, onClose }) => {
const [mediaType, setMediaType] = useState('text');
const [text, setText] = useState('Guvernul ascunde adevărul despre acest subiect, conform unor surse anonime neverificate.');
const [planType, setPlanType] = useState(1);
const [result, setResult] = useState<DryRunResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
React.useEffect(() => {
if (profile) { setMediaType(defaultMediaType(profile.profile_code)); setResult(null); setError(null); }
}, [profile]);
const runDryRun = async () => {
setLoading(true); setError(null); setResult(null);
try {
const r = await pipelinesApi.dryRun({
media_type: mediaType,
text: mediaType === 'text' || mediaType === 'url' ? text : undefined,
url: mediaType === 'url' ? 'https://example.com/article' : undefined,
media_url: ['image', 'audio', 'video'].includes(mediaType) ? 'https://example.com/media' : undefined,
user_id: 'dashboard-dryrun',
plan_type: planType,
});
setResult(r);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>
Dry-run — {profile?.profile_name}
<Typography variant="body2" color="text.secondary">
Rezolvă planul de execuție fără dispatch (zero credite consumate)
</Typography>
</DialogTitle>
<DialogContent dividers>
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<Select size="small" value={mediaType} onChange={e => setMediaType(e.target.value)}>
{MEDIA_TYPES.map(t => <MenuItem key={t} value={t}>{t}</MenuItem>)}
</Select>
<Select size="small" value={planType} onChange={e => setPlanType(Number(e.target.value))}>
{[1, 2, 3, 4, 5, 6].map(p => <MenuItem key={p} value={p}>plan {p}</MenuItem>)}
</Select>
<Button variant="contained" onClick={runDryRun} disabled={loading}>
{loading ? <CircularProgress size={20} /> : 'Resolve plan'}
</Button>
</Stack>
{(mediaType === 'text' || mediaType === 'url') && (
<TextField
fullWidth multiline minRows={2} size="small" sx={{ mb: 2 }}
label="Sample text" value={text} onChange={e => setText(e.target.value)}
/>
)}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{result && (
<Box>
<Paper variant="outlined" sx={{ p: 1.5, mb: 2, bgcolor: 'action.hover' }}>
<Typography variant="overline" color="text.secondary">Flow</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{result.flow}</Typography>
</Paper>
{result.verdict_profile && (
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }}>
<Typography variant="overline" color="text.secondary">
Verdict profile: {result.verdict_profile.profile_code}
</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 0.5 }}>
{Object.entries(result.verdict_profile.weights).map(([k, v]) => (
<Chip key={k} size="small" label={`${k}: ${v}`} />
))}
</Stack>
</Paper>
)}
<Typography variant="overline" color="text.secondary">Nodes ({result.nodes.length})</Typography>
{result.nodes.map(n => (
<Paper key={n.component} variant="outlined" sx={{ p: 1.5, mb: 1 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Chip size="small" color="primary" label={n.component} />
{n.queue && <Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{n.queue}</Typography>}
{n.timeout_ms && <Chip size="small" variant="outlined" label={`${n.timeout_ms / 1000}s`} />}
</Stack>
{n.depends_on && (
<Typography variant="caption" color="text.secondary">
depends_on: {n.depends_on.join(', ')}
</Typography>
)}
{n.produces && (
<Box sx={{ mt: 0.5 }}>
{n.produces.map(p => <Chip key={p} size="small" variant="outlined" label={p} sx={{ mr: 0.5, mb: 0.5 }} />)}
</Box>
)}
{n.stages && Object.entries(n.stages).map(([stage, tiers]) => (
<Box key={stage} sx={{ mt: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 700 }}>{stage}</Typography>
{Object.entries(tiers).map(([tier, models]) => (
<Table key={tier} size="small" sx={{ mb: 0.5 }}>
<TableBody>
{models.map(m => (
<TableRow key={`${tier}-${m.order}`}>
<TableCell sx={{ py: 0.2, border: 0, width: 60 }}>
<Typography variant="caption" color="text.secondary">{tier}</Typography>
</TableCell>
<TableCell sx={{ py: 0.2, border: 0, width: 80 }}>
<Typography variant="caption">{m.role}</Typography>
</TableCell>
<TableCell sx={{ py: 0.2, border: 0 }}>
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{m.model_key}</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
))}
</Box>
))}
</Paper>
))}
{result.skipped.length > 0 && (
<>
<Divider sx={{ my: 1 }} />
<Typography variant="overline" color="text.secondary">Skipped</Typography>
{result.skipped.map(s => (
<Typography key={s.component} variant="caption" display="block" color="text.secondary">
{s.component} — {s.reason}
</Typography>
))}
</>
)}
<Alert severity="info" sx={{ mt: 2 }}>{result.note}</Alert>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
};

View file

@ -0,0 +1,257 @@
/**
* Pipelines page — CRUD + lifecycle over the REAL pipeline definitions
* (input_type_profile) plus dry-run (Run Console-lite). Modul 1 caiet:
* „Workflow Builder: CRUD/versionare pipeline; Run Console".
*
* Backed by /framework/api/pipelines + /agent-v3/api/v3/pipeline/dry-run.
* NOT the dead pipelineApi (services/api/pipeline.ts).
*/
import React, { useEffect, useState, useCallback } from 'react';
import {
Box, Typography, Paper, Table, TableHead, TableRow, TableCell, TableBody,
Chip, IconButton, Button, Stack, CircularProgress, Alert, Tooltip,
Dialog, DialogTitle, DialogContent, DialogActions, TextField, Collapse,
} from '@mui/material';
import {
PlayArrow as DryRunIcon,
ContentCopy as CloneIcon,
ToggleOn as ActivateIcon,
ToggleOff as DeactivateIcon,
History as VersionsIcon,
Restore as RestoreIcon,
Refresh as RefreshIcon,
Download as ExportIcon,
Upload as ImportIcon,
} from '@mui/icons-material';
import { pipelinesApi, PipelineProfile, PipelineVersion } from './pipelinesApi';
import { DryRunDialog } from './DryRunDialog';
export const PipelinesPage: React.FC = () => {
const [profiles, setProfiles] = useState<PipelineProfile[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [dryRunProfile, setDryRunProfile] = useState<PipelineProfile | null>(null);
const [cloneSource, setCloneSource] = useState<PipelineProfile | null>(null);
const [cloneCode, setCloneCode] = useState('');
const [cloneName, setCloneName] = useState('');
const [versionsFor, setVersionsFor] = useState<string | null>(null);
const [versions, setVersions] = useState<PipelineVersion[]>([]);
const load = useCallback(async () => {
setLoading(true); setError(null);
try {
setProfiles(await pipelinesApi.list());
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(); }, [load]);
const toggleActive = async (p: PipelineProfile) => {
setBusy(p.profile_code);
try {
if (p.is_active) await pipelinesApi.deactivate(p.profile_code);
else await pipelinesApi.activate(p.profile_code);
await load();
} catch (e) { setError((e as Error).message); } finally { setBusy(null); }
};
const doClone = async () => {
if (!cloneSource || !cloneCode) return;
setBusy(cloneSource.profile_code);
try {
await pipelinesApi.clone(cloneSource.profile_code, cloneCode, cloneName || undefined);
setCloneSource(null); setCloneCode(''); setCloneName('');
await load();
} catch (e) { setError((e as Error).message); } finally { setBusy(null); }
};
const showVersions = async (code: string) => {
if (versionsFor === code) { setVersionsFor(null); return; }
try {
setVersions(await pipelinesApi.versions(code));
setVersionsFor(code);
} catch (e) { setError((e as Error).message); }
};
const restore = async (code: string, versionId: number) => {
setBusy(code);
try {
await pipelinesApi.restore(code, versionId);
await load();
setVersions(await pipelinesApi.versions(code));
} catch (e) { setError((e as Error).message); } finally { setBusy(null); }
};
const exportProfile = async (code: string) => {
try {
const full = await pipelinesApi.get(code); // includes overrides
const blob = new Blob([JSON.stringify(full, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `pipeline-${code}.json`; a.click();
URL.revokeObjectURL(url);
} catch (e) { setError((e as Error).message); }
};
const importProfile = (file: File) => {
const reader = new FileReader();
reader.onload = async () => {
try {
const parsed = JSON.parse(String(reader.result));
// Prefix new_code to avoid clashing with the source it was exported from.
const base = parsed.profile_code || 'imported';
await pipelinesApi.import({ ...parsed, new_code: `${base}_import` });
await load();
} catch (e) { setError(`Import failed: ${(e as Error).message}`); }
};
reader.readAsText(file);
};
return (
<Box sx={{ p: 3 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<Box>
<Typography variant="h4" sx={{ fontWeight: 700 }}>Pipelines</Typography>
<Typography variant="body2" color="text.secondary">
Definiții de pipeline (componente, ponderi, praguri per tip de input) — clonare, versionare, activare, dry-run
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Button
component="label"
size="small"
variant="outlined"
startIcon={<ImportIcon />}
>
Import JSON
<input
type="file"
accept="application/json"
hidden
onChange={e => { const f = e.target.files?.[0]; if (f) importProfile(f); e.target.value = ''; }}
/>
</Button>
<IconButton onClick={load}><RefreshIcon /></IconButton>
</Stack>
</Stack>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
) : (
<Paper variant="outlined">
<Table>
<TableHead>
<TableRow>
<TableCell>Pipeline</TableCell>
<TableCell>Status</TableCell>
<TableCell>Weights (tech / claims / ai / source)</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{profiles.map(p => (
<React.Fragment key={p.profile_code}>
<TableRow hover>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{p.profile_name}</Typography>
<Typography variant="caption" sx={{ fontFamily: 'monospace' }} color="text.secondary">{p.profile_code}</Typography>
</TableCell>
<TableCell>
<Chip size="small" color={p.is_active ? 'success' : 'default'} label={p.is_active ? 'active' : 'inactive'} />
</TableCell>
<TableCell>
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
{p.weight_techniques} / {p.weight_claims} / {p.weight_ai_tampered} / {p.weight_source}
</Typography>
</TableCell>
<TableCell align="right">
<Tooltip title="Dry-run (resolve plan)">
<IconButton size="small" onClick={() => setDryRunProfile(p)}><DryRunIcon /></IconButton>
</Tooltip>
<Tooltip title="Clone">
<IconButton size="small" onClick={() => { setCloneSource(p); setCloneCode(`${p.profile_code}_copy`); }}><CloneIcon /></IconButton>
</Tooltip>
<Tooltip title={p.is_active ? 'Deactivate' : 'Activate'}>
<span>
<IconButton size="small" disabled={busy === p.profile_code} onClick={() => toggleActive(p)}>
{p.is_active ? <DeactivateIcon /> : <ActivateIcon />}
</IconButton>
</span>
</Tooltip>
<Tooltip title="Export JSON">
<IconButton size="small" onClick={() => exportProfile(p.profile_code)}><ExportIcon /></IconButton>
</Tooltip>
<Tooltip title="Version history">
<IconButton size="small" onClick={() => showVersions(p.profile_code)}><VersionsIcon /></IconButton>
</Tooltip>
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={4} sx={{ p: 0, border: 0 }}>
<Collapse in={versionsFor === p.profile_code} unmountOnExit>
<Box sx={{ p: 2, bgcolor: 'action.hover' }}>
<Typography variant="overline" color="text.secondary">Version history</Typography>
{versions.length === 0 && <Typography variant="body2" color="text.secondary">No versions yet</Typography>}
{versions.map(v => (
<Stack key={v.version_id} direction="row" spacing={2} alignItems="center" sx={{ py: 0.5 }}>
<Chip size="small" label={`v${v.version_no}`} />
<Typography variant="body2">{v.change_note}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(v.changed_at).toLocaleString()} {v.changed_by ? `· ${v.changed_by}` : ''}
</Typography>
<Tooltip title="Restore this version">
<span>
<IconButton size="small" disabled={busy === p.profile_code} onClick={() => restore(p.profile_code, v.version_id)}>
<RestoreIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
))}
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
))}
</TableBody>
</Table>
</Paper>
)}
<DryRunDialog open={!!dryRunProfile} profile={dryRunProfile} onClose={() => setDryRunProfile(null)} />
<Dialog open={!!cloneSource} onClose={() => setCloneSource(null)} maxWidth="xs" fullWidth>
<DialogTitle>Clone pipeline</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
From <b>{cloneSource?.profile_code}</b>. The clone starts inactive.
</Typography>
<TextField
fullWidth size="small" label="New code" sx={{ mb: 2 }}
value={cloneCode} onChange={e => setCloneCode(e.target.value)}
helperText="lowercase, [a-z0-9_-]"
/>
<TextField
fullWidth size="small" label="New name (optional)"
value={cloneName} onChange={e => setCloneName(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setCloneSource(null)}>Cancel</Button>
<Button variant="contained" onClick={doClone} disabled={!cloneCode || busy === cloneSource?.profile_code}>Clone</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default PipelinesPage;

View file

@ -0,0 +1,115 @@
/**
* Pipelines API client — talks to the REAL endpoints:
* - didiFramework /framework/api/pipelines/* (input_type_profile = pipeline definition)
* - agent-v3 /agent-v3/api/v3/pipeline/dry-run and /:id/cancel
*
* NOT the dead pipelineApi (services/api/pipeline.ts → /api/v1/pipelines,
* which has no backend). Everything here is backed by live routes.
*/
const FW = '/framework/api/pipelines';
const AGENT = '/agent-v3/api/v3/pipeline';
function authHeader(): Record<string, string> {
const token = localStorage.getItem('keycloak_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function req<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
...init,
headers: { 'Content-Type': 'application/json', ...authHeader(), ...(init?.headers || {}) },
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body?.success === false) {
throw new Error(body?.error || `Request failed (${res.status})`);
}
return body;
}
export interface PipelineProfile {
profile_code: string;
profile_name: string;
description?: string;
is_active: boolean;
weight_techniques: number;
weight_claims: number;
weight_ai_tampered: number;
weight_source: number;
min_components: number;
override_cap: number;
overrides?: unknown[];
}
export interface PipelineVersion {
version_id: number;
version_no: number;
changed_at: string;
changed_by: string | null;
change_note: string;
}
export interface DryRunResult {
media_type: string;
plan_type: number;
flow: string;
results_queue: string;
nodes: Array<{
component: string;
queue?: string;
priority?: number;
timeout_ms?: number;
depends_on?: string[];
produces?: string[];
stages?: Record<string, Record<string, Array<{ order: number; role: string; model_key: string; provider: string; timeout_ms?: number }>>>;
prompt_config_keys?: string[];
}>;
skipped: Array<{ component: string; reason: string }>;
verdict_profile: {
profile_code: string;
profile_name: string;
weights: Record<string, number>;
min_components: number;
override_cap: number;
} | null;
note: string;
}
export const pipelinesApi = {
list: () => req<{ data: PipelineProfile[]; count: number }>(FW).then(r => r.data),
get: (code: string) => req<{ data: PipelineProfile }>(`${FW}/${code}`).then(r => r.data),
clone: (code: string, new_code: string, new_name?: string) =>
req<{ data: PipelineProfile }>(`${FW}/${code}/clone`, {
method: 'POST', body: JSON.stringify({ new_code, new_name }),
}).then(r => r.data),
activate: (code: string) => req(`${FW}/${code}/activate`, { method: 'POST' }),
deactivate: (code: string) => req(`${FW}/${code}/deactivate`, { method: 'POST' }),
update: (code: string, patch: Partial<PipelineProfile> & { change_note?: string }) =>
req<{ data: PipelineProfile; version_saved: number }>(`${FW}/${code}`, {
method: 'PUT', body: JSON.stringify(patch),
}),
versions: (code: string) =>
req<{ data: PipelineVersion[] }>(`${FW}/${code}/versions`).then(r => r.data),
restore: (code: string, versionId: number) =>
req(`${FW}/${code}/versions/${versionId}/restore`, { method: 'POST' }),
dryRun: (body: { media_type: string; text?: string; url?: string; media_url?: string; user_id?: string; plan_type?: number; options?: unknown }) =>
req<{ data: DryRunResult }>(`${AGENT}/dry-run`, {
method: 'POST', body: JSON.stringify(body),
}).then(r => r.data),
cancel: (sessionId: string, user_id?: string) =>
req(`${AGENT}/${sessionId}/cancel`, {
method: 'POST', body: JSON.stringify({ user_id }),
}),
import: (payload: unknown) =>
req<{ data: PipelineProfile }>(`${FW}/import`, {
method: 'POST', body: JSON.stringify(payload),
}).then(r => r.data),
};

View file

@ -0,0 +1,464 @@
/**
* LLM Providers Management - Full CRUD
*
* Manages LLM providers, models, component assignments, and API keys.
* Integrated with PostgreSQL backend via /framework/api/providers/* endpoints.
*
* Original 1159-line file split into:
* ProvidersManagement.types.ts — domain types + TabPanel + API_BASE
* api.ts — 13 fetch wrappers
* tabs/{Providers,Models,Assignments,ApiKeys}Tab.tsx — 4 entity tables
* dialogs/{Provider,Model,Assignment,ApiKey,DeleteConfirm}Dialog.tsx — 5 dialogs
* ProvidersManagement.tsx (this) — state + handlers + render orchestrator
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, Container, Paper, Typography, Tabs, Tab, Button, CircularProgress, Alert,
} from '@mui/material';
import {
Refresh as RefreshIcon,
Key as KeyIcon, Memory as ModelIcon,
Assignment as AssignmentIcon, CloudQueue as ProviderIcon,
} from '@mui/icons-material';
import {
type LlmProvider, type LlmModel, type ComponentAssignment, type ApiKey,
TabPanel,
} from './ProvidersManagement.types';
import {
fetchAll,
createProvider, updateProvider, deleteProvider,
createModel, updateModel, deleteModel,
createAssignment, updateAssignment, deleteAssignment,
createApiKey, updateApiKey, deleteApiKey,
testProvider,
} from './api';
import { ProvidersTab } from './tabs/ProvidersTab';
import { ModelsTab } from './tabs/ModelsTab';
import { AssignmentsTab } from './tabs/AssignmentsTab';
import { ApiKeysTab } from './tabs/ApiKeysTab';
import { ProviderDialog } from './dialogs/ProviderDialog';
import { ModelDialog } from './dialogs/ModelDialog';
import { AssignmentDialog } from './dialogs/AssignmentDialog';
import { ApiKeyDialog } from './dialogs/ApiKeyDialog';
import { DeleteConfirmDialog } from './dialogs/DeleteConfirmDialog';
export const ProvidersManagement: React.FC = () => {
const [tabValue, setTabValue] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [providers, setProviders] = useState<LlmProvider[]>([]);
const [models, setModels] = useState<LlmModel[]>([]);
const [assignments, setAssignments] = useState<ComponentAssignment[]>([]);
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
// Dialog states
const [providerDialog, setProviderDialog] = useState<{ open: boolean; data: Partial<LlmProvider> | null }>({ open: false, data: null });
const [modelDialog, setModelDialog] = useState<{ open: boolean; data: Partial<LlmModel> | null }>({ open: false, data: null });
const [assignmentDialog, setAssignmentDialog] = useState<{ open: boolean; data: Partial<ComponentAssignment> | null }>({ open: false, data: null });
const [apiKeyDialog, setApiKeyDialog] = useState<{ open: boolean; data: Partial<ApiKey & { api_key_value?: string }> | null }>({ open: false, data: null });
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; type: string; id: number; name: string } | null>(null);
// Test connection state
const [testingProvider, setTestingProvider] = useState<number | null>(null);
const [testResult, setTestResult] = useState<{ providerId: number; success: boolean; latencyMs?: number; error?: string } | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchAll();
if (data.success) {
setProviders(data.data.providers || []);
setModels(data.data.models || []);
setAssignments(data.data.assignments || []);
setApiKeys(data.data.keys || []);
} else {
setError(data.error || 'Failed to fetch data');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch data');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
const showSuccess = (message: string) => {
setSuccess(message);
setTimeout(() => setSuccess(null), 3000);
};
const showError = (message: string) => {
setError(message);
setTimeout(() => setError(null), 5000);
};
// ─── Provider CRUD ────────────────────────────────────────────────────────
const handleSaveProvider = async () => {
const data = providerDialog.data;
if (!data) return;
try {
const isEdit = 'provider_id' in data && data.provider_id;
const result = isEdit
? await updateProvider(data.provider_id as number, data)
: await createProvider(data);
if (result.success) {
showSuccess(isEdit ? 'Provider updated successfully' : 'Provider created successfully');
setProviderDialog({ open: false, data: null });
fetchData();
} else {
showError(result.error || 'Operation failed');
}
} catch (err) {
showError(err instanceof Error ? err.message : 'Operation failed');
}
};
const handleDeleteProvider = async () => {
if (!deleteDialog || deleteDialog.type !== 'provider') return;
try {
const result = await deleteProvider(deleteDialog.id);
if (result.success) {
showSuccess('Provider deleted successfully');
setDeleteDialog(null);
fetchData();
} else {
showError(result.error || 'Delete failed');
}
} catch (err) {
showError(err instanceof Error ? err.message : 'Delete failed');
}
};
const handleToggleProviderActive = async (provider: LlmProvider) => {
try {
const result = await updateProvider(provider.provider_id, { is_active: !provider.is_active });
if (result.success) fetchData();
} catch (err) {
showError(err instanceof Error ? err.message : 'Toggle failed');
}
};
const handleTestProvider = async (providerId: number) => {
setTestingProvider(providerId);
setTestResult(null);
try {
const result = await testProvider(providerId);
setTestResult({
providerId,
success: result.data?.success || false,
latencyMs: result.data?.latencyMs,
error: result.data?.error,
});
} catch (err) {
setTestResult({
providerId,
success: false,
error: err instanceof Error ? err.message : 'Test failed',
});
} finally {
setTestingProvider(null);
}
};
// ─── Model CRUD ───────────────────────────────────────────────────────────
const handleSaveModel = async () => {
const data = modelDialog.data;
if (!data) return;
try {
const isEdit = 'model_id' in data && data.model_id;
const result = isEdit
? await updateModel(data.model_id as number, data)
: await createModel(data);
if (result.success) {
showSuccess(isEdit ? 'Model updated successfully' : 'Model created successfully');
setModelDialog({ open: false, data: null });
fetchData();
} else {
showError(result.error || 'Operation failed');
}
} catch (err) {
showError(err instanceof Error ? err.message : 'Operation failed');
}
};
const handleDeleteModel = async () => {
if (!deleteDialog || deleteDialog.type !== 'model') return;
try {
const result = await deleteModel(deleteDialog.id);
if (result.success) {
showSuccess('Model deleted successfully');
setDeleteDialog(null);
fetchData();
} else {
showError(result.error || 'Delete failed');
}
} catch (err) {
showError(err instanceof Error ? err.message : 'Delete failed');
}
};
const handleToggleModelActive = async (model: LlmModel) => {
try {
const result = await updateModel(model.model_id, { is_active: !model.is_active });
if (result.success) fetchData();
} catch (err) {
showError(err instanceof Error ? err.message : 'Toggle failed');
}
};
// ─── Assignment CRUD ──────────────────────────────────────────────────────
const handleSaveAssignment = async () => {
const data = assignmentDialog.data;
if (!data) return;
try {
const isEdit = 'assignment_id' in data && data.assignment_id;
const result = isEdit
? await updateAssignment(data.assignment_id as number, data)
: await createAssignment(data);
if (result.success) {
showSuccess(isEdit ? 'Assignment updated successfully' : 'Assignment created successfully');
setAssignmentDialog({ open: false, data: null });
fetchData();
} else {
showError(result.error || 'Operation failed');
}
} catch (err) {
showError(err instanceof Error ? err.message : 'Operation failed');
}
};
const handleDeleteAssignment = async () => {
if (!deleteDialog || deleteDialog.type !== 'assignment') return;
try {
const result = await deleteAssignment(deleteDialog.id);
if (result.success) {
showSuccess('Assignment deleted successfully');
setDeleteDialog(null);
fetchData();
} else {
showError(result.error || 'Delete failed');
}
} catch (err) {
showError(err instanceof Error ? err.message : 'Delete failed');
}
};
const handleToggleAssignmentEnabled = async (assignment: ComponentAssignment) => {
try {
const result = await updateAssignment(assignment.assignment_id, { is_enabled: !assignment.is_enabled });
if (result.success) fetchData();
} catch (err) {
showError(err instanceof Error ? err.message : 'Toggle failed');
}
};
// ─── API Key CRUD ─────────────────────────────────────────────────────────
const handleSaveApiKey = async () => {
const data = apiKeyDialog.data;
if (!data) return;
try {
const isEdit = 'api_key_id' in data && data.api_key_id;
const result = isEdit
? await updateApiKey(data.api_key_id as number, data)
: await createApiKey(data as { provider_id: number; key_name: string; api_key_value: string });
if (result.success) {
showSuccess(isEdit ? 'API Key updated successfully' : 'API Key created successfully');
setApiKeyDialog({ open: false, data: null });
fetchData();
} else {
showError(result.error || 'Operation failed');
}
} catch (err) {
showError(err instanceof Error ? err.message : 'Operation failed');
}
};
const handleDeleteApiKey = async () => {
if (!deleteDialog || deleteDialog.type !== 'apikey') return;
try {
const result = await deleteApiKey(deleteDialog.id);
if (result.success) {
showSuccess('API Key deleted successfully');
setDeleteDialog(null);
fetchData();
} else {
showError(result.error || 'Delete failed');
}
} catch (err) {
showError(err instanceof Error ? err.message : 'Delete failed');
}
};
const handleToggleApiKeyActive = async (key: ApiKey) => {
try {
const result = await updateApiKey(key.api_key_id, { is_active: !key.is_active });
if (result.success) fetchData();
} catch (err) {
showError(err instanceof Error ? err.message : 'Toggle failed');
}
};
// ─── Dispatcher: which delete handler runs based on deleteDialog.type ────
const handleDeleteConfirm = () => {
if (!deleteDialog) return;
switch (deleteDialog.type) {
case 'provider': return handleDeleteProvider();
case 'model': return handleDeleteModel();
case 'assignment': return handleDeleteAssignment();
case 'apikey': return handleDeleteApiKey();
}
};
// ─── Render ───────────────────────────────────────────────────────────────
return (
<Container maxWidth="xl" sx={{ py: 3 }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Box>
<Typography variant="h4" gutterBottom>LLM Providers</Typography>
<Typography variant="body1" color="text.secondary">
Manage LLM providers, models, assignments, and API keys
</Typography>
</Box>
<Button startIcon={<RefreshIcon />} onClick={fetchData} disabled={loading}>
Refresh
</Button>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
{loading && providers.length === 0 ? (
<Box display="flex" justifyContent="center" p={4}>
<CircularProgress />
</Box>
) : (
<Paper sx={{ p: 2 }}>
<Tabs value={tabValue} onChange={(_e, v) => setTabValue(v)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tab icon={<ProviderIcon />} label={`Providers (${providers.length})`} iconPosition="start" />
<Tab icon={<ModelIcon />} label={`Models (${models.length})`} iconPosition="start" />
<Tab icon={<AssignmentIcon />} label={`Assignments (${assignments.length})`} iconPosition="start" />
<Tab icon={<KeyIcon />} label={`API Keys (${apiKeys.length})`} iconPosition="start" />
</Tabs>
<TabPanel value={tabValue} index={0}>
<ProvidersTab
providers={providers}
testingProvider={testingProvider}
testResult={testResult}
onAdd={() => setProviderDialog({
open: true,
data: { provider_code: '', provider_name: '', base_url: '', auth_type: 'bearer', is_active: true, priority: 100, rate_limit_rpm: 60, rate_limit_tpm: 100000, description: '' },
})}
onEdit={(p) => setProviderDialog({ open: true, data: p })}
onDelete={(p) => setDeleteDialog({ open: true, type: 'provider', id: p.provider_id, name: p.provider_name })}
onToggleActive={handleToggleProviderActive}
onTest={handleTestProvider}
/>
</TabPanel>
<TabPanel value={tabValue} index={1}>
<ModelsTab
models={models}
hasProviders={providers.length > 0}
onAdd={() => setModelDialog({
open: true,
data: { provider_id: providers[0]?.provider_id, model_code: '', model_name: '', context_window: 32000, max_output_tokens: 4096, input_cost_per_1m: 0, output_cost_per_1m: 0, supports_streaming: true, supports_tools: true, supports_vision: false, is_active: true, description: '' },
})}
onEdit={(m) => setModelDialog({ open: true, data: m })}
onDelete={(m) => setDeleteDialog({ open: true, type: 'model', id: m.model_id, name: m.model_name })}
onToggleActive={handleToggleModelActive}
/>
</TabPanel>
<TabPanel value={tabValue} index={2}>
<AssignmentsTab
assignments={assignments}
hasProvidersAndModels={providers.length > 0 && models.length > 0}
onAdd={() => setAssignmentDialog({
open: true,
data: { component_code: '', component_name: '', provider_id: providers[0]?.provider_id, model_id: models[0]?.model_id, temperature: 0.3, max_tokens: 4096, timeout_ms: 120000, is_enabled: true, description: '' },
})}
onEdit={(a) => setAssignmentDialog({ open: true, data: a })}
onDelete={(a) => setDeleteDialog({ open: true, type: 'assignment', id: a.assignment_id, name: a.component_name })}
onToggleEnabled={handleToggleAssignmentEnabled}
/>
</TabPanel>
<TabPanel value={tabValue} index={3}>
<ApiKeysTab
apiKeys={apiKeys}
hasProviders={providers.length > 0}
onAdd={() => setApiKeyDialog({
open: true,
data: { provider_id: providers[0]?.provider_id, key_name: '', api_key_value: '', is_active: true },
})}
onEdit={(k) => setApiKeyDialog({ open: true, data: k })}
onDelete={(k) => setDeleteDialog({ open: true, type: 'apikey', id: k.api_key_id, name: k.key_name })}
onToggleActive={handleToggleApiKeyActive}
/>
</TabPanel>
</Paper>
)}
{/* Dialogs */}
<ProviderDialog
open={providerDialog.open}
data={providerDialog.data}
onClose={() => setProviderDialog({ open: false, data: null })}
onChange={(data) => setProviderDialog({ open: true, data })}
onSave={handleSaveProvider}
/>
<ModelDialog
open={modelDialog.open}
data={modelDialog.data}
providers={providers}
onClose={() => setModelDialog({ open: false, data: null })}
onChange={(data) => setModelDialog({ open: true, data })}
onSave={handleSaveModel}
/>
<AssignmentDialog
open={assignmentDialog.open}
data={assignmentDialog.data}
providers={providers}
models={models}
onClose={() => setAssignmentDialog({ open: false, data: null })}
onChange={(data) => setAssignmentDialog({ open: true, data })}
onSave={handleSaveAssignment}
/>
<ApiKeyDialog
open={apiKeyDialog.open}
data={apiKeyDialog.data}
providers={providers}
onClose={() => setApiKeyDialog({ open: false, data: null })}
onChange={(data) => setApiKeyDialog({ open: true, data })}
onSave={handleSaveApiKey}
/>
<DeleteConfirmDialog
open={!!deleteDialog}
name={deleteDialog?.name ?? ''}
onCancel={() => setDeleteDialog(null)}
onConfirm={handleDeleteConfirm}
/>
</Container>
);
};
export default ProvidersManagement;

View file

@ -0,0 +1,85 @@
/**
* Domain types for the Providers Management page + a small TabPanel utility.
*
* All four entities (Provider, Model, ComponentAssignment, ApiKey) map 1:1
* to /framework/api/providers/* endpoints and are joined into a single
* `/all` payload by the backend so we can render the dashboard with one fetch.
*/
import React from 'react';
import { Box } from '@mui/material';
export const API_BASE = '/framework/api/providers';
export interface LlmProvider {
provider_id: number;
provider_code: string;
provider_name: string;
base_url: string;
auth_type: string;
is_active: boolean;
priority: number;
rate_limit_rpm: number;
rate_limit_tpm: number;
description: string;
}
export interface LlmModel {
model_id: number;
provider_id: number;
provider_code?: string;
provider_name?: string;
model_code: string;
model_name: string;
context_window: number;
max_output_tokens: number;
input_cost_per_1m: number;
output_cost_per_1m: number;
supports_streaming: boolean;
supports_tools: boolean;
supports_vision: boolean;
is_active: boolean;
description: string;
}
export interface ComponentAssignment {
assignment_id: number;
component_code: string;
component_name: string;
provider_id: number;
provider_code?: string;
model_id: number;
model_code?: string;
fallback_provider_id: number | null;
fallback_provider_code?: string;
fallback_model_id: number | null;
fallback_model_code?: string;
temperature: number;
max_tokens: number;
timeout_ms: number;
is_enabled: boolean;
description: string;
}
export interface ApiKey {
api_key_id: number;
provider_id: number;
provider_code?: string;
key_name: string;
key_prefix: string;
is_active: boolean;
usage_count: number;
last_used_at: string | null;
expires_at: string | null;
}
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
export const TabPanel: React.FC<TabPanelProps> = ({ children, value, index, ...other }) => (
<div role="tabpanel" hidden={value !== index} {...other}>
{value === index && <Box sx={{ py: 2 }}>{children}</Box>}
</div>
);

View file

@ -0,0 +1,114 @@
/**
* Thin fetch wrappers for the /framework/api/providers/* CRUD surface.
*
* Each function returns whatever the backend wraps in its response (typically
* `{ success, data?, error? }`). Callers check `.success` and use `.data` /
* surface `.error` themselves — no error handling here so the page can show
* its own UX for failures (toasts, inline errors).
*/
import {
API_BASE,
type LlmProvider, type LlmModel, type ComponentAssignment, type ApiKey,
} from './ProvidersManagement.types';
export async function fetchAll() {
const res = await fetch(`${API_BASE}/all`);
return res.json();
}
export async function createProvider(data: Partial<LlmProvider>) {
const res = await fetch(`${API_BASE}/configs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
export async function updateProvider(id: number, data: Partial<LlmProvider>) {
const res = await fetch(`${API_BASE}/configs/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
export async function deleteProvider(id: number) {
const res = await fetch(`${API_BASE}/configs/${id}`, { method: 'DELETE' });
return res.json();
}
export async function createModel(data: Partial<LlmModel>) {
const res = await fetch(`${API_BASE}/models`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
export async function updateModel(id: number, data: Partial<LlmModel>) {
const res = await fetch(`${API_BASE}/models/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
export async function deleteModel(id: number) {
const res = await fetch(`${API_BASE}/models/${id}`, { method: 'DELETE' });
return res.json();
}
export async function createAssignment(data: Partial<ComponentAssignment>) {
const res = await fetch(`${API_BASE}/assignments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
export async function updateAssignment(id: number, data: Partial<ComponentAssignment>) {
const res = await fetch(`${API_BASE}/assignments/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
export async function deleteAssignment(id: number) {
const res = await fetch(`${API_BASE}/assignments/${id}`, { method: 'DELETE' });
return res.json();
}
export async function createApiKey(data: { provider_id: number; key_name: string; api_key_value: string; is_active?: boolean }) {
const res = await fetch(`${API_BASE}/keys`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
export async function updateApiKey(id: number, data: Partial<ApiKey & { api_key_value?: string }>) {
const res = await fetch(`${API_BASE}/keys/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
export async function deleteApiKey(id: number) {
const res = await fetch(`${API_BASE}/keys/${id}`, { method: 'DELETE' });
return res.json();
}
export async function testProvider(providerId: number) {
const res = await fetch(`${API_BASE}/test/${providerId}`, { method: 'POST' });
return res.json();
}

View file

@ -0,0 +1,77 @@
/**
* Add/Edit dialog for an ApiKey row.
*
* `api_key_value` is a transient field — only sent to the backend on create
* or when explicitly changed during edit. Backend stores it encrypted and
* only returns the prefix on subsequent reads.
*
* Toggle button hides/shows the key value with a password-style mask.
*/
import React, { useState } from 'react';
import {
Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl,
InputLabel, Select, MenuItem, FormControlLabel, Checkbox, Button, IconButton,
InputAdornment,
} from '@mui/material';
import { Visibility, VisibilityOff } from '@mui/icons-material';
import type { ApiKey, LlmProvider } from '../ProvidersManagement.types';
interface Props {
open: boolean;
data: Partial<ApiKey & { api_key_value?: string }> | null;
providers: LlmProvider[];
onClose: () => void;
onChange: (data: Partial<ApiKey & { api_key_value?: string }>) => void;
onSave: () => void;
}
export const ApiKeyDialog: React.FC<Props> = ({ open, data, providers, onClose, onChange, onSave }) => {
const [showKey, setShowKey] = useState(false);
if (!data) return null;
const isEdit = 'api_key_id' in data && !!data.api_key_id;
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{isEdit ? 'Edit API Key' : 'Add API Key'}</DialogTitle>
<DialogContent>
<Box display="flex" flexDirection="column" gap={2} pt={1}>
<FormControl fullWidth required>
<InputLabel>Provider</InputLabel>
<Select value={data.provider_id || ''} label="Provider" onChange={(e) => onChange({ ...data, provider_id: Number(e.target.value) })}>
{providers.map((p) => (
<MenuItem key={p.provider_id} value={p.provider_id}>{p.provider_name}</MenuItem>
))}
</Select>
</FormControl>
<TextField label="Key Name" value={data.key_name || ''} onChange={(e) => onChange({ ...data, key_name: e.target.value })} required fullWidth placeholder="Primary Key, Backup Key, etc." />
<TextField
label={isEdit ? 'New API Key (leave empty to keep existing)' : 'API Key'}
value={data.api_key_value || ''}
onChange={(e) => onChange({ ...data, api_key_value: e.target.value })}
required={!isEdit}
fullWidth
type={showKey ? 'text' : 'password'}
placeholder="sk-..."
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowKey(!showKey)} edge="end">
{showKey ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<FormControlLabel control={<Checkbox checked={data.is_active !== false} onChange={(e) => onChange({ ...data, is_active: e.target.checked })} />} label="Active" />
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={onSave} disabled={!data.provider_id || !data.key_name || (!isEdit && !data.api_key_value)}>
{isEdit ? 'Update' : 'Create'}
</Button>
</DialogActions>
</Dialog>
);
};

View file

@ -0,0 +1,104 @@
/**
* Add/Edit dialog for a ComponentAssignment row.
*
* Maps a component (techniques/claims_extract/etc) to a primary provider+model
* with optional fallback provider+model. Models dropdown filters to only show
* models belonging to the currently-selected provider.
*
* Fallback section is fully optional — empty fallback_provider_id disables
* the fallback model dropdown.
*/
import React from 'react';
import {
Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl,
InputLabel, Select, MenuItem, FormControlLabel, Checkbox, Button, Typography,
} from '@mui/material';
import type { ComponentAssignment, LlmProvider, LlmModel } from '../ProvidersManagement.types';
interface Props {
open: boolean;
data: Partial<ComponentAssignment> | null;
providers: LlmProvider[];
models: LlmModel[];
onClose: () => void;
onChange: (data: Partial<ComponentAssignment>) => void;
onSave: () => void;
}
export const AssignmentDialog: React.FC<Props> = ({ open, data, providers, models, onClose, onChange, onSave }) => {
if (!data) return null;
const isEdit = 'assignment_id' in data && !!data.assignment_id;
const filteredModels = models.filter((m) => m.provider_id === data.provider_id);
const filteredFallbackModels = models.filter((m) => m.provider_id === data.fallback_provider_id);
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{isEdit ? 'Edit Assignment' : 'Add Assignment'}</DialogTitle>
<DialogContent>
<Box display="flex" flexDirection="column" gap={2} pt={1}>
<TextField label="Component Code" value={data.component_code || ''} onChange={(e) => onChange({ ...data, component_code: e.target.value })} required fullWidth placeholder="techniques, claims_extract, etc." />
<TextField label="Component Name" value={data.component_name || ''} onChange={(e) => onChange({ ...data, component_name: e.target.value })} required fullWidth />
<Typography variant="subtitle2" sx={{ mt: 1 }}>Primary Provider/Model</Typography>
<Box display="flex" gap={2}>
<FormControl sx={{ flex: 1 }} required>
<InputLabel>Provider</InputLabel>
<Select value={data.provider_id || ''} label="Provider" onChange={(e) => onChange({ ...data, provider_id: Number(e.target.value), model_id: undefined })}>
{providers.map((p) => (
<MenuItem key={p.provider_id} value={p.provider_id}>{p.provider_name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl sx={{ flex: 1 }} required>
<InputLabel>Model</InputLabel>
<Select value={data.model_id || ''} label="Model" onChange={(e) => onChange({ ...data, model_id: Number(e.target.value) })}>
{filteredModels.map((m) => (
<MenuItem key={m.model_id} value={m.model_id}>{m.model_name}</MenuItem>
))}
</Select>
</FormControl>
</Box>
<Typography variant="subtitle2" sx={{ mt: 1 }}>Fallback Provider/Model (Optional)</Typography>
<Box display="flex" gap={2}>
<FormControl sx={{ flex: 1 }}>
<InputLabel>Fallback Provider</InputLabel>
<Select value={data.fallback_provider_id || ''} label="Fallback Provider" onChange={(e) => onChange({ ...data, fallback_provider_id: e.target.value ? Number(e.target.value) : null, fallback_model_id: null })}>
<MenuItem value="">None</MenuItem>
{providers.map((p) => (
<MenuItem key={p.provider_id} value={p.provider_id}>{p.provider_name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl sx={{ flex: 1 }}>
<InputLabel>Fallback Model</InputLabel>
<Select value={data.fallback_model_id || ''} label="Fallback Model" onChange={(e) => onChange({ ...data, fallback_model_id: e.target.value ? Number(e.target.value) : null })} disabled={!data.fallback_provider_id}>
<MenuItem value="">None</MenuItem>
{filteredFallbackModels.map((m) => (
<MenuItem key={m.model_id} value={m.model_id}>{m.model_name}</MenuItem>
))}
</Select>
</FormControl>
</Box>
<Typography variant="subtitle2" sx={{ mt: 1 }}>Settings</Typography>
<Box display="flex" gap={2}>
<TextField label="Temperature" type="number" value={data.temperature || 0.3} onChange={(e) => onChange({ ...data, temperature: parseFloat(e.target.value) })} sx={{ flex: 1 }} inputProps={{ step: 0.1, min: 0, max: 2 }} />
<TextField label="Max Tokens" type="number" value={data.max_tokens || 4096} onChange={(e) => onChange({ ...data, max_tokens: parseInt(e.target.value) })} sx={{ flex: 1 }} />
<TextField label="Timeout (ms)" type="number" value={data.timeout_ms || 120000} onChange={(e) => onChange({ ...data, timeout_ms: parseInt(e.target.value) })} sx={{ flex: 1 }} />
</Box>
<TextField label="Description" value={data.description || ''} onChange={(e) => onChange({ ...data, description: e.target.value })} multiline rows={2} fullWidth />
<FormControlLabel control={<Checkbox checked={data.is_enabled !== false} onChange={(e) => onChange({ ...data, is_enabled: e.target.checked })} />} label="Enabled" />
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={onSave} disabled={!data.component_code || !data.component_name || !data.provider_id || !data.model_id}>
{isEdit ? 'Update' : 'Create'}
</Button>
</DialogActions>
</Dialog>
);
};

View file

@ -0,0 +1,33 @@
/**
* Generic delete-confirmation dialog used by all four entity types.
* Caller dispatches the actual delete via onConfirm based on the row's `type`.
*/
import React from 'react';
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from '@mui/material';
interface Props {
open: boolean;
name: string;
onCancel: () => void;
onConfirm: () => void;
}
export const DeleteConfirmDialog: React.FC<Props> = ({ open, name, onCancel, onConfirm }) => (
<Dialog open={open} onClose={onCancel}>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete <strong>{name}</strong>?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onCancel}>Cancel</Button>
<Button color="error" variant="contained" onClick={onConfirm}>
Delete
</Button>
</DialogActions>
</Dialog>
);

View file

@ -0,0 +1,65 @@
/**
* Add/Edit dialog for an LlmModel row.
* Provider dropdown is required; cost fields default to $0 (FREE).
*/
import React from 'react';
import {
Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl,
InputLabel, Select, MenuItem, FormControlLabel, Checkbox, Button,
} from '@mui/material';
import type { LlmModel, LlmProvider } from '../ProvidersManagement.types';
interface Props {
open: boolean;
data: Partial<LlmModel> | null;
providers: LlmProvider[];
onClose: () => void;
onChange: (data: Partial<LlmModel>) => void;
onSave: () => void;
}
export const ModelDialog: React.FC<Props> = ({ open, data, providers, onClose, onChange, onSave }) => {
if (!data) return null;
const isEdit = 'model_id' in data && !!data.model_id;
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{isEdit ? 'Edit Model' : 'Add Model'}</DialogTitle>
<DialogContent>
<Box display="flex" flexDirection="column" gap={2} pt={1}>
<FormControl fullWidth required>
<InputLabel>Provider</InputLabel>
<Select value={data.provider_id || ''} label="Provider" onChange={(e) => onChange({ ...data, provider_id: Number(e.target.value) })}>
{providers.map((p) => (
<MenuItem key={p.provider_id} value={p.provider_id}>{p.provider_name}</MenuItem>
))}
</Select>
</FormControl>
<TextField label="Model Code" value={data.model_code || ''} onChange={(e) => onChange({ ...data, model_code: e.target.value })} required fullWidth placeholder="gpt-4o, claude-3-opus, etc." />
<TextField label="Model Name" value={data.model_name || ''} onChange={(e) => onChange({ ...data, model_name: e.target.value })} required fullWidth />
<Box display="flex" gap={2}>
<TextField label="Context Window" type="number" value={data.context_window || 32000} onChange={(e) => onChange({ ...data, context_window: parseInt(e.target.value) })} sx={{ flex: 1 }} />
<TextField label="Max Output Tokens" type="number" value={data.max_output_tokens || 4096} onChange={(e) => onChange({ ...data, max_output_tokens: parseInt(e.target.value) })} sx={{ flex: 1 }} />
</Box>
<Box display="flex" gap={2}>
<TextField label="Input Cost ($/1M)" type="number" value={data.input_cost_per_1m || 0} onChange={(e) => onChange({ ...data, input_cost_per_1m: parseFloat(e.target.value) })} sx={{ flex: 1 }} inputProps={{ step: 0.01 }} />
<TextField label="Output Cost ($/1M)" type="number" value={data.output_cost_per_1m || 0} onChange={(e) => onChange({ ...data, output_cost_per_1m: parseFloat(e.target.value) })} sx={{ flex: 1 }} inputProps={{ step: 0.01 }} />
</Box>
<Box display="flex" gap={2}>
<FormControlLabel control={<Checkbox checked={data.supports_streaming !== false} onChange={(e) => onChange({ ...data, supports_streaming: e.target.checked })} />} label="Streaming" />
<FormControlLabel control={<Checkbox checked={data.supports_tools !== false} onChange={(e) => onChange({ ...data, supports_tools: e.target.checked })} />} label="Tools" />
<FormControlLabel control={<Checkbox checked={data.supports_vision === true} onChange={(e) => onChange({ ...data, supports_vision: e.target.checked })} />} label="Vision" />
</Box>
<TextField label="Description" value={data.description || ''} onChange={(e) => onChange({ ...data, description: e.target.value })} multiline rows={2} fullWidth />
<FormControlLabel control={<Checkbox checked={data.is_active !== false} onChange={(e) => onChange({ ...data, is_active: e.target.checked })} />} label="Active" />
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={onSave} disabled={!data.provider_id || !data.model_code || !data.model_name}>
{isEdit ? 'Update' : 'Create'}
</Button>
</DialogActions>
</Dialog>
);
};

View file

@ -0,0 +1,58 @@
/**
* Add/Edit dialog for an LlmProvider row.
* Save is disabled until provider_code + provider_name are non-empty.
*/
import React from 'react';
import {
Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl,
InputLabel, Select, MenuItem, FormControlLabel, Checkbox, Button,
} from '@mui/material';
import type { LlmProvider } from '../ProvidersManagement.types';
interface Props {
open: boolean;
data: Partial<LlmProvider> | null;
onClose: () => void;
onChange: (data: Partial<LlmProvider>) => void;
onSave: () => void;
}
export const ProviderDialog: React.FC<Props> = ({ open, data, onClose, onChange, onSave }) => {
if (!data) return null;
const isEdit = 'provider_id' in data && !!data.provider_id;
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{isEdit ? 'Edit Provider' : 'Add Provider'}</DialogTitle>
<DialogContent>
<Box display="flex" flexDirection="column" gap={2} pt={1}>
<TextField label="Provider Code" value={data.provider_code || ''} onChange={(e) => onChange({ ...data, provider_code: e.target.value })} required fullWidth />
<TextField label="Provider Name" value={data.provider_name || ''} onChange={(e) => onChange({ ...data, provider_name: e.target.value })} required fullWidth />
<TextField label="Base URL" value={data.base_url || ''} onChange={(e) => onChange({ ...data, base_url: e.target.value })} fullWidth placeholder="https://api.example.com/v1" />
<FormControl fullWidth>
<InputLabel>Auth Type</InputLabel>
<Select value={data.auth_type || 'bearer'} label="Auth Type" onChange={(e) => onChange({ ...data, auth_type: e.target.value })}>
<MenuItem value="bearer">Bearer Token</MenuItem>
<MenuItem value="api_key">API Key Header</MenuItem>
<MenuItem value="x-api-key">X-API-Key Header</MenuItem>
<MenuItem value="none">No Auth</MenuItem>
</Select>
</FormControl>
<Box display="flex" gap={2}>
<TextField label="Priority" type="number" value={data.priority || 100} onChange={(e) => onChange({ ...data, priority: parseInt(e.target.value) })} sx={{ flex: 1 }} />
<TextField label="Rate Limit (RPM)" type="number" value={data.rate_limit_rpm || 60} onChange={(e) => onChange({ ...data, rate_limit_rpm: parseInt(e.target.value) })} sx={{ flex: 1 }} />
<TextField label="Rate Limit (TPM)" type="number" value={data.rate_limit_tpm || 100000} onChange={(e) => onChange({ ...data, rate_limit_tpm: parseInt(e.target.value) })} sx={{ flex: 1 }} />
</Box>
<TextField label="Description" value={data.description || ''} onChange={(e) => onChange({ ...data, description: e.target.value })} multiline rows={2} fullWidth />
<FormControlLabel control={<Checkbox checked={data.is_active !== false} onChange={(e) => onChange({ ...data, is_active: e.target.checked })} />} label="Active" />
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={onSave} disabled={!data.provider_code || !data.provider_name}>
{isEdit ? 'Update' : 'Create'}
</Button>
</DialogActions>
</Dialog>
);
};

View file

@ -0,0 +1 @@
export { ProvidersManagement } from './ProvidersManagement';

View file

@ -0,0 +1,65 @@
/**
* API Keys tab — list of stored keys with usage telemetry.
*
* Backend redacts the key value to a prefix; frontend never sees the full key
* after creation. Edit dialog requires re-entering it if rotation is needed.
*/
import React from 'react';
import {
Box, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
Button, Chip, IconButton, Switch,
} from '@mui/material';
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
import type { ApiKey } from '../ProvidersManagement.types';
interface Props {
apiKeys: ApiKey[];
hasProviders: boolean;
onAdd: () => void;
onEdit: (k: ApiKey) => void;
onDelete: (k: ApiKey) => void;
onToggleActive: (k: ApiKey) => void;
}
export const ApiKeysTab: React.FC<Props> = ({ apiKeys, hasProviders, onAdd, onEdit, onDelete, onToggleActive }) => (
<>
<Box display="flex" justifyContent="flex-end" mb={2}>
<Button variant="contained" startIcon={<AddIcon />} onClick={onAdd} disabled={!hasProviders}>
Add API Key
</Button>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Key Name</TableCell>
<TableCell>Provider</TableCell>
<TableCell>Prefix</TableCell>
<TableCell>Usage</TableCell>
<TableCell>Last Used</TableCell>
<TableCell>Active</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{apiKeys.map((k) => (
<TableRow key={k.api_key_id} hover>
<TableCell><strong>{k.key_name}</strong></TableCell>
<TableCell><Chip label={k.provider_code} size="small" /></TableCell>
<TableCell><code>{k.key_prefix}...</code></TableCell>
<TableCell>{k.usage_count} calls</TableCell>
<TableCell>{k.last_used_at ? new Date(k.last_used_at).toLocaleDateString() : 'Never'}</TableCell>
<TableCell>
<Switch checked={k.is_active} onChange={() => onToggleActive(k)} size="small" />
</TableCell>
<TableCell>
<IconButton size="small" onClick={() => onEdit(k)}><EditIcon /></IconButton>
<IconButton size="small" onClick={() => onDelete(k)} color="error"><DeleteIcon /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
);

View file

@ -0,0 +1,79 @@
/**
* Assignments tab — maps each component to a primary provider+model with
* an optional fallback. Settings column packs temp/max/timeout into one
* caption row to keep the table scannable.
*/
import React from 'react';
import {
Box, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
Button, Chip, IconButton, Switch, Typography,
} from '@mui/material';
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
import type { ComponentAssignment } from '../ProvidersManagement.types';
interface Props {
assignments: ComponentAssignment[];
hasProvidersAndModels: boolean;
onAdd: () => void;
onEdit: (a: ComponentAssignment) => void;
onDelete: (a: ComponentAssignment) => void;
onToggleEnabled: (a: ComponentAssignment) => void;
}
export const AssignmentsTab: React.FC<Props> = ({ assignments, hasProvidersAndModels, onAdd, onEdit, onDelete, onToggleEnabled }) => (
<>
<Box display="flex" justifyContent="flex-end" mb={2}>
<Button variant="contained" startIcon={<AddIcon />} onClick={onAdd} disabled={!hasProvidersAndModels}>
Add Assignment
</Button>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Component</TableCell>
<TableCell>Provider / Model</TableCell>
<TableCell>Fallback</TableCell>
<TableCell>Settings</TableCell>
<TableCell>Enabled</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{assignments.map((a) => (
<TableRow key={a.assignment_id} hover>
<TableCell>
<strong>{a.component_name}</strong>
<Typography variant="caption" display="block" color="text.secondary">{a.component_code}</Typography>
</TableCell>
<TableCell>
<Chip label={a.provider_code} size="small" sx={{ mr: 0.5 }} />
<Chip label={a.model_code} size="small" variant="outlined" />
</TableCell>
<TableCell>
{a.fallback_provider_code ? (
<>
<Chip label={a.fallback_provider_code} size="small" sx={{ mr: 0.5 }} />
<Chip label={a.fallback_model_code} size="small" variant="outlined" />
</>
) : '-'}
</TableCell>
<TableCell>
<Typography variant="caption">
temp: {a.temperature} | max: {a.max_tokens} | timeout: {a.timeout_ms}ms
</Typography>
</TableCell>
<TableCell>
<Switch checked={a.is_enabled} onChange={() => onToggleEnabled(a)} size="small" />
</TableCell>
<TableCell>
<IconButton size="small" onClick={() => onEdit(a)}><EditIcon /></IconButton>
<IconButton size="small" onClick={() => onDelete(a)} color="error"><DeleteIcon /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
);

View file

@ -0,0 +1,77 @@
/**
* Models tab — list of LLM models with provider, context, cost, features.
*
* Cost column shows "FREE" chip when input cost is $0; otherwise shows
* "$<input> / $<output>" per 1M tokens. Features are 3 small chips
* (Streaming/Tools/Vision) shown only when supported.
*/
import React from 'react';
import {
Box, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
Button, Chip, IconButton, Switch, Typography,
} from '@mui/material';
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
import type { LlmModel } from '../ProvidersManagement.types';
interface Props {
models: LlmModel[];
hasProviders: boolean;
onAdd: () => void;
onEdit: (m: LlmModel) => void;
onDelete: (m: LlmModel) => void;
onToggleActive: (m: LlmModel) => void;
}
export const ModelsTab: React.FC<Props> = ({ models, hasProviders, onAdd, onEdit, onDelete, onToggleActive }) => (
<>
<Box display="flex" justifyContent="flex-end" mb={2}>
<Button variant="contained" startIcon={<AddIcon />} onClick={onAdd} disabled={!hasProviders}>
Add Model
</Button>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Model</TableCell>
<TableCell>Provider</TableCell>
<TableCell>Context</TableCell>
<TableCell>Cost (per 1M)</TableCell>
<TableCell>Features</TableCell>
<TableCell>Active</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{models.map((m) => (
<TableRow key={m.model_id} hover>
<TableCell>
<strong>{m.model_name}</strong>
<Typography variant="caption" display="block" color="text.secondary">{m.model_code}</Typography>
</TableCell>
<TableCell><Chip label={m.provider_code} size="small" variant="outlined" /></TableCell>
<TableCell>{(m.context_window / 1000).toFixed(0)}K</TableCell>
<TableCell>
{m.input_cost_per_1m === 0
? <Chip label="FREE" size="small" color="success" />
: `$${m.input_cost_per_1m} / $${m.output_cost_per_1m}`}
</TableCell>
<TableCell>
{m.supports_streaming && <Chip label="Stream" size="small" sx={{ mr: 0.5 }} />}
{m.supports_tools && <Chip label="Tools" size="small" sx={{ mr: 0.5 }} />}
{m.supports_vision && <Chip label="Vision" size="small" color="primary" />}
</TableCell>
<TableCell>
<Switch checked={m.is_active} onChange={() => onToggleActive(m)} size="small" />
</TableCell>
<TableCell>
<IconButton size="small" onClick={() => onEdit(m)}><EditIcon /></IconButton>
<IconButton size="small" onClick={() => onDelete(m)} color="error"><DeleteIcon /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
);

View file

@ -0,0 +1,90 @@
/**
* Providers tab — list of LLM providers with test-connection action.
*
* Test-connection runs against the backend's /test/:providerId endpoint
* and shows ✓ / ✗ + latency_ms inline next to the play button. Result
* stays visible until the next click (no auto-clear).
*/
import React from 'react';
import {
Box, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
Button, Chip, IconButton, Switch, Tooltip, CircularProgress,
} from '@mui/material';
import {
Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon,
PlayArrow as TestIcon, CheckCircle, Cancel,
} from '@mui/icons-material';
import type { LlmProvider } from '../ProvidersManagement.types';
interface Props {
providers: LlmProvider[];
testingProvider: number | null;
testResult: { providerId: number; success: boolean; latencyMs?: number; error?: string } | null;
onAdd: () => void;
onEdit: (p: LlmProvider) => void;
onDelete: (p: LlmProvider) => void;
onToggleActive: (p: LlmProvider) => void;
onTest: (id: number) => void;
}
export const ProvidersTab: React.FC<Props> = ({
providers, testingProvider, testResult,
onAdd, onEdit, onDelete, onToggleActive, onTest,
}) => (
<>
<Box display="flex" justifyContent="flex-end" mb={2}>
<Button variant="contained" startIcon={<AddIcon />} onClick={onAdd}>
Add Provider
</Button>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Provider</TableCell>
<TableCell>Code</TableCell>
<TableCell>Base URL</TableCell>
<TableCell>Auth</TableCell>
<TableCell>Priority</TableCell>
<TableCell>Rate Limits</TableCell>
<TableCell>Active</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{providers.map((p) => (
<TableRow key={p.provider_id} hover>
<TableCell><strong>{p.provider_name}</strong></TableCell>
<TableCell><Chip label={p.provider_code} size="small" /></TableCell>
<TableCell sx={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
<Tooltip title={p.base_url}><span>{p.base_url}</span></Tooltip>
</TableCell>
<TableCell><Chip label={p.auth_type} size="small" variant="outlined" /></TableCell>
<TableCell>{p.priority}</TableCell>
<TableCell>{p.rate_limit_rpm} RPM / {p.rate_limit_tpm} TPM</TableCell>
<TableCell>
<Switch checked={p.is_active} onChange={() => onToggleActive(p)} size="small" />
</TableCell>
<TableCell>
<Tooltip title="Test Connection">
<IconButton size="small" onClick={() => onTest(p.provider_id)} disabled={testingProvider === p.provider_id}>
{testingProvider === p.provider_id ? <CircularProgress size={16} /> : <TestIcon />}
</IconButton>
</Tooltip>
{testResult?.providerId === p.provider_id && (
<Tooltip title={testResult.success ? `${testResult.latencyMs}ms` : testResult.error}>
{testResult.success
? <CheckCircle color="success" fontSize="small" />
: <Cancel color="error" fontSize="small" />}
</Tooltip>
)}
<IconButton size="small" onClick={() => onEdit(p)}><EditIcon /></IconButton>
<IconButton size="small" onClick={() => onDelete(p)} color="error"><DeleteIcon /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
);

View file

@ -0,0 +1,245 @@
/**
* RolesDialog — multi-select Keycloak realm roles for a user.
*
* UX:
* 1. On open, load realm roles + user's current roles.
* 2. Show checkbox list with description tooltip.
* 3. Save → PUT /api/admin/users/:id/roles with the desired list.
* Server diffs add/remove and reports counts.
*/
import React, { useEffect, useMemo, useState } from 'react';
import {
Alert,
Checkbox,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
List,
ListItem,
ListItemText,
Stack,
TextField,
Tooltip,
Typography,
Button,
} from '@mui/material';
interface RealmRole {
name: string;
description: string;
}
interface PutRolesResponse {
success: boolean;
added?: string[];
removed?: string[];
errors?: string[];
error?: string;
}
interface Props {
open: boolean;
userId: number;
userEmail: string;
onClose: () => void;
onSaved: () => void;
}
const API_BASE = '/framework';
function authHeaders(): HeadersInit {
const token = localStorage.getItem('keycloak_token');
return token
? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
: { 'Content-Type': 'application/json' };
}
export const RolesDialog: React.FC<Props> = ({ open, userId, userEmail, onClose, onSaved }) => {
const [allRoles, setAllRoles] = useState<RealmRole[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [initial, setInitial] = useState<Set<string>>(new Set());
const [filter, setFilter] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const [rolesRes, userRolesRes] = await Promise.all([
fetch(`${API_BASE}/api/admin/realm-roles`, { headers: authHeaders() }),
fetch(`${API_BASE}/api/admin/users/${userId}/roles`, { headers: authHeaders() }),
]);
const roles = await rolesRes.json();
const userRoles = await userRolesRes.json();
if (cancelled) return;
if (!roles.success || !userRoles.success) {
setError((roles.error || userRoles.error) ?? 'Failed to load roles');
setLoading(false);
return;
}
const all = (roles.data ?? []) as RealmRole[];
const have = new Set<string>((userRoles.data?.roles ?? []) as string[]);
setAllRoles(all);
setSelected(new Set(have));
setInitial(new Set(have));
} catch (e) {
if (!cancelled) setError((e as Error).message);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [open, userId]);
const filtered = useMemo(() => {
const f = filter.trim().toLowerCase();
if (!f) return allRoles;
return allRoles.filter(
(r) =>
r.name.toLowerCase().includes(f) ||
r.description.toLowerCase().includes(f),
);
}, [allRoles, filter]);
const diffSummary = useMemo(() => {
const added: string[] = [];
const removed: string[] = [];
selected.forEach((n) => {
if (!initial.has(n)) added.push(n);
});
initial.forEach((n) => {
if (!selected.has(n)) removed.push(n);
});
return { added, removed };
}, [selected, initial]);
const toggle = (name: string) => {
const next = new Set(selected);
if (next.has(name)) next.delete(name);
else next.add(name);
setSelected(next);
};
const handleSave = async () => {
setSaving(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/api/admin/users/${userId}/roles`, {
method: 'PUT',
headers: authHeaders(),
body: JSON.stringify({ roles: Array.from(selected) }),
});
const data = (await res.json()) as PutRolesResponse;
if (!data.success) {
setError(data.error ?? `Saved with errors: ${(data.errors ?? []).join('; ')}`);
if (res.status === 207) onSaved();
return;
}
onSaved();
onClose();
} catch (e) {
setError((e as Error).message);
} finally {
setSaving(false);
}
};
const dirty = diffSummary.added.length > 0 || diffSummary.removed.length > 0;
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>
Realm roles — {userEmail}
<Typography variant="caption" display="block" color="text.secondary">
Realm: didi-clients (Keycloak SSO cluster)
</Typography>
</DialogTitle>
<DialogContent dividers>
{loading && (
<Stack alignItems="center" py={4}>
<CircularProgress size={28} />
</Stack>
)}
{!loading && (
<Stack spacing={2}>
<TextField
size="small"
label="Filter roles"
value={filter}
onChange={(e) => setFilter(e.target.value)}
fullWidth
/>
{filtered.length === 0 && (
<Alert severity="info">No roles match the filter.</Alert>
)}
<List dense disablePadding sx={{ maxHeight: 400, overflowY: 'auto' }}>
{filtered.map((r) => (
<ListItem key={r.name} disableGutters sx={{ py: 0 }}>
<FormControlLabel
sx={{ width: '100%' }}
control={
<Checkbox
checked={selected.has(r.name)}
onChange={() => toggle(r.name)}
/>
}
label={
<Tooltip title={r.description || '—'}>
<ListItemText
primary={r.name}
secondary={r.description || undefined}
primaryTypographyProps={{
fontFamily: 'monospace',
fontSize: 14,
}}
secondaryTypographyProps={{ fontSize: 11 }}
/>
</Tooltip>
}
/>
</ListItem>
))}
</List>
{dirty && (
<Alert severity="warning">
{diffSummary.added.length > 0 && (
<div>
<strong>Add:</strong> {diffSummary.added.join(', ')}
</div>
)}
{diffSummary.removed.length > 0 && (
<div>
<strong>Remove:</strong> {diffSummary.removed.join(', ')}
</div>
)}
</Alert>
)}
{error && <Alert severity="error">{error}</Alert>}
</Stack>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={!dirty || saving}
startIcon={saving ? <CircularProgress size={16} /> : undefined}
>
Save changes
</Button>
</DialogActions>
</Dialog>
);
};

View file

@ -0,0 +1,234 @@
/**
* UsageHistoryModal — last N analyses + total cheltuit pentru un user.
*
* Reads bos_sysadmin.ai_credit_usage via /api/admin/users/:id/usage-history.
* The endpoint returns rows with whatever columns the table has — we render
* defensively (display known fields if present, fall back to a small pretty
* JSON for unknown columns).
*/
import React, { useEffect, useState } from 'react';
import {
Alert,
Box,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
Button,
} from '@mui/material';
import { Close as CloseIcon, Refresh as RefreshIcon } from '@mui/icons-material';
interface UsageRow {
// Best-effort columns. Names match what we observed in the schema; any
// extra columns the row has are still rendered via JSON fallback.
internet_user_id?: number;
session_id?: string;
input_type?: string;
credits_used?: number;
used_at?: string;
created_at?: string;
[k: string]: unknown;
}
interface UsageResponse {
success: boolean;
data: UsageRow[];
stats?: { rows: number; total_credits: number };
error?: string;
}
interface Props {
open: boolean;
userId: number;
userEmail: string;
onClose: () => void;
}
const API_BASE = '/framework';
function authHeaders(): HeadersInit {
const token = localStorage.getItem('keycloak_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
const TYPE_COLOR: Record<string, 'primary' | 'secondary' | 'success' | 'warning' | 'info'> = {
text: 'primary',
url: 'info',
image: 'secondary',
audio: 'warning',
video: 'success',
};
function fmt(s: unknown): string {
if (!s) return '—';
try {
return new Date(s as string).toLocaleString();
} catch {
return String(s);
}
}
function pickWhen(r: UsageRow): unknown {
return r.used_at ?? r.created_at ?? (r as Record<string, unknown>).timestamp ?? null;
}
export const UsageHistoryModal: React.FC<Props> = ({ open, userId, userEmail, onClose }) => {
const [rows, setRows] = useState<UsageRow[]>([]);
const [stats, setStats] = useState<{ rows: number; total_credits: number } | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(
`${API_BASE}/api/admin/users/${userId}/usage-history?limit=100`,
{ headers: authHeaders() },
);
const data = (await res.json()) as UsageResponse;
if (!data.success) {
setError(data.error ?? 'Failed to load usage history');
return;
}
setRows(data.data ?? []);
setStats(data.stats ?? null);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open) load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, userId]);
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Box>
<Typography variant="h6">Usage history — {userEmail}</Typography>
{stats && (
<Typography variant="caption" color="text.secondary">
{stats.rows} rows · {stats.total_credits} credits used (sum)
</Typography>
)}
</Box>
<Stack direction="row" spacing={0.5}>
<IconButton onClick={load} title="Refresh">
<RefreshIcon />
</IconButton>
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</Stack>
</Stack>
</DialogTitle>
<DialogContent dividers>
{loading && (
<Stack alignItems="center" py={4}>
<CircularProgress size={28} />
</Stack>
)}
{!loading && error && <Alert severity="error">{error}</Alert>}
{!loading && !error && rows.length === 0 && (
<Alert severity="info">No usage rows recorded for this user.</Alert>
)}
{!loading && !error && rows.length > 0 && (
<TableContainer>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>When</TableCell>
<TableCell>Input type</TableCell>
<TableCell align="right">Credits</TableCell>
<TableCell>Session</TableCell>
<TableCell>Other</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((r, idx) => {
const inputType = (r.input_type ?? '') as string;
const credits = r.credits_used ??
(r as Record<string, unknown>).credits ??
(r as Record<string, unknown>).cost ??
null;
const session = (r.session_id ?? '') as string;
// Other columns — exclude the ones we already show.
const known = new Set(['internet_user_id','session_id','input_type','credits_used','used_at','created_at','credits','cost']);
const extras: Record<string, unknown> = {};
for (const [k, v] of Object.entries(r)) {
if (!known.has(k)) extras[k] = v;
}
const extrasStr = Object.keys(extras).length === 0
? '—'
: JSON.stringify(extras).slice(0, 80);
return (
<TableRow key={idx} hover>
<TableCell>
<Typography variant="caption">{fmt(pickWhen(r))}</Typography>
</TableCell>
<TableCell>
{inputType ? (
<Chip
size="small"
label={inputType}
color={TYPE_COLOR[inputType] ?? 'default'}
/>
) : (
'—'
)}
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight={600}>
{credits === null ? '—' : String(credits)}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="caption"
sx={{ fontFamily: 'monospace' }}
title={session}
>
{session ? session.slice(0, 12) + '…' : '—'}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="caption"
sx={{ fontFamily: 'monospace', color: 'text.secondary' }}
>
{extrasStr}
</Typography>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
};

View file

@ -0,0 +1,415 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Switch,
FormControlLabel,
Box,
Typography,
Alert,
CircularProgress,
Divider,
Stack,
Snackbar,
} from '@mui/material';
import { LockReset as LockResetIcon } from '@mui/icons-material';
interface User {
id: number;
email: string;
firstName: string;
lastName: string;
phone: string;
creditsRemained: number;
creditsSpent: number;
subscriptionPlanId: number;
subscriptionPlanName: string;
subscriptionStatus: number;
isActive: boolean;
keycloakId: string;
createdAt: string;
}
interface SubscriptionPlan {
id: number;
name: string;
description: string;
creditsIncluded: number;
price: number;
}
interface UserEditModalProps {
open: boolean;
user: User;
plans: SubscriptionPlan[];
onClose: () => void;
onSave: () => void;
}
const API_BASE = '/framework';
export const UserEditModal: React.FC<UserEditModalProps> = ({
open,
user,
plans,
onClose,
onSave,
}) => {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
phone: '',
isActive: true,
creditsRemained: 0,
subscriptionPlanId: 1,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Phase U — group select + reset password (live values from Keycloak)
const [allGroups, setAllGroups] = useState<{ id: string; name: string }[]>([]);
const [currentGroup, setCurrentGroup] = useState<string>('');
const [initialGroup, setInitialGroup] = useState<string>('');
const [resetSent, setResetSent] = useState<string | null>(null);
const [resetting, setResetting] = useState(false);
useEffect(() => {
if (user) {
setFormData({
firstName: user.firstName || '',
lastName: user.lastName || '',
phone: user.phone || '',
isActive: user.isActive,
creditsRemained: user.creditsRemained || 0,
subscriptionPlanId: user.subscriptionPlanId || 1,
});
}
}, [user]);
// Load Keycloak groups + the user's current group when the dialog opens.
useEffect(() => {
if (!open || !user?.id) return;
let cancelled = false;
(async () => {
try {
const [groupsRes, userGroupRes] = await Promise.all([
fetch(`${API_BASE}/api/admin/groups`, { headers: getAuthHeader() as any }),
fetch(`${API_BASE}/api/admin/users/${user.id}/group`, { headers: getAuthHeader() as any }),
]);
const groupsData = await groupsRes.json();
const userGroupData = await userGroupRes.json();
if (cancelled) return;
if (groupsData.success) setAllGroups(groupsData.data ?? []);
const g = (userGroupData.data?.groups ?? [])[0] ?? '';
setCurrentGroup(g);
setInitialGroup(g);
} catch {
// best-effort — leave dropdown empty if Keycloak unreachable
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, user?.id]);
const getAuthHeader = () => {
const token = localStorage.getItem('keycloak_token');
return token ? { Authorization: `Bearer ${token}` } : {};
};
const handleChange = (field: string) => (event: any) => {
const value = event.target.type === 'checkbox'
? event.target.checked
: event.target.value;
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
setLoading(true);
setError(null);
try {
// Update user profile
const profileResponse = await fetch(`${API_BASE}/api/admin/users/${user.id}`, {
method: 'PUT',
headers: {
...getAuthHeader() as Record<string, string>,
'Content-Type': 'application/json',
},
body: JSON.stringify({
firstName: formData.firstName,
lastName: formData.lastName,
phone: formData.phone,
isActive: formData.isActive,
creditsRemained: formData.creditsRemained,
}),
});
if (!profileResponse.ok) {
const data = await profileResponse.json();
throw new Error(data.error || 'Failed to update user');
}
// Update subscription if changed
if (formData.subscriptionPlanId !== user.subscriptionPlanId) {
const subscriptionResponse = await fetch(
`${API_BASE}/api/admin/users/${user.id}/subscription`,
{
method: 'PUT',
headers: {
...getAuthHeader() as Record<string, string>,
'Content-Type': 'application/json',
},
body: JSON.stringify({
planId: formData.subscriptionPlanId,
}),
}
);
if (!subscriptionResponse.ok) {
const data = await subscriptionResponse.json();
throw new Error(data.error || 'Failed to update subscription');
}
}
// Update Keycloak group if changed (Phase U)
if (currentGroup !== initialGroup) {
const grpRes = await fetch(
`${API_BASE}/api/admin/users/${user.id}/group`,
{
method: 'PUT',
headers: {
...getAuthHeader() as Record<string, string>,
'Content-Type': 'application/json',
},
body: JSON.stringify({ group: currentGroup || null }),
}
);
if (!grpRes.ok) {
const data = await grpRes.json();
throw new Error(data.error || 'Failed to update group');
}
}
onSave();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleResetPassword = async () => {
setResetting(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/api/admin/users/${user.id}/reset-password`, {
method: 'POST',
headers: {
...getAuthHeader() as Record<string, string>,
'Content-Type': 'application/json',
},
body: JSON.stringify({ lifespanSeconds: 86400 }),
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(
data.error ||
data.detail ||
`Reset request failed (HTTP ${res.status})`,
);
}
setResetSent(`Reset email queued for ${user.email}`);
} catch (err: any) {
setError(err.message);
} finally {
setResetting(false);
}
};
const selectedPlan = plans.find(p => p.id === formData.subscriptionPlanId);
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Edit User</DialogTitle>
<DialogContent>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
{/* Email (readonly) */}
<TextField
label="Email"
value={user.email}
disabled
fullWidth
size="small"
/>
{/* First Name */}
<TextField
label="First Name"
value={formData.firstName}
onChange={handleChange('firstName')}
fullWidth
size="small"
/>
{/* Last Name */}
<TextField
label="Last Name"
value={formData.lastName}
onChange={handleChange('lastName')}
fullWidth
size="small"
/>
{/* Phone */}
<TextField
label="Phone"
value={formData.phone}
onChange={handleChange('phone')}
fullWidth
size="small"
/>
<Divider sx={{ my: 1 }} />
<Typography variant="subtitle2" color="text.secondary">
Subscription & Credits
</Typography>
{/* Subscription Plan */}
<FormControl fullWidth size="small">
<InputLabel>Subscription Plan</InputLabel>
<Select
value={formData.subscriptionPlanId}
onChange={handleChange('subscriptionPlanId')}
label="Subscription Plan"
>
{plans.map(plan => (
<MenuItem key={plan.id} value={plan.id}>
{plan.name} ({plan.creditsIncluded} credits)
</MenuItem>
))}
</Select>
</FormControl>
{selectedPlan && (
<Typography variant="caption" color="text.secondary">
Plan includes {selectedPlan.creditsIncluded} credits.
{selectedPlan.price > 0 && ` Price: $${selectedPlan.price}`}
</Typography>
)}
{/* Credits */}
<TextField
label="Credits Remaining"
type="number"
value={formData.creditsRemained}
onChange={handleChange('creditsRemained')}
fullWidth
size="small"
inputProps={{ min: 0 }}
/>
<Typography variant="caption" color="text.secondary">
Credits spent: {user.creditsSpent}
</Typography>
<Divider sx={{ my: 1 }} />
<Typography variant="subtitle2" color="text.secondary">
Keycloak group
</Typography>
{/* Phase U — Keycloak group (single-select).
Empty value = remove from all groups. */}
<FormControl fullWidth size="small">
<InputLabel>Group</InputLabel>
<Select
value={currentGroup}
onChange={(e) => setCurrentGroup(e.target.value as string)}
label="Group"
disabled={!user.keycloakId}
>
<MenuItem value="">
<em>(no group)</em>
</MenuItem>
{allGroups.map((g) => (
<MenuItem key={g.id} value={g.name}>
{g.name}
</MenuItem>
))}
</Select>
</FormControl>
{!user.keycloakId && (
<Typography variant="caption" color="warning.main">
No keycloak_id — sync this user first to manage their group.
</Typography>
)}
<Divider sx={{ my: 1 }} />
{/* Status */}
<FormControlLabel
control={
<Switch
checked={formData.isActive}
onChange={handleChange('isActive')}
color="primary"
/>
}
label={formData.isActive ? 'Active' : 'Inactive'}
/>
<Divider sx={{ my: 1 }} />
<Stack direction="row" alignItems="center" spacing={2}>
<Button
onClick={handleResetPassword}
variant="outlined"
startIcon={resetting ? <CircularProgress size={16} /> : <LockResetIcon />}
disabled={resetting || !user.keycloakId}
color="warning"
>
Send password reset email
</Button>
<Typography variant="caption" color="text.secondary">
Triggers Keycloak's UPDATE_PASSWORD flow. 24h lifespan.
</Typography>
</Stack>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
onClick={handleSave}
variant="contained"
disabled={loading}
>
{loading ? <CircularProgress size={24} /> : 'Save Changes'}
</Button>
</DialogActions>
<Snackbar
open={resetSent !== null}
autoHideDuration={6000}
onClose={() => setResetSent(null)}
message={resetSent ?? ''}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
</Dialog>
);
};

View file

@ -0,0 +1,43 @@
/**
* Pure helpers + constants for UserManagement.
*
* getAuthHeader() reads the Keycloak token from localStorage and returns
* either a Bearer header or an empty object. Empty header is fine in staging
* when Keycloak auth bypass is on; backend ignores missing auth then.
*/
export function fmtBytes(n: number | undefined): string {
if (!n || n <= 0) return '0';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`;
}
export function fmtDate(s: string): string {
if (!s) return '—';
try {
return new Date(s).toLocaleString();
} catch {
return s;
}
}
export const ACTION_COLORS: Record<string, 'primary' | 'secondary' | 'success' | 'warning' | 'error'> = {
'user.update': 'primary',
'user.delete': 'error',
'user.sync': 'success',
'user.email_verified': 'secondary',
'user.subscription': 'primary',
'user.roles': 'warning',
'user.group': 'warning',
'user.reset_password': 'warning',
};
export function getAuthHeader(): Record<string, string> {
const token = localStorage.getItem('keycloak_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}

View file

@ -0,0 +1,548 @@
/**
* USER MANAGEMENT — admin page for managing users / plans / audit log.
*
* Original 1153-line file split into:
* UserManagement.types.ts — User, SubscriptionPlan, AuditEntry, etc.
* UserManagement.helpers.ts — fmtBytes, fmtDate, ACTION_COLORS, getAuthHeader
* tabs/UsersTab.tsx — filters + bulk toolbar + users table
* tabs/PlansTab.tsx — plans inline editor
* tabs/AuditLogTab.tsx — audit log table + filters
* dialogs/DeleteUserDialog.tsx — hard-delete confirmation
* dialogs/BulkCreditsDialog.tsx — bulk-set-credits dialog
* UserManagement.tsx (this) — state + handlers + tab dispatcher
*
* Bulk operations are sequential (never parallel) so audit-log ordering is
* stable and we don't hammer Keycloak with concurrent writes.
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
Container, Paper, Typography, Alert, Tab, Tabs, Snackbar,
} from '@mui/material';
import {
People as PeopleIcon,
CreditCard as PlansIcon,
ReceiptLong as AuditIcon,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
import { UserEditModal } from './UserEditModal';
import { RolesDialog } from './RolesDialog';
import { UsageHistoryModal } from './UsageHistoryModal';
import { UsersTab } from './tabs/UsersTab';
import { PlansTab } from './tabs/PlansTab';
import { AuditLogTab } from './tabs/AuditLogTab';
import { DeleteUserDialog } from './dialogs/DeleteUserDialog';
import { BulkCreditsDialog } from './dialogs/BulkCreditsDialog';
import { AddUserDialog } from './dialogs/AddUserDialog';
import {
type User, type SubscriptionPlan, type AuditEntry, type PaginationInfo,
type AuditFiltersState, type AuditPaginationState, type PlanFormState,
API_BASE,
} from './UserManagement.types';
import { getAuthHeader } from './UserManagement.helpers';
export const UserManagement: React.FC = () => {
const { hasRole } = useAuth();
void hasRole;
const [users, setUsers] = useState<User[]>([]);
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [snackMsg, setSnackMsg] = useState<string | null>(null);
const [pagination, setPagination] = useState<PaginationInfo>({
page: 1, limit: 20, total: 0, totalPages: 0,
});
// Filters
const [searchQuery, setSearchQuery] = useState('');
const [planFilter, setPlanFilter] = useState<string>('');
const [syncFilter, setSyncFilter] = useState<'all' | 'synced' | 'keycloak_only'>('all');
// UI state
const [activeTab, setActiveTab] = useState(0);
const [editModalOpen, setEditModalOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [userToDelete, setUserToDelete] = useState<User | null>(null);
const [rolesUser, setRolesUser] = useState<User | null>(null);
const [usageUser, setUsageUser] = useState<User | null>(null);
const [addUserDialogOpen, setAddUserDialogOpen] = useState(false);
// Bulk selection
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [bulkMenuAnchor, setBulkMenuAnchor] = useState<HTMLElement | null>(null);
const [bulkCreditsDialog, setBulkCreditsDialog] = useState(false);
const [bulkCreditsValue, setBulkCreditsValue] = useState<number>(100);
// Plans tab state
const [editingPlan, setEditingPlan] = useState<SubscriptionPlan | null>(null);
const [planForm, setPlanForm] = useState<PlanFormState>({ name: '', description: '', creditsIncluded: 0, price: 0 });
const [savingPlan, setSavingPlan] = useState(false);
// Audit Log tab state
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
const [auditLoading, setAuditLoading] = useState(false);
const [auditFilters, setAuditFilters] = useState<AuditFiltersState>({ action: '', actor: '', since: '' });
const [auditPagination, setAuditPagination] = useState<AuditPaginationState>({ limit: 50, offset: 0, total: 0 });
// ─── data fetching ────────────────────────────────────────────────────────
const fetchUsers = useCallback(async () => {
setLoading(true);
setError(null);
setSelectedIds(new Set());
try {
const params = new URLSearchParams();
if (searchQuery) params.append('search', searchQuery);
if (syncFilter !== 'all') params.append('sync_status', syncFilter);
const response = await fetch(`${API_BASE}/api/admin/users?${params.toString()}`, {
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
});
if (!response.ok) {
if (response.status === 403) throw new Error('Admin access required');
throw new Error('Failed to fetch users');
}
const data = await response.json();
let allUsers: User[] = data.data || [];
// Client-side plan filter (server doesn't support it).
if (planFilter) {
const id = parseInt(planFilter, 10);
allUsers = allUsers.filter((u) => u.subscriptionPlanId === id);
}
const total = allUsers.length;
setUsers(allUsers);
setPagination((prev) => ({
...prev,
total,
totalPages: Math.ceil(total / prev.limit) || 1,
}));
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}, [searchQuery, planFilter, syncFilter]);
const fetchPlans = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/api/admin/plans`, {
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
});
if (response.ok) {
const data = await response.json();
setPlans(data.data || []);
}
} catch (err) {
console.error('Failed to fetch plans:', err);
}
}, []);
const fetchAudit = useCallback(async () => {
setAuditLoading(true);
try {
const params = new URLSearchParams({
limit: String(auditPagination.limit),
offset: String(auditPagination.offset),
});
if (auditFilters.action) params.append('action', auditFilters.action);
if (auditFilters.actor) params.append('actor', auditFilters.actor);
if (auditFilters.since) params.append('since', auditFilters.since);
const res = await fetch(`${API_BASE}/api/admin/audit-log?${params.toString()}`, {
headers: getAuthHeader(),
});
const data = await res.json();
if (data.success) {
setAuditEntries(data.data || []);
setAuditPagination((p) => ({ ...p, total: data.total ?? 0 }));
}
} catch (err) {
console.error('Failed to fetch audit log:', err);
} finally {
setAuditLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [auditFilters, auditPagination.limit, auditPagination.offset]);
useEffect(() => { fetchUsers(); }, [fetchUsers]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
useEffect(() => { if (activeTab === 2) fetchAudit(); }, [activeTab, fetchAudit]);
// ─── derived ───────────────────────────────────────────────────────────────
const pagedUsers = useMemo(() => {
const start = (pagination.page - 1) * pagination.limit;
return users.slice(start, start + pagination.limit);
}, [users, pagination.page, pagination.limit]);
const allOnPageSelected = pagedUsers.length > 0 &&
pagedUsers.every((u) => u.id !== 0 && selectedIds.has(u.id));
const someOnPageSelected = pagedUsers.some((u) => u.id !== 0 && selectedIds.has(u.id));
// ─── handlers ──────────────────────────────────────────────────────────────
const handlePageChange = (_e: unknown, newPage: number) => {
setPagination((prev) => ({ ...prev, page: newPage + 1 }));
};
const handleRowsPerPageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPagination((prev) => ({ ...prev, limit: parseInt(e.target.value, 10), page: 1 }));
};
const handleEditUser = (user: User) => {
setSelectedUser(user);
setEditModalOpen(true);
};
const handleDeleteClick = (user: User) => {
setUserToDelete(user);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
if (!userToDelete) return;
try {
// Hard delete (Keycloak + PG + bucket cascade) instead of just deactivating —
// matches the destructive intent of the trash icon.
const response = await fetch(`${API_BASE}/api/admin/users/${userToDelete.id}`, {
method: 'DELETE',
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to delete user');
}
fetchUsers();
setSnackMsg(`User ${userToDelete.email} deleted`);
} catch (err: any) {
setError(err.message);
} finally {
setDeleteDialogOpen(false);
setUserToDelete(null);
}
};
const handleUserUpdated = () => {
fetchUsers();
setEditModalOpen(false);
setSelectedUser(null);
};
const handleEmailVerifiedToggle = async (user: User) => {
try {
const response = await fetch(`${API_BASE}/api/admin/users/${user.id}/email-verified`, {
method: 'PUT',
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
body: JSON.stringify({ emailVerified: !user.emailVerified }),
});
if (!response.ok) throw new Error('Failed to update email verification');
setUsers((prev) =>
prev.map((u) => (u.id === user.id ? { ...u, emailVerified: !u.emailVerified } : u))
);
} catch (err: any) {
setError(err.message);
}
};
const handleSyncUser = async (user: User) => {
try {
const response = await fetch(`${API_BASE}/api/admin/users/sync`, {
method: 'POST',
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
body: JSON.stringify({ keycloakId: user.keycloakId }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to sync user');
}
fetchUsers();
setSnackMsg(`User ${user.email} synced`);
} catch (err: any) {
setError(err.message);
}
};
// ─── plans tab ─────────────────────────────────────────────────────────────
const handleEditPlan = (plan: SubscriptionPlan) => {
setEditingPlan(plan);
setPlanForm({
name: plan.name,
description: plan.description || '',
creditsIncluded: plan.creditsIncluded,
price: plan.price,
});
};
const handleSavePlan = async () => {
if (!editingPlan) return;
setSavingPlan(true);
setError(null);
try {
const response = await fetch(`${API_BASE}/api/admin/plans/${editingPlan.id}`, {
method: 'PUT',
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
body: JSON.stringify(planForm),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to update plan');
}
setEditingPlan(null);
fetchPlans();
} catch (err: any) {
setError(err.message);
} finally {
setSavingPlan(false);
}
};
// ─── bulk operations ──────────────────────────────────────────────────────
const toggleSelectOne = (id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleSelectAllOnPage = () => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (allOnPageSelected) {
pagedUsers.forEach((u) => next.delete(u.id));
} else {
pagedUsers.forEach((u) => {
if (u.id !== 0) next.add(u.id);
});
}
return next;
});
};
// Generic sequential bulk runner — never parallel, to avoid hammering Keycloak
// and keep audit log entries cleanly ordered.
const runBulk = async (
label: string,
fn: (u: User) => Promise<void>,
) => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
setBulkMenuAnchor(null);
setError(null);
let ok = 0;
let fail = 0;
for (const id of ids) {
const user = users.find((u) => u.id === id);
if (!user) continue;
try {
await fn(user);
ok++;
} catch (e: any) {
console.error(`bulk ${label} failed for ${user.email}:`, e);
fail++;
}
}
setSnackMsg(`Bulk ${label}: ${ok} ok, ${fail} failed`);
setSelectedIds(new Set());
fetchUsers();
};
const bulkActivate = (active: boolean) =>
runBulk(active ? 'activate' : 'deactivate', async (u) => {
const r = await fetch(`${API_BASE}/api/admin/users/${u.id}`, {
method: 'PUT',
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: active }),
});
if (!r.ok) throw new Error(await r.text());
});
const bulkEmailVerify = () =>
runBulk('email-verify', async (u) => {
const r = await fetch(`${API_BASE}/api/admin/users/${u.id}/email-verified`, {
method: 'PUT',
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
body: JSON.stringify({ emailVerified: true }),
});
if (!r.ok) throw new Error(await r.text());
});
const bulkSetCredits = () =>
runBulk(`set-credits=${bulkCreditsValue}`, async (u) => {
const r = await fetch(`${API_BASE}/api/admin/users/${u.id}`, {
method: 'PUT',
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
body: JSON.stringify({ creditsRemained: bulkCreditsValue }),
});
if (!r.ok) throw new Error(await r.text());
});
const bulkDelete = () =>
runBulk('delete', async (u) => {
const r = await fetch(`${API_BASE}/api/admin/users/${u.id}`, {
method: 'DELETE',
headers: getAuthHeader(),
});
if (!r.ok) throw new Error(await r.text());
});
// ─── render ────────────────────────────────────────────────────────────────
return (
<Container maxWidth="xl" sx={{ py: 3 }}>
<Typography variant="h4" gutterBottom>User Management</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Manage users, subscriptions, credits, roles, groups, and audit history
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
<Paper sx={{ mb: 3 }}>
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}>
<Tab icon={<PeopleIcon />} iconPosition="start" label="Users" />
<Tab icon={<PlansIcon />} iconPosition="start" label="Subscription Plans" />
<Tab icon={<AuditIcon />} iconPosition="start" label="Audit Log" />
</Tabs>
</Paper>
{activeTab === 0 && (
<UsersTab
users={users}
pagedUsers={pagedUsers}
plans={plans}
loading={loading}
pagination={pagination}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
planFilter={planFilter}
setPlanFilter={setPlanFilter}
syncFilter={syncFilter}
setSyncFilter={setSyncFilter}
fetchUsers={fetchUsers}
selectedIds={selectedIds}
setSelectedIds={setSelectedIds}
allOnPageSelected={allOnPageSelected}
someOnPageSelected={someOnPageSelected}
toggleSelectOne={toggleSelectOne}
toggleSelectAllOnPage={toggleSelectAllOnPage}
bulkMenuAnchor={bulkMenuAnchor}
setBulkMenuAnchor={setBulkMenuAnchor}
bulkActivate={bulkActivate}
bulkEmailVerify={bulkEmailVerify}
bulkDelete={bulkDelete}
setBulkCreditsDialog={setBulkCreditsDialog}
handlePageChange={handlePageChange}
handleRowsPerPageChange={handleRowsPerPageChange}
handleEditUser={handleEditUser}
handleDeleteClick={handleDeleteClick}
handleEmailVerifiedToggle={handleEmailVerifiedToggle}
handleSyncUser={handleSyncUser}
setRolesUser={setRolesUser}
setUsageUser={setUsageUser}
onOpenAddUser={() => setAddUserDialogOpen(true)}
/>
)}
{activeTab === 1 && (
<PlansTab
plans={plans}
editingPlan={editingPlan}
setEditingPlan={setEditingPlan}
planForm={planForm}
setPlanForm={setPlanForm}
savingPlan={savingPlan}
handleEditPlan={handleEditPlan}
handleSavePlan={handleSavePlan}
/>
)}
{activeTab === 2 && (
<AuditLogTab
auditEntries={auditEntries}
auditLoading={auditLoading}
auditFilters={auditFilters}
setAuditFilters={setAuditFilters}
auditPagination={auditPagination}
setAuditPagination={setAuditPagination}
fetchAudit={fetchAudit}
/>
)}
{/* ─── dialogs / modals ───────────────────────────────────────────── */}
{selectedUser && (
<UserEditModal
open={editModalOpen}
user={selectedUser}
plans={plans}
onClose={() => { setEditModalOpen(false); setSelectedUser(null); }}
onSave={handleUserUpdated}
/>
)}
{rolesUser && (
<RolesDialog
open={rolesUser !== null}
userId={rolesUser.id}
userEmail={rolesUser.email}
onClose={() => setRolesUser(null)}
onSaved={() => { fetchUsers(); setSnackMsg('Roles updated'); }}
/>
)}
{usageUser && (
<UsageHistoryModal
open={usageUser !== null}
userId={usageUser.id}
userEmail={usageUser.email}
onClose={() => setUsageUser(null)}
/>
)}
<DeleteUserDialog
open={deleteDialogOpen}
user={userToDelete}
onCancel={() => setDeleteDialogOpen(false)}
onConfirm={handleDeleteConfirm}
/>
<BulkCreditsDialog
open={bulkCreditsDialog}
selectedCount={selectedIds.size}
bulkCreditsValue={bulkCreditsValue}
setBulkCreditsValue={setBulkCreditsValue}
onCancel={() => setBulkCreditsDialog(false)}
onApply={() => { setBulkCreditsDialog(false); bulkSetCredits(); }}
/>
<AddUserDialog
open={addUserDialogOpen}
plans={plans}
apiBase={API_BASE}
authHeader={getAuthHeader()}
onClose={() => setAddUserDialogOpen(false)}
onCreated={({ email, realm }) => {
setSnackMsg(`User ${email} created in ${realm}`);
fetchUsers();
}}
/>
<Snackbar
open={snackMsg !== null}
autoHideDuration={5000}
onClose={() => setSnackMsg(null)}
message={snackMsg ?? ''}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
</Container>
);
};

View file

@ -0,0 +1,80 @@
/**
* Shared types for the UserManagement page + its tabs/dialogs.
*
* User has both PG fields (id, plan, credits) and Keycloak-only echo fields
* (keycloakId, syncStatus). syncStatus='keycloak_only' means the user exists
* in Keycloak but hasn't been imported into PG yet — surface as a warning row.
*/
export interface User {
id: number;
email: string;
firstName: string;
lastName: string;
phone: string;
creditsRemained: number;
creditsSpent: number;
storageUsedBytes?: number;
storageLimitBytes?: number;
storagePct?: number;
subscriptionPlanId: number;
subscriptionPlanName: string;
subscriptionStatus: number;
isActive: boolean;
keycloakId: string;
createdAt: string;
emailVerified: boolean;
syncStatus: 'synced' | 'keycloak_only';
roles?: string[];
groups?: string[];
}
export interface SubscriptionPlan {
id: number;
name: string;
description: string;
creditsIncluded: number;
price: number;
}
export interface AuditEntry {
auditId: number;
internetUserId: number | null;
targetEmail: string | null;
targetKeycloakId: string | null;
actorKeycloakId: string | null;
actorEmail: string | null;
action: string;
payload: Record<string, unknown>;
requestIp: string | null;
userAgent: string | null;
createdAt: string;
}
export interface PaginationInfo {
page: number;
limit: number;
total: number;
totalPages: number;
}
export interface AuditFiltersState {
action: string;
actor: string;
since: string;
}
export interface AuditPaginationState {
limit: number;
offset: number;
total: number;
}
export interface PlanFormState {
name: string;
description: string;
creditsIncluded: number;
price: number;
}
export const API_BASE = '/framework';

View file

@ -0,0 +1,317 @@
/**
* Add User dialog — create user in Keycloak + PG from admin.
*
* Supports both realms (didi-clients = end-users, didi-admins = operators).
* - didi-clients: creates Keycloak user + person/internet_user/subscription
* rows in PG with the selected plan + MinIO prefix.
* - didi-admins: only Keycloak side (operator account, no PG profile).
*
* The user is created with a temporary password and the UPDATE_PASSWORD
* required action so they MUST change it at first login.
*/
import React, { useEffect, useState } from 'react';
import {
Alert,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
} from '@mui/material';
import type { SubscriptionPlan } from '../UserManagement.types';
interface Props {
open: boolean;
plans: SubscriptionPlan[];
apiBase: string;
authHeader: Record<string, string>;
onClose: () => void;
onCreated: (info: { email: string; realm: string }) => void;
}
const CLIENT_ROLES = ['viewer', 'analyst', 'api_user'];
const ADMIN_ROLES = ['admin', 'moderator', 'senior_moderator'];
const CLIENT_GROUPS = [
{ name: '(none)', value: '' },
{ name: 'free-users', value: 'free-users' },
{ name: 'paid-users', value: 'paid-users' },
{ name: 'enterprise-users', value: 'enterprise-users' },
];
export const AddUserDialog: React.FC<Props> = (p) => {
const [realm, setRealm] = useState<'didi-clients' | 'didi-admins'>('didi-clients');
const [email, setEmail] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [planId, setPlanId] = useState<number>(1);
const [roles, setRoles] = useState<string[]>([]);
const [groupName, setGroupName] = useState<string>('');
const [forceUpdatePwd, setForceUpdatePwd] = useState(true);
const [emailVerified, setEmailVerified] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset on open
useEffect(() => {
if (p.open) {
setRealm('didi-clients');
setEmail('');
setFirstName('');
setLastName('');
setPassword(genPassword());
setShowPassword(false);
setPlanId(1);
setRoles([]);
setGroupName('');
setForceUpdatePwd(true);
setEmailVerified(true);
setError(null);
}
}, [p.open]);
// When switching realm, reset role + group to safe defaults.
useEffect(() => {
setRoles([]);
setGroupName('');
}, [realm]);
const availableRoles = realm === 'didi-admins' ? ADMIN_ROLES : CLIENT_ROLES;
const isAdmin = realm === 'didi-admins';
function toggleRole(name: string) {
setRoles((cur) => (cur.includes(name) ? cur.filter((r) => r !== name) : [...cur, name]));
}
async function handleSubmit() {
setError(null);
if (!email || !email.includes('@')) {
setError('Invalid email');
return;
}
if (!firstName.trim() || !lastName.trim()) {
setError('First and last name are required');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setSaving(true);
try {
const body: Record<string, unknown> = {
realm,
email: email.trim().toLowerCase(),
firstName: firstName.trim(),
lastName: lastName.trim(),
password,
emailVerified,
requiredActions: forceUpdatePwd ? ['UPDATE_PASSWORD'] : [],
roles,
};
if (!isAdmin) {
body.planId = planId;
if (groupName) body.groupName = groupName;
}
const res = await fetch(`${p.apiBase}/api/admin/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...p.authHeader },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok || data.success === false) {
setError(data.error || `HTTP ${res.status}`);
setSaving(false);
return;
}
p.onCreated({ email: body.email as string, realm });
p.onClose();
} catch (e: any) {
setError(e.message || 'Network error');
} finally {
setSaving(false);
}
}
return (
<Dialog open={p.open} onClose={p.onClose} maxWidth="sm" fullWidth>
<DialogTitle>Create new user</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
{error && <Alert severity="error">{error}</Alert>}
<FormControl size="small" fullWidth>
<InputLabel>Realm</InputLabel>
<Select value={realm} onChange={(e) => setRealm(e.target.value as any)} label="Realm">
<MenuItem value="didi-clients">didi-clients — end user (analyst / viewer)</MenuItem>
<MenuItem value="didi-admins">didi-admins — operator (admin / moderator)</MenuItem>
</Select>
</FormControl>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField
fullWidth
size="small"
label="First name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
<TextField
fullWidth
size="small"
label="Last name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
</Stack>
<TextField
fullWidth
size="small"
type="email"
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
helperText="Will be used as both email and username in Keycloak"
/>
<div>
<Stack direction="row" spacing={1} alignItems="stretch">
<TextField
fullWidth
size="small"
type={showPassword ? 'text' : 'password'}
label="Temporary password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
size="small"
variant="outlined"
onClick={() => setShowPassword(!showPassword)}
sx={{ minWidth: 64, flexShrink: 0 }}
>
{showPassword ? 'Hide' : 'Show'}
</Button>
<Button
size="small"
variant="outlined"
onClick={() => setPassword(genPassword())}
sx={{ minWidth: 80, flexShrink: 0 }}
>
Generate
</Button>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, ml: 1.5 }}>
Min 8 characters. User must change it at first login.
</Typography>
</div>
<FormControlLabel
control={<Checkbox checked={forceUpdatePwd} onChange={(e) => setForceUpdatePwd(e.target.checked)} />}
label="Force password change at first login (UPDATE_PASSWORD)"
/>
<FormControlLabel
control={<Checkbox checked={emailVerified} onChange={(e) => setEmailVerified(e.target.checked)} />}
label="Mark email as verified"
/>
{!isAdmin && (
<>
<FormControl size="small" fullWidth>
<InputLabel>Subscription plan</InputLabel>
<Select value={planId} onChange={(e) => setPlanId(Number(e.target.value))} label="Subscription plan">
{p.plans.map((pl) => (
<MenuItem key={pl.id} value={pl.id}>
{pl.name} ({pl.creditsIncluded ?? '?'} credits)
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" fullWidth>
<InputLabel>Group (optional)</InputLabel>
<Select value={groupName} onChange={(e) => setGroupName(e.target.value)} label="Group (optional)">
{CLIENT_GROUPS.map((g) => (
<MenuItem key={g.value || 'none'} value={g.value}>
{g.name}
</MenuItem>
))}
</Select>
</FormControl>
</>
)}
<div>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Realm roles ({realm})
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{availableRoles.map((r) => (
<FormControlLabel
key={r}
control={<Checkbox size="small" checked={roles.includes(r)} onChange={() => toggleRole(r)} />}
label={r}
/>
))}
</Stack>
</div>
{isAdmin && (
<Alert severity="info">
Operator accounts ({realm}) get only a Keycloak entry — no PG profile, no credits, no plan.
</Alert>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={p.onClose} disabled={saving}>
Cancel
</Button>
<Button variant="contained" onClick={handleSubmit} disabled={saving}>
{saving ? 'Creating…' : 'Create user'}
</Button>
</DialogActions>
</Dialog>
);
};
// Generate a strong random password: 16 chars from mixed alphabet.
function genPassword(): string {
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const lower = 'abcdefghijkmnopqrstuvwxyz';
const digits = '23456789';
const symbols = '!@#$%&*';
const all = upper + lower + digits + symbols;
const out: string[] = [];
// Guarantee at least one of each class.
out.push(upper[Math.floor(Math.random() * upper.length)]);
out.push(lower[Math.floor(Math.random() * lower.length)]);
out.push(digits[Math.floor(Math.random() * digits.length)]);
out.push(symbols[Math.floor(Math.random() * symbols.length)]);
for (let i = 0; i < 12; i++) {
out.push(all[Math.floor(Math.random() * all.length)]);
}
// Shuffle
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out.join('');
}

View file

@ -0,0 +1,38 @@
/**
* Bulk credits dialog — sets the same credits value on N selected users.
* Apply triggers the parent's bulkSetCredits which runs sequentially per user.
*/
import React from 'react';
import { Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField } from '@mui/material';
interface Props {
open: boolean;
selectedCount: number;
bulkCreditsValue: number;
setBulkCreditsValue: (n: number) => void;
onCancel: () => void;
onApply: () => void;
}
export const BulkCreditsDialog: React.FC<Props> = (p) => (
<Dialog open={p.open} onClose={p.onCancel} maxWidth="xs" fullWidth>
<DialogTitle>Set credits for {p.selectedCount} users</DialogTitle>
<DialogContent>
<TextField
type="number"
fullWidth
margin="normal"
label="Credits"
value={p.bulkCreditsValue}
onChange={(e) => p.setBulkCreditsValue(Number(e.target.value))}
inputProps={{ min: 0 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={p.onCancel}>Cancel</Button>
<Button variant="contained" onClick={p.onApply}>
Apply to {p.selectedCount} users
</Button>
</DialogActions>
</Dialog>
);

View file

@ -0,0 +1,32 @@
/**
* Confirmation dialog for hard-delete (Keycloak + PG + MinIO cascade).
* Shown with the trash-icon flow; bulk delete uses window.confirm directly.
*/
import React from 'react';
import { Button, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions } from '@mui/material';
import type { User } from '../UserManagement.types';
interface Props {
open: boolean;
user: User | null;
onCancel: () => void;
onConfirm: () => void;
}
export const DeleteUserDialog: React.FC<Props> = ({ open, user, onCancel, onConfirm }) => (
<Dialog open={open} onClose={onCancel}>
<DialogTitle>Hard-delete user</DialogTitle>
<DialogContent>
<DialogContentText>
This permanently deletes <strong>{user?.email}</strong> from
Keycloak, PostgreSQL, and MinIO. This cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onCancel}>Cancel</Button>
<Button onClick={onConfirm} color="error" variant="contained">
Delete forever
</Button>
</DialogActions>
</Dialog>
);

View file

@ -0,0 +1,2 @@
export { UserManagement } from './UserManagement';
export { UserEditModal } from './UserEditModal';

View file

@ -0,0 +1,161 @@
/**
* AUDIT LOG TAB — paginated audit-trail table for admin actions.
*
* Filters: action (preset list), actor email substring, since (datetime-local).
* Payload column shows truncated JSON with hover tooltip showing pretty-printed
* full payload.
*
* Pagination is offset-based (no "load more" — admins want page jumps).
*/
import React from 'react';
import {
Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
TablePagination, TextField, Button, CircularProgress, FormControl, InputLabel,
Select, MenuItem, Chip, Typography, Stack, Tooltip,
} from '@mui/material';
import { Refresh as RefreshIcon } from '@mui/icons-material';
import type { AuditEntry, AuditFiltersState, AuditPaginationState } from '../UserManagement.types';
import { fmtDate, ACTION_COLORS } from '../UserManagement.helpers';
interface Props {
auditEntries: AuditEntry[];
auditLoading: boolean;
auditFilters: AuditFiltersState;
setAuditFilters: React.Dispatch<React.SetStateAction<AuditFiltersState>>;
auditPagination: AuditPaginationState;
setAuditPagination: React.Dispatch<React.SetStateAction<AuditPaginationState>>;
fetchAudit: () => void;
}
export const AuditLogTab: React.FC<Props> = (p) => {
return (
<>
<Paper sx={{ p: 2, mb: 2 }}>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="center" flexWrap="wrap">
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Action</InputLabel>
<Select
value={p.auditFilters.action}
onChange={(e) => p.setAuditFilters({ ...p.auditFilters, action: e.target.value })}
label="Action"
>
<MenuItem value="">All actions</MenuItem>
<MenuItem value="user.update">Update</MenuItem>
<MenuItem value="user.delete">Delete</MenuItem>
<MenuItem value="user.sync">Sync</MenuItem>
<MenuItem value="user.email_verified">Email verified</MenuItem>
<MenuItem value="user.subscription">Subscription</MenuItem>
<MenuItem value="user.roles">Roles</MenuItem>
<MenuItem value="user.group">Group</MenuItem>
<MenuItem value="user.reset_password">Reset password</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
placeholder="Actor email contains…"
value={p.auditFilters.actor}
onChange={(e) => p.setAuditFilters({ ...p.auditFilters, actor: e.target.value })}
sx={{ minWidth: 220 }}
/>
<TextField
size="small"
type="datetime-local"
label="Since"
InputLabelProps={{ shrink: true }}
value={p.auditFilters.since}
onChange={(e) => p.setAuditFilters({ ...p.auditFilters, since: e.target.value })}
/>
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={p.fetchAudit}>
Refresh
</Button>
</Stack>
</Paper>
<Paper>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ width: 170 }}>When</TableCell>
<TableCell>Action</TableCell>
<TableCell>Target</TableCell>
<TableCell>Actor</TableCell>
<TableCell>Payload</TableCell>
<TableCell sx={{ width: 120 }}>IP</TableCell>
</TableRow>
</TableHead>
<TableBody>
{p.auditLoading ? (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
<CircularProgress />
</TableCell>
</TableRow>
) : p.auditEntries.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary">No audit entries.</Typography>
</TableCell>
</TableRow>
) : (
p.auditEntries.map((e) => (
<TableRow key={e.auditId} hover>
<TableCell>
<Typography variant="caption">{fmtDate(e.createdAt)}</Typography>
</TableCell>
<TableCell>
<Chip
label={e.action}
size="small"
color={ACTION_COLORS[e.action] ?? 'default'}
sx={{ fontFamily: 'monospace', fontSize: 10 }}
/>
</TableCell>
<TableCell>
{e.targetEmail ?? (e.internetUserId ? `id=${e.internetUserId}` : '—')}
{e.targetKeycloakId && (
<Typography variant="caption" display="block" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
{e.targetKeycloakId.slice(0, 8)}…
</Typography>
)}
</TableCell>
<TableCell>{e.actorEmail ?? <Typography variant="caption" color="text.disabled">staging</Typography>}</TableCell>
<TableCell>
<Tooltip title={<pre style={{ margin: 0 }}>{JSON.stringify(e.payload, null, 2)}</pre>}>
<Typography
variant="caption"
sx={{ fontFamily: 'monospace', cursor: 'help' }}
>
{JSON.stringify(e.payload).slice(0, 80)}
{JSON.stringify(e.payload).length > 80 ? '…' : ''}
</Typography>
</Tooltip>
</TableCell>
<TableCell>
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
{e.requestIp ?? '—'}
</Typography>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={p.auditPagination.total}
page={p.auditPagination.offset / p.auditPagination.limit}
onPageChange={(_, np) => p.setAuditPagination((prev) => ({ ...prev, offset: np * prev.limit }))}
rowsPerPage={p.auditPagination.limit}
onRowsPerPageChange={(e) => p.setAuditPagination((prev) => ({
...prev,
limit: parseInt(e.target.value, 10),
offset: 0,
}))}
rowsPerPageOptions={[25, 50, 100, 200]}
/>
</Paper>
</>
);
};

View file

@ -0,0 +1,117 @@
/**
* PLANS TAB — subscription plans table with inline edit.
*
* editingPlan === plan.id puts that row into edit mode (each cell becomes a
* TextField). Save commits via PUT /api/admin/plans/:id; cancel just clears
* editingPlan. Other rows stay read-only during edit.
*/
import React from 'react';
import {
Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
TextField, IconButton, Button, Typography, Stack,
} from '@mui/material';
import { Edit as EditIcon, Save as SaveIcon } from '@mui/icons-material';
import type { SubscriptionPlan, PlanFormState } from '../UserManagement.types';
interface Props {
plans: SubscriptionPlan[];
editingPlan: SubscriptionPlan | null;
setEditingPlan: (p: SubscriptionPlan | null) => void;
planForm: PlanFormState;
setPlanForm: React.Dispatch<React.SetStateAction<PlanFormState>>;
savingPlan: boolean;
handleEditPlan: (p: SubscriptionPlan) => void;
handleSavePlan: () => void;
}
export const PlansTab: React.FC<Props> = (p) => {
return (
<Paper sx={{ p: 2 }}>
<Typography variant="h6" sx={{ mb: 2 }}>Subscription Plans</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Name</TableCell>
<TableCell>Description</TableCell>
<TableCell align="right">Credits</TableCell>
<TableCell align="right">Price</TableCell>
<TableCell align="center">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{p.plans.map((plan) => (
<TableRow key={plan.id} hover>
<TableCell>{plan.id}</TableCell>
<TableCell>
{p.editingPlan?.id === plan.id ? (
<TextField
size="small"
value={p.planForm.name}
onChange={(e) => p.setPlanForm({ ...p.planForm, name: e.target.value })}
/>
) : (
plan.name
)}
</TableCell>
<TableCell>
{p.editingPlan?.id === plan.id ? (
<TextField
size="small"
fullWidth
value={p.planForm.description}
onChange={(e) => p.setPlanForm({ ...p.planForm, description: e.target.value })}
/>
) : (
plan.description || '—'
)}
</TableCell>
<TableCell align="right">
{p.editingPlan?.id === plan.id ? (
<TextField
size="small"
type="number"
sx={{ width: 100 }}
value={p.planForm.creditsIncluded}
onChange={(e) => p.setPlanForm({ ...p.planForm, creditsIncluded: Number(e.target.value) })}
/>
) : (
plan.creditsIncluded
)}
</TableCell>
<TableCell align="right">
{p.editingPlan?.id === plan.id ? (
<TextField
size="small"
type="number"
sx={{ width: 100 }}
value={p.planForm.price}
onChange={(e) => p.setPlanForm({ ...p.planForm, price: Number(e.target.value) })}
/>
) : (
plan.price
)}
</TableCell>
<TableCell align="center">
{p.editingPlan?.id === plan.id ? (
<Stack direction="row" spacing={0.5} justifyContent="center">
<IconButton size="small" onClick={p.handleSavePlan} disabled={p.savingPlan}>
<SaveIcon fontSize="small" />
</IconButton>
<Button size="small" onClick={() => p.setEditingPlan(null)}>Cancel</Button>
</Stack>
) : (
<IconButton size="small" onClick={() => p.handleEditPlan(plan)} title="Edit plan">
<EditIcon fontSize="small" />
</IconButton>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
);
};

View file

@ -0,0 +1,378 @@
/**
* USERS TAB — filters + bulk action toolbar + users table.
*
* Receives all state + handlers from parent (UserManagement). Two visual modes:
* - normal row: hover, edit/roles/usage/delete actions
* - keycloak_only row: highlighted yellow + Sync button (no edit/delete
* until the user is imported into PG)
*
* Bulk toolbar appears only when selectedIds.size > 0; uses sequential-bulk
* runner from parent so audit log entries stay cleanly ordered.
*/
import React from 'react';
import {
Box, Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
TablePagination, TextField, InputAdornment, IconButton, Chip, Button,
CircularProgress, FormControl, InputLabel, Select, MenuItem, Divider, Menu,
Switch, Tooltip, Typography, Checkbox, LinearProgress, Stack, Toolbar,
} from '@mui/material';
import {
Search as SearchIcon, Edit as EditIcon, Delete as DeleteIcon,
Refresh as RefreshIcon, Sync as SyncIcon, Warning as WarningIcon,
AdminPanelSettings as RolesIcon, History as HistoryIcon,
MoreVert as MoreVertIcon,
} from '@mui/icons-material';
import type { User, SubscriptionPlan, PaginationInfo } from '../UserManagement.types';
import { fmtBytes } from '../UserManagement.helpers';
interface Props {
users: User[];
pagedUsers: User[];
plans: SubscriptionPlan[];
loading: boolean;
pagination: PaginationInfo;
searchQuery: string;
setSearchQuery: (s: string) => void;
planFilter: string;
setPlanFilter: (s: string) => void;
syncFilter: 'all' | 'synced' | 'keycloak_only';
setSyncFilter: (s: 'all' | 'synced' | 'keycloak_only') => void;
fetchUsers: () => void;
selectedIds: Set<number>;
setSelectedIds: React.Dispatch<React.SetStateAction<Set<number>>>;
allOnPageSelected: boolean;
someOnPageSelected: boolean;
toggleSelectOne: (id: number) => void;
toggleSelectAllOnPage: () => void;
bulkMenuAnchor: HTMLElement | null;
setBulkMenuAnchor: (el: HTMLElement | null) => void;
bulkActivate: (active: boolean) => void;
bulkEmailVerify: () => void;
bulkDelete: () => void;
setBulkCreditsDialog: (open: boolean) => void;
handlePageChange: (e: unknown, p: number) => void;
handleRowsPerPageChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleEditUser: (u: User) => void;
handleDeleteClick: (u: User) => void;
handleEmailVerifiedToggle: (u: User) => void;
handleSyncUser: (u: User) => void;
setRolesUser: (u: User | null) => void;
setUsageUser: (u: User | null) => void;
onOpenAddUser: () => void;
}
export const UsersTab: React.FC<Props> = (p) => {
void p.users;
return (
<>
<Paper sx={{ p: 2, mb: 2 }}>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="center" flexWrap="wrap">
<TextField
placeholder="Search by email or name..."
value={p.searchQuery}
onChange={(e) => p.setSearchQuery(e.target.value)}
size="small"
sx={{ minWidth: 250 }}
InputProps={{
startAdornment: <InputAdornment position="start"><SearchIcon /></InputAdornment>,
}}
/>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Plan</InputLabel>
<Select value={p.planFilter} onChange={(e) => p.setPlanFilter(e.target.value)} label="Plan">
<MenuItem value="">All Plans</MenuItem>
{p.plans.map((plan) => (
<MenuItem key={plan.id} value={plan.id.toString()}>{plan.name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 170 }}>
<InputLabel>Sync</InputLabel>
<Select
value={p.syncFilter}
onChange={(e) => p.setSyncFilter(e.target.value as any)}
label="Sync"
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="synced">Synced</MenuItem>
<MenuItem value="keycloak_only">Keycloak only</MenuItem>
</Select>
</FormControl>
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={p.fetchUsers}>
Refresh
</Button>
<Box sx={{ flex: 1 }} />
<Button variant="contained" color="primary" onClick={p.onOpenAddUser}>
+ Add user
</Button>
</Stack>
</Paper>
{/* Bulk action toolbar — only visible when items are selected */}
{p.selectedIds.size > 0 && (
<Paper sx={{ p: 1, mb: 2, bgcolor: 'action.selected' }}>
<Toolbar variant="dense" sx={{ minHeight: 48, gap: 1 }}>
<Typography variant="subtitle2" sx={{ flex: 1 }}>
{p.selectedIds.size} selected
</Typography>
<Button
size="small"
variant="contained"
startIcon={<MoreVertIcon />}
onClick={(e) => p.setBulkMenuAnchor(e.currentTarget)}
>
Bulk actions
</Button>
<Menu
anchorEl={p.bulkMenuAnchor}
open={p.bulkMenuAnchor !== null}
onClose={() => p.setBulkMenuAnchor(null)}
>
<MenuItem onClick={() => p.bulkActivate(true)}>Activate</MenuItem>
<MenuItem onClick={() => p.bulkActivate(false)}>Deactivate</MenuItem>
<MenuItem onClick={() => p.bulkEmailVerify()}>Mark email verified</MenuItem>
<MenuItem onClick={() => { p.setBulkMenuAnchor(null); p.setBulkCreditsDialog(true); }}>
Set credits to…
</MenuItem>
<Divider />
<MenuItem
onClick={() => {
if (window.confirm(`Hard-delete ${p.selectedIds.size} users (Keycloak + PG + MinIO)?`)) {
p.bulkDelete();
} else {
p.setBulkMenuAnchor(null);
}
}}
sx={{ color: 'error.main' }}
>
Delete (hard) ⚠
</MenuItem>
</Menu>
<Button size="small" onClick={() => p.setSelectedIds(new Set())}>
Clear selection
</Button>
</Toolbar>
</Paper>
)}
<Paper>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
size="small"
indeterminate={p.someOnPageSelected && !p.allOnPageSelected}
checked={p.allOnPageSelected}
onChange={p.toggleSelectAllOnPage}
/>
</TableCell>
<TableCell>Email</TableCell>
<TableCell>Name</TableCell>
<TableCell>Sync</TableCell>
<TableCell>Plan</TableCell>
<TableCell>Group</TableCell>
<TableCell>Roles</TableCell>
<TableCell align="center">Verified</TableCell>
<TableCell>Active</TableCell>
<TableCell align="right">Credits</TableCell>
<TableCell sx={{ minWidth: 120 }}>Storage</TableCell>
<TableCell align="center">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{p.loading ? (
<TableRow>
<TableCell colSpan={12} align="center" sx={{ py: 4 }}>
<CircularProgress />
</TableCell>
</TableRow>
) : p.pagedUsers.length === 0 ? (
<TableRow>
<TableCell colSpan={12} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary">No users found</Typography>
</TableCell>
</TableRow>
) : (
p.pagedUsers.map((user) => {
const isKcOnly = user.syncStatus === 'keycloak_only';
const isSelected = user.id !== 0 && p.selectedIds.has(user.id);
const roles = user.roles ?? [];
const groupName = (user.groups ?? [])[0];
const storagePct = (user.storagePct ?? 0) * 100;
return (
<TableRow
key={user.keycloakId || user.id}
hover
selected={isSelected}
sx={isKcOnly ? { bgcolor: 'warning.light' } : {}}
>
<TableCell padding="checkbox">
<Checkbox
size="small"
checked={isSelected}
onChange={() => p.toggleSelectOne(user.id)}
disabled={user.id === 0}
/>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{user.email}
</Typography>
{user.keycloakId && (
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
{user.keycloakId.slice(0, 8)}…
</Typography>
)}
</TableCell>
<TableCell>{user.firstName} {user.lastName}</TableCell>
<TableCell>
{isKcOnly ? (
<Chip icon={<WarningIcon />} label="KC only" size="small" color="warning" />
) : (
<Chip label="Synced" size="small" color="success" />
)}
</TableCell>
<TableCell>
<Chip
label={user.subscriptionPlanName || 'N/A'}
size="small"
color={
user.subscriptionPlanName?.includes('Enterprise') ? 'primary'
: user.subscriptionPlanName?.includes('Pro') ? 'secondary'
: 'default'
}
/>
</TableCell>
<TableCell>
{groupName ? (
<Chip label={groupName} size="small" variant="outlined" />
) : (
<Typography variant="caption" color="text.disabled">—</Typography>
)}
</TableCell>
<TableCell sx={{ maxWidth: 200 }}>
{roles.length === 0 ? (
<Typography variant="caption" color="text.disabled">—</Typography>
) : (
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{roles.slice(0, 3).map((r) => (
<Chip
key={r}
label={r}
size="small"
sx={{ fontSize: 10, height: 20 }}
/>
))}
{roles.length > 3 && (
<Tooltip title={roles.slice(3).join(', ')}>
<Chip label={`+${roles.length - 3}`} size="small" sx={{ fontSize: 10, height: 20 }} />
</Tooltip>
)}
</Stack>
)}
</TableCell>
<TableCell align="center">
<Tooltip title={user.emailVerified ? 'Click to unverify' : 'Click to verify'}>
<Switch
checked={user.emailVerified}
onChange={() => p.handleEmailVerifiedToggle(user)}
color="success"
size="small"
/>
</Tooltip>
</TableCell>
<TableCell>
<Chip
label={user.isActive ? 'Active' : 'Inactive'}
size="small"
color={user.isActive ? 'success' : 'error'}
/>
</TableCell>
<TableCell align="right">
<Tooltip title={`Spent: ${user.creditsSpent}`}>
<Typography variant="body2">{user.creditsRemained}</Typography>
</Tooltip>
</TableCell>
<TableCell>
{isKcOnly || !user.storageLimitBytes ? (
<Typography variant="caption" color="text.disabled">—</Typography>
) : (
<Tooltip
title={`${fmtBytes(user.storageUsedBytes)} of ${fmtBytes(user.storageLimitBytes)}`}
>
<Box sx={{ minWidth: 110 }}>
<LinearProgress
variant="determinate"
value={Math.min(100, storagePct)}
color={storagePct > 90 ? 'error' : storagePct > 70 ? 'warning' : 'primary'}
sx={{ height: 6, borderRadius: 3, mb: 0.3 }}
/>
<Typography variant="caption" color="text.secondary">
{storagePct.toFixed(0)}% · {fmtBytes(user.storageUsedBytes)}
</Typography>
</Box>
</Tooltip>
)}
</TableCell>
<TableCell align="center">
{isKcOnly ? (
<Tooltip title="Sync to PostgreSQL (Free plan + 100 credits)">
<IconButton size="small" onClick={() => p.handleSyncUser(user)} color="warning">
<SyncIcon />
</IconButton>
</Tooltip>
) : (
<Stack direction="row" spacing={0.25}>
<Tooltip title="Edit user">
<IconButton size="small" onClick={() => p.handleEditUser(user)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Roles">
<IconButton
size="small"
onClick={() => p.setRolesUser(user)}
disabled={!user.keycloakId}
>
<RolesIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Usage history">
<IconButton size="small" onClick={() => p.setUsageUser(user)}>
<HistoryIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete (hard)">
<IconButton
size="small"
onClick={() => p.handleDeleteClick(user)}
color="error"
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={p.pagination.total}
page={p.pagination.page - 1}
onPageChange={p.handlePageChange}
rowsPerPage={p.pagination.limit}
onRowsPerPageChange={p.handleRowsPerPageChange}
rowsPerPageOptions={[10, 20, 50, 100]}
/>
</Paper>
</>
);
};

View file

@ -0,0 +1,36 @@
import React, { useEffect } from 'react';
import { Box, CircularProgress, Typography } from '@mui/material';
import { useAuth } from '../../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
export const LoginRedirect: React.FC = () => {
const { isAuthenticated, login, initialized } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (initialized) {
if (!isAuthenticated) {
login();
} else {
navigate('/');
}
}
}, [isAuthenticated, login, navigate, initialized]);
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
}}
>
<CircularProgress size={60} />
<Typography variant="h6" sx={{ mt: 2 }}>
Redirecting to login...
</Typography>
</Box>
);
};

View file

@ -0,0 +1,52 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { CircularProgress, Box } from '@mui/material';
interface ProtectedRouteProps {
children: React.ReactNode;
/** Single role gate (kept for back-compat with existing usage). */
requiredRole?: string;
/** Any-of role gate — user passes if they have at least one of these roles. */
requiredAnyRole?: string[];
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
requiredRole,
requiredAnyRole,
}) => {
const { isAuthenticated, hasRole, initialized } = useAuth();
if (!initialized) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
}}
>
<CircularProgress />
</Box>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (requiredRole && !hasRole(requiredRole)) {
return <Navigate to="/unauthorized" replace />;
}
if (requiredAnyRole && requiredAnyRole.length > 0) {
const allowed = requiredAnyRole.some((r) => hasRole(r));
if (!allowed) {
return <Navigate to="/unauthorized" replace />;
}
}
return <>{children}</>;
};

View file

@ -0,0 +1,51 @@
import React from 'react';
import { Box, Typography, Button, Paper } from '@mui/material';
import { Block as BlockIcon } from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
export const Unauthorized: React.FC = () => {
const { user, logout } = useAuth();
const userEmail = (user as { email?: string } | undefined)?.email ?? 'unknown';
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
bgcolor: 'background.default',
p: 3,
}}
>
<Paper sx={{ p: 6, maxWidth: 520, textAlign: 'center' }} elevation={3}>
<BlockIcon sx={{ fontSize: 72, color: 'error.main', mb: 2 }} />
<Typography variant="h4" gutterBottom>403 — Access Denied</Typography>
<Typography variant="body1" color="text.secondary" mb={1}>
Your account does not have permission to access the DIDI admin dashboard.
</Typography>
<Typography variant="body2" color="text.secondary" mb={3}>
Logged in as <strong>{userEmail}</strong>. Required roles:
<Box component="span" sx={{ mx: 0.5, fontFamily: 'monospace' }}>admin</Box>
/
<Box component="span" sx={{ mx: 0.5, fontFamily: 'monospace' }}>moderator</Box>
/
<Box component="span" sx={{ mx: 0.5, fontFamily: 'monospace' }}>senior_moderator</Box>.
</Typography>
<Typography variant="caption" color="text.secondary" display="block" mb={3}>
End users (clients) should use the public web app, not the admin dashboard.
</Typography>
<Box display="flex" gap={2} justifyContent="center">
<Button variant="outlined" onClick={() => (window.location.href = '/')}>
Go to Public App
</Button>
<Button variant="contained" color="error" onClick={() => logout()}>
Log out
</Button>
</Box>
</Paper>
</Box>
);
};
export default Unauthorized;

View file

@ -0,0 +1,162 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Container,
Typography,
Box,
CircularProgress,
Alert,
Paper,
Divider,
Chip,
useTheme,
useMediaQuery,
} from '@mui/material';
import {
Storage as StorageIcon,
Security as SecurityIcon,
Analytics as AnalyticsIcon,
DataObject as DatabaseIcon,
Speed as MonitoringIcon,
} from '@mui/icons-material';
import { ServiceCard } from '../services/ServiceCard';
import { services, checkServiceHealth, ServiceStatus } from '../../services/api';
import { serviceGroups } from '../../config/serviceGroups';
import { useNavigate, useSearchParams } from 'react-router-dom';
export const ServicesDashboard: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>(services);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isInitialMount = useRef(true);
const theme = useTheme();
const isSmallMobile = useMediaQuery(theme.breakpoints.down('sm'));
const groupFilter = searchParams.get('group');
const checkAllServices = useCallback(async () => {
setLoading(true);
try {
const results = await Promise.all(
services.map(service => checkServiceHealth(service))
);
setServiceStatuses(results);
setError(null);
} catch {
setError('Failed to check services');
} finally {
setLoading(false);
isInitialMount.current = false;
}
}, []);
useEffect(() => {
if (isInitialMount.current) {
checkAllServices();
}
const interval = setInterval(checkAllServices, 30000);
return () => clearInterval(interval);
}, [checkAllServices]);
const handleRefresh = async (serviceName: string) => {
const service = serviceStatuses.find(s => s.name === serviceName);
if (service) {
const updated = await checkServiceHealth(service);
setServiceStatuses(prev =>
prev.map(s => s.name === serviceName ? updated : s)
);
}
};
const handleAccess = (service: ServiceStatus) => {
if (service.name === 'didi-framework') {
navigate('/framework');
} else if (service.name === 'didi-agent-v3') {
navigate('/llm-components');
} else if (service.uiUrl) {
window.open(service.uiUrl, '_blank');
}
};
if (loading && serviceStatuses.every(s => s.status === 'unknown')) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
<CircularProgress />
</Box>
);
}
const getGroupIcon = (icon: string) => {
switch (icon) {
case 'database': return <DatabaseIcon />;
case 'storage': return <StorageIcon />;
case 'security': return <SecurityIcon />;
case 'monitoring': return <MonitoringIcon />;
case 'analytics': return <AnalyticsIcon />;
default: return null;
}
};
return (
<Container maxWidth="xl" sx={{ py: { xs: 2, md: 4 }, px: { xs: 2, sm: 3 } }}>
<Typography variant="h4" gutterBottom>
Service Monitor
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
{groupFilter
? `Viewing: ${serviceGroups.find(g => g.id === groupFilter)?.title || 'Unknown'}`
: 'Monitor and access all backend services'
}
</Typography>
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
{serviceGroups
.filter(group => !groupFilter || group.id === groupFilter)
.map((group) => {
const groupServices = serviceStatuses.filter(service =>
group.services.includes(service.name)
);
if (groupServices.length === 0) return null;
return (
<Paper key={group.id} elevation={1} sx={{ mb: { xs: 3, md: 4 }, p: { xs: 2, md: 3 }, borderRadius: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1, flexWrap: 'wrap' }}>
<Box sx={{ color: group.color, display: { xs: 'none', sm: 'block' } }}>
{getGroupIcon(group.icon)}
</Box>
<Typography variant={isSmallMobile ? "h6" : "h5"} sx={{ fontWeight: 600 }}>
{group.title}
</Typography>
<Chip label={`${groupServices.length} services`} size="small" sx={{ ml: { xs: 0, sm: 'auto' } }} />
</Box>
<Typography variant="body2" color="textSecondary" sx={{ mb: 3, display: { xs: 'none', md: 'block' } }}>
{group.description}
</Typography>
<Divider sx={{ mb: { xs: 2, md: 3 } }} />
<Box sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: 'repeat(auto-fill, minmax(280px, 1fr))',
},
gap: { xs: 1.5, md: 2 },
}}>
{groupServices.map((service) => (
<ServiceCard
key={service.name}
service={service}
onRefresh={() => handleRefresh(service.name)}
onAccess={() => handleAccess(service)}
/>
))}
</Box>
</Paper>
);
})}
</Container>
);
};

View file

@ -0,0 +1,299 @@
import React, { useState, useCallback, useMemo } from 'react';
import ReactFlow, {
Node,
Edge,
Controls,
Background,
MiniMap,
useNodesState,
useEdgesState,
Position,
MarkerType,
} from 'reactflow';
import 'reactflow/dist/style.css';
import {
Box,
Drawer,
Typography,
IconButton,
CircularProgress,
Alert,
Paper,
} from '@mui/material';
import { Close as CloseIcon } from '@mui/icons-material';
import { useFrameworkData } from './context/FrameworkDataContext';
import StepNode from './nodes/StepNode';
import IntakeDetailPanel from './panels/IntakeDetailPanel';
import TechniqueDetailPanel from './panels/TechniqueDetailPanel';
import SourceDetailPanel from './panels/SourceDetailPanel';
import ClaimsDetailPanel from './panels/ClaimsDetailPanel';
import VerdictDetailPanel from './panels/VerdictDetailPanel';
import { AnalysisStep, StepId, StepNodeData } from './types';
// Definim tipurile de noduri custom
const nodeTypes = {
stepNode: StepNode,
};
// Pașii de analiză
const analysisSteps: AnalysisStep[] = [
{
id: 'intake',
title: 'Content Intake',
description: 'Receive input, preprocess, detect type & language',
status: 'completed',
},
{
id: 'techniques',
title: 'Technique Detection',
description: 'Analyze D1, D2, D3, D5, D7, D8 dimensions',
status: 'completed',
},
{
id: 'sources',
title: 'Source Assessment',
description: 'Platform detection, source credibility scoring',
status: 'active',
},
{
id: 'claims',
title: 'Claims Extraction',
description: 'Extract verifiable claims, prioritize',
status: 'pending',
},
{
id: 'verdict',
title: 'Verdict & Risk',
description: 'Aggregate scores, apply multipliers, final verdict',
status: 'pending',
},
];
// Poziții pentru noduri (layout scară - diagonal de la stânga-sus la dreapta-jos)
const nodePositions: Record<StepId, { x: number; y: number }> = {
intake: { x: 50, y: 0 },
techniques: { x: 200, y: 140 },
sources: { x: 350, y: 280 },
claims: { x: 500, y: 420 },
verdict: { x: 650, y: 560 },
};
interface AnalysisFlowVisualizationProps {
// Opțional: date de la o analiză în curs
analysisId?: string;
onStepClick?: (stepId: StepId) => void;
}
const AnalysisFlowVisualization: React.FC<AnalysisFlowVisualizationProps> = ({
analysisId,
onStepClick,
}) => {
const { loading, error, dimensions, techniques, verdictCategories } = useFrameworkData();
const [selectedStep, setSelectedStep] = useState<StepId | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
// Crează nodurile pentru ReactFlow - fără date hardcodate
const initialNodes: Node<StepNodeData>[] = useMemo(() => {
return analysisSteps.map(step => ({
id: step.id,
type: 'stepNode',
position: nodePositions[step.id],
data: {
step,
// Scorurile vin de la o analiză reală, nu hardcodate
score: undefined,
details: step.id === 'techniques'
? {
dimensions: dimensions.length,
techniques: techniques.length,
}
: undefined,
onExpand: () => handleNodeExpand(step.id),
},
sourcePosition: Position.Right,
targetPosition: Position.Left,
}));
}, [dimensions.length, techniques.length]);
// Crează edge-urile (conexiunile între noduri) - stil smoothstep pentru layout diagonal
const initialEdges: Edge[] = useMemo(() => [
{
id: 'e-intake-techniques',
source: 'intake',
target: 'techniques',
type: 'smoothstep',
animated: true,
style: { stroke: '#4caf50', strokeWidth: 3 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#4caf50', width: 20, height: 20 },
},
{
id: 'e-techniques-sources',
source: 'techniques',
target: 'sources',
type: 'smoothstep',
animated: true,
style: { stroke: '#4caf50', strokeWidth: 3 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#4caf50', width: 20, height: 20 },
},
{
id: 'e-sources-claims',
source: 'sources',
target: 'claims',
type: 'smoothstep',
animated: true,
style: { stroke: '#1976d2', strokeWidth: 3 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#1976d2', width: 20, height: 20 },
},
{
id: 'e-claims-verdict',
source: 'claims',
target: 'verdict',
type: 'smoothstep',
style: { stroke: '#9e9e9e', strokeWidth: 3, strokeDasharray: '8,4' },
markerEnd: { type: MarkerType.ArrowClosed, color: '#9e9e9e', width: 20, height: 20 },
},
], []);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const handleNodeExpand = useCallback((stepId: StepId) => {
setSelectedStep(stepId);
setDrawerOpen(true);
if (onStepClick) {
onStepClick(stepId);
}
}, [onStepClick]);
const handleNodeClick = useCallback((event: React.MouseEvent, node: Node) => {
handleNodeExpand(node.id as StepId);
}, [handleNodeExpand]);
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight={400}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>Loading framework data...</Typography>
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ m: 2 }}>
Error loading framework data: {error}
</Alert>
);
}
return (
<Box sx={{ height: '100%', minHeight: 800 }}>
{/* Header cu statistici */}
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6" gutterBottom>
Analysis Flow Visualization
</Typography>
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
<Typography variant="body2" color="text.secondary">
<strong>{dimensions.length}</strong> Dimensions loaded
</Typography>
<Typography variant="body2" color="text.secondary">
<strong>{techniques.length}</strong> Techniques available
</Typography>
<Typography variant="body2" color="text.secondary">
<strong>{verdictCategories.length}</strong> Verdict categories
</Typography>
<Typography variant="body2" color="text.secondary">
Data source: <code>bos_parammgmt</code> (DB)
</Typography>
</Box>
</Paper>
{/* ReactFlow Canvas */}
<Box sx={{ height: 700, border: '1px solid #e0e0e0', borderRadius: 2, bgcolor: '#fafafa' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick}
nodeTypes={nodeTypes}
fitView
attributionPosition="bottom-left"
>
<Controls />
<MiniMap
nodeColor={(node) => {
const status = (node.data as StepNodeData)?.step?.status;
if (status === 'completed') return '#4caf50';
if (status === 'active') return '#1976d2';
return '#9e9e9e';
}}
/>
<Background color="#f5f5f5" gap={16} />
</ReactFlow>
</Box>
{/* Legendă */}
<Paper sx={{ p: 1.5, mt: 2 }}>
<Box sx={{ display: 'flex', gap: 3, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="caption" fontWeight="bold">Legend:</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, bgcolor: '#4caf50', borderRadius: '50%' }} />
<Typography variant="caption">Completed</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, bgcolor: '#1976d2', borderRadius: '50%' }} />
<Typography variant="caption">Active</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, bgcolor: '#9e9e9e', borderRadius: '50%' }} />
<Typography variant="caption">Pending</Typography>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
Click on a node to see details
</Typography>
</Box>
</Paper>
{/* Drawer pentru detalii */}
<Drawer
anchor="right"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
PaperProps={{
sx: { width: { xs: '100%', sm: 600, md: 700 } },
}}
>
<Box sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">
{selectedStep ? analysisSteps.find(s => s.id === selectedStep)?.title : 'Details'}
</Typography>
<IconButton onClick={() => setDrawerOpen(false)}>
<CloseIcon />
</IconButton>
</Box>
{/* Afișează panoul corect în funcție de pasul selectat */}
{selectedStep === 'intake' && <IntakeDetailPanel />}
{selectedStep === 'techniques' && (
<TechniqueDetailPanel />
)}
{selectedStep === 'sources' && <SourceDetailPanel />}
{selectedStep === 'claims' && <ClaimsDetailPanel />}
{selectedStep === 'verdict' && (
<VerdictDetailPanel />
)}
</Box>
</Drawer>
</Box>
);
};
export default AnalysisFlowVisualization;

View file

@ -0,0 +1,296 @@
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import {
FrameworkData,
Dimension,
Subdimension,
Technique,
TechniqueIndicator,
TechniqueValidationRule,
VerdictCategory,
RiskMapping,
Platform,
PlatformModifier,
SourceType,
SourceCredibility,
DomainAgeScore,
DomainRiskLevel,
DomainRedFlag,
AuthorClassification,
AuthorCredibility,
ClaimStatus,
ClaimType,
ConfidenceLevel,
Interpretation,
ComponentWeight,
WeightScenario,
Multiplier,
SeverityAssessment,
} from '../types';
const FRAMEWORK_API_URL = '/framework';
interface FrameworkDataContextType extends FrameworkData {
refresh: () => Promise<void>;
getTechniquesByDimension: (dimensionId: number) => Technique[];
getIndicatorsByTechnique: (techniqueId: number) => TechniqueIndicator[];
getRulesByTechnique: (techniqueId: number) => TechniqueValidationRule[];
getVerdictByScore: (score: number) => VerdictCategory | undefined;
getRiskByScore: (score: number) => RiskMapping | undefined;
}
const initialState: FrameworkData = {
dimensions: [],
subdimensions: [],
techniques: [],
indicators: [],
validationRules: [],
verdictCategories: [],
riskMappings: [],
// Source Assessment - complete hierarchy
platforms: [],
platformModifiers: [],
sourceTypes: [],
sourceCredibility: [],
domainAgeScores: [],
domainRiskLevels: [],
domainRedFlags: [],
authorClassifications: [],
authorCredibility: [],
// Claims Analysis - complete hierarchy
claimStatus: [],
claimTypes: [],
confidenceLevels: [],
interpretations: [],
// Weights & multipliers
componentWeights: [],
weightScenarios: [],
multipliers: [],
// Severity Assessment
severityAssessments: [],
loading: true,
error: null,
};
const FrameworkDataContext = createContext<FrameworkDataContextType | null>(null);
export const useFrameworkData = (): FrameworkDataContextType => {
const context = useContext(FrameworkDataContext);
if (!context) {
throw new Error('useFrameworkData must be used within a FrameworkDataProvider');
}
return context;
};
interface ProviderProps {
children: ReactNode;
}
export const FrameworkDataProvider: React.FC<ProviderProps> = ({ children }) => {
const [data, setData] = useState<FrameworkData>(initialState);
const fetchData = useCallback(async () => {
setData(prev => ({ ...prev, loading: true, error: null }));
try {
// Fetch all data in parallel - including Source Assessment and Claims Analysis
const [
dimensionsRes,
subdimensionsRes,
techniquesRes,
indicatorsRes,
validationRulesRes,
verdictsRes,
riskRes,
platformsRes,
platformModifiersRes,
sourcesRes,
sourceCredibilityRes,
domainAgeRes,
domainRiskRes,
domainRedFlagsRes,
authorClassRes,
authorCredRes,
claimStatusRes,
claimTypesRes,
confidenceRes,
interpretationRes,
weightsComponentsRes,
weightsScenariosRes,
multipliersRes,
severityRes,
] = await Promise.all([
fetch(`${FRAMEWORK_API_URL}/api/dimensions`),
fetch(`${FRAMEWORK_API_URL}/api/subdimensions`),
fetch(`${FRAMEWORK_API_URL}/api/techniques`),
fetch(`${FRAMEWORK_API_URL}/api/indicators`),
fetch(`${FRAMEWORK_API_URL}/api/validation-rules`),
fetch(`${FRAMEWORK_API_URL}/api/verdicts/categories`),
fetch(`${FRAMEWORK_API_URL}/api/verdicts/risk`),
// Source Assessment - Platform section
fetch(`${FRAMEWORK_API_URL}/api/platforms`),
fetch(`${FRAMEWORK_API_URL}/api/platform-modifiers`),
// Source Assessment - Publication section
fetch(`${FRAMEWORK_API_URL}/api/sources`),
fetch(`${FRAMEWORK_API_URL}/api/source-credibility`),
// Source Assessment - Domain section
fetch(`${FRAMEWORK_API_URL}/api/domain-age-scores`),
fetch(`${FRAMEWORK_API_URL}/api/domain-risk-levels`),
fetch(`${FRAMEWORK_API_URL}/api/domain-red-flags`),
// Source Assessment - Author section
fetch(`${FRAMEWORK_API_URL}/api/author-classifications`),
fetch(`${FRAMEWORK_API_URL}/api/author-credibility`),
// Claims Analysis
fetch(`${FRAMEWORK_API_URL}/api/claims/status`),
fetch(`${FRAMEWORK_API_URL}/api/claims/types`),
fetch(`${FRAMEWORK_API_URL}/api/claims/confidence`),
fetch(`${FRAMEWORK_API_URL}/api/claims/interpretation`),
// Weights & multipliers
fetch(`${FRAMEWORK_API_URL}/api/weights/components`),
fetch(`${FRAMEWORK_API_URL}/api/weights/scenarios`),
fetch(`${FRAMEWORK_API_URL}/api/weights/multipliers`),
// Severity Assessment
fetch(`${FRAMEWORK_API_URL}/api/verdicts/severity`),
]);
const [
dimensionsData,
subdimensionsData,
techniquesData,
indicatorsData,
validationRulesData,
verdictsData,
riskData,
platformsData,
platformModifiersData,
sourcesData,
sourceCredibilityData,
domainAgeData,
domainRiskData,
domainRedFlagsData,
authorClassData,
authorCredData,
claimStatusData,
claimTypesData,
confidenceData,
interpretationData,
weightsComponentsData,
weightsScenariosData,
multipliersData,
severityData,
] = await Promise.all([
dimensionsRes.json(),
subdimensionsRes.json(),
techniquesRes.json(),
indicatorsRes.json(),
validationRulesRes.json(),
verdictsRes.json(),
riskRes.json(),
platformsRes.json(),
platformModifiersRes.json(),
sourcesRes.json(),
sourceCredibilityRes.json(),
domainAgeRes.json(),
domainRiskRes.json(),
domainRedFlagsRes.json(),
authorClassRes.json(),
authorCredRes.json(),
claimStatusRes.json(),
claimTypesRes.json(),
confidenceRes.json(),
interpretationRes.json(),
weightsComponentsRes.json(),
weightsScenariosRes.json(),
multipliersRes.json(),
severityRes.json(),
]);
setData({
dimensions: dimensionsData.success ? dimensionsData.data : [],
subdimensions: subdimensionsData.success ? subdimensionsData.data : [],
techniques: techniquesData.success ? techniquesData.data : [],
indicators: indicatorsData.success ? indicatorsData.data : [],
validationRules: validationRulesData.success ? validationRulesData.data : [],
verdictCategories: verdictsData.success ? verdictsData.data : [],
riskMappings: riskData.success ? riskData.data : [],
// Source Assessment - complete hierarchy
platforms: platformsData.success ? platformsData.data : [],
platformModifiers: platformModifiersData.success ? platformModifiersData.data : [],
sourceTypes: sourcesData.success ? sourcesData.data : [],
sourceCredibility: sourceCredibilityData.success ? sourceCredibilityData.data : [],
domainAgeScores: domainAgeData.success ? domainAgeData.data : [],
domainRiskLevels: domainRiskData.success ? domainRiskData.data : [],
domainRedFlags: domainRedFlagsData.success ? domainRedFlagsData.data : [],
authorClassifications: authorClassData.success ? authorClassData.data : [],
authorCredibility: authorCredData.success ? authorCredData.data : [],
// Claims Analysis - complete hierarchy
claimStatus: claimStatusData.success ? claimStatusData.data : [],
claimTypes: claimTypesData.success ? claimTypesData.data : [],
confidenceLevels: confidenceData.success ? confidenceData.data : [],
interpretations: interpretationData.success ? interpretationData.data : [],
// Weights & multipliers
componentWeights: weightsComponentsData.success ? weightsComponentsData.data : [],
weightScenarios: weightsScenariosData.success ? weightsScenariosData.data : [],
multipliers: multipliersData.success ? multipliersData.data : [],
// Severity Assessment
severityAssessments: severityData.success ? severityData.data : [],
loading: false,
error: null,
});
} catch (error) {
setData(prev => ({
...prev,
loading: false,
error: error instanceof Error ? error.message : 'Failed to fetch framework data',
}));
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
// Helper: Get techniques by dimension (via subdimension mapping)
const getTechniquesByDimension = useCallback((dimensionId: number): Technique[] => {
const dimSubdimensions = data.subdimensions.filter(s => s.dimension_id === dimensionId);
const subdimIds = dimSubdimensions.map(s => s.subdimension_id);
return data.techniques.filter(t => subdimIds.includes(t.subdimension_id));
}, [data.subdimensions, data.techniques]);
// Helper: Get indicators by technique
const getIndicatorsByTechnique = useCallback((techniqueId: number): TechniqueIndicator[] => {
return data.indicators.filter(i => i.technique_id === techniqueId);
}, [data.indicators]);
// Helper: Get validation rules by technique
const getRulesByTechnique = useCallback((techniqueId: number): TechniqueValidationRule[] => {
return data.validationRules.filter(r => r.technique_id === techniqueId);
}, [data.validationRules]);
// Helper: Get verdict category by score
const getVerdictByScore = useCallback((score: number): VerdictCategory | undefined => {
return data.verdictCategories.find(v => score >= v.start_range && score <= v.end_range);
}, [data.verdictCategories]);
// Helper: Get risk mapping by score
const getRiskByScore = useCallback((score: number): RiskMapping | undefined => {
return data.riskMappings.find(r => score >= r.start_range && score <= r.end_range);
}, [data.riskMappings]);
const contextValue: FrameworkDataContextType = {
...data,
refresh: fetchData,
getTechniquesByDimension,
getIndicatorsByTechnique,
getRulesByTechnique,
getVerdictByScore,
getRiskByScore,
};
return (
<FrameworkDataContext.Provider value={contextValue}>
{children}
</FrameworkDataContext.Provider>
);
};
export default FrameworkDataContext;

View file

@ -0,0 +1,21 @@
// AnalysisFlowVisualization - Main export
// Vizualizează pipeline-ul de analiză în 5 pași cu date din DB
export { default } from './AnalysisFlowVisualization';
export { default as AnalysisFlowVisualization } from './AnalysisFlowVisualization';
// Context & Hook
export { FrameworkDataProvider, useFrameworkData } from './context/FrameworkDataContext';
// Nodes
export { default as StepNode } from './nodes/StepNode';
// Panels
export { default as IntakeDetailPanel } from './panels/IntakeDetailPanel';
export { default as TechniqueDetailPanel } from './panels/TechniqueDetailPanel';
export { default as SourceDetailPanel } from './panels/SourceDetailPanel';
export { default as ClaimsDetailPanel } from './panels/ClaimsDetailPanel';
export { default as VerdictDetailPanel } from './panels/VerdictDetailPanel';
// Types
export * from './types';

View file

@ -0,0 +1,177 @@
import React, { memo } from 'react';
import { Handle, Position, NodeProps } from 'reactflow';
import {
Box,
Card,
CardContent,
Typography,
Chip,
LinearProgress,
IconButton,
Tooltip,
} from '@mui/material';
import {
Input as IntakeIcon,
Psychology as TechniqueIcon,
Source as SourceIcon,
FactCheck as ClaimsIcon,
Gavel as VerdictIcon,
ExpandMore as ExpandIcon,
CheckCircle as CompletedIcon,
RadioButtonUnchecked as PendingIcon,
PlayCircle as ActiveIcon,
} from '@mui/icons-material';
import { StepNodeData, StepId } from '../types';
const stepIcons: Record<StepId, React.ReactNode> = {
intake: <IntakeIcon />,
techniques: <TechniqueIcon />,
sources: <SourceIcon />,
claims: <ClaimsIcon />,
verdict: <VerdictIcon />,
};
const statusIcons = {
pending: <PendingIcon fontSize="small" color="disabled" />,
active: <ActiveIcon fontSize="small" color="primary" />,
completed: <CompletedIcon fontSize="small" color="success" />,
};
const statusColors = {
pending: 'default',
active: 'primary',
completed: 'success',
} as const;
interface StepNodeProps extends NodeProps<StepNodeData> {}
const StepNode: React.FC<StepNodeProps> = ({ data, selected }) => {
const { step, score, details, onExpand } = data;
const Icon = stepIcons[step.id];
return (
<>
{/* Input handle - not for intake (pe stânga pentru layout scară) */}
{step.id !== 'intake' && (
<Handle
type="target"
position={Position.Left}
style={{ background: '#1976d2', width: 10, height: 10, borderRadius: '50%' }}
/>
)}
<Card
sx={{
minWidth: 220,
maxWidth: 280,
border: selected ? '2px solid #1976d2' : '1px solid #e0e0e0',
boxShadow: selected ? 4 : 1,
transition: 'all 0.2s ease',
'&:hover': {
boxShadow: 3,
borderColor: '#1976d2',
},
}}
>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
{/* Header */}
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
<Box display="flex" alignItems="center" gap={1}>
<Box
sx={{
bgcolor: step.status === 'completed' ? 'success.light' :
step.status === 'active' ? 'primary.light' : 'grey.200',
borderRadius: 1,
p: 0.5,
display: 'flex',
}}
>
{Icon}
</Box>
<Typography variant="subtitle2" fontWeight="bold">
{step.title}
</Typography>
</Box>
{statusIcons[step.status]}
</Box>
{/* Description */}
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
{step.description}
</Typography>
{/* Score (if available) */}
{score !== undefined && (
<Box mb={1}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={0.5}>
<Typography variant="caption" fontWeight="medium">
Score
</Typography>
<Typography variant="caption" fontWeight="bold">
{score.toFixed(1)}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={Math.min(score, 100)}
sx={{
height: 6,
borderRadius: 3,
bgcolor: 'grey.200',
'& .MuiLinearProgress-bar': {
borderRadius: 3,
bgcolor: score > 70 ? 'error.main' : score > 40 ? 'warning.main' : 'success.main',
},
}}
/>
</Box>
)}
{/* Details preview */}
{details && Object.keys(details).length > 0 && (
<Box display="flex" flexWrap="wrap" gap={0.5} mb={1}>
{Object.entries(details).slice(0, 3).map(([key, value]) => (
<Chip
key={key}
label={`${key}: ${value}`}
size="small"
variant="outlined"
sx={{ fontSize: '0.65rem', height: 20 }}
/>
))}
{Object.keys(details).length > 3 && (
<Chip
label={`+${Object.keys(details).length - 3}`}
size="small"
sx={{ fontSize: '0.65rem', height: 20 }}
/>
)}
</Box>
)}
{/* Expand button */}
{onExpand && (
<Box display="flex" justifyContent="flex-end">
<Tooltip title="View details">
<IconButton size="small" onClick={onExpand}>
<ExpandIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
</CardContent>
</Card>
{/* Output handle - not for verdict (pe dreapta pentru layout scară) */}
{step.id !== 'verdict' && (
<Handle
type="source"
position={Position.Right}
style={{ background: '#1976d2', width: 10, height: 10, borderRadius: '50%' }}
/>
)}
</>
);
};
export default memo(StepNode);

View file

@ -0,0 +1,80 @@
import React, { useState } from 'react';
import { Box, Typography, Paper, Chip, IconButton, Collapse, Divider } from '@mui/material';
import { ExpandMore as ExpandIcon, ChevronRight as ChevronRightIcon } from '@mui/icons-material';
import { colors } from './colors';
interface Props {
title: string;
icon: React.ReactNode;
itemCount: number;
children: React.ReactNode;
defaultExpanded?: boolean;
subtitle?: string;
}
export const ClaimsCategoryRow: React.FC<Props> = ({
title,
icon,
itemCount,
children,
defaultExpanded = false,
subtitle,
}) => {
const [expanded, setExpanded] = useState(defaultExpanded);
return (
<Paper
elevation={0}
sx={{
mb: 1,
border: '1px solid',
borderColor: expanded ? colors.primary : colors.border,
borderRadius: 1,
overflow: 'hidden',
}}
>
<Box
onClick={() => setExpanded(!expanded)}
sx={{
p: 1.5,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 1,
bgcolor: expanded ? colors.surfaceAlt : '#fff',
'&:hover': { bgcolor: colors.surfaceAlt },
}}
>
<IconButton size="small" sx={{ p: 0, color: colors.secondary }}>
{expanded ? <ExpandIcon /> : <ChevronRightIcon />}
</IconButton>
<Box sx={{ color: colors.primary }}>{icon}</Box>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: colors.text }}>
{title}
</Typography>
{subtitle && (
<Typography variant="caption" sx={{ color: colors.textSecondary }}>
{subtitle}
</Typography>
)}
</Box>
<Chip
label={`${itemCount} items`}
size="small"
sx={{ bgcolor: colors.primary, color: '#fff', fontWeight: 600 }}
/>
</Box>
<Collapse in={expanded}>
<Divider />
<Box sx={{ p: 1.5, bgcolor: colors.surface }}>
{children}
</Box>
</Collapse>
</Paper>
);
};

View file

@ -0,0 +1,46 @@
export const colors = {
primary: '#1a237e',
secondary: '#37474f',
accent: '#455a64',
surface: '#f8f9fa',
surfaceAlt: '#eceff1',
border: '#cfd8dc',
borderLight: '#e0e0e0',
text: '#212121',
textSecondary: '#616161',
success: '#2e7d32',
warning: '#e65100',
error: '#c62828',
info: '#0277bd',
};
export const getWeightColor = (weight: number): string => {
if (weight >= 0.8) return colors.success;
if (weight >= 0.6) return '#558b2f';
if (weight >= 0.5) return colors.warning;
if (weight >= 0.4) return '#d84315';
return colors.error;
};
export const getStatusColorHex = (colorName: string): string => {
const map: Record<string, string> = {
green: '#2e7d32',
lightgreen: '#558b2f',
gray: '#757575',
orange: '#e65100',
red: '#c62828',
blue: '#1565c0',
darkgray: '#424242',
};
return map[colorName] || '#757575';
};
export const getConfidenceColorHex = (colorName: string): string => {
const map: Record<string, string> = {
yellow: '#f9a825',
orange: '#e65100',
red: '#c62828',
darkred: '#b71c1c',
};
return map[colorName] || '#757575';
};

View file

@ -0,0 +1,232 @@
import React from 'react';
import { Box, Typography, Paper, Chip, LinearProgress, Divider } from '@mui/material';
import {
FactCheck as ClaimIcon,
Category as TypeIcon,
Verified as VerifiedIcon,
Speed as SpeedIcon,
Handshake as ConcordanceIcon,
ArrowForward as ArrowIcon,
} from '@mui/icons-material';
import { useFrameworkData } from '../../context/FrameworkDataContext';
import { colors } from './colors';
import { ClaimsCategoryRow } from './ClaimsCategoryRow';
import { WeightBar, StatusBadge, ClaimTypeCard, ConfidenceLevelRow, InterpretationRow } from './rows';
const ClaimsDetailPanel: React.FC = () => {
const {
claimStatus,
claimTypes,
confidenceLevels,
interpretations,
componentWeights,
loading,
} = useFrameworkData();
const claimsWeight = componentWeights.find(cw => cw.component_name === 'claims');
const sortedClaimTypes = [...claimTypes].sort((a, b) => b.base_weight - a.base_weight);
if (loading) {
return (
<Box sx={{ p: 2 }}>
<Typography sx={{ color: colors.textSecondary }}>Loading claims data...</Typography>
<LinearProgress sx={{ mt: 1 }} />
</Box>
);
}
return (
<Box>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: '8px',
bgcolor: colors.primary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<ClaimIcon sx={{ color: '#fff' }} />
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, color: colors.text, lineHeight: 1.2 }}>
Claims Analysis
</Typography>
<Typography variant="caption" sx={{ color: colors.textSecondary }}>
Claim verification and truth status determination
</Typography>
</Box>
</Box>
{/* Formula Box */}
<Paper elevation={0} sx={{ p: 2, mb: 2, bgcolor: colors.primary, borderRadius: 2 }}>
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.7)', letterSpacing: 1, display: 'block', mb: 0.5 }}>
Confidence Formula
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 600, color: '#fff', mb: 1.5 }}>
Confidence(claim) = W_type x W_sources x W_concordance x W_recency
</Typography>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.2)', mb: 1.5 }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.9)' }}>
Claims contribute {claimsWeight?.component_weight || 25}% to final risk score
</Typography>
</Paper>
{/* Stats */}
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Paper elevation={0} sx={{ flex: 1, p: 1, textAlign: 'center', border: `1px solid ${colors.border}` }}>
<Typography variant="h6" sx={{ color: colors.primary, fontWeight: 700 }}>{claimTypes.length}</Typography>
<Typography variant="caption" sx={{ color: colors.textSecondary }}>Claim Types</Typography>
</Paper>
<Paper elevation={0} sx={{ flex: 1, p: 1, textAlign: 'center', border: `1px solid ${colors.border}` }}>
<Typography variant="h6" sx={{ color: colors.success, fontWeight: 700 }}>{claimStatus.length}</Typography>
<Typography variant="caption" sx={{ color: colors.textSecondary }}>Status Values</Typography>
</Paper>
<Paper elevation={0} sx={{ flex: 1, p: 1, textAlign: 'center', border: `1px solid ${colors.border}` }}>
<Typography variant="h6" sx={{ color: colors.warning, fontWeight: 700 }}>{confidenceLevels.length}</Typography>
<Typography variant="caption" sx={{ color: colors.textSecondary }}>Confidence</Typography>
</Paper>
<Paper elevation={0} sx={{ flex: 1, p: 1, textAlign: 'center', border: `1px solid ${colors.border}` }}>
<Typography variant="h6" sx={{ color: colors.accent, fontWeight: 700 }}>{interpretations.length}</Typography>
<Typography variant="caption" sx={{ color: colors.textSecondary }}>Concordance</Typography>
</Paper>
</Box>
{/* Processing Flow */}
<Paper elevation={0} sx={{ p: 1.5, mb: 2, bgcolor: colors.surface, border: `1px solid ${colors.border}`, borderRadius: 1 }}>
<Typography variant="overline" sx={{ color: colors.secondary, letterSpacing: 1, display: 'block', mb: 1 }}>
Processing Flow
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Chip label="1. Extract Claims" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
<Chip label="2. Classify Type" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
<Chip label="3. Verify Sources" size="small" variant="outlined" sx={{ borderColor: colors.warning }} />
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
<Chip label="4. Concordance" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
<Chip label="5. Status" size="small" sx={{ bgcolor: colors.primary, color: '#fff' }} />
</Box>
</Paper>
{/* Category 1: Claim Types */}
<ClaimsCategoryRow
title="Claim Types (W_type)"
subtitle="Claim categories with base weights"
icon={<TypeIcon />}
itemCount={claimTypes.length}
defaultExpanded
>
<Paper elevation={0} sx={{ p: 1.5, mb: 1.5, border: `1px solid ${colors.borderLight}`, borderRadius: 1 }}>
<Typography variant="overline" sx={{ color: colors.secondary, letterSpacing: 1, display: 'block', mb: 1 }}>
Weight Overview
</Typography>
{sortedClaimTypes.map(ct => (
<WeightBar
key={ct.claim_type_id}
weight={ct.base_weight}
label={`${ct.claim_type_code} - ${ct.claim_type_name}`}
/>
))}
</Paper>
{sortedClaimTypes.map(ct => (
<ClaimTypeCard key={ct.claim_type_id} claimType={ct} />
))}
</ClaimsCategoryRow>
{/* Category 2: Claim Status */}
<ClaimsCategoryRow
title="Claim Status"
subtitle="Verification result - final output"
icon={<VerifiedIcon />}
itemCount={claimStatus.length}
defaultExpanded
>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
<Box>
<Typography variant="overline" sx={{ color: colors.success, letterSpacing: 1, display: 'block', mb: 0.5 }}>
Verified / Likely
</Typography>
{claimStatus
.filter(s => ['VT', 'LT', 'UV'].includes(s.claim_code))
.map(status => <StatusBadge key={status.claim_id} status={status} />)}
</Box>
<Box>
<Typography variant="overline" sx={{ color: colors.error, letterSpacing: 1, display: 'block', mb: 0.5 }}>
False / Other
</Typography>
{claimStatus
.filter(s => ['LF', 'VF', 'OP', 'NV'].includes(s.claim_code))
.map(status => <StatusBadge key={status.claim_id} status={status} />)}
</Box>
</Box>
</ClaimsCategoryRow>
{/* Category 3: Confidence Levels */}
<ClaimsCategoryRow
title="Confidence Levels"
subtitle="Confidence thresholds with recommended actions"
icon={<SpeedIcon />}
itemCount={confidenceLevels.length}
>
{confidenceLevels.map(level => (
<ConfidenceLevelRow key={level.confidence_id} level={level} />
))}
</ClaimsCategoryRow>
{/* Category 4: Interpretation (W_concordance) */}
<ClaimsCategoryRow
title="Source Concordance (W_concordance)"
subtitle="Agreement level between sources"
icon={<ConcordanceIcon />}
itemCount={interpretations.length}
>
{interpretations.map((item, index) => (
<InterpretationRow
key={item.interpretation_id}
item={item}
index={index}
total={interpretations.length}
/>
))}
</ClaimsCategoryRow>
{/* Decision Matrix */}
<Paper
elevation={0}
sx={{
mt: 2,
p: 1.5,
bgcolor: '#fff3e0',
border: `1px solid ${colors.warning}`,
borderRadius: 1,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: colors.warning, mb: 1 }}>
Decision Matrix
</Typography>
<Typography variant="body2" sx={{ color: colors.textSecondary, mb: 1 }}>
Final status is determined by combining:
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip label="Claim Type" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
<Typography variant="caption" sx={{ color: colors.textSecondary }}>x</Typography>
<Chip label="Nr. Sources" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
<Typography variant="caption" sx={{ color: colors.textSecondary }}>x</Typography>
<Chip label="Concordance" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
<Typography variant="caption" sx={{ color: colors.textSecondary }}>x</Typography>
<Chip label="Recency" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
<Chip label="Final Status" size="small" sx={{ bgcolor: colors.success, color: '#fff', fontWeight: 600 }} />
</Box>
</Paper>
</Box>
);
};
export default ClaimsDetailPanel;

View file

@ -0,0 +1,228 @@
import React from 'react';
import { Box, Typography, Paper, Chip, Tooltip, LinearProgress } from '@mui/material';
import {
Search as SearchIcon,
Psychology as LLMIcon,
HelpOutline as HelpIcon,
TrendingUp as TrendingUpIcon,
} from '@mui/icons-material';
import type { ClaimStatus, ClaimType, ConfidenceLevel, Interpretation } from '../../types';
import { colors, getWeightColor, getStatusColorHex, getConfidenceColorHex } from './colors';
export const WeightBar: React.FC<{ weight: number; label: string }> = ({ weight, label }) => {
const percent = weight * 100;
const color = getWeightColor(weight);
return (
<Box sx={{ mb: 0.75 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" sx={{ fontWeight: 500, color: colors.text }}>{label}</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color }}>{percent.toFixed(0)}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={percent}
sx={{
height: 6,
borderRadius: 3,
bgcolor: colors.surfaceAlt,
'& .MuiLinearProgress-bar': { bgcolor: color, borderRadius: 3 },
}}
/>
</Box>
);
};
export const StatusBadge: React.FC<{ status: ClaimStatus }> = ({ status }) => {
const bgColor = getStatusColorHex(status.claim_color);
const hasRange = status.start_range !== null && status.end_range !== null;
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
p: 1,
mb: 0.5,
bgcolor: '#fff',
borderRadius: 1,
border: '1px solid',
borderColor: colors.borderLight,
borderLeft: `3px solid ${bgColor}`,
}}
>
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: bgColor,
mr: 1,
flexShrink: 0,
}}
/>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: colors.text }}>
{status.claim_code} - {status.claim_name}
</Typography>
</Box>
{hasRange ? (
<Chip
label={`${status.start_range}-${status.end_range}%`}
size="small"
sx={{ bgcolor: bgColor, color: '#fff', fontWeight: 600 }}
/>
) : (
<Chip
label="N/A"
size="small"
variant="outlined"
sx={{ borderColor: bgColor, color: bgColor }}
/>
)}
</Box>
);
};
export const ClaimTypeCard: React.FC<{ claimType: ClaimType }> = ({ claimType }) => {
const weightColor = getWeightColor(claimType.base_weight);
const getMethodIcon = (method: string) => {
if (method.includes('LLM')) return <LLMIcon sx={{ fontSize: 16 }} />;
if (method.includes('Web Search')) return <SearchIcon sx={{ fontSize: 16 }} />;
if (method.includes('Not verifiable')) return <HelpIcon sx={{ fontSize: 16 }} />;
return <SearchIcon sx={{ fontSize: 16 }} />;
};
return (
<Paper
elevation={0}
sx={{
p: 1.5,
mb: 1,
border: '1px solid',
borderColor: colors.borderLight,
borderLeft: `3px solid ${weightColor}`,
borderRadius: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Chip
label={claimType.claim_type_code}
size="small"
sx={{ bgcolor: weightColor, color: '#fff', fontWeight: 700 }}
/>
<Typography variant="body2" sx={{ fontWeight: 600, flexGrow: 1, color: colors.text }}>
{claimType.claim_type_name}
</Typography>
<Tooltip title="Base Weight">
<Chip
label={`${(claimType.base_weight * 100).toFixed(0)}%`}
size="small"
variant="outlined"
sx={{ borderColor: weightColor, color: weightColor, fontWeight: 600 }}
/>
</Tooltip>
</Box>
<Typography variant="caption" sx={{ color: colors.textSecondary, display: 'block', mb: 0.5 }}>
{claimType.description}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{getMethodIcon(claimType.verification_method)}
<Typography variant="caption" sx={{ color: colors.info }}>
{claimType.verification_method}
</Typography>
</Box>
</Paper>
);
};
export const ConfidenceLevelRow: React.FC<{ level: ConfidenceLevel }> = ({ level }) => {
const color = getConfidenceColorHex(level.confidence_color);
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
p: 1,
mb: 0.5,
bgcolor: '#fff',
borderRadius: 1,
border: '1px solid',
borderColor: colors.borderLight,
}}
>
<Box
sx={{
width: 24,
height: 24,
borderRadius: '4px',
bgcolor: color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontWeight: 700,
fontSize: '0.75rem',
mr: 1,
flexShrink: 0,
}}
>
{level.confidence_level}
</Box>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: colors.text, textTransform: 'capitalize' }}>
{level.confidence_name}
</Typography>
<Typography variant="caption" sx={{ color: colors.textSecondary }}>
Action: {level.action}
</Typography>
</Box>
<Chip
label={`${level.start_range}-${level.end_range}%`}
size="small"
sx={{ bgcolor: color, color: '#fff', fontWeight: 600 }}
/>
</Box>
);
};
export const InterpretationRow: React.FC<{ item: Interpretation; index: number; total: number }> = ({ item, index, total }) => {
const percent = index / (total - 1);
const r = Math.round(198 - (percent * 152));
const g = Math.round(40 + (percent * 86));
const b = Math.round(40 + (percent * 10));
const color = `rgb(${r}, ${g}, ${b})`;
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
p: 1,
mb: 0.5,
bgcolor: '#fff',
borderRadius: 1,
border: '1px solid',
borderColor: colors.borderLight,
borderLeft: `3px solid ${color}`,
}}
>
<TrendingUpIcon sx={{ color, mr: 1, fontSize: 18 }} />
<Box sx={{ flexGrow: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: colors.text }}>
{item.interpretation}
</Typography>
</Box>
<Chip
label={`${item.start_range}-${item.end_range}%`}
size="small"
variant="outlined"
sx={{ borderColor: color, color, fontWeight: 600 }}
/>
</Box>
);
};

View file

@ -0,0 +1,428 @@
import React from 'react';
import {
Box,
Typography,
Paper,
Chip,
Divider,
} from '@mui/material';
import {
Input as IntakeIcon,
TextFields as TextIcon,
Image as ImageIcon,
AudioFile as AudioIcon,
VideoFile as VideoIcon,
Link as UrlIcon,
Translate as LangIcon,
CheckCircle as CheckIcon,
Block as BlockIcon,
ArrowForward as ArrowIcon,
FilterAlt as FilterIcon,
Settings as ProcessIcon,
} from '@mui/icons-material';
// ============================================================================
// DESIGN SYSTEM - Professional colors
// ============================================================================
const colors = {
primary: '#1a237e',
secondary: '#37474f',
accent: '#455a64',
surface: '#f8f9fa',
border: '#e0e0e0',
text: '#212121',
textSecondary: '#616161',
success: '#2e7d32',
warning: '#e65100',
error: '#c62828',
};
// ============================================================================
// STEP ITEM COMPONENT
// ============================================================================
interface StepItemProps {
number: number;
title: string;
description: string;
}
const StepItem: React.FC<StepItemProps> = ({ number, title, description }) => (
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1.5,
mb: 1.5,
}}
>
<Box
sx={{
width: 28,
height: 28,
borderRadius: '4px',
bgcolor: colors.primary,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: '0.875rem',
flexShrink: 0,
}}
>
{number}
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: colors.text }}>
{title}
</Typography>
<Typography variant="caption" sx={{ color: colors.textSecondary }}>
{description}
</Typography>
</Box>
</Box>
);
// ============================================================================
// CONTENT TYPE CHIP
// ============================================================================
interface ContentTypeChipProps {
icon: React.ReactNode;
label: string;
sublabel?: string;
}
const ContentTypeChip: React.FC<ContentTypeChipProps> = ({ icon, label, sublabel }) => (
<Paper
variant="outlined"
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1.5,
py: 0.75,
borderColor: colors.border,
bgcolor: '#fff',
}}
>
<Box sx={{ color: colors.secondary }}>{icon}</Box>
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', lineHeight: 1.2 }}>
{label}
</Typography>
{sublabel && (
<Typography variant="caption" sx={{ color: colors.textSecondary, fontSize: '0.65rem' }}>
{sublabel}
</Typography>
)}
</Box>
</Paper>
);
// ============================================================================
// MAIN COMPONENT
// ============================================================================
const IntakeDetailPanel: React.FC = () => {
return (
<Box>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: '8px',
bgcolor: colors.primary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IntakeIcon sx={{ color: '#fff' }} />
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, color: colors.text, lineHeight: 1.2 }}>
Content Intake
</Typography>
<Typography variant="caption" sx={{ color: colors.textSecondary }}>
Pre-processing and eligibility verification
</Typography>
</Box>
</Box>
{/* Processing Pipeline */}
<Paper
elevation={0}
sx={{
p: 2,
mb: 2,
bgcolor: colors.surface,
border: `1px solid ${colors.border}`,
borderRadius: 2,
}}
>
<Typography
variant="overline"
sx={{
color: colors.secondary,
fontWeight: 700,
letterSpacing: 1,
display: 'block',
mb: 1.5,
}}
>
Processing Pipeline
</Typography>
<StepItem
number={1}
title="Receive Input"
description="Accept text, image, audio, video, or URL content"
/>
<StepItem
number={2}
title="Pre-Processing"
description="OCR for images, STT for audio, content extraction for URLs"
/>
<StepItem
number={3}
title="Language Detection"
description="Identify content language (RO/EN supported)"
/>
<StepItem
number={4}
title="Eligibility Check"
description="Verify content meets analysis criteria"
/>
</Paper>
{/* Supported Content Types */}
<Paper
elevation={0}
sx={{
p: 2,
mb: 2,
bgcolor: '#fff',
border: `1px solid ${colors.border}`,
borderRadius: 2,
}}
>
<Typography
variant="overline"
sx={{
color: colors.secondary,
fontWeight: 700,
letterSpacing: 1,
display: 'block',
mb: 1.5,
}}
>
Supported Content Types
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<ContentTypeChip icon={<TextIcon fontSize="small" />} label="Text" sublabel="Direct input" />
<ContentTypeChip icon={<ImageIcon fontSize="small" />} label="Image" sublabel="OCR extraction" />
<ContentTypeChip icon={<AudioIcon fontSize="small" />} label="Audio" sublabel="Speech-to-text" />
<ContentTypeChip icon={<VideoIcon fontSize="small" />} label="Video" sublabel="Frame + audio" />
<ContentTypeChip icon={<UrlIcon fontSize="small" />} label="URL" sublabel="Content fetch" />
</Box>
</Paper>
{/* Supported Languages */}
<Paper
elevation={0}
sx={{
p: 2,
mb: 2,
bgcolor: '#fff',
border: `1px solid ${colors.border}`,
borderRadius: 2,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<LangIcon sx={{ color: colors.secondary, fontSize: 20 }} />
<Typography
variant="overline"
sx={{
color: colors.secondary,
fontWeight: 700,
letterSpacing: 1,
}}
>
Supported Languages
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip
label="RO - Romanian"
size="small"
sx={{
bgcolor: colors.success,
color: '#fff',
fontWeight: 600,
}}
/>
<Chip
label="EN - English"
size="small"
sx={{
bgcolor: colors.success,
color: '#fff',
fontWeight: 600,
}}
/>
</Box>
</Paper>
<Divider sx={{ my: 2 }} />
{/* Eligibility Criteria */}
<Paper
elevation={0}
sx={{
p: 2,
mb: 2,
bgcolor: '#fff',
border: `1px solid ${colors.border}`,
borderRadius: 2,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<FilterIcon sx={{ color: colors.secondary, fontSize: 20 }} />
<Typography
variant="overline"
sx={{
color: colors.secondary,
fontWeight: 700,
letterSpacing: 1,
}}
>
Eligibility Criteria
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckIcon sx={{ color: colors.success, fontSize: 18 }} />
<Typography variant="body2" sx={{ color: colors.text }}>
Content must be in supported language (RO/EN)
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckIcon sx={{ color: colors.success, fontSize: 18 }} />
<Typography variant="body2" sx={{ color: colors.text }}>
Minimum 50 characters of extractable text
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckIcon sx={{ color: colors.success, fontSize: 18 }} />
<Typography variant="body2" sx={{ color: colors.text }}>
Contains verifiable claims or statements
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<BlockIcon sx={{ color: colors.error, fontSize: 18 }} />
<Typography variant="body2" sx={{ color: colors.text }}>
Not pure opinion, satire, or fiction
</Typography>
</Box>
</Box>
</Paper>
{/* Refusal Policy */}
<Paper
elevation={0}
sx={{
p: 2,
mb: 2,
bgcolor: '#fafafa',
border: `1px solid ${colors.error}`,
borderLeft: `4px solid ${colors.error}`,
borderRadius: 2,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<BlockIcon sx={{ color: colors.error, fontSize: 20 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: colors.error }}>
Refusal Policy
</Typography>
</Box>
<Typography variant="body2" sx={{ color: colors.textSecondary, mb: 1 }}>
Content will be refused if:
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, pl: 1 }}>
<Typography variant="caption" sx={{ color: colors.text }}>
- Language not supported
</Typography>
<Typography variant="caption" sx={{ color: colors.text }}>
- Insufficient extractable content
</Typography>
<Typography variant="caption" sx={{ color: colors.text }}>
- Pure entertainment/fiction content
</Typography>
<Typography variant="caption" sx={{ color: colors.text }}>
- Technical/code content without claims
</Typography>
<Typography variant="caption" sx={{ color: colors.text }}>
- Duplicate of recently analyzed content
</Typography>
</Box>
</Paper>
{/* Output Flow */}
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: colors.surface,
border: `1px solid ${colors.border}`,
borderRadius: 2,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<ProcessIcon sx={{ color: colors.secondary, fontSize: 20 }} />
<Typography
variant="overline"
sx={{
color: colors.secondary,
fontWeight: 700,
letterSpacing: 1,
}}
>
Output
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Chip
label="Eligible"
size="small"
sx={{ bgcolor: colors.success, color: '#fff', fontWeight: 600 }}
/>
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
<Typography variant="body2" sx={{ color: colors.text }}>
Proceeds to <strong>Technique Detection</strong>
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mt: 1 }}>
<Chip
label="Ineligible"
size="small"
sx={{ bgcolor: colors.error, color: '#fff', fontWeight: 600 }}
/>
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
<Typography variant="body2" sx={{ color: colors.text }}>
Returns refusal with reason code
</Typography>
</Box>
</Paper>
</Box>
);
};
export default IntakeDetailPanel;

View file

@ -0,0 +1,22 @@
import React from 'react';
import { Box, Typography, LinearProgress } from '@mui/material';
import { colors } from './colors';
export const WeightBar: React.FC<{ weight: number; label: string }> = ({ weight, label }) => (
<Box sx={{ mb: 0.75 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" sx={{ fontWeight: 500, color: colors.text }}>{label}</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: colors.primary }}>{weight}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={weight}
sx={{
height: 6,
borderRadius: 3,
bgcolor: colors.surfaceAlt,
'& .MuiLinearProgress-bar': { bgcolor: colors.primary, borderRadius: 3 },
}}
/>
</Box>
);

View file

@ -0,0 +1,18 @@
/**
* SourceDetailPanel — design system colors
*/
export const colors = {
primary: '#1a237e',
secondary: '#37474f',
accent: '#455a64',
surface: '#f8f9fa',
surfaceAlt: '#eceff1',
border: '#cfd8dc',
borderLight: '#e0e0e0',
text: '#212121',
textSecondary: '#616161',
success: '#2e7d32',
warning: '#e65100',
error: '#c62828',
info: '#0277bd',
};

View file

@ -0,0 +1,151 @@
/**
* SourceDetailPanel — collapsible container components
*/
import React, { useState } from 'react';
import { Box, Typography, Paper, Chip, Tooltip, IconButton, Collapse, Divider } from '@mui/material';
import { ExpandMore as ExpandIcon, ChevronRight as ChevronRightIcon } from '@mui/icons-material';
import { colors } from './colors';
interface SourceCategoryRowProps {
title: string;
weight: number;
icon: React.ReactNode;
itemCount: number;
children: React.ReactNode;
defaultExpanded?: boolean;
}
export const SourceCategoryRow: React.FC<SourceCategoryRowProps> = ({
title,
weight,
icon,
itemCount,
children,
defaultExpanded = false,
}) => {
const [expanded, setExpanded] = useState(defaultExpanded);
return (
<Paper
elevation={0}
sx={{
mb: 1,
border: '1px solid',
borderColor: expanded ? colors.primary : colors.border,
borderRadius: 1,
overflow: 'hidden',
}}
>
<Box
onClick={() => setExpanded(!expanded)}
sx={{
p: 1.5,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 1,
bgcolor: expanded ? colors.surfaceAlt : '#fff',
'&:hover': { bgcolor: colors.surfaceAlt },
}}
>
<IconButton size="small" sx={{ p: 0, color: colors.secondary }}>
{expanded ? <ExpandIcon /> : <ChevronRightIcon />}
</IconButton>
<Box sx={{ color: colors.primary }}>{icon}</Box>
<Typography variant="subtitle2" sx={{ flexGrow: 1, fontWeight: 600, color: colors.text }}>
{title}
</Typography>
<Tooltip title="Weight in source score">
<Chip
label={`${weight}%`}
size="small"
sx={{ bgcolor: colors.primary, color: '#fff', fontWeight: 600 }}
/>
</Tooltip>
<Chip
label={`${itemCount}`}
size="small"
variant="outlined"
sx={{ borderColor: colors.border }}
/>
</Box>
<Collapse in={expanded}>
<Divider />
<Box sx={{ p: 1.5, bgcolor: colors.surface }}>
{children}
</Box>
</Collapse>
</Paper>
);
};
interface SubsectionRowProps {
title: string;
itemCount: number;
children: React.ReactNode;
defaultExpanded?: boolean;
}
export const SubsectionRow: React.FC<SubsectionRowProps> = ({
title,
itemCount,
children,
defaultExpanded = false,
}) => {
const [expanded, setExpanded] = useState(defaultExpanded);
return (
<Paper
elevation={0}
sx={{
mb: 0.75,
ml: 2,
border: '1px solid',
borderColor: expanded ? colors.accent : colors.borderLight,
borderRadius: 1,
overflow: 'hidden',
}}
>
<Box
onClick={() => setExpanded(!expanded)}
sx={{
p: 1,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 1,
bgcolor: '#fff',
'&:hover': { bgcolor: colors.surface },
}}
>
<IconButton size="small" sx={{ p: 0, color: colors.secondary }}>
{expanded ? <ExpandIcon fontSize="small" /> : <ChevronRightIcon fontSize="small" />}
</IconButton>
<Typography variant="body2" sx={{ flexGrow: 1, fontWeight: 500, color: colors.text }}>
{title}
</Typography>
<Chip
label={`${itemCount} items`}
size="small"
variant="outlined"
sx={{ fontSize: '0.7rem', borderColor: colors.borderLight }}
/>
</Box>
<Collapse in={expanded}>
<Divider />
<Box sx={{ p: 1, bgcolor: colors.surface }}>
{children}
</Box>
</Collapse>
</Paper>
);
};

View file

@ -0,0 +1,235 @@
import React from 'react';
import { Box, Typography, Paper, Chip, Divider } from '@mui/material';
import {
Web as PlatformIcon,
Source as SourceIcon,
Domain as DomainIcon,
Person as AuthorIcon,
Warning as WarningIcon,
Security as SecurityIcon,
ArrowForward as ArrowIcon,
} from '@mui/icons-material';
import { useFrameworkData } from '../../context/FrameworkDataContext';
import { colors } from './colors';
import { WeightBar } from './WeightBar';
import { SourceCategoryRow, SubsectionRow } from './containers';
import { SourceTypeRow, SourceCredibilityRow } from './rows/source';
import { PlatformRow, PlatformModifierRow } from './rows/platform';
import { DomainAgeRow, DomainRiskRow, DomainRedFlagRow } from './rows/domain';
import { AuthorClassificationRow, AuthorCredibilityRow } from './rows/author';
const SourceDetailPanel: React.FC = () => {
const {
platforms,
platformModifiers,
sourceTypes,
sourceCredibility,
domainAgeScores,
domainRiskLevels,
domainRedFlags,
authorClassifications,
authorCredibility,
} = useFrameworkData();
const totalItems = platforms.length + platformModifiers.length + sourceTypes.length +
sourceCredibility.length + domainAgeScores.length + domainRiskLevels.length +
domainRedFlags.length + authorClassifications.length + authorCredibility.length;
const positiveModifiers = platformModifiers.filter(m => m.score > 0).length +
authorCredibility.filter(c => c.impact > 0).length;
const negativeModifiers = platformModifiers.filter(m => m.score < 0).length +
authorCredibility.filter(c => c.impact < 0).length +
domainRedFlags.length;
return (
<Box>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: '8px',
bgcolor: colors.primary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<SecurityIcon sx={{ color: '#fff' }} />
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, color: colors.text, lineHeight: 1.2 }}>
Source Assessment
</Typography>
<Typography variant="caption" sx={{ color: colors.textSecondary }}>
Shared module - Claims, Manipulation, Verdict
</Typography>
</Box>
</Box>
{/* Formula Box */}
<Paper
elevation={0}
sx={{
p: 2,
mb: 2,
bgcolor: colors.primary,
borderRadius: 2,
}}
>
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.7)', letterSpacing: 1, display: 'block', mb: 0.5 }}>
Source Score Formula
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: 'monospace', fontWeight: 600, color: '#fff', mb: 1.5 }}
>
SOURCE_SCORE = (Publication x 35%) + (Domain x 25%) + (Author x 25%) + (Platform x 15%)
</Typography>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.2)', mb: 1.5 }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.9)' }}>
Applied modifiers: domain_multiplier x author_modifier
</Typography>
</Paper>
{/* Weight Distribution */}
<Paper elevation={0} sx={{ p: 1.5, mb: 2, border: `1px solid ${colors.border}`, borderRadius: 1 }}>
<Typography variant="overline" sx={{ color: colors.secondary, letterSpacing: 1, display: 'block', mb: 1 }}>
Weight Distribution
</Typography>
<WeightBar weight={35} label="Publication Type" />
<WeightBar weight={25} label="Domain Analysis" />
<WeightBar weight={25} label="Author Assessment" />
<WeightBar weight={15} label="Platform" />
</Paper>
{/* Stats */}
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Paper elevation={0} sx={{ flex: 1, p: 1, textAlign: 'center', border: `1px solid ${colors.border}` }}>
<Typography variant="h6" sx={{ color: colors.primary, fontWeight: 700 }}>{totalItems}</Typography>
<Typography variant="caption" sx={{ color: colors.textSecondary }}>Parameters</Typography>
</Paper>
<Paper elevation={0} sx={{ flex: 1, p: 1, textAlign: 'center', border: `1px solid ${colors.border}` }}>
<Typography variant="h6" sx={{ color: colors.success, fontWeight: 700 }}>{positiveModifiers}</Typography>
<Typography variant="caption" sx={{ color: colors.textSecondary }}>Positive</Typography>
</Paper>
<Paper elevation={0} sx={{ flex: 1, p: 1, textAlign: 'center', border: `1px solid ${colors.border}` }}>
<Typography variant="h6" sx={{ color: colors.error, fontWeight: 700 }}>{negativeModifiers}</Typography>
<Typography variant="caption" sx={{ color: colors.textSecondary }}>Negative</Typography>
</Paper>
<Paper elevation={0} sx={{ flex: 1, p: 1, textAlign: 'center', border: `1px solid ${colors.border}` }}>
<Typography variant="h6" sx={{ color: colors.warning, fontWeight: 700 }}>{domainRedFlags.length}</Typography>
<Typography variant="caption" sx={{ color: colors.textSecondary }}>Red Flags</Typography>
</Paper>
</Box>
{/* Processing Flow */}
<Paper elevation={0} sx={{ p: 1.5, mb: 2, bgcolor: colors.surface, border: `1px solid ${colors.border}`, borderRadius: 1 }}>
<Typography variant="overline" sx={{ color: colors.secondary, letterSpacing: 1, display: 'block', mb: 1 }}>
Processing Flow
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Chip label="1. Extract Metadata" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
<Chip label="2. Identify Platform" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
<Chip label="3. Domain Check" size="small" variant="outlined" sx={{ borderColor: colors.warning }} />
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
<Chip label="4. Author Assess" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
<Chip label="5. Calculate" size="small" sx={{ bgcolor: colors.primary, color: '#fff' }} />
</Box>
</Paper>
{/* Categories */}
<SourceCategoryRow
title="Publication Type"
weight={35}
icon={<SourceIcon />}
itemCount={sourceTypes.length + sourceCredibility.length}
defaultExpanded
>
<SubsectionRow title="Source Types" itemCount={sourceTypes.length} defaultExpanded>
<Typography variant="caption" sx={{ color: colors.textSecondary, display: 'block', mb: 1, fontFamily: 'monospace' }}>
Table: source_type | Base scores 5-100%
</Typography>
{sourceTypes.map(item => <SourceTypeRow key={item.source_type_id} item={item} />)}
</SubsectionRow>
<SubsectionRow title="Credibility Modifiers" itemCount={sourceCredibility.length}>
<Typography variant="caption" sx={{ color: colors.textSecondary, display: 'block', mb: 1, fontFamily: 'monospace' }}>
Table: source_credibility | Multipliers
</Typography>
{sourceCredibility.map(item => <SourceCredibilityRow key={item.source_credibility_id} item={item} />)}
</SubsectionRow>
</SourceCategoryRow>
<SourceCategoryRow
title="Domain Analysis"
weight={25}
icon={<DomainIcon />}
itemCount={domainAgeScores.length + domainRiskLevels.length + domainRedFlags.length}
>
<SubsectionRow title="Domain Age Scores" itemCount={domainAgeScores.length} defaultExpanded>
{domainAgeScores.map(item => <DomainAgeRow key={item.domain_age_score} item={item} />)}
</SubsectionRow>
<SubsectionRow title="Domain Risk Levels" itemCount={domainRiskLevels.length}>
{domainRiskLevels.map(item => <DomainRiskRow key={item.domain_risk_level_id} item={item} />)}
</SubsectionRow>
<SubsectionRow title="Red Flags" itemCount={domainRedFlags.length}>
{domainRedFlags.map(item => <DomainRedFlagRow key={item.domain_red_flag_id} item={item} />)}
</SubsectionRow>
</SourceCategoryRow>
<SourceCategoryRow
title="Author Assessment"
weight={25}
icon={<AuthorIcon />}
itemCount={authorClassifications.length + authorCredibility.length}
>
<SubsectionRow title="Classifications" itemCount={authorClassifications.length} defaultExpanded>
{authorClassifications.map(item => <AuthorClassificationRow key={item.author_classification_id} item={item} />)}
</SubsectionRow>
<SubsectionRow title="Credibility Indicators" itemCount={authorCredibility.length}>
{authorCredibility.map(item => <AuthorCredibilityRow key={item.author_credibility_id} item={item} />)}
</SubsectionRow>
</SourceCategoryRow>
<SourceCategoryRow
title="Platform"
weight={15}
icon={<PlatformIcon />}
itemCount={platforms.length + platformModifiers.length}
>
<SubsectionRow title="Platforms" itemCount={platforms.length} defaultExpanded>
{platforms.map(item => <PlatformRow key={item.platform_id} item={item} />)}
</SubsectionRow>
<SubsectionRow title="Modifiers" itemCount={platformModifiers.length}>
{platformModifiers.map(item => <PlatformModifierRow key={item.platform_modifier_id} item={item} />)}
</SubsectionRow>
</SourceCategoryRow>
{/* Unknown Source Penalties */}
<Paper elevation={0} sx={{ mt: 2, p: 1.5, bgcolor: '#fff3e0', border: `1px solid ${colors.warning}`, borderRadius: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<WarningIcon sx={{ color: colors.warning, fontSize: 18 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: colors.warning }}>
Unknown Source Penalties
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip label="Confidence: x0.50" size="small" sx={{ bgcolor: colors.warning, color: '#fff' }} />
<Chip label="Cap: 70% max" size="small" sx={{ bgcolor: colors.warning, color: '#fff' }} />
<Chip label="Risk floor: 30% min" size="small" sx={{ bgcolor: colors.error, color: '#fff' }} />
</Box>
</Paper>
{/* DB Source */}
<Typography variant="caption" sx={{ color: colors.textSecondary, mt: 1.5, display: 'block', fontFamily: 'monospace' }}>
Source: bos_parammgmt @ didi-postgres:5000/DIDI
</Typography>
</Box>
);
};
export default SourceDetailPanel;

View file

@ -0,0 +1,83 @@
import React from 'react';
import { Box, Typography, Chip } from '@mui/material';
import { CheckCircle as CheckIcon, Cancel as CancelIcon } from '@mui/icons-material';
import type { AuthorClassification, AuthorCredibility } from '../../../types';
import { colors } from '../colors';
export const AuthorClassificationRow: React.FC<{ item: AuthorClassification }> = ({ item }) => (
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1,
mb: 0.5,
p: 0.75,
borderRadius: 1,
bgcolor: '#fff',
border: '1px solid',
borderColor: colors.borderLight,
}}
>
<Typography
variant="caption"
sx={{
fontFamily: 'monospace',
bgcolor: colors.surfaceAlt,
px: 0.75,
py: 0.25,
borderRadius: 0.5,
fontSize: '0.65rem',
}}
>
{item.author_classification_code}
</Typography>
<Typography variant="body2" sx={{ flexGrow: 1, color: colors.text }}>
{item.author_classification_name}
</Typography>
<Chip
label={`x${(item.score / 100).toFixed(2)}`}
size="small"
sx={{
bgcolor: item.score >= 100 ? colors.success : item.score >= 70 ? colors.warning : colors.error,
color: '#fff',
fontWeight: 600,
}}
/>
</Box>
);
export const AuthorCredibilityRow: React.FC<{ item: AuthorCredibility }> = ({ item }) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 0.5,
p: 0.75,
borderRadius: 1,
bgcolor: '#fff',
border: '1px solid',
borderColor: colors.borderLight,
borderLeft: `3px solid ${item.impact >= 0 ? colors.success : colors.error}`,
}}
>
{item.impact >= 0 ? (
<CheckIcon sx={{ color: colors.success, fontSize: 18 }} />
) : (
<CancelIcon sx={{ color: colors.error, fontSize: 18 }} />
)}
<Typography variant="body2" sx={{ flexGrow: 1, color: colors.text }}>
{item.author_credibility}
</Typography>
<Chip
label={`${item.impact >= 0 ? '+' : ''}${item.impact} pts`}
size="small"
variant="outlined"
sx={{
borderColor: item.impact >= 0 ? colors.success : colors.error,
color: item.impact >= 0 ? colors.success : colors.error,
fontWeight: 600,
}}
/>
</Box>
);

Some files were not shown because too many files have changed in this diff Show more