diff --git a/admin/src/app/ai-moderation/page.tsx b/admin/src/app/ai-moderation/page.tsx new file mode 100644 index 0000000..5def702 --- /dev/null +++ b/admin/src/app/ai-moderation/page.tsx @@ -0,0 +1,412 @@ +'use client'; + +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 } 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)' }, +]; + +interface ModelInfo { + id: string; + name: string; + description?: string; + pricing: { prompt: string; completion: string; image?: string }; + context_length: number; + architecture?: Record; +} + +interface ModerationConfig { + id: string; + moderation_type: string; + model_id: string; + model_name: string; + system_prompt: string; + enabled: boolean; + updated_at: string; +} + +export default function AIModerationPage() { + const [configs, setConfigs] = useState([]); + const [loading, setLoading] = useState(true); + const [activeType, setActiveType] = useState('text'); + + const loadConfigs = useCallback(() => { + setLoading(true); + api.getAIModerationConfigs() + .then((data) => setConfigs(data.configs || [])) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { loadConfigs(); }, [loadConfigs]); + + const getConfig = (type: string) => configs.find(c => c.moderation_type === type); + + return ( + +
+

+ AI Moderation +

+

Configure AI models for content moderation via OpenRouter

+
+ + {/* Config Cards */} +
+ {MODERATION_TYPES.map((mt) => { + const config = getConfig(mt.key); + const Icon = mt.icon; + return ( + + ); + })} +
+ + {/* Active Config Editor */} + +
+ ); +} + +// ─── 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 [saving, setSaving] = useState(false); + + // Model picker + const [showPicker, setShowPicker] = useState(false); + const [models, setModels] = useState([]); + const [modelsLoading, setModelsLoading] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [capability, setCapability] = useState(''); + const searchTimer = useRef(null); + + // Test + const [testInput, setTestInput] = useState(''); + const [testResult, setTestResult] = useState(null); + const [testing, setTesting] = useState(false); + + const loadModels = useCallback((search?: string, cap?: string) => { + setModelsLoading(true); + api.listOpenRouterModels({ search, capability: cap }) + .then((data) => setModels(data.models || [])) + .catch(() => {}) + .finally(() => setModelsLoading(false)); + }, []); + + useEffect(() => { + if (showPicker) loadModels(searchTerm || undefined, capability || undefined); + }, [showPicker]); + + 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 handleSave = async () => { + setSaving(true); + try { + await api.setAIModerationConfig({ + moderation_type: moderationType, + model_id: modelId, + model_name: modelName, + system_prompt: systemPrompt, + enabled, + }); + onSaved(); + } catch (e: any) { + alert(e.message); + } finally { + setSaving(false); + } + }; + + const handleTest = async () => { + if (!testInput.trim()) return; + setTesting(true); + setTestResult(null); + try { + const data = moderationType === 'text' + ? { moderation_type: moderationType, content: testInput } + : { moderation_type: moderationType, image_url: testInput }; + const res = await api.testAIModeration(data); + setTestResult(res.result); + } catch (e: any) { + setTestResult({ error: e.message }); + } 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'); + }; + + return ( +
+ {/* Model Selection */} +
+
+

+ {MODERATION_TYPES.find(t => t.key === moderationType)?.label} Configuration +

+ +
+ + {/* Selected Model */} +
+ +
+
setShowPicker(!showPicker)} + className="flex-1 flex items-center justify-between px-3 py-2 border border-warm-300 rounded-lg cursor-pointer hover:bg-warm-50 transition-colors" + > + {modelId ? ( +
+ {modelName || modelId} + {modelId} +
+ ) : ( + Click to select a model... + )} + +
+
+
+ + {/* Model Picker */} + {showPicker && ( +
+
+
+ + 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" + /> +
+ +
+
+ {modelsLoading ? ( +
+ Loading models... +
+ ) : models.length === 0 ? ( +
No models found
+ ) : ( + models.map((m) => ( +
selectModel(m)} + className={`px-3 py-2.5 border-b border-warm-100 cursor-pointer hover:bg-brand-50 transition-colors ${modelId === m.id ? 'bg-brand-50' : ''}`} + > +
+
+ {modelId === m.id && } +
+
{m.name}
+
{m.id}
+
+
+
+ {isVision(m) && ( + Vision + )} + {isFree(m) ? ( + Free + ) : ( + ${m.pricing.prompt}/tok + )} + {(m.context_length / 1000).toFixed(0)}k ctx +
+
+
+ )) + )} +
+
+ )} + + {/* System Prompt */} +
+ +