sojorn/admin/src/app/ai-moderation/page.tsx

550 lines
22 KiB
TypeScript

'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { useEffect, useState, useCallback, useRef } from 'react';
import { Brain, Search, Check, ChevronDown, Play, Loader2, Eye, MessageSquare, Video, Shield, MapPin, Users, AlertTriangle, Server, Cloud, Cpu, Terminal, Upload } from 'lucide-react';
const MODERATION_TYPES = [
{ 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 },
{ id: 'azure', label: 'Azure OpenAI', icon: Cloud },
];
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;
pricing: { prompt: string; completion: string };
context_length: number;
architecture?: Record<string, any>;
}
interface ModerationConfig {
id: string;
moderation_type: string;
model_id: string;
model_name: string;
system_prompt: string;
enabled: boolean;
engines: string[];
updated_at: string;
}
interface EngineInfo {
id: string;
name: string;
status: string;
}
export default function AIModerationPage() {
const [configs, setConfigs] = useState<ModerationConfig[]>([]);
const [engines, setEngines] = useState<EngineInfo[]>([]);
const [loading, setLoading] = useState(true);
// Selection states
const [selectedType, setSelectedType] = useState('text');
const [selectedEngine, setSelectedEngine] = useState('local_ai');
// Config states
const [enabled, setEnabled] = useState(false);
const [modelId, setModelId] = useState('');
const [modelName, setModelName] = useState('');
const [systemPrompt, setSystemPrompt] = useState('');
const [saving, setSaving] = useState(false);
// OpenRouter model picker
const [showPicker, setShowPicker] = useState(false);
const [models, setModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
// Test states
const [testInput, setTestInput] = useState('');
const [testResponse, setTestResponse] = useState<any>(null);
const [testing, setTesting] = useState(false);
const [testHistory, setTestHistory] = useState<any[]>([]);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(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(() => {
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 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: selectedType,
model_id: modelId,
model_name: modelName,
system_prompt: systemPrompt,
enabled,
engines: [selectedEngine],
});
loadConfigs();
} catch (e: any) {
alert(e.message);
} finally {
setSaving(false);
}
};
const handleFileUpload = async (file: File) => {
setUploading(true);
try {
const result = await api.uploadTestImage(file);
setTestInput(result.url);
setUploadedFile(file);
} catch (e: any) {
console.error('Upload error:', e);
alert('Upload failed: ' + e.message);
} finally {
setUploading(false);
}
};
const handleTest = async () => {
if (!testInput.trim() && !uploadedFile) return;
setTesting(true);
const startTime = Date.now();
try {
const isImage = selectedType.includes('image') || selectedType === 'video';
const data: any = {
moderation_type: selectedType,
engine: selectedEngine,
};
if (isImage) {
data.image_url = testInput; // Use the uploaded file URL
} else {
data.content = testInput;
}
const res = await api.testAIModeration(data);
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) {
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 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' };
};
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 AI moderation engines</p>
</div>
{/* 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 items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${status.dot}`} />
<span className={status.color}>{eng.label}</span>
</div>
);
})}
</div>
</div>
<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
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"
>
{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>
{/* AI Moderation Instructions */}
<div className="card p-4">
<label className="text-sm font-semibold text-gray-700 block mb-2">
Moderation Instructions
</label>
<p className="text-xs text-gray-500 mb-2">
Provide specific guidelines for the AI to follow when moderating {typeLabel.toLowerCase()} content.
</p>
<textarea
rows={4}
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
placeholder="Example: Flag content that promotes violence, hate speech, or illegal activities. Allow political discussion and criticism. Be lenient with humor and satire..."
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"
/>
</div>
{/* Engine Configuration */}
<div className="card p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700">{engineLabel} Configuration</h3>
<div className="text-xs text-gray-500">
Current: {modelName || 'Not configured'}
</div>
</div>
{selectedEngine === 'local_ai' && (
<div>
<label className="text-xs font-medium text-gray-600 block mb-1">Model</label>
<select
value={modelId}
onChange={(e) => {
const selected = LOCAL_MODELS.find(m => m.id === e.target.value);
setModelId(e.target.value);
setModelName(selected?.name || '');
}}
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"
>
<option value="">Select model...</option>
{LOCAL_MODELS.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
)}
{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 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50"
>
{modelId ? (
<span className="text-sm">{modelName || modelId}</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>
{showPicker && (
<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) => {
setSearchTerm(e.target.value);
loadModels(e.target.value);
}}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
/>
</div>
<div className="max-h-64 overflow-y-auto">
{modelsLoading ? (
<div className="p-4 text-center text-sm text-gray-400">Loading...</div>
) : (
models.map(m => (
<div
key={m.id}
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="text-sm font-medium">{m.name}</div>
<div className="text-xs text-gray-400">{m.id}</div>
</div>
))
)}
</div>
</div>
)}
</div>
</div>
)}
{selectedEngine === 'openai' && (
<p className="text-xs text-gray-500">OpenAI moderation is automatically configured. No additional settings needed.</p>
)}
{selectedEngine === 'google' && (
<p className="text-xs text-gray-500">Google Vision SafeSearch is configured via service account. No additional settings needed.</p>
)}
{selectedEngine === 'azure' && (
<div>
<label className="text-xs font-medium text-gray-600 block mb-1">Deployment Name</label>
<input
type="text"
value={modelId}
onChange={(e) => {
setModelId(e.target.value);
setModelName(e.target.value);
}}
placeholder="e.g., gpt-4o-vision"
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"
/>
<p className="text-xs text-gray-500 mt-2">
Azure OpenAI deployment name (configured in Azure portal). Uses your Azure credits.
</p>
</div>
)}
</div>
{/* 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>
{/* 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>
{(selectedType.includes('image') || selectedType === 'video') ? (
<div className="space-y-3">
{/* File Upload */}
<div className="flex gap-2">
<label className="flex-1">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
handleFileUpload(file);
}
}}
className="hidden"
/>
<div 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 cursor-pointer hover:bg-gray-50 flex items-center justify-center gap-2">
<Upload className="w-4 h-4" />
{uploading ? 'Uploading...' : (uploadedFile ? `Uploaded: ${uploadedFile.name}` : 'Click to upload image...')}
</div>
</label>
</div>
{/* URL Input */}
<div className="flex items-center gap-2 text-xs text-gray-500">
<span>OR</span>
</div>
<div className="flex gap-2">
<input
type="text"
value={testInput}
onChange={(e) => setTestInput(e.target.value)}
placeholder="Image URL..."
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()}
/>
</div>
</div>
) : (
<div className="flex gap-2">
<input
type="text"
value={testInput}
onChange={(e) => setTestInput(e.target.value)}
placeholder="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()}
/>
</div>
)}
<div className="flex gap-2 mt-3">
<button
onClick={handleTest}
disabled={testing || (!testInput.trim() && !uploadedFile)}
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>
{uploadedFile && (
<button
onClick={() => {
setUploadedFile(null);
setTestInput('');
}}
className="text-sm text-gray-500 hover:text-gray-700"
>
Clear Upload
</button>
)}
</div>
</div>
{/* 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>
)}
{entry.result.categories && entry.result.categories.length > 0 && (
<div className="text-yellow-400 pl-4">
Categories: {entry.result.categories.join(', ')}
</div>
)}
</div>
) : null}
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</AdminShell>
);
}