Redesign AI Moderation page - clean dropdowns, single engine selector, terminal-style test output
This commit is contained in:
parent
e931c6cdb3
commit
8e0a054a84
|
|
@ -3,23 +3,34 @@
|
|||
import AdminShell from '@/components/AdminShell';
|
||||
import { api } from '@/lib/api';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Brain, Search, Check, Power, PowerOff, ChevronDown, Play, Loader2, Eye, MessageSquare, Video, Sparkles, Shield, MapPin, Users, AlertTriangle, RefreshCw, Server, Cloud, Cpu, Zap } from 'lucide-react';
|
||||
import { Brain, Search, Check, ChevronDown, Play, Loader2, Eye, MessageSquare, Video, Shield, MapPin, Users, AlertTriangle, Server, Cloud, Cpu, Terminal } from 'lucide-react';
|
||||
|
||||
const MODERATION_TYPES = [
|
||||
{ key: 'text', label: 'Text Moderation', icon: MessageSquare, desc: 'Analyze post text, comments, and captions for policy violations' },
|
||||
{ key: 'image', label: 'Image Moderation', icon: Eye, desc: 'Analyze uploaded images for inappropriate content (requires vision model)' },
|
||||
{ key: 'video', label: 'Video Moderation', icon: Video, desc: 'Analyze video frames extracted from Quips (requires vision model)' },
|
||||
{ key: 'group_text', label: 'Group Chat Moderation', icon: Users, desc: 'AI moderation for private group messages — pre-send check before E2EE encryption' },
|
||||
{ key: 'group_image', label: 'Group Image Moderation', icon: Shield, desc: 'AI moderation for images shared in private groups (requires vision model)' },
|
||||
{ key: 'beacon_text', label: 'Beacon Text Moderation', icon: MapPin, desc: 'AI moderation for beacon reports — safety/incident content on the map' },
|
||||
{ key: 'beacon_image', label: 'Beacon Image Moderation', icon: AlertTriangle, desc: 'AI moderation for beacon images — photos attached to safety reports (requires vision model)' },
|
||||
{ key: 'text', label: 'Text Moderation', icon: MessageSquare },
|
||||
{ key: 'image', label: 'Image Moderation', icon: Eye },
|
||||
{ key: 'video', label: 'Video Moderation', icon: Video },
|
||||
{ key: 'group_text', label: 'Group Chat', icon: Users },
|
||||
{ key: 'group_image', label: 'Group Image', icon: Shield },
|
||||
{ key: 'beacon_text', label: 'Beacon Text', icon: MapPin },
|
||||
{ key: 'beacon_image', label: 'Beacon Image', icon: AlertTriangle },
|
||||
];
|
||||
|
||||
const ENGINES = [
|
||||
{ id: 'local_ai', label: 'Local AI (Ollama)', icon: Cpu },
|
||||
{ id: 'openrouter', label: 'OpenRouter', icon: Cloud },
|
||||
{ id: 'openai', label: 'OpenAI', icon: Server },
|
||||
{ id: 'google', label: 'Google Vision', icon: Eye },
|
||||
];
|
||||
|
||||
const LOCAL_MODELS = [
|
||||
{ id: 'llama-guard3:1b', name: 'LLaMA Guard 3 (1B)' },
|
||||
{ id: 'qwen2.5:7b-instruct-q4_K_M', name: 'Qwen 2.5 (7B)' },
|
||||
];
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
pricing: { prompt: string; completion: string; image?: string };
|
||||
pricing: { prompt: string; completion: string };
|
||||
context_length: number;
|
||||
architecture?: Record<string, any>;
|
||||
}
|
||||
|
|
@ -35,270 +46,93 @@ interface ModerationConfig {
|
|||
updated_at: string;
|
||||
}
|
||||
|
||||
const ALL_ENGINES = [
|
||||
{ id: 'local_ai', label: 'Local AI', desc: 'Ollama llama-guard (free, on-server)', icon: Cpu },
|
||||
{ id: 'openrouter', label: 'OpenRouter', desc: 'Cloud models (configurable below)', icon: Cloud },
|
||||
{ id: 'openai', label: 'OpenAI', desc: 'Three Poisons moderation API', icon: Server },
|
||||
{ id: 'google', label: 'Google Vision', desc: 'SafeSearch image moderation API', icon: Eye },
|
||||
];
|
||||
|
||||
interface EngineInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
configured: boolean;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
export default function AIModerationPage() {
|
||||
const [configs, setConfigs] = useState<ModerationConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeType, setActiveType] = useState('text');
|
||||
const [engines, setEngines] = useState<EngineInfo[]>([]);
|
||||
const [enginesLoading, setEnginesLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadConfigs = useCallback(() => {
|
||||
setLoading(true);
|
||||
api.getAIModerationConfigs()
|
||||
.then((data) => setConfigs(data.configs || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
// Selection states
|
||||
const [selectedType, setSelectedType] = useState('text');
|
||||
const [selectedEngine, setSelectedEngine] = useState('local_ai');
|
||||
|
||||
const loadEngines = useCallback(() => {
|
||||
setEnginesLoading(true);
|
||||
api.getAIEngines()
|
||||
.then((data) => setEngines(data.engines || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setEnginesLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadConfigs(); loadEngines(); }, [loadConfigs, loadEngines]);
|
||||
|
||||
const getConfig = (type: string) => configs.find(c => c.moderation_type === type);
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Brain className="w-6 h-6" /> AI Moderation
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Configure and monitor AI moderation engines</p>
|
||||
</div>
|
||||
|
||||
{/* Engines Status Panel */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Zap className="w-4 h-4" /> Moderation Engines
|
||||
</h2>
|
||||
<button onClick={loadEngines} disabled={enginesLoading} className="text-xs text-gray-400 hover:text-gray-600 flex items-center gap-1">
|
||||
<RefreshCw className={`w-3 h-3 ${enginesLoading ? 'animate-spin' : ''}`} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{engines.map((engine) => {
|
||||
const Icon = engine.id === 'local_ai' ? Cpu : engine.id === 'openrouter' ? Cloud : engine.id === 'google' ? Eye : Server;
|
||||
const statusColor = engine.status === 'ready' ? 'text-green-600 bg-green-50 border-green-200' :
|
||||
engine.status === 'down' ? 'text-red-600 bg-red-50 border-red-200' :
|
||||
engine.status === 'not_configured' ? 'text-gray-400 bg-gray-50 border-gray-200' :
|
||||
'text-amber-600 bg-amber-50 border-amber-200';
|
||||
const dotColor = engine.status === 'ready' ? 'bg-green-500' :
|
||||
engine.status === 'down' ? 'bg-red-500' :
|
||||
engine.status === 'not_configured' ? 'bg-gray-300' : 'bg-amber-500';
|
||||
return (
|
||||
<div key={engine.id} className={`rounded-xl border p-4 ${statusColor}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="font-semibold text-sm">{engine.name}</span>
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium">
|
||||
<span className={`w-2 h-2 rounded-full ${dotColor} ${engine.status === 'ready' ? 'animate-pulse' : ''}`} />
|
||||
{engine.status === 'ready' ? 'Online' : engine.status === 'down' ? 'Down' : engine.status === 'not_configured' ? 'Not Configured' : engine.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs opacity-75 mb-2">{engine.description}</p>
|
||||
{engine.details && engine.id === 'local_ai' && engine.status === 'ready' && (
|
||||
<div className="flex gap-3 text-xs opacity-60">
|
||||
<span>Redis: {engine.details.redis}</span>
|
||||
<span>Ollama: {engine.details.ollama}</span>
|
||||
<span>Judge Q: {engine.details.queue_judge}</span>
|
||||
<span>Writer Q: {engine.details.queue_writer}</span>
|
||||
</div>
|
||||
)}
|
||||
{engine.details && engine.id === 'openrouter' && (
|
||||
<div className="text-xs opacity-60">
|
||||
{engine.details.enabled_configs}/{engine.details.total_configs} configs enabled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{engines.length === 0 && !enginesLoading && (
|
||||
<div className="col-span-3 text-center text-sm text-gray-400 py-4">No engine data available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
{MODERATION_TYPES.map((mt) => {
|
||||
const config = getConfig(mt.key);
|
||||
const Icon = mt.icon;
|
||||
return (
|
||||
<button
|
||||
key={mt.key}
|
||||
onClick={() => setActiveType(mt.key)}
|
||||
className={`card p-4 text-left transition-all ${
|
||||
activeType === mt.key ? 'ring-2 ring-brand-500 shadow-md' : 'hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-5 h-5 text-gray-600" />
|
||||
<span className="font-semibold text-gray-900 text-sm">{mt.label}</span>
|
||||
</div>
|
||||
{config?.enabled ? (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-green-700 bg-green-100 px-2 py-0.5 rounded-full">
|
||||
<Power className="w-3 h-3" /> On
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
|
||||
<PowerOff className="w-3 h-3" /> Off
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-2">{mt.desc}</p>
|
||||
{config?.engines && config.engines.length > 0 ? (
|
||||
<div className="flex gap-1 mb-1 flex-wrap">
|
||||
{config.engines.map(e => (
|
||||
<span key={e} className="text-[10px] font-medium bg-brand-100 text-brand-700 px-1.5 py-0.5 rounded">
|
||||
{e === 'local_ai' ? 'Local AI' : e === 'openrouter' ? 'OpenRouter' : e === 'google' ? 'Google Vision' : 'OpenAI'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-1 mb-1">
|
||||
<span className="text-[10px] text-gray-300 italic">No engines configured</span>
|
||||
</div>
|
||||
)}
|
||||
{config?.model_id ? (
|
||||
<p className="text-xs font-mono text-brand-600 truncate">{config.model_name || config.model_id}</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-300 italic">No model selected</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Active Config Editor */}
|
||||
<ConfigEditor
|
||||
key={activeType}
|
||||
moderationType={activeType}
|
||||
config={getConfig(activeType)}
|
||||
onSaved={loadConfigs}
|
||||
/>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Local AI models available on Ollama ─────────
|
||||
const LOCAL_MODELS = [
|
||||
{ id: 'llama-guard3:1b', name: 'LLaMA Guard 3 (1B)', desc: 'Content safety classifier — fast, accurate moderation', type: 'judge' },
|
||||
{ id: 'qwen2.5:7b-instruct-q4_K_M', name: 'Qwen 2.5 (7B)', desc: 'General-purpose reasoning model — slower, deeper analysis', type: 'writer' },
|
||||
];
|
||||
|
||||
// ─── Config Editor for a single moderation type ─────────
|
||||
|
||||
function ConfigEditor({ moderationType, config, onSaved }: {
|
||||
moderationType: string;
|
||||
config?: ModerationConfig;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [modelId, setModelId] = useState(config?.model_id || '');
|
||||
const [modelName, setModelName] = useState(config?.model_name || '');
|
||||
const [systemPrompt, setSystemPrompt] = useState(config?.system_prompt || '');
|
||||
const [enabled, setEnabled] = useState(config?.enabled || false);
|
||||
const [engines, setEngines] = useState<string[]>(config?.engines || ['local_ai', 'openrouter', 'openai']);
|
||||
// Config states
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [modelId, setModelId] = useState('');
|
||||
const [modelName, setModelName] = useState('');
|
||||
const [systemPrompt, setSystemPrompt] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<string>(engines[0] || 'local_ai');
|
||||
|
||||
const toggleEngine = (engineId: string) => {
|
||||
setEngines(prev => {
|
||||
const next = prev.includes(engineId) ? prev.filter(e => e !== engineId) : [...prev, engineId];
|
||||
// If we just enabled this engine, switch tab to it
|
||||
if (!prev.includes(engineId)) setActiveTab(engineId);
|
||||
// If we disabled the active tab, switch to first remaining
|
||||
if (engineId === activeTab && next.length > 0) setActiveTab(next[0]);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Sync testEngine with activeTab when tab changes
|
||||
useEffect(() => {
|
||||
setTestEngine(activeTab);
|
||||
}, [activeTab]);
|
||||
|
||||
// OpenRouter model picker
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [capability, setCapability] = useState('');
|
||||
const searchTimer = useRef<any>(null);
|
||||
|
||||
// Test
|
||||
// Test states
|
||||
const [testInput, setTestInput] = useState('');
|
||||
const [testResponse, setTestResponse] = useState<any>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testEngine, setTestEngine] = useState<string>(engines[0] || 'local_ai');
|
||||
const [testHistory, setTestHistory] = useState<any[]>([]);
|
||||
|
||||
const loadModels = useCallback((search?: string, cap?: string) => {
|
||||
setModelsLoading(true);
|
||||
api.listOpenRouterModels({ search, capability: cap })
|
||||
.then((data) => setModels(data.models || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setModelsLoading(false));
|
||||
const loadConfigs = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
api.getAIModerationConfigs(),
|
||||
api.getAIEngines()
|
||||
])
|
||||
.then(([configData, engineData]) => {
|
||||
setConfigs(configData.configs || []);
|
||||
setEngines(engineData.engines || []);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadConfigs(); }, [loadConfigs]);
|
||||
|
||||
// Load config when type changes
|
||||
useEffect(() => {
|
||||
if (showPicker) loadModels(searchTerm || undefined, capability || undefined);
|
||||
}, [showPicker]);
|
||||
const config = configs.find(c => c.moderation_type === selectedType);
|
||||
if (config) {
|
||||
setEnabled(config.enabled);
|
||||
setModelId(config.model_id || '');
|
||||
setModelName(config.model_name || '');
|
||||
setSystemPrompt(config.system_prompt || '');
|
||||
if (config.engines && config.engines.length > 0) {
|
||||
setSelectedEngine(config.engines[0]);
|
||||
}
|
||||
} else {
|
||||
setEnabled(false);
|
||||
setModelId('');
|
||||
setModelName('');
|
||||
setSystemPrompt('');
|
||||
}
|
||||
}, [selectedType, configs]);
|
||||
|
||||
const onSearchChange = (val: string) => {
|
||||
setSearchTerm(val);
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
searchTimer.current = setTimeout(() => {
|
||||
loadModels(val || undefined, capability || undefined);
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const onCapabilityChange = (val: string) => {
|
||||
setCapability(val);
|
||||
loadModels(searchTerm || undefined, val || undefined);
|
||||
};
|
||||
|
||||
const selectModel = (m: ModelInfo) => {
|
||||
setModelId(m.id);
|
||||
setModelName(m.name);
|
||||
setShowPicker(false);
|
||||
};
|
||||
const loadModels = useCallback((search?: string) => {
|
||||
setModelsLoading(true);
|
||||
api.listOpenRouterModels({ search })
|
||||
.then((data) => setModels(data.models || []))
|
||||
.finally(() => setModelsLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.setAIModerationConfig({
|
||||
moderation_type: moderationType,
|
||||
moderation_type: selectedType,
|
||||
model_id: modelId,
|
||||
model_name: modelName,
|
||||
system_prompt: systemPrompt,
|
||||
enabled,
|
||||
engines,
|
||||
engines: [selectedEngine],
|
||||
});
|
||||
onSaved();
|
||||
loadConfigs();
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
|
|
@ -309,12 +143,12 @@ function ConfigEditor({ moderationType, config, onSaved }: {
|
|||
const handleTest = async () => {
|
||||
if (!testInput.trim()) return;
|
||||
setTesting(true);
|
||||
setTestResponse(null);
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const isImage = moderationType.includes('image') || moderationType === 'video';
|
||||
const isImage = selectedType.includes('image') || selectedType === 'video';
|
||||
const data: any = {
|
||||
moderation_type: moderationType,
|
||||
engine: testEngine,
|
||||
moderation_type: selectedType,
|
||||
engine: selectedEngine,
|
||||
};
|
||||
if (isImage) {
|
||||
data.image_url = testInput;
|
||||
|
|
@ -322,418 +156,282 @@ function ConfigEditor({ moderationType, config, onSaved }: {
|
|||
data.content = testInput;
|
||||
}
|
||||
const res = await api.testAIModeration(data);
|
||||
setTestResponse(res);
|
||||
const duration = Date.now() - startTime;
|
||||
const entry = { ...res, timestamp: new Date().toISOString(), duration };
|
||||
setTestResponse(entry);
|
||||
setTestHistory(prev => [entry, ...prev].slice(0, 10));
|
||||
} catch (e: any) {
|
||||
setTestResponse({ error: e.message, engine: testEngine, moderation_type: moderationType, input: testInput });
|
||||
const entry = {
|
||||
error: e.message,
|
||||
engine: selectedEngine,
|
||||
moderation_type: selectedType,
|
||||
input: testInput,
|
||||
timestamp: new Date().toISOString(),
|
||||
duration: Date.now() - startTime
|
||||
};
|
||||
setTestResponse(entry);
|
||||
setTestHistory(prev => [entry, ...prev].slice(0, 10));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isFree = (m: ModelInfo) => m.pricing.prompt === '0' || m.pricing.prompt === '0.0';
|
||||
const isVision = (m: ModelInfo) => {
|
||||
const modality = m.architecture?.modality;
|
||||
return typeof modality === 'string' && modality.includes('image');
|
||||
const typeLabel = MODERATION_TYPES.find(t => t.key === selectedType)?.label || selectedType;
|
||||
const engineLabel = ENGINES.find(e => e.id === selectedEngine)?.label || selectedEngine;
|
||||
|
||||
const getEngineStatus = (id: string) => {
|
||||
const engine = engines.find(e => e.id === id);
|
||||
if (!engine) return { color: 'text-gray-400', dot: 'bg-gray-300', label: 'Unknown' };
|
||||
if (engine.status === 'ready') return { color: 'text-green-600', dot: 'bg-green-500', label: 'Online' };
|
||||
if (engine.status === 'down') return { color: 'text-red-600', dot: 'bg-red-500', label: 'Down' };
|
||||
return { color: 'text-gray-400', dot: 'bg-gray-300', label: 'Not Configured' };
|
||||
};
|
||||
|
||||
const typeLabel = MODERATION_TYPES.find(t => t.key === moderationType)?.label || moderationType;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="card p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="font-semibold text-gray-900">{typeLabel}</h2>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<span className="text-sm text-gray-500">Enabled</span>
|
||||
<button
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors ${enabled ? 'bg-green-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${enabled ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</label>
|
||||
<AdminShell>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Brain className="w-6 h-6" /> AI Moderation
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Configure AI moderation engines</p>
|
||||
</div>
|
||||
|
||||
{/* Engine Tabs — compact pill toggles */}
|
||||
<div className="flex items-center gap-1 p-1 bg-warm-100 rounded-lg mb-4">
|
||||
{ALL_ENGINES.map((eng) => {
|
||||
const active = engines.includes(eng.id);
|
||||
const isTab = activeTab === eng.id;
|
||||
const Icon = eng.icon;
|
||||
{/* Engine Status - Compact */}
|
||||
<div className="card p-4 mb-4">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="font-semibold text-gray-700">Engine Status:</span>
|
||||
{ENGINES.map(eng => {
|
||||
const status = getEngineStatus(eng.id);
|
||||
return (
|
||||
<div key={eng.id} className="flex-1 relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (active) setActiveTab(eng.id); // clicking active pill = switch view
|
||||
else toggleEngine(eng.id); // clicking inactive = enable + switch
|
||||
}}
|
||||
className={`w-full flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-semibold transition-all ${
|
||||
active && isTab
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: active
|
||||
? 'bg-white/50 text-gray-600 hover:bg-white/70'
|
||||
: 'text-gray-400 hover:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{eng.label}
|
||||
</button>
|
||||
{/* Active indicator dot */}
|
||||
{active && (
|
||||
<span className="absolute -top-1 -right-1 w-2 h-2 bg-green-500 rounded-full border border-white" />
|
||||
)}
|
||||
{/* X button to disable */}
|
||||
{active && engines.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); toggleEngine(eng.id); }}
|
||||
className="absolute -top-1.5 -left-1 w-4 h-4 bg-red-400 hover:bg-red-500 text-white rounded-full flex items-center justify-center text-[9px] font-bold leading-none transition-colors"
|
||||
title={`Disable ${eng.label}`}
|
||||
>×</button>
|
||||
)}
|
||||
<div key={eng.id} className="flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${status.dot}`} />
|
||||
<span className={status.color}>{eng.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{engines.length === 0 && (
|
||||
<div className="text-center py-4 text-sm text-red-500">Select at least one engine above</div>
|
||||
)}
|
||||
|
||||
{/* ─── Local AI Config Panel ─── */}
|
||||
{activeTab === 'local_ai' && engines.includes('local_ai') && (
|
||||
<div className="rounded-lg border border-warm-200 p-4 mb-4 bg-warm-50/50">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu className="w-4 h-4 text-brand-600" />
|
||||
<span className="text-sm font-semibold text-gray-800">Local AI — On-Server Ollama</span>
|
||||
<span className="text-[10px] bg-green-100 text-green-700 px-1.5 py-0.5 rounded font-medium ml-auto">Free</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-3">Runs locally on your server. No data leaves the machine. Select the model to use for this moderation type:</p>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: Configuration */}
|
||||
<div className="space-y-4">
|
||||
{/* Type Selector */}
|
||||
<div className="card p-4">
|
||||
<label className="text-sm font-semibold text-gray-700 block mb-2">Moderation Type</label>
|
||||
<select
|
||||
className="w-full text-sm border border-warm-300 rounded-lg px-3 py-2.5 bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
defaultValue="llama-guard3:1b"
|
||||
value={selectedType}
|
||||
onChange={(e) => setSelectedType(e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
{LOCAL_MODELS.map((lm) => (
|
||||
<option key={lm.id} value={lm.id}>
|
||||
{lm.name} — {lm.desc}
|
||||
</option>
|
||||
{MODERATION_TYPES.map(t => (
|
||||
<option key={t.key} value={t.key}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Engine Selector */}
|
||||
<div className="card p-4">
|
||||
<label className="text-sm font-semibold text-gray-700 block mb-2">Engine</label>
|
||||
<select
|
||||
value={selectedEngine}
|
||||
onChange={(e) => setSelectedEngine(e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
{ENGINES.map(e => (
|
||||
<option key={e.id} value={e.id}>{e.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Engine Configuration */}
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">{engineLabel} Configuration</h3>
|
||||
|
||||
{selectedEngine === 'local_ai' && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Model</label>
|
||||
<select className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500">
|
||||
{LOCAL_MODELS.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[11px] text-gray-400 mt-2">LLaMA Guard 3 is recommended for moderation — fast (~1-2s) and purpose-built for safety classification.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── OpenRouter Config Panel ─── */}
|
||||
{activeTab === 'openrouter' && engines.includes('openrouter') && (
|
||||
<div className="rounded-lg border border-warm-200 p-4 mb-4 bg-warm-50/50">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cloud className="w-4 h-4 text-brand-600" />
|
||||
<span className="text-sm font-semibold text-gray-800">OpenRouter — Cloud Models</span>
|
||||
<span className="text-[10px] bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-medium ml-auto">Paid / Free Tier</span>
|
||||
</div>
|
||||
|
||||
{/* Model Dropdown */}
|
||||
{selectedEngine === 'openrouter' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Model</label>
|
||||
<div
|
||||
onClick={() => setShowPicker(!showPicker)}
|
||||
className="flex items-center justify-between px-3 py-2.5 border border-warm-300 rounded-lg cursor-pointer hover:bg-white transition-colors bg-white mb-2"
|
||||
className="flex items-center justify-between px-3 py-2 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50"
|
||||
>
|
||||
{modelId ? (
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900">{modelName || modelId}</span>
|
||||
<span className="text-xs text-gray-400 ml-2 font-mono">{modelId}</span>
|
||||
</div>
|
||||
<span className="text-sm">{modelName || modelId}</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">Click to select a model...</span>
|
||||
<span className="text-sm text-gray-400">Select model...</span>
|
||||
)}
|
||||
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform ${showPicker ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
|
||||
{/* Model Picker */}
|
||||
{showPicker && (
|
||||
<div className="mb-3 border border-warm-200 rounded-lg overflow-hidden">
|
||||
<div className="p-2.5 bg-warm-100 flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<div className="mt-2 border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="p-2 bg-gray-50">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search models..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm border border-warm-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
loadModels(e.target.value);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={capability}
|
||||
onChange={(e) => onCapabilityChange(e.target.value)}
|
||||
className="text-sm border border-warm-300 rounded-lg px-2 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="vision">Vision</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{modelsLoading ? (
|
||||
<div className="p-4 text-center text-gray-400 text-sm flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Loading...
|
||||
</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-400 text-sm">No models found</div>
|
||||
<div className="p-4 text-center text-sm text-gray-400">Loading...</div>
|
||||
) : (
|
||||
models.map((m) => (
|
||||
models.map(m => (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => selectModel(m)}
|
||||
className={`px-3 py-2 border-b border-warm-100 cursor-pointer hover:bg-brand-50 transition-colors ${modelId === m.id ? 'bg-brand-50' : ''}`}
|
||||
onClick={() => {
|
||||
setModelId(m.id);
|
||||
setModelName(m.name);
|
||||
setShowPicker(false);
|
||||
}}
|
||||
className="px-3 py-2 hover:bg-gray-50 cursor-pointer border-b border-gray-100"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{modelId === m.id && <Check className="w-3.5 h-3.5 text-brand-600 flex-shrink-0" />}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">{m.name}</div>
|
||||
<div className="text-[11px] text-gray-400 font-mono truncate">{m.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 ml-2">
|
||||
{isVision(m) && <span className="text-[10px] bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded font-medium">Vision</span>}
|
||||
{isFree(m) ? (
|
||||
<span className="text-[10px] bg-green-100 text-green-700 px-1.5 py-0.5 rounded font-medium">Free</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-gray-400">${m.pricing.prompt}/tok</span>
|
||||
)}
|
||||
<span className="text-[10px] text-gray-300">{(m.context_length / 1000).toFixed(0)}k</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium">{m.name}</div>
|
||||
<div className="text-xs text-gray-400">{m.id}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* System Prompt */}
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">
|
||||
System Prompt <span className="text-gray-400 font-normal">(leave blank for default)</span>
|
||||
</label>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">System Prompt (optional)</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
rows={3}
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
placeholder="Custom system prompt for this moderation type..."
|
||||
className="w-full text-sm border border-warm-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500 font-mono bg-white"
|
||||
placeholder="Custom system prompt..."
|
||||
className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── OpenAI Config Panel ─── */}
|
||||
{activeTab === 'openai' && engines.includes('openai') && (
|
||||
<div className="rounded-lg border border-warm-200 p-4 mb-4 bg-warm-50/50">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Server className="w-4 h-4 text-brand-600" />
|
||||
<span className="text-sm font-semibold text-gray-800">OpenAI Moderation — Three Poisons</span>
|
||||
<span className="text-[10px] bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded font-medium ml-auto">Per-call</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-2">OpenAI's moderation endpoint with Three Poisons scoring (Hate, Greed, Delusion). Automatically configured — no model selection needed.</p>
|
||||
<div className="flex gap-4 text-xs">
|
||||
<div className="flex items-center gap-1.5 text-gray-500">
|
||||
<span className="w-2 h-2 rounded-full bg-red-400" /> <strong>Hate</strong> — hate, harassment, violence, sexual
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-gray-500">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400" /> <strong>Greed</strong> — keyword fallback
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-gray-500">
|
||||
<span className="w-2 h-2 rounded-full bg-purple-400" /> <strong>Delusion</strong> — self-harm
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Google Vision Config Panel ─── */}
|
||||
{activeTab === 'google' && engines.includes('google') && (
|
||||
<div className="rounded-lg border border-warm-200 p-4 mb-4 bg-warm-50/50">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Eye className="w-4 h-4 text-brand-600" />
|
||||
<span className="text-sm font-semibold text-gray-800">Google Vision — SafeSearch</span>
|
||||
<span className="text-[10px] bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded font-medium ml-auto">Per-call</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-2">Google Cloud Vision SafeSearch API for image moderation. Detects adult, violence, racy, spoof, and medical content in images. Scores are mapped to Three Poisons.</p>
|
||||
<div className="flex gap-4 text-xs flex-wrap">
|
||||
<div className="flex items-center gap-1.5 text-gray-500">
|
||||
<span className="w-2 h-2 rounded-full bg-red-400" /> <strong>Adult + Violence + Racy</strong> → Hate
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-gray-500">
|
||||
<span className="w-2 h-2 rounded-full bg-purple-400" /> <strong>Medical</strong> → Delusion
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-400 mt-3">Configured via service account credentials. Image-only — text posts pass through without Google Vision analysis.</p>
|
||||
</div>
|
||||
{selectedEngine === 'openai' && (
|
||||
<p className="text-xs text-gray-500">OpenAI moderation is automatically configured. No additional settings needed.</p>
|
||||
)}
|
||||
|
||||
{/* Save */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-gray-400">
|
||||
{config?.updated_at && `Last updated: ${new Date(config.updated_at).toLocaleString()}`}
|
||||
{selectedEngine === 'google' && (
|
||||
<p className="text-xs text-gray-500">Google Vision SafeSearch is configured via service account. No additional settings needed.</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={handleSave} disabled={saving || engines.length === 0} className="btn-primary text-sm flex items-center gap-1.5 disabled:opacity-40">
|
||||
<Sparkles className="w-4 h-4" /> {saving ? 'Saving...' : 'Save Configuration'}
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="card p-4 flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className="w-4 h-4 text-brand-600 rounded focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Enable {typeLabel}</span>
|
||||
</label>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-primary text-sm disabled:opacity-40"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Configuration'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Panel */}
|
||||
<div className="card p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||
{/* Right: Test Terminal */}
|
||||
<div className="space-y-4">
|
||||
{/* Test Input */}
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
|
||||
<Play className="w-4 h-4" /> Test Moderation
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
{moderationType.includes('image') || moderationType === 'video'
|
||||
? 'Enter an image URL to test vision moderation'
|
||||
: 'Enter text content to test moderation'}
|
||||
</p>
|
||||
|
||||
{/* Engine selector + input */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<select
|
||||
value={testEngine}
|
||||
onChange={(e) => setTestEngine(e.target.value)}
|
||||
className="text-sm border border-warm-300 rounded-lg px-2 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
{engines.map(e => (
|
||||
<option key={e} value={e}>
|
||||
{e === 'local_ai' ? 'Local AI' : e === 'openrouter' ? 'OpenRouter' : e === 'google' ? 'Google Vision' : 'OpenAI'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={testInput}
|
||||
onChange={(e) => setTestInput(e.target.value)}
|
||||
placeholder={moderationType.includes('image') || moderationType === 'video' ? 'Enter image URL...' : 'Enter test text...'}
|
||||
className="flex-1 text-sm border border-warm-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder={selectedType.includes('image') || selectedType === 'video' ? 'Image URL...' : 'Test text...'}
|
||||
className="flex-1 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleTest()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={testing || !enabled || engines.length === 0}
|
||||
disabled={testing || !testInput.trim()}
|
||||
className="btn-primary text-sm flex items-center gap-1.5 disabled:opacity-40"
|
||||
>
|
||||
{testing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Full response display */}
|
||||
{testResponse && (
|
||||
<div className="space-y-3">
|
||||
{/* Meta bar: engine + type + input */}
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 bg-warm-50 rounded-lg p-2.5">
|
||||
<span className="font-semibold">Engine:</span>
|
||||
<span className="bg-brand-100 text-brand-700 px-1.5 py-0.5 rounded font-medium">
|
||||
{testResponse.engine === 'local_ai' ? 'Local AI' : testResponse.engine === 'openrouter' ? 'OpenRouter' : testResponse.engine === 'google' ? 'Google Vision' : 'OpenAI'}
|
||||
</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="font-semibold">Type:</span> {testResponse.moderation_type}
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="font-semibold">Input:</span>
|
||||
<span className="text-gray-600 truncate max-w-[200px]" title={testResponse.input}>{testResponse.input}</span>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{testResponse.error && (
|
||||
<div className="bg-red-50 text-red-700 p-3 rounded-lg text-sm">
|
||||
<span className="font-semibold">Error:</span> {testResponse.error}
|
||||
{/* Terminal Output */}
|
||||
<div className="card p-0 overflow-hidden">
|
||||
<div className="bg-gray-900 px-4 py-2 flex items-center gap-2 border-b border-gray-700">
|
||||
<Terminal className="w-4 h-4 text-green-400" />
|
||||
<span className="text-xs font-mono text-green-400">moderation-test</span>
|
||||
</div>
|
||||
<div className="bg-gray-950 p-4 font-mono text-xs text-green-400 h-[500px] overflow-y-auto">
|
||||
{testHistory.length === 0 ? (
|
||||
<div className="text-gray-600">Waiting for test input...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{testHistory.map((entry, idx) => (
|
||||
<div key={idx} className="border-b border-gray-800 pb-3 last:border-0">
|
||||
<div className="text-gray-500 mb-1">
|
||||
[{new Date(entry.timestamp).toLocaleTimeString()}] {entry.engine} • {entry.moderation_type} • {entry.duration}ms
|
||||
</div>
|
||||
|
||||
{entry.error ? (
|
||||
<div className="text-red-400">ERROR: {entry.error}</div>
|
||||
) : entry.result ? (
|
||||
<div className="space-y-1">
|
||||
<div className={entry.result.flagged ? 'text-red-400' : 'text-green-400'}>
|
||||
{entry.result.flagged ? '⛔ FLAGGED' : '✅ CLEAN'}
|
||||
{entry.result.reason && `: ${entry.result.reason}`}
|
||||
</div>
|
||||
{entry.result.explanation && (
|
||||
<div className="text-gray-400 pl-4">{entry.result.explanation}</div>
|
||||
)}
|
||||
{entry.result.hate !== undefined && (
|
||||
<div className="text-gray-400 pl-4">
|
||||
Hate: {(entry.result.hate * 100).toFixed(1)}% |
|
||||
Greed: {(entry.result.greed * 100).toFixed(1)}% |
|
||||
Delusion: {(entry.result.delusion * 100).toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result display */}
|
||||
{testResponse.result && (
|
||||
<div className={`p-4 rounded-lg text-sm ${
|
||||
testResponse.result.action === 'flag' ? 'bg-red-50' :
|
||||
testResponse.result.action === 'nsfw' ? 'bg-amber-50' : 'bg-green-50'
|
||||
}`}>
|
||||
<div className="space-y-3">
|
||||
{/* Verdict */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-lg font-bold ${
|
||||
testResponse.result.action === 'flag' ? 'text-red-700' :
|
||||
testResponse.result.action === 'nsfw' ? 'text-amber-700' : 'text-green-700'
|
||||
}`}>
|
||||
{testResponse.result.action === 'flag' ? '⛔ FLAGGED' :
|
||||
testResponse.result.action === 'nsfw' ? '⚠️ NSFW' : '✅ CLEAN'}
|
||||
</span>
|
||||
{testResponse.result.nsfw_reason && (
|
||||
<span className="text-xs font-medium bg-amber-200 text-amber-800 px-2 py-0.5 rounded-full">{testResponse.result.nsfw_reason}</span>
|
||||
)}
|
||||
{testResponse.result.reason && <span className="text-gray-600">— {testResponse.result.reason}</span>}
|
||||
</div>
|
||||
|
||||
{/* Explanation */}
|
||||
{testResponse.result.explanation && (
|
||||
<div className="bg-white/60 rounded-lg p-3 border border-warm-200">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase mb-1">AI Analysis</p>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{testResponse.result.explanation}</p>
|
||||
{entry.result.categories && entry.result.categories.length > 0 && (
|
||||
<div className="text-yellow-400 pl-4">
|
||||
Categories: {entry.result.categories.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Categories (for local AI) */}
|
||||
{testResponse.result.categories && testResponse.result.categories.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{testResponse.result.categories.map((cat: string, i: number) => (
|
||||
<span key={i} className="text-xs font-medium bg-red-100 text-red-700 px-2 py-0.5 rounded">{cat}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Score Bars (for OpenRouter/OpenAI) */}
|
||||
{(testResponse.result.hate !== undefined || testResponse.result.greed !== undefined) && (
|
||||
<div className="space-y-2">
|
||||
<ScoreBarDetailed label="Hate" value={testResponse.result.hate} detail={testResponse.result.hate_detail} />
|
||||
<ScoreBarDetailed label="Greed" value={testResponse.result.greed} detail={testResponse.result.greed_detail} />
|
||||
<ScoreBarDetailed label="Delusion" value={testResponse.result.delusion} detail={testResponse.result.delusion_detail} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw response — always visible */}
|
||||
{testResponse.result.raw_content && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase mb-1">Raw Response</p>
|
||||
<pre className="text-xs bg-white p-2.5 rounded border border-warm-200 overflow-x-auto whitespace-pre-wrap font-mono text-gray-700">{testResponse.result.raw_content}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full JSON dump for debugging */}
|
||||
<details>
|
||||
<summary className="text-xs text-gray-400 cursor-pointer">Full JSON response</summary>
|
||||
<pre className="mt-1 text-xs bg-gray-50 p-2.5 rounded border border-warm-200 overflow-x-auto whitespace-pre-wrap font-mono text-gray-600">
|
||||
{JSON.stringify(testResponse, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreBarDetailed({ label, value, detail }: { label: string; value: number; detail?: string }) {
|
||||
const pct = Math.round((value || 0) * 100);
|
||||
const color = pct > 50 ? 'bg-red-500' : pct > 25 ? 'bg-amber-400' : 'bg-green-400';
|
||||
const textColor = pct > 50 ? 'text-red-700' : pct > 25 ? 'text-amber-700' : 'text-green-700';
|
||||
return (
|
||||
<div className="bg-white/50 rounded-lg p-2.5 border border-warm-100">
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="font-semibold text-gray-700">{label}</span>
|
||||
<span className={`font-mono font-bold ${textColor}`}>{pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-200 rounded-full overflow-hidden mb-1.5">
|
||||
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
{detail && <p className="text-xs text-gray-500 leading-relaxed">{detail}</p>}
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue