feat(ai): ADR-032 Typhoon OCR integration - models, processors, cache, VRAM monitor, sandbox UI
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
// File: frontend/app/(admin)/admin/ai/page.tsx
|
||||
'use client';
|
||||
// Change Log
|
||||
// - 2026-05-21: เพิ่มหน้า AI Admin Console สำหรับเปิด/ปิด AI features.
|
||||
// - 2026-05-21: เพิ่มส่วนแสดงผลสถานะสุขภาพของระบบ AI (Ollama, Qdrant, queues) แบบ real-time polling 30s (T030, T031).
|
||||
@@ -7,6 +6,9 @@
|
||||
// - 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)
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -24,6 +26,7 @@ import { projectService } from '@/lib/services/project.service';
|
||||
import { adminAiService, AiSandboxJobResult, AiAvailableModel } from '@/lib/services/admin-ai.service';
|
||||
import { toast } from 'sonner';
|
||||
import OcrSandboxPromptManager from '@/components/admin/ai/OcrSandboxPromptManager';
|
||||
import OcrEngineSelector from '@/components/admin/ai/OcrEngineSelector';
|
||||
|
||||
interface SandboxProject {
|
||||
publicId: string;
|
||||
@@ -45,7 +48,6 @@ export default function AiAdminConsolePage() {
|
||||
const [sandboxProgress, setSandboxProgress] = useState<number>(0);
|
||||
const [sandboxStatusText, setSandboxStatusText] = useState<string>('');
|
||||
|
||||
|
||||
// AI Model Management State (ADR-027)
|
||||
const { data: aiModelsData, refetch: refetchModels } = useQuery<{ models: AiAvailableModel[]; activeModel: string }>({
|
||||
queryKey: ['ai-available-models'],
|
||||
@@ -56,6 +58,15 @@ export default function AiAdminConsolePage() {
|
||||
const availableModels = aiModelsData?.models ?? [];
|
||||
const activeModel = aiModelsData?.activeModel ?? '';
|
||||
|
||||
// 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<SandboxProject[]>({
|
||||
queryKey: ['admin-sandbox-projects'],
|
||||
queryFn: async () => {
|
||||
@@ -63,17 +74,23 @@ export default function AiAdminConsolePage() {
|
||||
return res as SandboxProject[];
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggle = async (enabled: boolean): Promise<void> => {
|
||||
await toggleMutation.mutateAsync(enabled);
|
||||
};
|
||||
|
||||
const handleModelChange = async (modelName: string): Promise<void> => {
|
||||
const handleModelChange = async (modelId: string): Promise<void> => {
|
||||
try {
|
||||
await adminAiService.setActiveModel(modelName);
|
||||
toast.success(`เปลี่ยนโมเดลเป็น ${modelName} สำเร็จ`);
|
||||
const selectedModel = availableModels.find(m => m.modelId === modelId || String(m.id) === modelId);
|
||||
const name = selectedModel?.modelName || modelId;
|
||||
await adminAiService.setActiveModel(modelId);
|
||||
toast.success(`เปลี่ยนโมเดลเป็น ${name} สำเร็จ`);
|
||||
await refetchModels();
|
||||
} catch {
|
||||
toast.error('ไม่สามารถเปลี่ยนโมเดลได้');
|
||||
refetchVram();
|
||||
} catch (err: unknown) {
|
||||
const errorResponse = err as { response?: { data?: { message?: string } } };
|
||||
const errorMsg = errorResponse.response?.data?.message || 'ไม่สามารถเปลี่ยนโมเดลได้เนื่องจาก VRAM ไม่เพียงพอ';
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -97,9 +114,11 @@ export default function AiAdminConsolePage() {
|
||||
toast.error('ไม่สามารถลบโมเดลได้');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshAll = async (): Promise<void> => {
|
||||
await Promise.all([refetch(), refetchHealth()]);
|
||||
await Promise.all([refetch(), refetchHealth(), refetchModels(), refetchVram()]);
|
||||
};
|
||||
|
||||
const handleSubmitSandbox = async (e: React.FormEvent): Promise<void> => {
|
||||
e.preventDefault();
|
||||
if (!selectedProject) {
|
||||
@@ -125,6 +144,7 @@ export default function AiAdminConsolePage() {
|
||||
setSandboxStatusText('');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!sandboxJobId) return;
|
||||
let timer: NodeJS.Timeout;
|
||||
@@ -182,6 +202,7 @@ export default function AiAdminConsolePage() {
|
||||
return <Badge variant="destructive">Down</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -272,7 +293,7 @@ export default function AiAdminConsolePage() {
|
||||
<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" />
|
||||
PaddleOCR Sidecar
|
||||
OCR Sidecar (Tesseract)
|
||||
</CardTitle>
|
||||
{isHealthLoading ? <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /> : renderStatusBadge(health?.ocr?.status)}
|
||||
</CardHeader>
|
||||
@@ -342,7 +363,62 @@ export default function AiAdminConsolePage() {
|
||||
)}
|
||||
</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">
|
||||
{vramStatus.loadedModels && vramStatus.loadedModels.length > 0 ? (
|
||||
vramStatus.loadedModels.map((m) => (
|
||||
<Badge key={m.modelId || m.modelName} className="bg-primary/10 text-primary border-none hover:bg-primary/20 text-[10px]">
|
||||
{m.modelName} ({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">
|
||||
@@ -394,7 +470,7 @@ export default function AiAdminConsolePage() {
|
||||
โมเดล AI ที่ใช้งานอยู่ (Global)
|
||||
</label>
|
||||
<Select
|
||||
value={activeModel}
|
||||
value={availableModels.find((m) => m.modelName === activeModel)?.modelId || availableModels.find((m) => m.modelName === activeModel)?.id?.toString() || ''}
|
||||
onValueChange={handleModelChange}
|
||||
>
|
||||
<SelectTrigger id="model-select" className="w-full sm:w-[300px]">
|
||||
@@ -404,13 +480,13 @@ export default function AiAdminConsolePage() {
|
||||
{availableModels
|
||||
.filter((m) => m.isActive)
|
||||
.map((model) => (
|
||||
<SelectItem key={model.modelName} value={model.modelName}>
|
||||
<SelectItem key={model.modelId || model.modelName} value={model.modelId || model.id?.toString() || model.modelName}>
|
||||
{model.modelName}
|
||||
{model.isDefault && (
|
||||
<Badge variant="secondary" className="ml-2 text-[10px]">Default</Badge>
|
||||
)}
|
||||
{model.vramGb && (
|
||||
<span className="ml-1 text-muted-foreground">({model.vramGb}GB)</span>
|
||||
{model.vramRequirementMB && (
|
||||
<span className="ml-1 text-muted-foreground">({Math.round(model.vramRequirementMB / 1024 * 10) / 10}GB VRAM)</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -430,7 +506,7 @@ export default function AiAdminConsolePage() {
|
||||
) : (
|
||||
availableModels.map((model) => (
|
||||
<div
|
||||
key={model.modelName}
|
||||
key={model.modelId || model.modelName}
|
||||
className="flex items-center justify-between p-2 rounded border bg-background/50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -447,6 +523,11 @@ export default function AiAdminConsolePage() {
|
||||
{activeModel === model.modelName && (
|
||||
<Badge variant="default" className="text-[10px] bg-emerald-500">Current</Badge>
|
||||
)}
|
||||
{model.vramRequirementMB && (
|
||||
<Badge variant="outline" className="text-[10px] border-amber-500/20 text-amber-500 bg-amber-500/5">
|
||||
{Math.round(model.vramRequirementMB / 1024 * 10) / 10} GB VRAM
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!model.isDefault && (
|
||||
@@ -478,6 +559,9 @@ export default function AiAdminConsolePage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* OCR Engine Management Card (ADR-032) */}
|
||||
<OcrEngineSelector />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -507,6 +591,7 @@ export default function AiAdminConsolePage() {
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="playground" className="space-y-6">
|
||||
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader>
|
||||
@@ -689,6 +774,7 @@ export default function AiAdminConsolePage() {
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ocr" className="space-y-6">
|
||||
<OcrSandboxPromptManager />
|
||||
</TabsContent>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// File: frontend/components/admin/ai/OcrEngineSelector.tsx
|
||||
// Change Log
|
||||
// - 2026-05-30: สร้าง OcrEngineSelector สำหรับดึงและสลับ OCR Engine แบบไดนามิก (T019, T020, US1)
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { ScanText, Server, AlertCircle, CheckCircle2, Cpu } from 'lucide-react';
|
||||
import { adminAiService, OcrEngineResponse } from '@/lib/services/admin-ai.service';
|
||||
|
||||
/** Component สำหรับเลือกและจัดการ OCR Engine ในระบบ */
|
||||
export default function OcrEngineSelector() {
|
||||
const [engines, setEngines] = useState<OcrEngineResponse[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isUpdating, setIsUpdating] = useState<string | null>(null);
|
||||
|
||||
const fetchEngines = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await adminAiService.getOcrEngines();
|
||||
setEngines(data);
|
||||
} catch (_err: unknown) {
|
||||
toast.error('ไม่สามารถดึงข้อมูล OCR Engines ได้');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchEngines();
|
||||
}, []);
|
||||
|
||||
const handleSelectEngine = async (engineId: string, engineName: string) => {
|
||||
try {
|
||||
setIsUpdating(engineId);
|
||||
await adminAiService.selectOcrEngine(engineId);
|
||||
toast.success(`เปลี่ยนเอนจิน OCR หลักเป็น ${engineName} สำเร็จ`);
|
||||
await fetchEngines();
|
||||
} catch (_err: unknown) {
|
||||
toast.error('ไม่สามารถเปลี่ยนเอนจิน OCR ได้');
|
||||
} finally {
|
||||
setIsUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="h-6 w-48 bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 w-64 bg-muted animate-pulse rounded mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="h-20 bg-muted animate-pulse rounded" />
|
||||
<div className="h-20 bg-muted animate-pulse rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<ScanText className="h-4 w-4 text-primary" />
|
||||
ระบบจัดการ OCR Engine
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
เลือกเอนจินประมวลผลหลักสำหรับระบบสกัดเอกสารและการรัน Sandbox
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{engines.map((engine) => {
|
||||
const isTyphoon = engine.engineType === 'typhoon_ocr';
|
||||
return (
|
||||
<div
|
||||
key={engine.engineId}
|
||||
className={`relative flex flex-col sm:flex-row sm:items-center justify-between p-4 rounded-lg border transition-all duration-300 ${
|
||||
engine.isCurrentActive
|
||||
? 'border-primary/50 bg-primary/5 shadow-sm'
|
||||
: 'border-border/30 hover:border-border/60 bg-background/30'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-1.5 pr-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm">{engine.engineName}</span>
|
||||
{engine.isCurrentActive && (
|
||||
<Badge variant="default" className="text-[10px] h-4 flex items-center gap-0.5">
|
||||
<CheckCircle2 className="h-2.5 w-2.5" />
|
||||
กำลังใช้งาน
|
||||
</Badge>
|
||||
)}
|
||||
{isTyphoon && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20">
|
||||
AI Powered
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{isTyphoon
|
||||
? 'สกัดภาษาไทยความแม่นยำสูง (95%+) เหมาะสำหรับภาษาไทยผสมอังกฤษ'
|
||||
: 'เอนจินมาตรฐานเบสไลน์ ประมวลผลรวดเร็วและใช้ทรัพยากรต่ำ'}
|
||||
</p>
|
||||
<div className="flex gap-4 text-[10px] text-muted-foreground flex-wrap pt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<Server className="h-3 w-3" />
|
||||
จำกัดพร้อมกัน: {engine.concurrentLimit} งาน
|
||||
</span>
|
||||
{isTyphoon && (
|
||||
<>
|
||||
<span className="flex items-center gap-1 text-purple-600 dark:text-purple-400">
|
||||
<Cpu className="h-3 w-3" />
|
||||
ต้องการ VRAM: {(engine.vramRequirementMB / 1024).toFixed(1)} GB
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
เอนจินสำรอง: Tesseract OCR
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 sm:mt-0 flex items-center justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={engine.isCurrentActive ? 'secondary' : 'default'}
|
||||
disabled={engine.isCurrentActive || isUpdating === engine.engineId}
|
||||
onClick={() => handleSelectEngine(engine.engineId, engine.engineName)}
|
||||
className="w-full sm:w-auto text-xs min-w-[100px]"
|
||||
>
|
||||
{isUpdating === engine.engineId ? 'กำลังเปลี่ยน...' : engine.isCurrentActive ? 'เลือกอยู่แล้ว' : 'สลับใช้งาน'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -107,10 +107,15 @@ export default function OcrSandboxPromptManager() {
|
||||
const [activeTab, setActiveTab] = useState<'editor' | 'sandbox'>('editor');
|
||||
// 2-step flow states
|
||||
const [sandboxStep, setSandboxStep] = useState<'ocr' | 'ai'>('ocr');
|
||||
const [selectedOcrEngine, setSelectedOcrEngine] = useState<
|
||||
'auto' | 'tesseract' | 'typhoon-ocr-3b'
|
||||
>('auto');
|
||||
const [ocrResult, setOcrResult] = useState<{
|
||||
requestPublicId: string;
|
||||
ocrText: string;
|
||||
ocrUsed: boolean;
|
||||
engineUsed?: string;
|
||||
fallbackUsed?: boolean;
|
||||
} | null>(null);
|
||||
const [selectedPromptVersion, setSelectedPromptVersion] = useState<number | undefined>(undefined);
|
||||
const { state: sandboxState, jobId: sandboxJobId, reset: resetSandbox } =
|
||||
@@ -195,7 +200,10 @@ export default function OcrSandboxPromptManager() {
|
||||
try {
|
||||
resetSandbox();
|
||||
setSandboxStep('ocr');
|
||||
const { requestPublicId } = await adminAiService.submitSandboxOcr(ocrFile);
|
||||
const { requestPublicId } = await adminAiService.submitSandboxOcr(
|
||||
ocrFile,
|
||||
selectedOcrEngine
|
||||
);
|
||||
toast.success(t('ai.prompt.uploadSuccess'));
|
||||
// Poll สำหรับผลลัพธ์ OCR
|
||||
const pollInterval = setInterval(async () => {
|
||||
@@ -207,6 +215,8 @@ export default function OcrSandboxPromptManager() {
|
||||
requestPublicId,
|
||||
ocrText: result.ocrText || '',
|
||||
ocrUsed: result.ocrUsed || false,
|
||||
engineUsed: result.engineUsed,
|
||||
fallbackUsed: result.fallbackUsed,
|
||||
});
|
||||
setSandboxStep('ai');
|
||||
toast.success('OCR completed successfully');
|
||||
@@ -270,6 +280,7 @@ export default function OcrSandboxPromptManager() {
|
||||
setSandboxStep('ocr');
|
||||
setOcrResult(null);
|
||||
setSelectedPromptVersion(undefined);
|
||||
setSelectedOcrEngine('auto');
|
||||
setOcrFile(null);
|
||||
resetSandbox();
|
||||
};
|
||||
@@ -369,6 +380,22 @@ export default function OcrSandboxPromptManager() {
|
||||
{sandboxStep === 'ocr' ? (
|
||||
<form onSubmit={handleStep1Ocr} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">OCR Engine</label>
|
||||
<select
|
||||
value={selectedOcrEngine}
|
||||
onChange={(e) =>
|
||||
setSelectedOcrEngine(
|
||||
e.target.value as 'auto' | 'tesseract' | 'typhoon-ocr-3b'
|
||||
)
|
||||
}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-xs"
|
||||
>
|
||||
<option value="auto">Auto (Current Baseline)</option>
|
||||
<option value="tesseract">Tesseract OCR</option>
|
||||
<option value="typhoon-ocr-3b">Typhoon OCR-3B</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-lg border border-dashed p-8 transition-all',
|
||||
@@ -508,10 +535,19 @@ export default function OcrSandboxPromptManager() {
|
||||
OCR Raw Text (Step 1 Result)
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{ocrResult.ocrUsed ? 'Tesseract' : 'Fast Path (Text Layer)'}
|
||||
{ocrResult.engineUsed === 'typhoon-ocr-3b'
|
||||
? 'Typhoon OCR-3B'
|
||||
: ocrResult.ocrUsed
|
||||
? 'Tesseract'
|
||||
: 'Fast Path (Text Layer)'}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{ocrResult.fallbackUsed && (
|
||||
<div className="mb-3 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
Typhoon OCR unavailable. Fallback to Tesseract was used for this run.
|
||||
</div>
|
||||
)}
|
||||
<div className="relative rounded-md bg-muted p-4 font-mono text-xs overflow-auto max-h-[200px] border border-border/10">
|
||||
<pre className="text-blue-600 dark:text-blue-400 select-text leading-relaxed whitespace-pre-wrap">
|
||||
{ocrResult.ocrText || '(ไม่มีข้อความ)'}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
// - 2026-05-25: เพิ่ม methods สำหรับจัดการโมเดล AI แบบไดนามิก (ADR-027).
|
||||
// - 2026-05-29: เพิ่ม ocr field ใน AiSystemHealth interface ตาม OcrService.checkHealth()
|
||||
// - 2026-05-29: เพิ่ม ocrText, ocrUsed, promptVersionUsed ใน AiSandboxJobResult
|
||||
// - 2026-05-30: เพิ่มเมธอด getOcrEngines และ selectOcrEngine สำหรับจัดการ OCR engines (T017, T018, US1)
|
||||
// - 2026-05-30: เพิ่ม getVramStatus และปรับปรุง getAvailableModels/setActiveModel/addModel ให้เรียกใช้ endpoints ใหม่ที่มี VRAM capacity check (T031-T034, US2)
|
||||
|
||||
import api from '../api/client';
|
||||
|
||||
@@ -63,6 +65,8 @@ export interface AiSandboxJobResult {
|
||||
answer?: string;
|
||||
ocrText?: string;
|
||||
ocrUsed?: boolean;
|
||||
engineUsed?: string;
|
||||
fallbackUsed?: boolean;
|
||||
promptVersionUsed?: number;
|
||||
citations?: AiRagCitation[];
|
||||
confidence?: number;
|
||||
@@ -71,12 +75,30 @@ export interface AiSandboxJobResult {
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface LoadedModelInfo {
|
||||
modelId: string;
|
||||
modelName: string;
|
||||
vramUsageMB: number;
|
||||
}
|
||||
|
||||
export interface VramStatusResponse {
|
||||
totalVRAMMB: number;
|
||||
usedVRAMMB: number;
|
||||
usagePercent: number;
|
||||
thresholdPercent: number;
|
||||
loadedModels: LoadedModelInfo[];
|
||||
canLoadModel: boolean;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
export interface AiAvailableModel {
|
||||
id: number;
|
||||
id?: number;
|
||||
modelId?: string;
|
||||
modelName: string;
|
||||
modelVersion: string;
|
||||
description?: string;
|
||||
vramGb?: number;
|
||||
vramRequirementMB?: number;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
@@ -147,10 +169,12 @@ export const adminAiService = {
|
||||
// --- Step 1: OCR Only (สำหรับตรวจคุณภาพ OCR ก่อนทดสอบ AI) ---
|
||||
|
||||
submitSandboxOcr: async (
|
||||
file: File
|
||||
file: File,
|
||||
engineType: 'auto' | 'tesseract' | 'typhoon-ocr-3b' = 'auto'
|
||||
): Promise<{ requestPublicId: string; jobId: string; status: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('engineType', engineType);
|
||||
const { data } = await api.post('/ai/admin/sandbox/ocr', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
@@ -172,10 +196,10 @@ export const adminAiService = {
|
||||
return extractData<{ requestPublicId: string; jobId: string; status: string }>(data);
|
||||
},
|
||||
|
||||
// --- AI Model Management (ADR-027) ---
|
||||
// --- AI Model Management (ADR-027, US2) ---
|
||||
|
||||
getAvailableModels: async (): Promise<AiModelsResponse> => {
|
||||
const { data } = await api.get('/ai/admin/models');
|
||||
const { data } = await api.get('/ai/models');
|
||||
return extractData<AiModelsResponse>(data);
|
||||
},
|
||||
|
||||
@@ -184,15 +208,20 @@ export const adminAiService = {
|
||||
return extractData<AiActiveModelResponse>(data);
|
||||
},
|
||||
|
||||
setActiveModel: async (modelName: string): Promise<AiActiveModelResponse> => {
|
||||
const { data } = await api.post('/ai/admin/models/active', { modelName });
|
||||
setActiveModel: async (modelId: string): Promise<AiActiveModelResponse> => {
|
||||
const { data } = await api.patch(`/ai/models/${encodeURIComponent(modelId)}/activate`, {});
|
||||
return extractData<AiActiveModelResponse>(data);
|
||||
},
|
||||
|
||||
getVramStatus: async (): Promise<VramStatusResponse> => {
|
||||
const { data } = await api.get('/ai/vram/status');
|
||||
return extractData<VramStatusResponse>(data);
|
||||
},
|
||||
|
||||
addModel: async (
|
||||
model: Omit<AiAvailableModel, 'id' | 'createdAt' | 'updatedAt'>
|
||||
): Promise<{ model: AiAvailableModel }> => {
|
||||
const { data } = await api.post('/ai/admin/models', model);
|
||||
const { data } = await api.post('/ai/models', model);
|
||||
return extractData<{ model: AiAvailableModel }>(data);
|
||||
},
|
||||
|
||||
@@ -204,4 +233,30 @@ export const adminAiService = {
|
||||
removeModel: async (modelName: string): Promise<void> => {
|
||||
await api.delete(`/ai/admin/models/${encodeURIComponent(modelName)}`);
|
||||
},
|
||||
|
||||
// --- OCR Engine Management (ADR-032) ---
|
||||
|
||||
getOcrEngines: async (): Promise<OcrEngineResponse[]> => {
|
||||
const { data } = await api.get('/ai/ocr-engines');
|
||||
return extractData<OcrEngineResponse[]>(data);
|
||||
},
|
||||
|
||||
selectOcrEngine: async (engineId: string): Promise<{ activeEngineName: string }> => {
|
||||
const { data } = await api.post(`/ai/ocr-engines/${encodeURIComponent(engineId)}/select`, {});
|
||||
return extractData<{ activeEngineName: string }>(data);
|
||||
},
|
||||
};
|
||||
|
||||
export interface OcrEngineResponse {
|
||||
engineId: string;
|
||||
engineName: string;
|
||||
engineType: string;
|
||||
isActive: boolean;
|
||||
isCurrentActive: boolean;
|
||||
vramRequirementMB: number;
|
||||
processingTimeLimitSeconds: number;
|
||||
concurrentLimit: number;
|
||||
fallbackEngineId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -44,5 +44,38 @@
|
||||
"delete_confirm": "ต้องการลบ Pattern นี้?",
|
||||
"loading": "กำลังโหลด...",
|
||||
"not_found": "ไม่พบ Intent"
|
||||
},
|
||||
"typhoon_ocr": {
|
||||
"engine_name": "Typhoon OCR-3B",
|
||||
"engine_description": "OCR ด้วย AI สำหรับเอกสารภาษาไทย (ความแม่นยำสูง)",
|
||||
"engine_tesseract": "Tesseract OCR (มาตรฐาน)",
|
||||
"engine_auto": "อัตโนมัติ (ตรวจข้อความก่อน)",
|
||||
"select_engine": "เลือก OCR Engine",
|
||||
"processing": "กำลังประมวลผลด้วย Typhoon OCR...",
|
||||
"cache_hit": "ใช้ผลลัพธ์จาก Cache",
|
||||
"cache_miss": "ประมวลผล OCR ใหม่",
|
||||
"fallback_used": "ใช้ Tesseract แทน (Typhoon ไม่พร้อมใช้งาน)",
|
||||
"vram_insufficient": "VRAM ไม่เพียงพอ — กรุณาลองใหม่ภายหลัง",
|
||||
"vram_status": "สถานะ VRAM",
|
||||
"vram_free": "VRAM ว่าง",
|
||||
"vram_used": "VRAM ที่ใช้",
|
||||
"vram_mb": "MB",
|
||||
"model_loaded": "โมเดลพร้อมใช้งาน",
|
||||
"model_unloaded": "โมเดลไม่ได้โหลด",
|
||||
"error_ollama_unavailable": "ไม่สามารถเชื่อมต่อ Ollama ได้ — ใช้ Tesseract แทน",
|
||||
"error_timeout": "หมดเวลาการประมวลผล OCR",
|
||||
"error_vram": "VRAM ไม่เพียงพอสำหรับโหลดโมเดล Typhoon OCR"
|
||||
},
|
||||
"typhoon_llm": {
|
||||
"model_name": "Typhoon 2.1 Gemma3 4B",
|
||||
"model_description": "LLM ภาษาไทย/อังกฤษ สำหรับสกัด Metadata จากเอกสาร",
|
||||
"model_gemma4": "Gemma4 E4B (มาตรฐาน)",
|
||||
"select_model": "เลือก AI Model",
|
||||
"add_typhoon": "เพิ่ม Typhoon 2.1 Gemma3 4B",
|
||||
"vram_required": "VRAM ที่ต้องการ: 4.5 GB",
|
||||
"processing": "กำลังประมวลผลด้วย Typhoon LLM...",
|
||||
"error_vram": "VRAM ไม่เพียงพอสำหรับโหลดโมเดล Typhoon LLM",
|
||||
"error_timeout": "หมดเวลาการประมวลผล LLM (120 วินาที)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user