690618:2126 239 #01
CI / CD Pipeline / build (push) Successful in 6m54s
CI / CD Pipeline / deploy (push) Successful in 10m58s

This commit is contained in:
2026-06-18 21:26:07 +07:00
parent e8e10e8c04
commit 78e61fd300
15 changed files with 1432 additions and 344 deletions
+361 -303
View File
@@ -10,12 +10,27 @@
// - 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
// - 2026-06-18: อัปเดต OCR Sandbox tab ให้ใช้ PromptManagementTabs และ SandboxTabs ตาม spec 238 (FR-006, FR-011, FR-013)
// - 2026-06-18: [239] ปรับ AI Console UX ให้ health/system controls แสดงทุก tab และเปลี่ยนชื่อ sandbox tab ให้ตรงกับ 3-step pipeline.
'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 {
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';
@@ -26,13 +41,10 @@ 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 { adminAiService, AiSandboxJobResult, AiRagCitation } from '@/lib/services/admin-ai.service';
import { toast } from 'sonner';
import OcrSandboxPromptManager from '@/components/admin/ai/OcrSandboxPromptManager';
import { PromptManagementTabs } from '@/components/admin/ai/PromptManagementTabs';
import SandboxTabs from '@/components/admin/ai/SandboxTabs';
interface SandboxProject {
publicId: string;
@@ -137,18 +149,20 @@ export default function AiAdminConsolePage() {
},
});
const rawHealthOllamaModels = ensureArray<string>(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 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<string>(health?.qdrant?.collections);
const vramLoadedModels = normalizeLoadedModels(vramStatus?.loadedModels);
const sandboxProjects = ensureArray<SandboxProject>(projects);
const sandboxCitations = ensureArray<AiRagCitation>(
sandboxJobResult?.citations
);
const sandboxCitations = ensureArray<AiRagCitation>(sandboxJobResult?.citations);
const handleToggle = async (enabled: boolean): Promise<void> => {
await toggleMutation.mutateAsync(enabled);
@@ -234,9 +248,15 @@ export default function AiAdminConsolePage() {
if (!status) return <Badge variant="outline">Unknown</Badge>;
switch (status) {
case 'HEALTHY':
return <Badge className="border-emerald-500/20 bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/20">Healthy</Badge>;
return (
<Badge className="border-emerald-500/20 bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/20">
Healthy
</Badge>
);
case 'DEGRADED':
return <Badge className="border-amber-500/20 bg-amber-500/10 text-amber-500 hover:bg-amber-500/20">Degraded</Badge>;
return (
<Badge className="border-amber-500/20 bg-amber-500/10 text-amber-500 hover:bg-amber-500/20">Degraded</Badge>
);
default:
return <Badge variant="destructive">Down</Badge>;
}
@@ -250,291 +270,320 @@ export default function AiAdminConsolePage() {
<Brain className="h-6 w-6" />
AI Console
</h1>
<p className="mt-1 text-sm text-muted-foreground"> AI features </p>
<p className="mt-1 text-sm text-muted-foreground"> AI Superadmin</p>
</div>
<Badge variant={aiEnabled ? 'default' : 'destructive'} className="w-fit">
{aiEnabled ? 'AI Enabled' : 'AI Disabled'}
</Badge>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card className="relative overflow-hidden border border-border/50 bg-background/50 backdrop-blur-md">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Cpu className="h-4 w-4 text-primary" />
Ollama AI Engine
</CardTitle>
{isHealthLoading ? (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
) : (
renderStatusBadge(health?.ollama?.status)
)}
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span></span>
<span className="font-semibold text-foreground">
{health?.ollama?.latencyMs !== undefined ? `${health.ollama.latencyMs} ms` : '-'}
</span>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">:</span>
<div className="flex flex-wrap gap-1">
{healthOllamaModels.length > 0 ? (
healthOllamaModels.map((m) => (
<Badge key={m} variant="secondary" className="text-[10px] py-0 px-1">
{m}
</Badge>
))
) : (
<span className="text-[10px] text-muted-foreground italic"></span>
)}
</div>
</div>
{health?.ollama?.error && (
<p className="mt-1 text-[10px] text-destructive line-clamp-2">{health.ollama.error}</p>
)}
</CardContent>
</Card>
<Card className="relative overflow-hidden border border-border/50 bg-background/50 backdrop-blur-md">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Database className="h-4 w-4 text-primary" />
Qdrant Vector DB
</CardTitle>
{isHealthLoading ? (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
) : (
renderStatusBadge(health?.qdrant?.status)
)}
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span></span>
<span className="font-semibold text-foreground">
{health?.qdrant?.latencyMs !== undefined ? `${health.qdrant.latencyMs} ms` : '-'}
</span>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">:</span>
<div className="flex flex-wrap gap-1">
{healthQdrantCollections.length > 0 ? (
healthQdrantCollections.map((c) => (
<Badge key={c} variant="outline" className="text-[10px] py-0 px-1 bg-background/30">
{c}
</Badge>
))
) : (
<span className="text-[10px] text-muted-foreground italic"></span>
)}
</div>
</div>
{health?.qdrant?.error && (
<p className="mt-1 text-[10px] text-destructive line-clamp-2">{health.qdrant.error}</p>
)}
</CardContent>
</Card>
<Card className="relative overflow-hidden border border-border/50 bg-background/50 backdrop-blur-md">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<ScanText className="h-4 w-4 text-primary" />
OCR Sidecar (np-dms-ocr)
</CardTitle>
{isHealthLoading ? (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
) : (
renderStatusBadge(health?.ocr?.status)
)}
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span></span>
<span className="font-semibold text-foreground">
{health?.ocr?.latencyMs !== undefined ? `${health.ocr.latencyMs} ms` : '-'}
</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>URL</span>
<span className="font-mono text-[10px] text-foreground truncate max-w-[160px]">
{health?.ocr?.url ?? '-'}
</span>
</div>
{health?.ocr?.error && <p className="mt-1 text-[10px] text-destructive line-clamp-2">{health.ocr.error}</p>}
</CardContent>
</Card>
<Card className="relative overflow-hidden border border-border/50 bg-background/50 backdrop-blur-md">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Activity className="h-4 w-4 text-primary" />
BullMQ Queue Health
</CardTitle>
{isHealthLoading ? (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
) : (
<Badge variant="outline" className="text-[10px]">
{health?.timestamp ? new Date(health.timestamp).toLocaleTimeString() : 'N/A'}
</Badge>
)}
</CardHeader>
<CardContent className="space-y-2">
<div className="space-y-1 text-xs">
<div className="flex items-center justify-between font-medium text-[11px] border-b pb-1 mb-1">
<span> / </span>
<span>Active / Waiting / Failed</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span className="flex items-center gap-1 font-mono">
realtime
{health?.queues?.realtime?.isPaused && (
<span className="text-[9px] text-amber-500 font-sans">(Paused)</span>
)}
</span>
<span className="font-semibold text-foreground">
{health?.queues?.realtime?.active ?? 0} / {health?.queues?.realtime?.waiting ?? 0} /{' '}
<span className={(health?.queues?.realtime?.failed ?? 0) > 0 ? 'text-destructive' : ''}>
{health?.queues?.realtime?.failed ?? 0}
</span>
</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span className="flex items-center gap-1 font-mono">
batch
{health?.queues?.batch?.isPaused && (
<span className="text-[9px] text-amber-500 font-sans">(Paused)</span>
)}
</span>
<span className="font-semibold text-foreground">
{health?.queues?.batch?.active ?? 0} / {health?.queues?.batch?.waiting ?? 0} /{' '}
<span className={(health?.queues?.batch?.failed ?? 0) > 0 ? 'text-destructive' : ''}>
{health?.queues?.batch?.failed ?? 0}
</span>
</span>
</div>
</div>
{(health?.queues?.realtime?.error || health?.queues?.batch?.error) && (
<p className="mt-1 text-[10px] text-destructive line-clamp-1">
{health.queues.realtime.error || health.queues.batch.error}
</p>
)}
</CardContent>
</Card>
<Card className="relative overflow-hidden border border-border/50 bg-background/50 backdrop-blur-md md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Cpu className="h-4 w-4 text-primary" />
VRAM GPU Monitor
</CardTitle>
{vramStatus ? (
<Badge variant={vramStatus.usagePercent > 85 ? 'destructive' : 'secondary'} className="text-[10px]">
{vramStatus.usagePercent}% Used
</Badge>
) : (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
</CardHeader>
<CardContent className="space-y-4">
{vramStatus ? (
<>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">GPU VRAM Usage</span>
<span className="font-semibold text-foreground">
{vramStatus.usedVRAMMB} MB / {vramStatus.totalVRAMMB} MB
</span>
</div>
<Progress value={vramStatus.usagePercent} className="h-2" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1 text-xs">
<span className="text-muted-foreground block"> GPU :</span>
<div className="flex flex-wrap gap-1 mt-1">
{vramLoadedModels.length > 0 ? (
vramLoadedModels.map((m) => (
<Badge
key={m.modelId}
className="bg-primary/10 text-primary border-none hover:bg-primary/20 text-[10px]"
>
{m.modelName}
{typeof m.vramUsageMB === 'number' ? ` (${m.vramUsageMB} MB)` : ''}
</Badge>
))
) : (
<span className="text-[10px] text-muted-foreground italic">
</span>
)}
</div>
</div>
<div className="space-y-1 text-xs sm:text-right">
<span className="text-muted-foreground block">:</span>
<Badge variant={vramStatus.canLoadModel ? 'default' : 'destructive'} className="mt-1 text-[10px]">
{vramStatus.canLoadModel ? 'พร้อมโหลดโมเดลหลัก' : 'หน่วยความจำไม่เพียงพอ (OOM Guard)'}
</Badge>
</div>
</div>
</>
) : (
<p className="text-xs text-muted-foreground italic text-center py-4"> GPU VRAM...</p>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Power className="h-5 w-5" />
System Toggle
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="text-base font-medium">
{aiEnabled ? 'AI พร้อมให้ผู้ใช้ทั่วไปใช้งาน' : 'AI ถูกปิดสำหรับผู้ใช้ทั่วไป'}
</div>
<div className="text-sm text-muted-foreground">
Superadmin
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1.5 pt-1 flex-wrap">
<span>Active Models:</span>
<Badge
variant="outline"
className="text-[10px] py-0 px-1.5 border-primary/20 text-primary bg-primary/5 font-semibold"
>
{isHealthLoading ? 'Loading...' : toCanonicalModel(health?.activeModels?.main ?? 'np-dms-ai')}
</Badge>
<span className="text-muted-foreground/50">+</span>
<Badge
variant="outline"
className="text-[10px] py-0 px-1.5 border-purple-500/20 text-purple-600 dark:text-purple-400 bg-purple-500/5 font-semibold"
>
{isHealthLoading ? 'Loading...' : toCanonicalModel(health?.activeModels?.ocr ?? 'np-dms-ocr')}
</Badge>
</div>
</div>
<div className="flex items-center gap-3">
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
<Switch
checked={aiEnabled}
disabled={busy || isError}
aria-label="Toggle AI features"
onCheckedChange={handleToggle}
/>
</div>
</div>
{isError && (
<div className="mt-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
AI
</div>
)}
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-5 w-5" />
Protection
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
AI AI inference endpoints HTTP 503
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Polling</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span>
30
{(isFetching || isHealthLoading) && !(isLoading || isHealthLoading) ? ' (กำลังรีเฟรช)' : ''}
</span>
<Button type="button" variant="outline" size="sm" onClick={() => void handleRefreshAll()}>
Refresh
</Button>
</CardContent>
</Card>
</div>
<Tabs defaultValue="overview" className="w-full space-y-6">
<TabsList className="grid w-full grid-cols-3 max-w-[500px]">
<TabsTrigger value="overview">Overview & Health</TabsTrigger>
<TabsTrigger value="overview">System Toggle</TabsTrigger>
<TabsTrigger value="playground">RAG Playground</TabsTrigger>
<TabsTrigger value="ocr">OCR Sandbox</TabsTrigger>
<TabsTrigger value="sandbox">3-Step Pipeline Sandbox</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
<div className="grid gap-4 md:grid-cols-3">
<Card className="relative overflow-hidden border border-border/50 bg-background/50 backdrop-blur-md">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Cpu className="h-4 w-4 text-primary" />
Ollama AI Engine
</CardTitle>
{isHealthLoading ? <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /> : renderStatusBadge(health?.ollama?.status)}
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span></span>
<span className="font-semibold text-foreground">{health?.ollama?.latencyMs !== undefined ? `${health.ollama.latencyMs} ms` : '-'}</span>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">:</span>
<div className="flex flex-wrap gap-1">
{healthOllamaModels.length > 0 ? (
healthOllamaModels.map((m) => (
<Badge key={m} variant="secondary" className="text-[10px] py-0 px-1">
{m}
</Badge>
))
) : (
<span className="text-[10px] text-muted-foreground italic"></span>
)}
</div>
</div>
{health?.ollama?.error && (
<p className="mt-1 text-[10px] text-destructive line-clamp-2">{health.ollama.error}</p>
)}
</CardContent>
</Card>
<Card className="relative overflow-hidden border border-border/50 bg-background/50 backdrop-blur-md">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Database className="h-4 w-4 text-primary" />
Qdrant Vector DB
</CardTitle>
{isHealthLoading ? <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /> : renderStatusBadge(health?.qdrant?.status)}
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span></span>
<span className="font-semibold text-foreground">{health?.qdrant?.latencyMs !== undefined ? `${health.qdrant.latencyMs} ms` : '-'}</span>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">:</span>
<div className="flex flex-wrap gap-1">
{healthQdrantCollections.length > 0 ? (
healthQdrantCollections.map((c) => (
<Badge key={c} variant="outline" className="text-[10px] py-0 px-1 bg-background/30">
{c}
</Badge>
))
) : (
<span className="text-[10px] text-muted-foreground italic"></span>
)}
</div>
</div>
{health?.qdrant?.error && (
<p className="mt-1 text-[10px] text-destructive line-clamp-2">{health.qdrant.error}</p>
)}
</CardContent>
</Card>
<Card className="relative overflow-hidden border border-border/50 bg-background/50 backdrop-blur-md">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<ScanText className="h-4 w-4 text-primary" />
OCR Sidecar (np-dms-ocr)
</CardTitle>
{isHealthLoading ? <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /> : renderStatusBadge(health?.ocr?.status)}
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span></span>
<span className="font-semibold text-foreground">{health?.ocr?.latencyMs !== undefined ? `${health.ocr.latencyMs} ms` : '-'}</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>URL</span>
<span className="font-mono text-[10px] text-foreground truncate max-w-[160px]">{health?.ocr?.url ?? '-'}</span>
</div>
{health?.ocr?.error && (
<p className="mt-1 text-[10px] text-destructive line-clamp-2">{health.ocr.error}</p>
)}
</CardContent>
</Card>
<Card className="relative overflow-hidden border border-border/50 bg-background/50 backdrop-blur-md">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Activity className="h-4 w-4 text-primary" />
BullMQ Queue Health
</CardTitle>
{isHealthLoading ? (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
) : (
<Badge variant="outline" className="text-[10px]">
{health?.timestamp ? new Date(health.timestamp).toLocaleTimeString() : 'N/A'}
</Badge>
)}
</CardHeader>
<CardContent className="space-y-2">
<div className="space-y-1 text-xs">
<div className="flex items-center justify-between font-medium text-[11px] border-b pb-1 mb-1">
<span> / </span>
<span>Active / Waiting / Failed</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span className="flex items-center gap-1 font-mono">
realtime
{health?.queues?.realtime?.isPaused && <span className="text-[9px] text-amber-500 font-sans">(Paused)</span>}
</span>
<span className="font-semibold text-foreground">
{health?.queues?.realtime?.active ?? 0} / {health?.queues?.realtime?.waiting ?? 0} /{' '}
<span className={(health?.queues?.realtime?.failed ?? 0) > 0 ? 'text-destructive' : ''}>
{health?.queues?.realtime?.failed ?? 0}
</span>
</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span className="flex items-center gap-1 font-mono">
batch
{health?.queues?.batch?.isPaused && <span className="text-[9px] text-amber-500 font-sans">(Paused)</span>}
</span>
<span className="font-semibold text-foreground">
{health?.queues?.batch?.active ?? 0} / {health?.queues?.batch?.waiting ?? 0} /{' '}
<span className={(health?.queues?.batch?.failed ?? 0) > 0 ? 'text-destructive' : ''}>
{health?.queues?.batch?.failed ?? 0}
</span>
</span>
</div>
</div>
{(health?.queues?.realtime?.error || health?.queues?.batch?.error) && (
<p className="mt-1 text-[10px] text-destructive line-clamp-1">
{health.queues.realtime.error || health.queues.batch.error}
</p>
)}
</CardContent>
</Card>
<Card className="relative overflow-hidden border border-border/50 bg-background/50 backdrop-blur-md md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Cpu className="h-4 w-4 text-primary" />
VRAM GPU Monitor
</CardTitle>
{vramStatus ? (
<Badge variant={vramStatus.usagePercent > 85 ? 'destructive' : 'secondary'} className="text-[10px]">
{vramStatus.usagePercent}% Used
</Badge>
) : (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
</CardHeader>
<CardContent className="space-y-4">
{vramStatus ? (
<>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">GPU VRAM Usage</span>
<span className="font-semibold text-foreground">
{vramStatus.usedVRAMMB} MB / {vramStatus.totalVRAMMB} MB
</span>
</div>
<Progress value={vramStatus.usagePercent} className="h-2" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1 text-xs">
<span className="text-muted-foreground block"> GPU :</span>
<div className="flex flex-wrap gap-1 mt-1">
{vramLoadedModels.length > 0 ? (
vramLoadedModels.map((m) => (
<Badge key={m.modelId} className="bg-primary/10 text-primary border-none hover:bg-primary/20 text-[10px]">
{m.modelName}
{typeof m.vramUsageMB === 'number'
? ` (${m.vramUsageMB} MB)`
: ''}
</Badge>
))
) : (
<span className="text-[10px] text-muted-foreground italic"></span>
)}
</div>
</div>
<div className="space-y-1 text-xs sm:text-right">
<span className="text-muted-foreground block">:</span>
<Badge variant={vramStatus.canLoadModel ? 'default' : 'destructive'} className="mt-1 text-[10px]">
{vramStatus.canLoadModel ? 'พร้อมโหลดโมเดลหลัก' : 'หน่วยความจำไม่เพียงพอ (OOM Guard)'}
</Badge>
</div>
</div>
</>
) : (
<p className="text-xs text-muted-foreground italic text-center py-4"> GPU VRAM...</p>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Power className="h-5 w-5" />
System Toggle
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="text-base font-medium">
{aiEnabled ? 'AI พร้อมให้ผู้ใช้ทั่วไปใช้งาน' : 'AI ถูกปิดสำหรับผู้ใช้ทั่วไป'}
</div>
<div className="text-sm text-muted-foreground">
Superadmin
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1.5 pt-1 flex-wrap">
<span>Active Models:</span>
<Badge variant="outline" className="text-[10px] py-0 px-1.5 border-primary/20 text-primary bg-primary/5 font-semibold">
{isHealthLoading ? 'Loading...' : toCanonicalModel(health?.activeModels?.main ?? 'np-dms-ai')}
</Badge>
<span className="text-muted-foreground/50">+</span>
<Badge variant="outline" className="text-[10px] py-0 px-1.5 border-purple-500/20 text-purple-600 dark:text-purple-400 bg-purple-500/5 font-semibold">
{isHealthLoading ? 'Loading...' : toCanonicalModel(health?.activeModels?.ocr ?? 'np-dms-ocr')}
</Badge>
</div>
</div>
<div className="flex items-center gap-3">
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
<Switch
checked={aiEnabled}
disabled={busy || isError}
aria-label="Toggle AI features"
onCheckedChange={handleToggle}
/>
</div>
</div>
{isError && (
<div className="mt-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
AI
</div>
)}
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-5 w-5" />
Protection
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
AI AI inference endpoints HTTP 503
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Polling</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span>
30
{(isFetching || isHealthLoading) && !(isLoading || isHealthLoading) ? ' (กำลังรีเฟรช)' : ''}
</span>
<Button type="button" variant="outline" size="sm" onClick={() => void handleRefreshAll()}>
Refresh
</Button>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="playground" className="space-y-6">
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
@@ -544,7 +593,8 @@ export default function AiAdminConsolePage() {
RAG Sandbox Playground (isolated)
</CardTitle>
<p className="text-sm text-muted-foreground">
Retrieval-Augmented Generation (RAG) (Priority 1)
Retrieval-Augmented Generation (RAG)
(Priority 1)
</p>
</CardHeader>
<CardContent>
@@ -586,9 +636,7 @@ export default function AiAdminConsolePage() {
rows={4}
className="resize-none border border-input bg-background/50"
/>
<div className="text-right text-[11px] text-muted-foreground">
{question.length}
</div>
<div className="text-right text-[11px] text-muted-foreground">{question.length} </div>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button
@@ -641,7 +689,10 @@ export default function AiAdminConsolePage() {
(RAG Sandbox Answer)
</CardTitle>
{sandboxJobResult.usedFallbackModel && (
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/20 bg-amber-500/5">
<Badge
variant="outline"
className="text-[10px] text-amber-500 border-amber-500/20 bg-amber-500/5"
>
(Fallback)
</Badge>
)}
@@ -681,7 +732,10 @@ export default function AiAdminConsolePage() {
{cite.docNumber || 'ไม่มีเลขที่เอกสาร'}
</span>
</div>
<Badge variant="outline" className="text-[10px] py-0 border-border/50 text-muted-foreground">
<Badge
variant="outline"
className="text-[10px] py-0 border-border/50 text-muted-foreground"
>
Score Match: {(cite.score * 100).toFixed(1)}%
</Badge>
</div>
@@ -710,7 +764,8 @@ export default function AiAdminConsolePage() {
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground leading-relaxed">
{sandboxJobResult.errorMessage || 'เกิดข้อผิดพลาดในการเรียกใช้ Local LLM หรือ Vector DB ใน Sandbox Sandbox process ล้มเหลว กรุณาตรวจสอบสถานะสุขภาพของ Ollama Engine/Qdrant DB ใน Overview Tab'}
{sandboxJobResult.errorMessage ||
'เกิดข้อผิดพลาดในการเรียกใช้ Local LLM หรือ Vector DB ใน Sandbox Sandbox process ล้มเหลว กรุณาตรวจสอบสถานะสุขภาพของ Ollama Engine/Qdrant DB ใน Overview Tab'}
</p>
</CardContent>
</Card>
@@ -719,8 +774,11 @@ export default function AiAdminConsolePage() {
)}
</TabsContent>
<TabsContent value="ocr" className="space-y-6">
<OcrSandboxPromptManager />
<TabsContent value="sandbox" className="space-y-6">
<PromptManagementTabs />
<div className="mt-8">
<SandboxTabs />
</div>
</TabsContent>
</Tabs>
</div>
+74 -21
View File
@@ -3,8 +3,9 @@
// - 2026-06-14: Created SandboxTabs component with 3-step testing (OCR -> AI Extract -> RAG Prep) (conforming to task T037)
// - 2026-06-15: ลบ Tesseract ออกจาก OCR Engine dropdown — canonical engines: auto + np-dms-ocr เท่านั้น (ADR-034)
// - 2026-06-15: เพิ่ม read-only prompt info banner แสดง version + template snippet ที่กำลังทดสอบ
// - 2026-06-18: อัปเดตให้รองรับ prompt_type='ocr_system' และ 'ocr_extraction' แยกกันตาม spec 238 (FR-006, FR-007)
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
@@ -12,6 +13,7 @@ import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { adminAiService } from '@/lib/services/admin-ai.service';
import { adminAiPromptService } from '@/lib/services/admin-ai-prompt.service';
import { useProjects, useContracts } from '@/hooks/use-master-data';
import { toast } from 'sonner';
import {
@@ -26,7 +28,7 @@ import {
} from 'lucide-react';
interface SandboxTabsProps {
promptType: string;
promptType?: string;
selectedVersionNumber?: number;
selectedTemplate?: string;
onActivateVersion?: (versionNumber: number) => void;
@@ -54,9 +56,9 @@ interface SandboxJobResult {
}
export default function SandboxTabs({
promptType: _promptType,
promptType: _promptType = 'ocr_system',
selectedVersionNumber,
selectedTemplate,
selectedTemplate: _selectedTemplate,
onActivateVersion,
}: SandboxTabsProps) {
// Master data state
@@ -88,6 +90,37 @@ export default function SandboxTabs({
const [step3Complete, setStep3Complete] = useState<boolean>(false);
const allStepsComplete = step1Complete && step2Complete && step3Complete;
// Load active prompt templates from service (FR-009, FR-010)
const [activeOcrSystemTemplate, setActiveOcrSystemTemplate] = useState<string>('');
const [activeOcrSystemVersion, setActiveOcrSystemVersion] = useState<number | null>(null);
const [activeExtractionTemplate, setActiveExtractionTemplate] = useState<string>('');
const [activeExtractionVersion, setActiveExtractionVersion] = useState<number | null>(null);
useEffect(() => {
const loadActivePrompts = async () => {
try {
// Load OCR system prompt (Step 1)
const ocrSystemPrompts = await adminAiPromptService.getPrompts('ocr_system');
const ocrSystemActive = ocrSystemPrompts.find((p) => p.isActive);
if (ocrSystemActive) {
setActiveOcrSystemTemplate(ocrSystemActive.template);
setActiveOcrSystemVersion(ocrSystemActive.versionNumber);
}
// Load AI extraction prompt (Step 2)
const extractionPrompts = await adminAiPromptService.getPrompts('ocr_extraction');
const extractionActive = extractionPrompts.find((p) => p.isActive);
if (extractionActive) {
setActiveExtractionTemplate(extractionActive.template);
setActiveExtractionVersion(extractionActive.versionNumber);
}
} catch {
// Silent fail - will use default warning banner
}
};
loadActivePrompts();
}, []);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setFile(e.target.files[0]);
@@ -173,7 +206,7 @@ export default function SandboxTabs({
try {
const res = await adminAiService.submitSandboxAiExtract(
requestPublicId,
selectedVersionNumber,
activeExtractionVersion || undefined,
selectedProject,
selectedContract || undefined
);
@@ -233,37 +266,57 @@ export default function SandboxTabs({
</CardDescription>
</CardHeader>
<CardContent className="pt-5 space-y-6">
{/* Prompt info banner — read-only, แสดง version + template snippet ที่กำลังทดสอบ */}
{/* Prompt info banner — read-only, แสดง version + template snippet ที่กำลังทดสอบ (FR-009, FR-010) */}
<div className="rounded-lg border border-primary/20 bg-primary/[0.03] px-4 py-3 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[11px] font-semibold text-primary">
(Prompt Under Test)
</span>
{selectedVersionNumber ? (
<span className="font-mono text-[11px] font-bold text-foreground">v{selectedVersionNumber}</span>
) : (
<span className="text-[11px] text-muted-foreground italic"> Active</span>
</div>
<div className="space-y-2">
{/* Step 1: OCR System Prompt */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Step 1 (OCR):</span>
{activeOcrSystemVersion ? (
<span className="font-mono text-[10px] font-bold text-foreground">v{activeOcrSystemVersion}</span>
) : (
<span className="text-[10px] text-amber-600 dark:text-amber-400 italic"> Active</span>
)}
</div>
{activeOcrSystemTemplate && (
<p className="text-[10px] text-muted-foreground font-mono leading-relaxed line-clamp-2 whitespace-pre-wrap select-text bg-background/50 p-2 rounded">
{activeOcrSystemTemplate.slice(0, 200)}{activeOcrSystemTemplate.length > 200 ? '…' : ''}
</p>
)}
{/* Step 2: AI Extraction Prompt */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Step 2 (AI Extract):</span>
{activeExtractionVersion ? (
<span className="font-mono text-[10px] font-bold text-foreground">v{activeExtractionVersion}</span>
) : (
<span className="text-[10px] text-amber-600 dark:text-amber-400 italic"> Active</span>
)}
</div>
{activeExtractionTemplate && (
<p className="text-[10px] text-muted-foreground font-mono leading-relaxed line-clamp-2 whitespace-pre-wrap select-text bg-background/50 p-2 rounded">
{activeExtractionTemplate.slice(0, 200)}{activeExtractionTemplate.length > 200 ? '…' : ''}
</p>
)}
</div>
{selectedTemplate ? (
<p className="text-[10px] text-muted-foreground font-mono leading-relaxed line-clamp-3 whitespace-pre-wrap select-text">
{selectedTemplate.slice(0, 300)}{selectedTemplate.length > 300 ? '…' : ''}
</p>
) : (
<p className="text-[10px] text-muted-foreground italic"> Version History template</p>
)}
</div>
{/* UI fallback warning when no active OCR system prompt (gap-3) */}
{_promptType === 'ocr_system' && !selectedTemplate && (
{/* UI fallback warning when no active prompts (gap-3) */}
{(!activeOcrSystemTemplate || !activeExtractionTemplate) && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/[0.05] px-4 py-3 space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold text-amber-600 dark:text-amber-400">
คำเตือน: ไม่มี OCR System Prompt
คำเตือน: ไม่มี Prompt
</span>
</div>
<p className="text-[10px] text-amber-700 dark:text-amber-300 leading-relaxed">
(default) OCR OCR System Prompt
{!activeOcrSystemTemplate && '• ไม่มี OCR System Prompt (Step 1) '}
{!activeExtractionTemplate && '• ไม่มี AI Extraction Prompt (Step 2) '}
(default) Prompt
</p>
</div>
)}
+4 -1
View File
@@ -200,12 +200,15 @@ const normalizeVramStatus = (value: unknown): VramStatusResponse => {
const usedVRAMMB = raw.usedVRAMMB ?? raw.usedVramMb ?? 0;
const usagePercent = raw.usagePercent ?? (totalVRAMMB > 0 ? Math.round((usedVRAMMB / totalVRAMMB) * 100) : 0);
// Backend now sends loadedModels with vramUsageMB directly
const loadedModels = normalizeLoadedModels(raw.loadedModels);
return {
totalVRAMMB,
usedVRAMMB,
usagePercent,
thresholdPercent: raw.thresholdPercent ?? 90,
loadedModels: normalizeLoadedModels(raw.loadedModels),
loadedModels,
canLoadModel: raw.canLoadModel ?? raw.hasCapacity ?? false,
lastUpdated: raw.lastUpdated ?? new Date().toISOString(),
};