feat(ai): ADR-032 Typhoon OCR integration - models, processors, cache, VRAM monitor, sandbox UI
CI / CD Pipeline / build (push) Successful in 4m51s
CI / CD Pipeline / deploy (push) Successful in 12m7s

This commit is contained in:
2026-05-30 22:18:51 +07:00
parent f86fcc05f5
commit ae1b1f35e1
56 changed files with 4057 additions and 153 deletions
@@ -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 || '(ไม่มีข้อความ)'}