// File: frontend/app/(admin)/admin/ai/page.tsx // Change Log // - 2026-05-21: เพิ่มหน้า AI Admin Console สำหรับเปิด/ปิด AI features. // - 2026-05-21: เพิ่มส่วนแสดงผลสถานะสุขภาพของระบบ AI (Ollama, Qdrant, queues) แบบ real-time polling 30s (T030, T031). // - 2026-05-21: เพิ่ม RAG Playground Sandbox tab สำหรับ Superadmin (T037, T038). // - 2026-05-21: เพิ่ม OCR Sandbox tab พร้อมการอัปเดตสถานะและการแสดงผล JSON แบบมีสีสำหรับ Superadmin (T043-T045). // - 2026-05-21: แก้ไข ESLint error เกี่ยวกับ any type และ console.error statement ให้ตรงตามมาตรฐาน Tier 1/2 // - 2026-05-25: เพิ่ม AI Model Management UI สำหรับเลือกโมเดลแบบไดนามิก (ADR-027). // - 2026-05-30: นำเข้าและแสดงผล OcrEngineSelector component ใน Overview tab (T019, T020) // - 2026-06-02: เพิ่มตัวบ่งชี้โมเดลหลักที่กำลังใช้งาน (Active Global Model badge) บนการ์ด System Toggle (T010, ADR-033) // - 2026-06-13: [235] ลบ AI Model Management (ADR-027) และ OCR Engine Selector ออก; แก้ System Toggle แสดง canonical names (np-dms-ai/np-dms-ocr); แก้ label OCR Sidecar // - 2026-06-13: ADR-036 — ใช้ canonical model constants สำหรับหน้า AI Admin Console 'use client'; import { useState, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Brain, Loader2, Power, ShieldCheck, Cpu, Database, Activity, Search, Info, HelpCircle, AlertCircle, ScanText } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { Progress } from '@/components/ui/progress'; import { useAiStatus, useToggleAiFeatures, useAiHealth } from '@/hooks/use-ai-status'; import { projectService } from '@/lib/services/project.service'; import { adminAiService, AiSandboxJobResult, AiRagCitation, } from '@/lib/services/admin-ai.service'; import { toast } from 'sonner'; import OcrSandboxPromptManager from '@/components/admin/ai/OcrSandboxPromptManager'; interface SandboxProject { publicId: string; projectName: string; projectCode: string; } interface VramLoadedModelView { modelId: string; modelName: string; vramUsageMB?: number; } const MAIN_MODEL_NAME = 'np-dms-ai'; const OCR_MODEL_NAME = 'np-dms-ocr'; function ensureArray(value: unknown): T[] { return Array.isArray(value) ? value : []; } function normalizeLoadedModels(value: unknown): VramLoadedModelView[] { if (!Array.isArray(value)) { return []; } return value.map((item, index) => { if (typeof item === 'string') { const name = item.toLowerCase(); let normName = item; if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) { normName = OCR_MODEL_NAME; } else if (name.includes('typhoon') || name.includes(MAIN_MODEL_NAME)) { normName = MAIN_MODEL_NAME; } return { modelId: `${item}-${index}`, modelName: normName, }; } if (item && typeof item === 'object') { const model = item as { modelId?: string; modelName?: string; name?: string; vramUsageMB?: number; }; const rawName = model.modelName ?? model.name ?? `model-${index + 1}`; const name = rawName.toLowerCase(); let normName = rawName; if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) { normName = OCR_MODEL_NAME; } else if (name.includes('typhoon') || name.includes(MAIN_MODEL_NAME)) { normName = MAIN_MODEL_NAME; } return { modelId: model.modelId ?? rawName, modelName: normName, vramUsageMB: model.vramUsageMB, }; } return { modelId: `unknown-${index}`, modelName: `Unknown Model ${index + 1}`, }; }); } function toCanonicalModel(rawName: string): string { const name = rawName.toLowerCase(); if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) return OCR_MODEL_NAME; if (name.includes('typhoon') || name.includes(MAIN_MODEL_NAME)) return MAIN_MODEL_NAME; return rawName; } export default function AiAdminConsolePage() { const { data, isLoading, isError, refetch, isFetching } = useAiStatus(); const { data: health, isLoading: isHealthLoading, refetch: refetchHealth } = useAiHealth(); const toggleMutation = useToggleAiFeatures(); const aiEnabled = data?.aiFeaturesEnabled ?? false; const busy = isLoading || toggleMutation.isPending; const [selectedProject, setSelectedProject] = useState(''); const [question, setQuestion] = useState(''); const [sandboxJobId, setSandboxJobId] = useState(null); const [sandboxJobResult, setSandboxJobResult] = useState(null); const [isSandboxPolling, setIsSandboxPolling] = useState(false); const [sandboxProgress, setSandboxProgress] = useState(0); const [sandboxStatusText, setSandboxStatusText] = useState(''); // VRAM Monitoring State (T034, T036, US2) const { data: vramStatus, refetch: refetchVram } = useQuery({ queryKey: ['ai-vram-status'], queryFn: async () => { return await adminAiService.getVramStatus(); }, refetchInterval: 15000, }); const { data: projects = [], isLoading: isProjectsLoading } = useQuery({ queryKey: ['admin-sandbox-projects'], queryFn: async () => { const res = await projectService.getAll({ isActive: true, limit: 100 }); return res as SandboxProject[]; }, }); const rawHealthOllamaModels = ensureArray(health?.ollama?.models); const healthOllamaModels = Array.from(new Set(rawHealthOllamaModels.map((m) => { const name = m.toLowerCase(); if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) return OCR_MODEL_NAME; if (name.includes('typhoon') || name.includes(MAIN_MODEL_NAME)) return MAIN_MODEL_NAME; return m; }))); const healthQdrantCollections = ensureArray(health?.qdrant?.collections); const vramLoadedModels = normalizeLoadedModels(vramStatus?.loadedModels); const sandboxProjects = ensureArray(projects); const sandboxCitations = ensureArray( sandboxJobResult?.citations ); const handleToggle = async (enabled: boolean): Promise => { await toggleMutation.mutateAsync(enabled); }; const handleRefreshAll = async (): Promise => { await Promise.all([refetch(), refetchHealth(), refetchVram()]); }; const handleSubmitSandbox = async (e: React.FormEvent): Promise => { e.preventDefault(); if (!selectedProject) { toast.error('กรุณาเลือกโครงการ'); return; } if (!question.trim()) { toast.error('กรุณากรอกคำถาม'); return; } try { setSandboxJobResult(null); setSandboxProgress(10); setSandboxStatusText('กำลังส่งคำถาม RAG เข้าสู่ระบบคิว...'); const response = await adminAiService.submitSandboxRag(selectedProject, question); setSandboxJobId(response.requestPublicId); setIsSandboxPolling(true); toast.success('ส่งคำถามเข้าสู่คิว sandbox สำเร็จ'); } catch (err) { const error = err as { response?: { data?: { message?: string } } }; toast.error(error.response?.data?.message || 'เกิดข้อผิดพลาดในการส่งคำถาม RAG'); setSandboxProgress(0); setSandboxStatusText(''); } }; useEffect(() => { if (!sandboxJobId) return; let timer: NodeJS.Timeout; const pollSandboxJob = async () => { try { const res = await adminAiService.getSandboxJobStatus(sandboxJobId); setSandboxJobResult(res); if (res.status === 'pending') { setSandboxProgress(20); setSandboxStatusText('อยู่ระหว่างเข้าคิวรอประมวลผล (Pending in BullMQ)...'); } else if (res.status === 'processing') { setSandboxProgress(60); setSandboxStatusText('กำลังค้นหาเอกสารผ่าน Qdrant และประมวลผล RAG ด้วย Local LLM...'); } else if (res.status === 'completed') { setSandboxProgress(100); setSandboxStatusText('ประมวลผลคำตอบเสร็จสิ้น'); setIsSandboxPolling(false); setSandboxJobId(null); toast.success('RAG Sandbox ตอบคำถามสำเร็จ'); } else if (res.status === 'failed') { setSandboxProgress(100); setSandboxStatusText('การประมวลผลล้มเหลว'); setIsSandboxPolling(false); setSandboxJobId(null); toast.error(res.errorMessage || 'เกิดข้อผิดพลาดในการรัน RAG Playground'); } else if (res.status === 'cancelled') { setSandboxProgress(100); setSandboxStatusText('การประมวลผลถูกยกเลิก'); setIsSandboxPolling(false); setSandboxJobId(null); toast.error('Sandbox job ถูกยกเลิก'); } else if (res.status === 'not_found') { setSandboxProgress(15); setSandboxStatusText('กำลังเตรียมการจัดคิว...'); } } catch { // เงียบข้อผิดพลาดตามนโยบาย UI } }; pollSandboxJob(); timer = setInterval(pollSandboxJob, 5000); return () => { clearInterval(timer); }; }, [sandboxJobId]); const renderStatusBadge = (status?: 'HEALTHY' | 'DEGRADED' | 'DOWN') => { if (!status) return Unknown; switch (status) { case 'HEALTHY': return Healthy; case 'DEGRADED': return Degraded; default: return Down; } }; return (

AI Console

ควบคุมสถานะ AI features สำหรับผู้ใช้ทั่วไป

{aiEnabled ? 'AI Enabled' : 'AI Disabled'}
Overview & Health RAG Playground OCR Sandbox
Ollama AI Engine {isHealthLoading ? : renderStatusBadge(health?.ollama?.status)}
ความเร็วตอบสนอง {health?.ollama?.latencyMs !== undefined ? `${health.ollama.latencyMs} ms` : '-'}
โมเดลที่โหลดอยู่:
{healthOllamaModels.length > 0 ? ( healthOllamaModels.map((m) => ( {m} )) ) : ( ไม่มีโมเดลที่โหลดอยู่ )}
{health?.ollama?.error && (

{health.ollama.error}

)}
Qdrant Vector DB {isHealthLoading ? : renderStatusBadge(health?.qdrant?.status)}
ความเร็วตอบสนอง {health?.qdrant?.latencyMs !== undefined ? `${health.qdrant.latencyMs} ms` : '-'}
คอลเลกชัน:
{healthQdrantCollections.length > 0 ? ( healthQdrantCollections.map((c) => ( {c} )) ) : ( ไม่มีคอลเลกชัน )}
{health?.qdrant?.error && (

{health.qdrant.error}

)}
OCR Sidecar (np-dms-ocr) {isHealthLoading ? : renderStatusBadge(health?.ocr?.status)}
ความเร็วตอบสนอง {health?.ocr?.latencyMs !== undefined ? `${health.ocr.latencyMs} ms` : '-'}
URL {health?.ocr?.url ?? '-'}
{health?.ocr?.error && (

{health.ocr.error}

)}
BullMQ Queue Health {isHealthLoading ? ( ) : ( {health?.timestamp ? new Date(health.timestamp).toLocaleTimeString() : 'N/A'} )}
คิว / สถานะงาน Active / Waiting / Failed
realtime {health?.queues?.realtime?.isPaused && (Paused)} {health?.queues?.realtime?.active ?? 0} / {health?.queues?.realtime?.waiting ?? 0} /{' '} 0 ? 'text-destructive' : ''}> {health?.queues?.realtime?.failed ?? 0}
batch {health?.queues?.batch?.isPaused && (Paused)} {health?.queues?.batch?.active ?? 0} / {health?.queues?.batch?.waiting ?? 0} /{' '} 0 ? 'text-destructive' : ''}> {health?.queues?.batch?.failed ?? 0}
{(health?.queues?.realtime?.error || health?.queues?.batch?.error) && (

{health.queues.realtime.error || health.queues.batch.error}

)}
VRAM GPU Monitor {vramStatus ? ( 85 ? 'destructive' : 'secondary'} className="text-[10px]"> {vramStatus.usagePercent}% Used ) : ( )} {vramStatus ? ( <>
GPU VRAM Usage {vramStatus.usedVRAMMB} MB / {vramStatus.totalVRAMMB} MB
โมเดลที่โหลดบน GPU ในปัจจุบัน:
{vramLoadedModels.length > 0 ? ( vramLoadedModels.map((m) => ( {m.modelName} {typeof m.vramUsageMB === 'number' ? ` (${m.vramUsageMB} MB)` : ''} )) ) : ( ไม่มีโมเดลที่โหลดค้างในหน่วยความจำ )}
ความสามารถในการโหลดโมเดลใหม่: {vramStatus.canLoadModel ? 'พร้อมโหลดโมเดลหลัก' : 'หน่วยความจำไม่เพียงพอ (OOM Guard)'}
) : (

กำลังดึงข้อมูลสถานะ GPU VRAM...

)}
System Toggle
{aiEnabled ? 'AI พร้อมให้ผู้ใช้ทั่วไปใช้งาน' : 'AI ถูกปิดสำหรับผู้ใช้ทั่วไป'}
Superadmin ยังสามารถเข้าถึงส่วนทดสอบและดูแลระบบได้ตามสิทธิ์
Active Models: {isHealthLoading ? 'Loading...' : toCanonicalModel(health?.activeModels?.main ?? 'np-dms-ai')} + {isHealthLoading ? 'Loading...' : toCanonicalModel(health?.activeModels?.ocr ?? 'np-dms-ocr')}
{busy && }
{isError && (
ไม่สามารถโหลดสถานะ AI ได้ กรุณาลองใหม่อีกครั้ง
)}
Protection เมื่อปิด AI ระบบจะบล็อก AI inference endpoints สำหรับผู้ใช้ทั่วไปด้วย HTTP 503 และให้ผู้ใช้กรอกข้อมูลเองชั่วคราว Polling อัปเดตสถานะทุก 30 วินาที {(isFetching || isHealthLoading) && !(isLoading || isHealthLoading) ? ' (กำลังรีเฟรช)' : ''}
RAG Sandbox Playground (isolated)

พื้นที่ทดสอบสืบค้นเอกสารและสรุปผลด้วย Retrieval-Augmented Generation (RAG) คิวงานใช้ระดับความสำคัญพิเศษ (Priority 1)

{isProjectsLoading ? (
กำลังโหลดรายการโครงการ...
) : ( )}