690525:2327 ADR-023-229 dynamic prompt #01
This commit is contained in:
@@ -23,6 +23,7 @@ import { useAiStatus, useToggleAiFeatures, useAiHealth } from '@/hooks/use-ai-st
|
||||
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';
|
||||
|
||||
interface SandboxProject {
|
||||
publicId: string;
|
||||
@@ -43,12 +44,7 @@ export default function AiAdminConsolePage() {
|
||||
const [isSandboxPolling, setIsSandboxPolling] = useState<boolean>(false);
|
||||
const [sandboxProgress, setSandboxProgress] = useState<number>(0);
|
||||
const [sandboxStatusText, setSandboxStatusText] = useState<string>('');
|
||||
const [ocrFile, setOcrFile] = useState<File | null>(null);
|
||||
const [ocrJobId, setOcrJobId] = useState<string | null>(null);
|
||||
const [ocrJobResult, setOcrJobResult] = useState<AiSandboxJobResult | null>(null);
|
||||
const [isOcrPolling, setIsOcrPolling] = useState<boolean>(false);
|
||||
const [ocrProgress, setOcrProgress] = useState<number>(0);
|
||||
const [ocrStatusText, setOcrStatusText] = useState<string>('');
|
||||
|
||||
|
||||
// AI Model Management State (ADR-027)
|
||||
const { data: aiModelsData, refetch: refetchModels } = useQuery<{ models: AiAvailableModel[]; activeModel: string }>({
|
||||
@@ -174,80 +170,7 @@ export default function AiAdminConsolePage() {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [sandboxJobId]);
|
||||
const handleSubmitOcr = async (e: React.FormEvent): Promise<void> => {
|
||||
e.preventDefault();
|
||||
if (!ocrFile) {
|
||||
toast.error('กรุณาเลือกไฟล์ PDF สำหรับทำ OCR');
|
||||
return;
|
||||
}
|
||||
if (ocrFile.size > 50 * 1024 * 1024) {
|
||||
toast.error('ขนาดไฟล์เกินกว่า 50MB');
|
||||
return;
|
||||
}
|
||||
if (ocrFile.type !== 'application/pdf' && !ocrFile.name.toLowerCase().endsWith('.pdf')) {
|
||||
toast.error('กรุณาอัปโหลดไฟล์ในรูปแบบ PDF เท่านั้น');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setOcrJobResult(null);
|
||||
setOcrProgress(10);
|
||||
setOcrStatusText('กำลังอัปโหลดไฟล์ไปยังระบบเซิร์ฟเวอร์...');
|
||||
const response = await adminAiService.submitSandboxExtract(ocrFile);
|
||||
setOcrJobId(response.requestPublicId);
|
||||
setIsOcrPolling(true);
|
||||
toast.success('อัปโหลดไฟล์สำเร็จและเข้าสู่คิว sandbox OCR');
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
toast.error(error.response?.data?.message || 'เกิดข้อผิดพลาดในการทำ OCR Sandbox');
|
||||
setOcrProgress(0);
|
||||
setOcrStatusText('');
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!ocrJobId) return;
|
||||
let timer: NodeJS.Timeout;
|
||||
const pollOcrJob = async () => {
|
||||
try {
|
||||
const res = await adminAiService.getSandboxJobStatus(ocrJobId);
|
||||
setOcrJobResult(res);
|
||||
if (res.status === 'pending') {
|
||||
setOcrProgress(30);
|
||||
setOcrStatusText('อยู่ในคิวรอดำเนินการ (Pending in BullMQ)...');
|
||||
} else if (res.status === 'processing') {
|
||||
setOcrProgress(70);
|
||||
setOcrStatusText('กำลังอ่านไฟล์ PDF และสกัดข้อความด้วย OCR & LLM...');
|
||||
} else if (res.status === 'completed') {
|
||||
setOcrProgress(100);
|
||||
setOcrStatusText('การทำ OCR และสกัดข้อมูลเมตาดาต้าเสร็จสิ้น');
|
||||
setIsOcrPolling(false);
|
||||
setOcrJobId(null);
|
||||
toast.success('ทำ OCR Sandbox สำเร็จ');
|
||||
} else if (res.status === 'failed') {
|
||||
setOcrProgress(100);
|
||||
setOcrStatusText('การทำ OCR ล้มเหลว');
|
||||
setIsOcrPolling(false);
|
||||
setOcrJobId(null);
|
||||
toast.error(res.errorMessage || 'การทำ OCR Sandbox เกิดข้อผิดพลาด');
|
||||
} else if (res.status === 'cancelled') {
|
||||
setOcrProgress(100);
|
||||
setOcrStatusText('การทำ OCR ถูกยกเลิก');
|
||||
setIsOcrPolling(false);
|
||||
setOcrJobId(null);
|
||||
toast.error('OCR sandbox job ถูกยกเลิก');
|
||||
} else if (res.status === 'not_found') {
|
||||
setOcrProgress(20);
|
||||
setOcrStatusText('กำลังตรวจสอบสถานะคิวงาน...');
|
||||
}
|
||||
} catch {
|
||||
// เงียบข้อผิดพลาดตามนโยบาย UI
|
||||
}
|
||||
};
|
||||
pollOcrJob();
|
||||
timer = setInterval(pollOcrJob, 5000);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [ocrJobId]);
|
||||
|
||||
const renderStatusBadge = (status?: 'HEALTHY' | 'DEGRADED' | 'DOWN') => {
|
||||
if (!status) return <Badge variant="outline">Unknown</Badge>;
|
||||
switch (status) {
|
||||
@@ -745,167 +668,7 @@ export default function AiAdminConsolePage() {
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="ocr" className="space-y-6">
|
||||
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Brain className="h-5 w-5 text-primary" />
|
||||
OCR Sandbox Playground (isolated)
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
พื้นที่อัปโหลดไฟล์ PDF เพื่อทำการทดสอบทำ OCR และจำลองการดึง Metadata ออกมาในรูปแบบโครงสร้าง JSON โดยไม่บันทึกข้อมูลลงฐานข้อมูลจริง
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmitOcr} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
อัปโหลดเอกสาร PDF (ขนาดไม่เกิน 50MB)
|
||||
</label>
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center rounded-lg border border-dashed p-8 transition-colors ${
|
||||
ocrFile
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-muted-foreground/20 hover:bg-muted/10'
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (isOcrPolling) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
|
||||
setOcrFile(file);
|
||||
} else {
|
||||
toast.error('กรุณาเลือกไฟล์ PDF เท่านั้น');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Activity className="h-10 w-10 text-muted-foreground/60 mb-2" />
|
||||
{ocrFile ? (
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">{ocrFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
({(ocrFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isOcrPolling}
|
||||
onClick={() => setOcrFile(null)}
|
||||
className="mt-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
ลบไฟล์
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
ลากและวางไฟล์ PDF หรือคลิกเพื่ออัปโหลด
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
disabled={isOcrPolling}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) setOcrFile(file);
|
||||
}}
|
||||
className="hidden"
|
||||
id="ocr-file-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="ocr-file-upload"
|
||||
className="mt-2 inline-flex h-8 items-center justify-center rounded-md bg-secondary px-3 text-xs font-medium text-secondary-foreground ring-offset-background transition-colors hover:bg-secondary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
เลือกไฟล์
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isOcrPolling || !ocrFile}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isOcrPolling ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
กำลังประมวลผล OCR...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Power className="h-4 w-4" />
|
||||
เริ่มทำ OCR Sandbox
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{isOcrPolling && (
|
||||
<Card className="border border-amber-500/20 bg-amber-500/5">
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-center justify-between text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-amber-500" />
|
||||
<span>{ocrStatusText}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{ocrProgress}%</span>
|
||||
</div>
|
||||
<Progress value={ocrProgress} className="h-2" />
|
||||
<div className="rounded bg-background/50 p-2 text-[11px] text-muted-foreground font-mono flex items-center gap-2">
|
||||
<Info className="h-3 w-3" />
|
||||
ID คำขอ: {ocrJobId}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{ocrJobResult && (
|
||||
<div className="space-y-6">
|
||||
{ocrJobResult.status === 'completed' && (
|
||||
<Card className="border border-emerald-500/20 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader className="border-b border-border/30 pb-3 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base text-emerald-600 dark:text-emerald-400 flex items-center gap-2">
|
||||
<Brain className="h-4 w-4" />
|
||||
ผลลัพธ์การสกัด Metadata แบบโครงสร้าง (JSON Output)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="relative rounded-md bg-muted p-4 font-mono text-xs overflow-auto max-h-[400px]">
|
||||
<pre className="text-emerald-600 dark:text-emerald-400 select-text">
|
||||
{ocrJobResult.answer}
|
||||
</pre>
|
||||
</div>
|
||||
{ocrJobResult.completedAt && (
|
||||
<div className="mt-4 text-right text-[10px] text-muted-foreground">
|
||||
เสร็จสิ้นเมื่อ: {new Date(ocrJobResult.completedAt).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{ocrJobResult.status === 'failed' && (
|
||||
<Card className="border border-destructive/20 bg-destructive/5">
|
||||
<CardHeader className="flex flex-row items-center gap-2 pb-2 text-destructive">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<CardTitle className="text-sm font-medium">ประมวลผล OCR Sandbox ล้มเหลว</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{ocrJobResult.errorMessage || 'เกิดข้อผิดพลาดขึ้นระหว่างการอ่านไฟล์เอกสาร PDF หรือการเรียก LLM Sandbox สำหรับถอดความเมตาดาต้า กรุณาตรวจสอบสถานะสุขภาพของตัวบริการ'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<OcrSandboxPromptManager />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
// File: frontend/components/admin/ai/OcrSandboxPromptManager.tsx
|
||||
// Change Log
|
||||
// - 2026-05-25: Created OcrSandboxPromptManager component for dynamic prompt editing, version control, and sandbox testing (ADR-029)
|
||||
// - 2026-05-25: Extracted inline strings to i18n keys via useTranslations() (Obs #1 fix)
|
||||
// - 2026-05-25: Refactored sandbox polling to useSandboxRun hook (Obs #2 fix)
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Brain,
|
||||
Save,
|
||||
AlertCircle,
|
||||
Upload,
|
||||
Play,
|
||||
FileJson,
|
||||
ScrollText,
|
||||
Loader2,
|
||||
StickyNote,
|
||||
} from 'lucide-react';
|
||||
import { useAiPrompts, useSandboxRun } from '@/hooks/use-ai-prompts';
|
||||
import { useTranslations } from '@/hooks/use-translations';
|
||||
import PromptVersionHistory from './PromptVersionHistory';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AiPrompt } from '@/types/ai-prompts';
|
||||
|
||||
/**
|
||||
* Component หลักสำหรับจัดการ Prompt versions ของ OCR sandbox และ Migration
|
||||
* ประกอบไปด้วยตัวแก้ไข Prompt, รายการเวอร์ชัน, และส่วนสกัดทดสอบ (Sandbox run)
|
||||
*/
|
||||
export default function OcrSandboxPromptManager() {
|
||||
const t = useTranslations();
|
||||
const promptType = 'ocr_extraction';
|
||||
const {
|
||||
versionsQuery,
|
||||
createMutation,
|
||||
activateMutation,
|
||||
deleteMutation,
|
||||
updateNoteMutation,
|
||||
} = useAiPrompts(promptType);
|
||||
const versions = versionsQuery.data ?? [];
|
||||
const activePrompt = versions.find((v) => v.isActive);
|
||||
const [templateText, setTemplateText] = useState<string>('');
|
||||
const [ocrFile, setOcrFile] = useState<File | null>(null);
|
||||
const [manualNote, setManualNote] = useState<string>('');
|
||||
const [activeTab, setActiveTab] = useState<'editor' | 'sandbox'>('editor');
|
||||
const { state: sandboxState, jobId: sandboxJobId, submit: submitSandbox, reset: resetSandbox } =
|
||||
useSandboxRun(() => {
|
||||
// เมื่อ sandbox เสร็จสิ้น: รีเฟรชรายการเวอร์ชัน
|
||||
versionsQuery.refetch();
|
||||
toast.success(t('ai.prompt.sandboxSuccess'));
|
||||
});
|
||||
useEffect(() => {
|
||||
if (activePrompt && !templateText) {
|
||||
setTemplateText(activePrompt.template);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activePrompt]);
|
||||
const handleSaveVersion = async () => {
|
||||
if (!templateText.includes('{{ocr_text}}')) {
|
||||
toast.error(t('ai.prompt.placeholderError'));
|
||||
return;
|
||||
}
|
||||
if (templateText.length > 4000) {
|
||||
toast.error(t('ai.prompt.charLimitError'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createMutation.mutateAsync(templateText);
|
||||
toast.success(t('ai.prompt.saveVersionSuccess'));
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
toast.error(error.response?.data?.message || t('ai.prompt.saveVersionError'));
|
||||
}
|
||||
};
|
||||
const handleLoadTemplate = (version: AiPrompt) => {
|
||||
setTemplateText(version.template);
|
||||
setActiveTab('editor');
|
||||
toast.success(t('ai.prompt.loadSuccess', { version: String(version.versionNumber) }));
|
||||
};
|
||||
const handleActivateVersion = async (versionNumber: number) => {
|
||||
try {
|
||||
await activateMutation.mutateAsync(versionNumber);
|
||||
toast.success(t('ai.prompt.activateSuccess', { version: String(versionNumber) }));
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
toast.error(error.response?.data?.message || t('ai.prompt.activateError'));
|
||||
}
|
||||
};
|
||||
const handleDeleteVersion = async (versionNumber: number) => {
|
||||
if (!confirm(t('ai.prompt.deleteConfirm', { version: String(versionNumber) }))) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(versionNumber);
|
||||
toast.success(t('ai.prompt.deleteSuccess', { version: String(versionNumber) }));
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
toast.error(error.response?.data?.message || t('ai.prompt.deleteError'));
|
||||
}
|
||||
};
|
||||
const handleSaveManualNote = async (versionNumber: number) => {
|
||||
try {
|
||||
await updateNoteMutation.mutateAsync({ versionNumber, note: manualNote });
|
||||
toast.success(t('ai.prompt.saveNoteSuccess'));
|
||||
setManualNote('');
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
toast.error(error.response?.data?.message || t('ai.prompt.saveNoteError'));
|
||||
}
|
||||
};
|
||||
const handleSubmitOcr = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!activePrompt) {
|
||||
toast.error(t('ai.prompt.noActivePrompt'));
|
||||
return;
|
||||
}
|
||||
if (!ocrFile) {
|
||||
toast.error(t('ai.prompt.noFile'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resetSandbox();
|
||||
await submitSandbox(ocrFile);
|
||||
toast.success(t('ai.prompt.uploadSuccess'));
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
toast.error(error.response?.data?.message || t('ai.prompt.uploadError'));
|
||||
}
|
||||
};
|
||||
// แปล status key เป็นข้อความตาม locale ปัจจุบัน
|
||||
const statusLabel = sandboxState.statusText ? t(sandboxState.statusText) : '';
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-12 items-start">
|
||||
<div className="lg:col-span-8 space-y-6">
|
||||
<div className="flex border-b border-border/20">
|
||||
<button
|
||||
onClick={() => setActiveTab('editor')}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-semibold border-b-2 -mb-[2px] transition-all',
|
||||
activeTab === 'editor'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t('ai.prompt.tabEditor')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('sandbox')}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-semibold border-b-2 -mb-[2px] transition-all',
|
||||
activeTab === 'sandbox'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t('ai.prompt.tabSandbox')}
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === 'editor' ? (
|
||||
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader className="pb-3 flex flex-row justify-between items-center">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<ScrollText className="h-4 w-4 text-primary" />
|
||||
{t('ai.prompt.cardTitle')}
|
||||
</CardTitle>
|
||||
{activePrompt && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t('ai.prompt.activeLabel', { version: String(activePrompt.versionNumber) })}
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={templateText}
|
||||
onChange={(e) => setTemplateText(e.target.value)}
|
||||
disabled={createMutation.isPending}
|
||||
rows={15}
|
||||
placeholder={t('ai.prompt.editorPlaceholder')}
|
||||
className="font-mono text-xs leading-relaxed resize-none border border-input bg-background/30"
|
||||
/>
|
||||
<div className="flex justify-between items-center text-[10px] text-muted-foreground">
|
||||
<span className={cn(templateText.includes('{{ocr_text}}') ? 'text-emerald-500' : 'text-amber-500')}>
|
||||
{templateText.includes('{{ocr_text}}')
|
||||
? t('ai.prompt.placeholderOk')
|
||||
: t('ai.prompt.placeholderMissing')}
|
||||
</span>
|
||||
<span className={cn(templateText.length > 4000 ? 'text-destructive font-bold' : '')}>
|
||||
{t('ai.prompt.charCount', { count: String(templateText.length) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
onClick={handleSaveVersion}
|
||||
disabled={createMutation.isPending || templateText.length === 0}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{t('ai.prompt.saveVersion')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<Upload className="h-4 w-4 text-primary" />
|
||||
{t('ai.prompt.sandboxCardTitle')}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ai.prompt.sandboxCardDesc')}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmitOcr} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-lg border border-dashed p-8 transition-all',
|
||||
ocrFile ? 'border-primary/50 bg-primary/5' : 'border-muted-foreground/20 hover:bg-muted/10'
|
||||
)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (sandboxState.isRunning) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file?.name.toLowerCase().endsWith('.pdf')) {
|
||||
setOcrFile(file);
|
||||
} else {
|
||||
toast.error(t('ai.prompt.dropzonePdfOnly'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Brain className="h-9 w-9 text-muted-foreground/50 mb-2 animate-bounce" />
|
||||
{ocrFile ? (
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm font-semibold">{ocrFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
({(ocrFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={sandboxState.isRunning}
|
||||
onClick={() => setOcrFile(null)}
|
||||
className="mt-2 text-xs text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{t('ai.prompt.removeFile')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ai.prompt.dropzoneDrag')}
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
disabled={sandboxState.isRunning}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) setOcrFile(file);
|
||||
}}
|
||||
className="hidden"
|
||||
id="ocr-sandbox-file"
|
||||
/>
|
||||
<label
|
||||
htmlFor="ocr-sandbox-file"
|
||||
className="mt-2.5 inline-flex h-8 items-center justify-center rounded-md bg-secondary px-3.5 text-xs font-semibold cursor-pointer hover:bg-secondary/85 transition-colors"
|
||||
>
|
||||
{t('ai.prompt.dropzoneChoose')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={sandboxState.isRunning || !ocrFile || !activePrompt}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{sandboxState.isRunning ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('ai.prompt.running')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
{t('ai.prompt.runSandbox')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{sandboxState.isRunning && (
|
||||
<Card className="border border-amber-500/20 bg-amber-500/5">
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-center justify-between text-xs font-medium">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-amber-500" />
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span>{sandboxState.progress}%</span>
|
||||
</div>
|
||||
<Progress value={sandboxState.progress} className="h-1.5" />
|
||||
<div className="text-[10px] text-muted-foreground font-mono bg-background/50 p-2 rounded">
|
||||
Request ID: {sandboxJobId}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{sandboxState.result && sandboxState.result.status === 'completed' && (
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-emerald-500/20 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader className="border-b border-border/30 pb-3 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base text-emerald-600 dark:text-emerald-400 flex items-center gap-2">
|
||||
<FileJson className="h-4 w-4" />
|
||||
{t('ai.prompt.resultTitle')}
|
||||
</CardTitle>
|
||||
{activePrompt && (
|
||||
<Badge variant="outline" className="text-xs text-emerald-500 border-emerald-500/20 bg-emerald-500/5">
|
||||
{t('ai.prompt.resultVersionBadge', { version: String(activePrompt.versionNumber) })}
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 space-y-4">
|
||||
<div className="relative rounded-md bg-muted p-4 font-mono text-xs overflow-auto max-h-[300px] border border-border/10">
|
||||
<pre className="text-emerald-600 dark:text-emerald-400 select-text leading-relaxed">
|
||||
{sandboxState.result.answer}
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{activePrompt && (
|
||||
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<StickyNote className="h-4 w-4 text-amber-500 animate-pulse" />
|
||||
{t('ai.prompt.noteCardTitle')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea
|
||||
value={manualNote}
|
||||
onChange={(e) => setManualNote(e.target.value)}
|
||||
placeholder={t('ai.prompt.notePlaceholder')}
|
||||
rows={3}
|
||||
className="text-xs leading-relaxed resize-none bg-background/30"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
disabled={updateNoteMutation.isPending || !manualNote.trim()}
|
||||
onClick={() => handleSaveManualNote(activePrompt.versionNumber)}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
size="sm"
|
||||
>
|
||||
{updateNoteMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('ai.prompt.saveNote', { version: String(activePrompt.versionNumber) })}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{sandboxState.result && sandboxState.result.status === 'failed' && (
|
||||
<Card className="border border-destructive/20 bg-destructive/5">
|
||||
<CardHeader className="flex flex-row items-center gap-2 pb-2 text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<CardTitle className="text-sm font-medium">{t('ai.prompt.sandboxErrorTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{sandboxState.result.errorMessage || t('ai.prompt.sandboxErrorDefault')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="lg:col-span-4">
|
||||
<PromptVersionHistory
|
||||
versions={versions}
|
||||
isLoading={versionsQuery.isLoading}
|
||||
onLoadTemplate={handleLoadTemplate}
|
||||
onActivateVersion={handleActivateVersion}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
isActivating={activateMutation.isPending}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// File: frontend/components/admin/ai/PromptVersionHistory.tsx
|
||||
// Change Log
|
||||
// - 2026-05-25: Created PromptVersionHistory component (ADR-029)
|
||||
|
||||
import React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { CheckCircle2, Trash2, BookOpen, Clock, StickyNote } from 'lucide-react';
|
||||
import { AiPrompt } from '@/types/ai-prompts';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PromptVersionHistoryProps {
|
||||
versions: AiPrompt[];
|
||||
isLoading: boolean;
|
||||
onLoadTemplate: (version: AiPrompt) => void;
|
||||
onActivateVersion: (versionNumber: number) => void;
|
||||
onDeleteVersion: (versionNumber: number) => void;
|
||||
isActivating: boolean;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* แผงประวัติและเวอร์ชันของ AI Prompts ทางฝั่งขวามือ
|
||||
* แสดงรายการเวอร์ชันพร้อมปุ่มเรียกใช้ เปิดทำงาน หรือลบทิ้ง
|
||||
*/
|
||||
export default function PromptVersionHistory({
|
||||
versions,
|
||||
isLoading,
|
||||
onLoadTemplate,
|
||||
onActivateVersion,
|
||||
onDeleteVersion,
|
||||
isActivating,
|
||||
isDeleting,
|
||||
}: PromptVersionHistoryProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-[400px] items-center justify-center text-sm text-muted-foreground">
|
||||
<Clock className="mr-2 h-4 w-4 animate-spin text-primary" />
|
||||
กำลังโหลดประวัติเวอร์ชัน...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card className="border border-border/50 bg-background/30 backdrop-blur-md transition-all duration-300 hover:shadow-md">
|
||||
<CardHeader className="pb-3 border-b border-border/10">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold tracking-wide text-foreground">
|
||||
<BookOpen className="h-4 w-4 text-primary animate-pulse" />
|
||||
ประวัติเวอร์ชัน (Version History)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 px-3 sm:px-4 max-h-[600px] overflow-y-auto space-y-3">
|
||||
{versions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center text-xs text-muted-foreground italic">
|
||||
ไม่พบเวอร์ชันอื่นในระบบ
|
||||
</div>
|
||||
) : (
|
||||
versions.map((version) => (
|
||||
<div
|
||||
key={version.versionNumber}
|
||||
className={cn(
|
||||
'group relative rounded-lg border border-border/30 bg-background/50 p-3.5 transition-all duration-200 hover:border-primary/30 hover:bg-background/80',
|
||||
version.isActive && 'border-emerald-500/20 bg-emerald-500/[0.02] shadow-[inset_0_1px_3px_rgba(16,185,129,0.03)]'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-bold text-foreground">
|
||||
v{version.versionNumber}
|
||||
</span>
|
||||
{version.isActive ? (
|
||||
<Badge className="border-emerald-500/20 bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/20 text-[10px] py-0 px-1.5 flex items-center gap-1 select-none">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
ใช้งานจริง (Active)
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] text-muted-foreground border-border/50 bg-background/40 select-none">
|
||||
ร่าง (Inactive)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-[11px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
สร้าง: {new Date(version.createdAt).toLocaleString('th-TH')}
|
||||
</span>
|
||||
{version.lastTestedAt && (
|
||||
<span className="flex items-center gap-1 text-emerald-500/90">
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
||||
ทดสอบแล้ว: {new Date(version.lastTestedAt).toLocaleString('th-TH')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 opacity-90 sm:opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] text-muted-foreground hover:bg-secondary"
|
||||
onClick={() => onLoadTemplate(version)}
|
||||
>
|
||||
โหลด (Load)
|
||||
</Button>
|
||||
{!version.isActive && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isActivating}
|
||||
className="h-7 text-[10px] text-emerald-500 hover:text-emerald-600 hover:bg-emerald-500/10"
|
||||
onClick={() => onActivateVersion(version.versionNumber)}
|
||||
>
|
||||
ใช้งาน (Activate)
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isDeleting}
|
||||
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onDeleteVersion(version.versionNumber)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{version.manualNote && (
|
||||
<div className="mt-2.5 rounded bg-muted/30 p-2 border border-border/10 flex gap-1.5 items-start text-[11px] text-muted-foreground select-text">
|
||||
<StickyNote className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<p className="leading-relaxed whitespace-pre-wrap">{version.manualNote}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
// File: frontend/eslint.config.mjs
|
||||
// Change Log
|
||||
// - 2026-05-25: Added coverage/** to ignored directories to prevent linting auto-generated test coverage files
|
||||
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import typescriptParser from '@typescript-eslint/parser';
|
||||
@@ -79,6 +83,7 @@ const eslintConfig = [
|
||||
'out/**',
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'coverage/**',
|
||||
'*.config.js',
|
||||
'*.config.mjs',
|
||||
'*.config.ts',
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// File: frontend/hooks/use-ai-prompts.ts
|
||||
// Change Log
|
||||
// - 2026-05-25: Created useAiPrompts unified hook for React Query prompt operations (ADR-029)
|
||||
// - 2026-05-25: Added useSandboxRun hook to encapsulate submit + polling logic (Obs #2 fix)
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { aiPromptsService } from '@/lib/services/ai-prompts.service';
|
||||
import { adminAiService, AiSandboxJobResult } from '@/lib/services/admin-ai.service';
|
||||
|
||||
/** สถานะการรัน OCR Sandbox */
|
||||
export interface SandboxRunState {
|
||||
/** กำลังอัปโหลดหรือ polling อยู่ */
|
||||
isRunning: boolean;
|
||||
/** ความคืบหน้า 0-100 */
|
||||
progress: number;
|
||||
/** ข้อความสถานะที่แสดงต่อผู้ใช้ */
|
||||
statusText: string;
|
||||
/** ผลลัพธ์สุดท้ายจาก job (null ก่อนเสร็จสิ้น) */
|
||||
result: AiSandboxJobResult | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified hook สำหรับการจัดการประวัติและการเปิดใช้งาน Prompt Versions ผ่าน React Query
|
||||
*/
|
||||
export function useAiPrompts(promptType: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = ['ai', 'prompts', promptType] as const;
|
||||
const versionsQuery = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => aiPromptsService.listVersions(promptType),
|
||||
enabled: !!promptType,
|
||||
});
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (template: string) => aiPromptsService.createVersion(promptType, template),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
const activateMutation = useMutation({
|
||||
mutationFn: (versionNumber: number) =>
|
||||
aiPromptsService.activateVersion(promptType, versionNumber),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (versionNumber: number) =>
|
||||
aiPromptsService.deleteVersion(promptType, versionNumber),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
const updateNoteMutation = useMutation({
|
||||
mutationFn: ({ versionNumber, note }: { versionNumber: number; note: string | null }) =>
|
||||
aiPromptsService.updateNote(promptType, versionNumber, note),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
return {
|
||||
versionsQuery,
|
||||
createMutation,
|
||||
activateMutation,
|
||||
deleteMutation,
|
||||
updateNoteMutation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook แยกสำหรับการส่ง OCR Sandbox job และ polling ผลลัพธ์
|
||||
* ให้ใช้แทนการเขียน polling logic โดยตรงในหน้า Component
|
||||
*/
|
||||
export function useSandboxRun(onCompleted?: () => void) {
|
||||
const [state, setState] = useState<SandboxRunState>({
|
||||
isRunning: false,
|
||||
progress: 0,
|
||||
statusText: '',
|
||||
result: null,
|
||||
});
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// หยุด polling เมื่อ unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
// เริ่ม polling เมื่อมี jobId
|
||||
useEffect(() => {
|
||||
if (!jobId) return;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await adminAiService.getSandboxJobStatus(jobId);
|
||||
setState((prev) => ({ ...prev, result: res }));
|
||||
if (res.status === 'pending') {
|
||||
setState((prev) => ({ ...prev, progress: 30, statusText: 'ai.prompt.statusPending' }));
|
||||
} else if (res.status === 'processing') {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
progress: 70,
|
||||
statusText: 'ai.prompt.statusProcessing',
|
||||
}));
|
||||
} else if (res.status === 'completed') {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
setJobId(null);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isRunning: false,
|
||||
progress: 100,
|
||||
statusText: 'ai.prompt.statusCompleted',
|
||||
}));
|
||||
onCompleted?.();
|
||||
} else if (res.status === 'failed') {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
setJobId(null);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isRunning: false,
|
||||
progress: 100,
|
||||
statusText: 'ai.prompt.statusFailed',
|
||||
}));
|
||||
} else if (res.status === 'cancelled') {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
setJobId(null);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isRunning: false,
|
||||
progress: 100,
|
||||
statusText: 'ai.prompt.statusCancelled',
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// เงียบข้อผิดพลาดระหว่าง polling
|
||||
}
|
||||
};
|
||||
poll();
|
||||
timerRef.current = setInterval(poll, 4000);
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [jobId, onCompleted]);
|
||||
/**
|
||||
* ส่ง PDF file เข้า sandbox queue และเริ่ม polling อัตโนมัติ
|
||||
* @returns requestPublicId หรือ throw Error เมื่อล้มเหลว
|
||||
*/
|
||||
const submit = useCallback(async (file: File): Promise<string> => {
|
||||
setState({
|
||||
isRunning: true,
|
||||
progress: 10,
|
||||
statusText: 'ai.prompt.uploading',
|
||||
result: null,
|
||||
});
|
||||
const response = await adminAiService.submitSandboxExtract(file);
|
||||
setJobId(response.requestPublicId);
|
||||
return response.requestPublicId;
|
||||
}, []);
|
||||
/** รีเซ็ตสถานะทั้งหมด (ใช้ก่อนรันใหม่) */
|
||||
const reset = useCallback(() => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
setJobId(null);
|
||||
setState({ isRunning: false, progress: 0, statusText: '', result: null });
|
||||
}, []);
|
||||
return { state, jobId, submit, reset };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// File: frontend/lib/services/ai-prompts.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-25: Created aiPromptsService for prompt versioning and sandbox operations (ADR-029)
|
||||
|
||||
import api from '../api/client';
|
||||
import { AiPrompt } from '../../types/ai-prompts';
|
||||
|
||||
const extractData = <T>(value: unknown): T => {
|
||||
if (value && typeof value === 'object' && 'data' in value) {
|
||||
return (value as { data: T }).data;
|
||||
}
|
||||
return value as T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Service สำหรับเรียก API ในการจัดการ AI prompt templates ทางฝั่งหน้าบ้าน
|
||||
*/
|
||||
export const aiPromptsService = {
|
||||
/**
|
||||
* ดึงรายการ Prompt Versions ทั้งหมดสำหรับ prompt_type ที่กำหนด
|
||||
*/
|
||||
listVersions: async (promptType: string): Promise<AiPrompt[]> => {
|
||||
const { data } = await api.get(`/ai/prompts/${encodeURIComponent(promptType)}`);
|
||||
return extractData<AiPrompt[]>(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* สร้าง Prompt Version ใหม่ (เริ่มต้นเป็น inactive)
|
||||
*/
|
||||
createVersion: async (promptType: string, template: string): Promise<AiPrompt> => {
|
||||
const { data } = await api.post(`/ai/prompts/${encodeURIComponent(promptType)}`, { template });
|
||||
return extractData<AiPrompt>(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* เปิดใช้งาน Prompt Version เพื่อใช้เป็น active version
|
||||
*/
|
||||
activateVersion: async (promptType: string, versionNumber: number): Promise<AiPrompt> => {
|
||||
const { data } = await api.post(
|
||||
`/ai/prompts/${encodeURIComponent(promptType)}/${versionNumber}/activate`
|
||||
);
|
||||
return extractData<AiPrompt>(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* ลบ Prompt Version (ต้องไม่เป็น active version)
|
||||
*/
|
||||
deleteVersion: async (promptType: string, versionNumber: number): Promise<void> => {
|
||||
await api.delete(`/ai/prompts/${encodeURIComponent(promptType)}/${versionNumber}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* อัปเดต manual note ของเวอร์ชันที่กำหนด
|
||||
*/
|
||||
updateNote: async (
|
||||
promptType: string,
|
||||
versionNumber: number,
|
||||
manualNote: string | null
|
||||
): Promise<AiPrompt> => {
|
||||
const { data } = await api.patch(
|
||||
`/ai/prompts/${encodeURIComponent(promptType)}/${versionNumber}/note`,
|
||||
{ manualNote }
|
||||
);
|
||||
return extractData<AiPrompt>(data);
|
||||
},
|
||||
};
|
||||
@@ -91,5 +91,57 @@
|
||||
"ai.staging.thresholdWarning": "Improvement Recommended",
|
||||
"ai.staging.thresholdWarningDesc": "Override rate reached {{rate}}% in recent records.",
|
||||
"ai.staging.thresholdNote": "* Threshold values must be set via Backend Environment Variables.",
|
||||
"ai.staging.thresholdDocs": "View Configuration Guide"
|
||||
"ai.staging.thresholdDocs": "View Configuration Guide",
|
||||
|
||||
"ai.prompt.tabEditor": "Prompt Template Editor",
|
||||
"ai.prompt.tabSandbox": "OCR Sandbox Runner",
|
||||
"ai.prompt.cardTitle": "Prompt Template",
|
||||
"ai.prompt.activeLabel": "Active: v{{version}}",
|
||||
"ai.prompt.editorPlaceholder": "Write the Prompt template with {{ocr_text}} here...",
|
||||
"ai.prompt.placeholderOk": "✓ {{ocr_text}} placeholder present",
|
||||
"ai.prompt.placeholderMissing": "✗ Missing {{ocr_text}} placeholder",
|
||||
"ai.prompt.charCount": "{{count}} / 4000 characters",
|
||||
"ai.prompt.saveVersion": "Save as New Version (Draft)",
|
||||
"ai.prompt.saveVersionSuccess": "New version saved successfully (draft)",
|
||||
"ai.prompt.saveVersionError": "Failed to save Prompt version",
|
||||
"ai.prompt.placeholderError": "Template must contain {{ocr_text}} placeholder",
|
||||
"ai.prompt.charLimitError": "Template exceeds 4,000 character limit",
|
||||
"ai.prompt.loadSuccess": "Loaded content of v{{version}} into Editor",
|
||||
"ai.prompt.activateSuccess": "Prompt Version v{{version}} is now active",
|
||||
"ai.prompt.activateError": "Failed to activate prompt version",
|
||||
"ai.prompt.deleteConfirm": "Delete v{{version}}?",
|
||||
"ai.prompt.deleteSuccess": "Prompt Version v{{version}} deleted",
|
||||
"ai.prompt.deleteError": "Failed to delete prompt version",
|
||||
"ai.prompt.deleteActiveError": "Cannot delete the active version",
|
||||
"ai.prompt.saveNote": "Save Note for v{{version}}",
|
||||
"ai.prompt.saveNoteSuccess": "Manual note saved successfully",
|
||||
"ai.prompt.saveNoteError": "Failed to save note",
|
||||
"ai.prompt.sandboxCardTitle": "Test OCR Sandbox with Active Prompt",
|
||||
"ai.prompt.sandboxCardDesc": "Upload a PDF to extract and evaluate metadata structure using the active prompt.",
|
||||
"ai.prompt.dropzoneDrag": "Drag & drop a PDF or click below to upload",
|
||||
"ai.prompt.dropzoneChoose": "Choose PDF File",
|
||||
"ai.prompt.dropzonePdfOnly": "Please select a PDF file only",
|
||||
"ai.prompt.removeFile": "Remove file",
|
||||
"ai.prompt.runSandbox": "Run OCR Sandbox",
|
||||
"ai.prompt.running": "Extracting data...",
|
||||
"ai.prompt.noActivePrompt": "No active prompt found. Please configure and activate a prompt before running sandbox.",
|
||||
"ai.prompt.noFile": "Please select a PDF file to test",
|
||||
"ai.prompt.uploadSuccess": "File uploaded — queued for sandbox OCR",
|
||||
"ai.prompt.uploadError": "Failed to start sandbox",
|
||||
"ai.prompt.uploading": "Uploading file for Sandbox run...",
|
||||
"ai.prompt.statusPending": "Queued (Pending in BullMQ)...",
|
||||
"ai.prompt.statusProcessing": "Reading file and extracting metadata with Active Prompt (Ollama running)...",
|
||||
"ai.prompt.statusCompleted": "OCR Sandbox completed",
|
||||
"ai.prompt.statusFailed": "OCR Sandbox failed",
|
||||
"ai.prompt.statusCancelled": "Sandbox job cancelled",
|
||||
"ai.prompt.sandboxSuccess": "OCR Sandbox completed (result saved to version history)",
|
||||
"ai.prompt.sandboxFailed": "OCR Sandbox run failed",
|
||||
"ai.prompt.sandboxCancelled": "Sandbox job was cancelled",
|
||||
"ai.prompt.resultTitle": "Extracted JSON Metadata",
|
||||
"ai.prompt.resultVersionBadge": "Extracted with v{{version}}",
|
||||
"ai.prompt.noteCardTitle": "Add Evaluation Note for This Version (Manual Annotation)",
|
||||
"ai.prompt.notePlaceholder": "Write analysis, differences, or suggestions for this prompt version...",
|
||||
"ai.prompt.sandboxErrorTitle": "Sandbox Run Failed",
|
||||
"ai.prompt.sandboxErrorDefault": "Processing timed out or an error occurred while loading the model.",
|
||||
"ai.prompt.timeoutInfo": "System waits up to 120 seconds — Ollama may take time to load on cold start"
|
||||
}
|
||||
|
||||
@@ -91,5 +91,57 @@
|
||||
"ai.staging.thresholdWarning": "ควรปรับปรุง Model หรือ Threshold",
|
||||
"ai.staging.thresholdWarningDesc": "ตรวจพบอัตราการแก้ไขสูงถึง {{rate}}% ในช่วงที่ผ่านมา",
|
||||
"ai.staging.thresholdNote": "* การเปลี่ยนค่า Threshold ต้องทำผ่าน Environment Variables ของ Backend",
|
||||
"ai.staging.thresholdDocs": "อ่านคู่มือการตั้งค่า"
|
||||
"ai.staging.thresholdDocs": "อ่านคู่มือการตั้งค่า",
|
||||
|
||||
"ai.prompt.tabEditor": "Prompt Template Editor",
|
||||
"ai.prompt.tabSandbox": "OCR Sandbox Runner",
|
||||
"ai.prompt.cardTitle": "Prompt Template",
|
||||
"ai.prompt.activeLabel": "Active: v{{version}}",
|
||||
"ai.prompt.editorPlaceholder": "เขียน Prompt template พร้อม {{ocr_text}} ที่นี่...",
|
||||
"ai.prompt.placeholderOk": "✓ มี {{ocr_text}} placeholder ครบถ้วน",
|
||||
"ai.prompt.placeholderMissing": "✗ ขาด {{ocr_text}} placeholder",
|
||||
"ai.prompt.charCount": "{{count}} / 4000 ตัวอักษร",
|
||||
"ai.prompt.saveVersion": "บันทึก Version ใหม่ (Save Draft)",
|
||||
"ai.prompt.saveVersionSuccess": "บันทึก Version ใหม่สำเร็จ (ร่าง)",
|
||||
"ai.prompt.saveVersionError": "เกิดข้อผิดพลาดในการบันทึก Prompt",
|
||||
"ai.prompt.placeholderError": "template ต้องมี {{ocr_text}} placeholder",
|
||||
"ai.prompt.charLimitError": "Template exceeds 4,000 character limit",
|
||||
"ai.prompt.loadSuccess": "โหลดเนื้อหาของ v{{version}} เข้าสู่ Editor แล้ว",
|
||||
"ai.prompt.activateSuccess": "เปิดใช้งาน Prompt Version v{{version}} เป็นหลักแล้ว",
|
||||
"ai.prompt.activateError": "เกิดข้อผิดพลาดในการ activate",
|
||||
"ai.prompt.deleteConfirm": "ต้องการลบ v{{version}} ใช่หรือไม่?",
|
||||
"ai.prompt.deleteSuccess": "ลบ Prompt Version v{{version}} สำเร็จ",
|
||||
"ai.prompt.deleteError": "เกิดข้อผิดพลาดในการลบ",
|
||||
"ai.prompt.deleteActiveError": "ไม่สามารถลบ active version ได้",
|
||||
"ai.prompt.saveNote": "บันทึกหมายเหตุ v{{version}}",
|
||||
"ai.prompt.saveNoteSuccess": "บันทึก Manual Note สำเร็จ",
|
||||
"ai.prompt.saveNoteError": "ไม่สามารถบันทึกหมายเหตุได้",
|
||||
"ai.prompt.sandboxCardTitle": "ทดสอบ OCR Sandbox ด้วย Active Prompt",
|
||||
"ai.prompt.sandboxCardDesc": "สุ่มและอัปโหลดไฟล์ PDF เพื่อเปรียบเทียบหรือสกัดโครงสร้างเมตาดาต้า และประเมินผล",
|
||||
"ai.prompt.dropzoneDrag": "ลากและวางไฟล์ PDF หรือคลิกด้านล่างเพื่ออัปโหลด",
|
||||
"ai.prompt.dropzoneChoose": "เลือกไฟล์ PDF",
|
||||
"ai.prompt.dropzonePdfOnly": "กรุณาเลือกไฟล์ PDF เท่านั้น",
|
||||
"ai.prompt.removeFile": "ลบไฟล์",
|
||||
"ai.prompt.runSandbox": "เริ่มประมวลผล OCR Sandbox",
|
||||
"ai.prompt.running": "กำลังสกัดข้อมูล...",
|
||||
"ai.prompt.noActivePrompt": "ไม่พบ active prompt กรุณาตั้งค่าและเปิดใช้งาน prompt ก่อนรัน sandbox",
|
||||
"ai.prompt.noFile": "กรุณาเลือกไฟล์ PDF สำหรับทดสอบ",
|
||||
"ai.prompt.uploadSuccess": "อัปโหลดไฟล์สำเร็จ เข้าสู่คิว sandbox OCR",
|
||||
"ai.prompt.uploadError": "เกิดข้อผิดพลาดในการเริ่ม sandbox",
|
||||
"ai.prompt.uploading": "กำลังอัปโหลดไฟล์สำหรับรัน Sandbox...",
|
||||
"ai.prompt.statusPending": "อยู่ในคิวรอดำเนินการ (Pending in BullMQ)...",
|
||||
"ai.prompt.statusProcessing": "กำลังอ่านไฟล์และใช้ Active Prompt สกัดเมตาดาต้า (สิทธิ์รัน Ollama)...",
|
||||
"ai.prompt.statusCompleted": "ประมวลผล OCR Sandbox เสร็จสิ้น",
|
||||
"ai.prompt.statusFailed": "OCR Sandbox ล้มเหลว",
|
||||
"ai.prompt.statusCancelled": "การทำงานถูกยกเลิก",
|
||||
"ai.prompt.sandboxSuccess": "ทำ OCR Sandbox สำเร็จ (ข้อมูลเซฟลงประวัติเวอร์ชันแล้ว)",
|
||||
"ai.prompt.sandboxFailed": "การรัน OCR Sandbox เกิดข้อผิดพลาด",
|
||||
"ai.prompt.sandboxCancelled": "Sandbox job ถูกยกเลิก",
|
||||
"ai.prompt.resultTitle": "ผลลัพธ์โครงสร้างข้อมูล JSON ที่ถอดออกมาได้",
|
||||
"ai.prompt.resultVersionBadge": "ถอดด้วย v{{version}}",
|
||||
"ai.prompt.noteCardTitle": "เพิ่มข้อเขียนประเมินสำหรับเวอร์ชันนี้ (Manual Annotation Note)",
|
||||
"ai.prompt.notePlaceholder": "เขียนวิเคราะห์ความแตกต่างหรือข้อเสนอแนะเกี่ยวกับผลลัพธ์ของ prompt vนี้...",
|
||||
"ai.prompt.sandboxErrorTitle": "รัน Sandbox ล้มเหลว",
|
||||
"ai.prompt.sandboxErrorDefault": "ระบบใช้เวลาประมวลผลนานเกินกำหนดหรือเกิดข้อผิดพลาดในการโหลดโมเดล",
|
||||
"ai.prompt.timeoutInfo": "ระบบรอผลสูงสุด 120 วินาที — Ollama อาจใช้เวลาโหลดโมเดลเมื่อเริ่มต้นใหม่"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// File: frontend/types/ai-prompts.ts
|
||||
// Change Log
|
||||
// - 2026-05-25: Created types for dynamic prompt management (ADR-029)
|
||||
|
||||
/**
|
||||
* Interface สำหรับข้อมูล Prompt Version แต่ละรายการ
|
||||
*/
|
||||
export interface AiPrompt {
|
||||
promptType: string;
|
||||
versionNumber: number;
|
||||
template: string;
|
||||
isActive: boolean;
|
||||
testResultJson: Record<string, unknown> | null;
|
||||
manualNote: string | null;
|
||||
lastTestedAt: string | null;
|
||||
activatedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface สำหรับผลการทดสอบ Sandbox OCR
|
||||
*/
|
||||
export interface SandboxResult {
|
||||
requestPublicId: string;
|
||||
status: 'processing' | 'completed' | 'failed';
|
||||
answer?: string;
|
||||
errorMessage?: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user