feat(ai): implement unified prompt management UX/UI (ADR-037)
- Add context config endpoints (GET/PUT /api/ai/prompts/:type/:version/context-config) - Add execution profile endpoints (CRUD /api/ai/execution-profiles) - Add sandbox RAG Prep endpoint (POST /api/ai/admin/sandbox/rag-prep) - Create Prompt Management UI with multi-type support - Add ContextConfigEditor, PromptEditor, RuntimeParametersPanel components - Add SandboxTabs for 3-step workflow (OCR, Extract, RAG Prep) - Add database deltas for ai_execution_profiles and additional prompt types - Update quickstart.md with production backend URLs - Add comprehensive test coverage for new features
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
// File: frontend/components/admin/ai/ContextConfigEditor.tsx
|
||||
// Change Log:
|
||||
// - 2026-06-14: Created ContextConfigEditor component with project/contract loaders and selectors (conforming to task T028)
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { CheckCircle2, Settings } from 'lucide-react';
|
||||
import { ContextConfig } from '@/lib/types/ai-prompts';
|
||||
import { projectService } from '@/lib/services/project.service';
|
||||
import { contractService } from '@/lib/services/contract.service';
|
||||
|
||||
interface ContextConfigEditorProps {
|
||||
initialConfig: ContextConfig | null;
|
||||
onSave: (config: ContextConfig) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
interface ProjectOption {
|
||||
publicId: string;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
interface ContractOption {
|
||||
publicId: string;
|
||||
contractName: string;
|
||||
project?: {
|
||||
publicId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* คอมโพเนนต์ฟอร์มสำหรับแก้ไขบริบทข้อมูล (Context Configuration)
|
||||
* จัดการตัวเลือกการกรองข้อมูลรายโครงการ (Project Filter) และรายสัญญา (Contract Filter) รวมทั้งภาษาและจำนวนประวัติการดึงข้อมูล
|
||||
*/
|
||||
export default function ContextConfigEditor({
|
||||
initialConfig,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: ContextConfigEditorProps) {
|
||||
const [projects, setProjects] = useState<ProjectOption[]>([]);
|
||||
const [contracts, setContracts] = useState<ContractOption[]>([]);
|
||||
const [filteredContracts, setFilteredContracts] = useState<ContractOption[]>([]);
|
||||
|
||||
// State ฟอร์ม
|
||||
const [projectId, setProjectId] = useState<string>('all');
|
||||
const [contractId, setContractId] = useState<string>('all');
|
||||
const [pageSize, setPageSize] = useState<number>(3);
|
||||
const [language, setLanguage] = useState<string>('th');
|
||||
const [outputLanguage, setOutputLanguage] = useState<string>('th');
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const projList = await projectService.getAll();
|
||||
setProjects(
|
||||
Array.isArray(projList)
|
||||
? (projList as unknown as Record<string, unknown>[]).map((p) => ({
|
||||
publicId: String(p.publicId || ''),
|
||||
projectName: String(p.projectName || ''),
|
||||
}))
|
||||
: []
|
||||
);
|
||||
const contrList = await contractService.getAll();
|
||||
setContracts(
|
||||
Array.isArray(contrList)
|
||||
? (contrList as unknown as Record<string, unknown>[]).map((c) => ({
|
||||
publicId: String(c.publicId || ''),
|
||||
contractName: String(c.contractName || ''),
|
||||
project: c.project
|
||||
? {
|
||||
publicId: String((c.project as unknown as Record<string, unknown>).publicId || ''),
|
||||
}
|
||||
: undefined,
|
||||
}))
|
||||
: []
|
||||
);
|
||||
} catch (_err) {
|
||||
// error handling silently per rules (use NestJS Logger on backend, avoid console.log on frontend)
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// พรีโหลดค่าตั้งต้น
|
||||
useEffect(() => {
|
||||
if (initialConfig) {
|
||||
setProjectId(initialConfig.filter?.projectId || 'all');
|
||||
setContractId(initialConfig.filter?.contractId || 'all');
|
||||
setPageSize(initialConfig.pageSize || 3);
|
||||
setLanguage(initialConfig.language || 'th');
|
||||
setOutputLanguage(initialConfig.outputLanguage || 'th');
|
||||
} else {
|
||||
setProjectId('all');
|
||||
setContractId('all');
|
||||
setPageSize(3);
|
||||
setLanguage('th');
|
||||
setOutputLanguage('th');
|
||||
}
|
||||
}, [initialConfig]);
|
||||
|
||||
// กรองรายการสัญญาตามโครงการที่เลือก
|
||||
useEffect(() => {
|
||||
if (projectId && projectId !== 'all') {
|
||||
const filtered = contracts.filter((c) => c.project?.publicId === projectId);
|
||||
setFilteredContracts(filtered);
|
||||
// รีเซ็ตสัญญาถ้าไม่ได้ผูกกับโครงการที่เลือก
|
||||
const isStillValid = filtered.some((c) => c.publicId === contractId);
|
||||
if (!isStillValid && contractId !== 'all') {
|
||||
setContractId('all');
|
||||
}
|
||||
} else {
|
||||
setFilteredContracts(contracts);
|
||||
}
|
||||
}, [projectId, contracts, contractId]);
|
||||
|
||||
const handleSave = () => {
|
||||
const config: ContextConfig = {
|
||||
filter: {
|
||||
projectId: projectId === 'all' ? null : projectId,
|
||||
contractId: contractId === 'all' ? null : contractId,
|
||||
},
|
||||
pageSize: Number(pageSize),
|
||||
language,
|
||||
outputLanguage,
|
||||
};
|
||||
onSave(config);
|
||||
};
|
||||
|
||||
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">
|
||||
<Settings className="h-4 w-4 text-primary" />
|
||||
การตั้งค่าบริบทข้อมูล (Context Configuration)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 space-y-4">
|
||||
{/* เลือกล็อคโครงการ */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
ตัวกรองโครงการ (Project Filter)
|
||||
</label>
|
||||
<Select value={projectId} onValueChange={setProjectId}>
|
||||
<SelectTrigger className="w-full bg-background/50 border-border/50 backdrop-blur-sm">
|
||||
<SelectValue placeholder="เลือกโครงการ..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">ทั้งหมด (ไม่กรอง / Global)</SelectItem>
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.publicId} value={p.publicId}>
|
||||
{p.projectName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* เลือกล็อคสัญญา */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
ตัวกรองสัญญา (Contract Filter)
|
||||
</label>
|
||||
<Select value={contractId} onValueChange={setContractId}>
|
||||
<SelectTrigger className="w-full bg-background/50 border-border/50 backdrop-blur-sm">
|
||||
<SelectValue placeholder="เลือกสัญญา..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">ทั้งหมด (ไม่กรอง / Global)</SelectItem>
|
||||
{filteredContracts.map((c) => (
|
||||
<SelectItem key={c.publicId} value={c.publicId}>
|
||||
{c.contractName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* ปริมาณเอกสารอ้างอิงและภาษา */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
ประวัติอ้างอิง (Page Size)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(Math.max(1, Number(e.target.value)))}
|
||||
className="bg-background/50 border-border/50 text-sm focus-visible:ring-primary/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
ภาษาต้นทาง (Language)
|
||||
</label>
|
||||
<Select value={language} onValueChange={setLanguage}>
|
||||
<SelectTrigger className="bg-background/50 border-border/50 backdrop-blur-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="th">ไทย (TH)</SelectItem>
|
||||
<SelectItem value="en">English (EN)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
ภาษาปลายทาง (Output)
|
||||
</label>
|
||||
<Select value={outputLanguage} onValueChange={setOutputLanguage}>
|
||||
<SelectTrigger className="bg-background/50 border-border/50 backdrop-blur-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="th">ไทย (TH)</SelectItem>
|
||||
<SelectItem value="en">English (EN)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between border-t border-border/10 pt-4 bg-muted/5 rounded-b-xl">
|
||||
<span className="text-[11px] text-muted-foreground italic flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||||
การตั้งค่านี้จะผูกกับเวอร์ชันของพรอมต์โดยตรง
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
size="sm"
|
||||
className="bg-primary hover:bg-primary/95 font-semibold text-xs"
|
||||
>
|
||||
{isSaving ? 'กำลังบันทึก...' : 'บันทึกบริบท (Save Config)'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// File: frontend/components/admin/ai/PromptEditor.tsx
|
||||
// Change Log:
|
||||
// - 2026-06-14: Created PromptEditor component with live placeholder validation and save actions (conforming to task T018)
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { AlertCircle, CheckCircle, Save, HelpCircle } from 'lucide-react';
|
||||
import { PromptType } from '@/lib/types/ai-prompts';
|
||||
import { PLACEHOLDER_REQUIREMENTS } from '@/contracts/frontend-types';
|
||||
|
||||
interface PromptEditorProps {
|
||||
promptType: PromptType;
|
||||
initialTemplate: string;
|
||||
onSave: (template: string, manualNote: string) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* คอมโพเนนต์เครื่องมือแก้ไขเทมเพลตพรอมต์ (Prompt Editor)
|
||||
* มีระบบตรวจเช็คตัวแปร/เพลสโฮลเดอร์ (Placeholder Validation) ในตัวแบบเรียลไทม์
|
||||
*/
|
||||
export default function PromptEditor({
|
||||
promptType,
|
||||
initialTemplate,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: PromptEditorProps) {
|
||||
const [template, setTemplate] = useState(initialTemplate);
|
||||
const [manualNote, setManualNote] = useState('');
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setTemplate(initialTemplate);
|
||||
setManualNote('');
|
||||
}, [initialTemplate, promptType]);
|
||||
|
||||
// ตรวจสอบตัวแปรที่ต้องมีในพรอมต์เทมเพลต (Real-time Validation)
|
||||
useEffect(() => {
|
||||
const requirements = PLACEHOLDER_REQUIREMENTS[promptType] || [];
|
||||
const missing = requirements.filter((req) => !template.includes(req));
|
||||
setValidationErrors(missing);
|
||||
}, [template, promptType]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (validationErrors.length > 0) return;
|
||||
onSave(template, manualNote);
|
||||
};
|
||||
|
||||
const getFriendlyTypeName = (type: PromptType) => {
|
||||
switch (type) {
|
||||
case 'ocr_extraction':
|
||||
return 'สกัดข้อความ OCR (OCR Extraction)';
|
||||
case 'rag_query_prompt':
|
||||
return 'ค้นหาข้อมูล RAG (RAG Query)';
|
||||
case 'rag_prep_prompt':
|
||||
return 'เตรียมข้อมูล RAG (RAG Prep)';
|
||||
case 'classification_prompt':
|
||||
return 'จำแนกประเภทเอกสาร (Classification)';
|
||||
}
|
||||
};
|
||||
|
||||
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="text-sm font-semibold tracking-wide text-foreground">
|
||||
แก้ไขพรอมต์เทมเพลต ({getFriendlyTypeName(promptType)})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-muted-foreground flex items-center gap-1">
|
||||
โครงสร้างเทมเพลต (Template Body)
|
||||
<span title="ใส่ตัวแปรให้ตรงตามความต้องการของพรอมต์แต่ละประเภท">
|
||||
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</span>
|
||||
</span>
|
||||
<span className={template.length > 4000 ? 'text-destructive font-semibold' : 'text-muted-foreground'}>
|
||||
{template.length} / 4000 อักขระ
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
placeholder="เขียนพรอมต์ของคุณที่นี่..."
|
||||
className="font-mono text-sm min-h-[250px] bg-background/50 border-border/50 focus-visible:ring-primary/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ระบบตรวจสอบตัวแปร (Placeholder Checklist) */}
|
||||
<div className="rounded-lg border border-border/30 bg-muted/20 p-3.5 space-y-2.5">
|
||||
<h4 className="text-xs font-semibold text-foreground">การตรวจสอบความถูกต้อง (Placeholder Verification)</h4>
|
||||
<div className="space-y-1.5">
|
||||
{(PLACEHOLDER_REQUIREMENTS[promptType] || []).map((req) => {
|
||||
const hasReq = template.includes(req);
|
||||
return (
|
||||
<div key={req} className="flex items-center gap-2 text-xs">
|
||||
{hasReq ? (
|
||||
<CheckCircle className="h-4 w-4 text-emerald-500 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0 animate-bounce" />
|
||||
)}
|
||||
<span className={hasReq ? 'text-muted-foreground line-through opacity-70' : 'text-foreground font-medium'}>
|
||||
ต้องมีตัวแปร <code className="bg-muted px-1.5 py-0.5 rounded font-mono text-[11px] border border-border/30 text-primary">{req}</code>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
บันทึกหมายเหตุรุ่น (Manual Version Note)
|
||||
</label>
|
||||
<Input
|
||||
value={manualNote}
|
||||
onChange={(e) => setManualNote(e.target.value)}
|
||||
placeholder="เช่น ปรับปรุงสัดส่วนความเที่ยงตรง, เพิ่มหมวดหมู่ย่อย"
|
||||
className="bg-background/50 border-border/50 focus-visible:ring-primary/30 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-end border-t border-border/10 pt-4 bg-muted/5 rounded-b-xl">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || validationErrors.length > 0 || template.length > 4000 || template.trim() === ''}
|
||||
className="flex items-center gap-2 bg-primary hover:bg-primary/95 text-primary-foreground font-medium shadow-sm transition-all duration-200"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{isSaving ? 'กำลังบันทึก...' : 'บันทึกเวอร์ชันใหม่ (Save New Version)'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// File: frontend/components/admin/ai/PromptTypeDropdown.tsx
|
||||
// Change Log:
|
||||
// - 2026-06-14: Created PromptTypeDropdown component (conforming to task T016)
|
||||
|
||||
import React from 'react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { PromptType } from '@/lib/types/ai-prompts';
|
||||
|
||||
interface PromptTypeDropdownProps {
|
||||
value: PromptType;
|
||||
onChange: (value: PromptType) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* คอมโพเนนต์ Dropdown สำหรับเลือกประเภทของ AI Prompt
|
||||
* รองรับ: OCR Extraction, RAG Query, RAG Prep, และ Document Classification
|
||||
*/
|
||||
export default function PromptTypeDropdown({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: PromptTypeDropdownProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 w-full">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
ประเภทของพรอมต์ (Prompt Type)
|
||||
</label>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val as PromptType)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-background/50 border-border/50 backdrop-blur-sm">
|
||||
<SelectValue placeholder="เลือกประเภทพรอมต์..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ocr_extraction">
|
||||
สกัดข้อความ OCR (OCR Extraction)
|
||||
</SelectItem>
|
||||
<SelectItem value="rag_query_prompt">
|
||||
ค้นหาข้อมูล RAG (RAG Query)
|
||||
</SelectItem>
|
||||
<SelectItem value="rag_prep_prompt">
|
||||
เตรียมข้อมูล RAG (RAG Prep)
|
||||
</SelectItem>
|
||||
<SelectItem value="classification_prompt">
|
||||
จำแนกประเภทเอกสาร (Classification)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// File: frontend/components/admin/ai/RuntimeParametersPanel.tsx
|
||||
// Change Log:
|
||||
// - 2026-06-14: Created RuntimeParametersPanel component for managing sandbox parameters (conforming to task T048)
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { adminAiService, SandboxProfileParams } from '@/lib/services/admin-ai.service';
|
||||
import { toast } from 'sonner';
|
||||
import { Save, RefreshCw, CheckCircle, Sliders } from 'lucide-react';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
|
||||
interface RuntimeParametersPanelProps {
|
||||
onProfileChange?: (params: SandboxProfileParams) => void;
|
||||
}
|
||||
|
||||
const PROFILE_OPTIONS = [
|
||||
{ value: 'standard', label: 'มาตรฐาน (Standard)' },
|
||||
{ value: 'quality', label: 'คุณภาพสูง (Quality)' },
|
||||
{ value: 'interactive', label: 'โต้ตอบเร็ว (Interactive)' },
|
||||
{ value: 'deep-analysis', label: 'วิเคราะห์เชิงลึก (Deep Analysis)' },
|
||||
{ value: 'ocr-extract', label: 'สกัด OCR (OCR Extract)' },
|
||||
];
|
||||
|
||||
export default function RuntimeParametersPanel({ onProfileChange }: RuntimeParametersPanelProps) {
|
||||
const [selectedProfile, setSelectedProfile] = useState<string>('standard');
|
||||
const [params, setParams] = useState<SandboxProfileParams | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [isResetting, setIsResetting] = useState<boolean>(false);
|
||||
const [isApplying, setIsApplying] = useState<boolean>(false);
|
||||
|
||||
const fetchProfileParams = useCallback(async (profileName: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await adminAiService.getSandboxProfile(profileName);
|
||||
setParams(data);
|
||||
if (onProfileChange) {
|
||||
onProfileChange(data);
|
||||
}
|
||||
} catch (_err) {
|
||||
toast.error('ไม่สามารถดึงค่าพารามิเตอร์ Sandbox ได้');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [onProfileChange]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfileParams(selectedProfile);
|
||||
}, [selectedProfile, fetchProfileParams]);
|
||||
|
||||
const handleSliderChange = (field: keyof SandboxProfileParams, val: number) => {
|
||||
if (!params) return;
|
||||
setParams({
|
||||
...params,
|
||||
[field]: val,
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof SandboxProfileParams, val: string) => {
|
||||
if (!params) return;
|
||||
const parsed = val === '' ? null : Number(val);
|
||||
setParams({
|
||||
...params,
|
||||
[field]: parsed,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
if (!params) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const key = uuidv7();
|
||||
const res = await adminAiService.saveSandboxProfile(selectedProfile, params, key);
|
||||
setParams(res);
|
||||
toast.success('บันทึกแบบร่าง Sandbox สำเร็จ');
|
||||
if (onProfileChange) {
|
||||
onProfileChange(res);
|
||||
}
|
||||
} catch (_err) {
|
||||
toast.error('ไม่สามารถบันทึกแบบร่างได้');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetDraft = async () => {
|
||||
setIsResetting(true);
|
||||
try {
|
||||
const res = await adminAiService.resetSandboxProfile(selectedProfile);
|
||||
setParams(res);
|
||||
toast.success('รีเซ็ตแบบร่างเป็นค่าเริ่มต้นแล้ว');
|
||||
if (onProfileChange) {
|
||||
onProfileChange(res);
|
||||
}
|
||||
} catch (_err) {
|
||||
toast.error('ไม่สามารถรีเซ็ตแบบร่างได้');
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyToProduction = async () => {
|
||||
setIsApplying(true);
|
||||
try {
|
||||
const key = uuidv7();
|
||||
await adminAiService.applyProfile(selectedProfile, key);
|
||||
toast.success('ปรับใช้พารามิเตอร์จริงสำเร็จ');
|
||||
} catch (_err) {
|
||||
toast.error('ไม่สามารถปรับใช้พารามิเตอร์จริงได้');
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || !params) {
|
||||
return (
|
||||
<div className="flex h-[300px] items-center justify-center text-sm text-muted-foreground">
|
||||
<RefreshCw 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">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold tracking-wide text-foreground">
|
||||
<Sliders className="h-4 w-4 text-primary" />
|
||||
จัดการพารามิเตอร์รันไทม์ (Runtime Parameters)
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
ปรับเปลี่ยนพารามิเตอร์การทำงานของโมเดล AI ในระบบทดสอบ Sandbox
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="w-full sm:w-[200px]">
|
||||
<Select value={selectedProfile} onValueChange={setSelectedProfile}>
|
||||
<SelectTrigger className="w-full bg-background/50 border-border/50 backdrop-blur-sm h-8 text-xs">
|
||||
<SelectValue placeholder="เลือกโปรไฟล์..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROFILE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-5 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
|
||||
อุณหภูมิความสร้างสรรค์ (Temperature)
|
||||
</Label>
|
||||
<span className="font-mono text-xs font-bold text-primary bg-primary/10 px-1.5 py-0.5 rounded">
|
||||
{params.temperature.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1.5"
|
||||
step="0.05"
|
||||
value={params.temperature}
|
||||
onChange={(e) => handleSliderChange('temperature', Number(e.target.value))}
|
||||
className="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
ค่ายิ่งสูงโมเดลยิ่งตอบอย่างอิสระและมีความคิดสร้างสรรค์ (Temperature สูงเหมาะกับการเขียน) ค่ายิ่งต่ำยิ่งมั่นใจในความถูกต้อง (Temperature ต่ำเหมาะกับการสกัดข้อความ)
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
|
||||
Top-P (Nucleus Sampling)
|
||||
</Label>
|
||||
<span className="font-mono text-xs font-bold text-primary bg-primary/10 px-1.5 py-0.5 rounded">
|
||||
{params.topP.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={params.topP}
|
||||
onChange={(e) => handleSliderChange('topP', Number(e.target.value))}
|
||||
className="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
กำหนดขอบเขตของคำที่เป็นไปได้ในการเลือกคำถัดไป แนะนำให้ตั้งไว้ที่ 0.8 - 0.95
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
|
||||
บทลงโทษคำซ้ำ (Repeat Penalty)
|
||||
</Label>
|
||||
<span className="font-mono text-xs font-bold text-primary bg-primary/10 px-1.5 py-0.5 rounded">
|
||||
{params.repeatPenalty.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="2"
|
||||
step="0.05"
|
||||
value={params.repeatPenalty}
|
||||
onChange={(e) => handleSliderChange('repeatPenalty', Number(e.target.value))}
|
||||
className="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
ลดโอกาสที่โมเดลจะสร้างคำที่เคยพูดไปแล้วซ้ำๆ ค่ายิ่งสูงยิ่งช่วยลดปัญหาคำซ้ำ
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="maxTokens" className="text-xs font-semibold text-foreground">
|
||||
Max Output Tokens
|
||||
</Label>
|
||||
<Input
|
||||
id="maxTokens"
|
||||
type="number"
|
||||
placeholder="เช่น 4096 (null = สูงสุด)"
|
||||
value={params.maxTokens ?? ''}
|
||||
onChange={(e) => handleInputChange('maxTokens', e.target.value)}
|
||||
className="bg-background/50 border-border/50 h-8 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="numCtx" className="text-xs font-semibold text-foreground">
|
||||
Context Window Size (Ctx Size)
|
||||
</Label>
|
||||
<Input
|
||||
id="numCtx"
|
||||
type="number"
|
||||
placeholder="เช่น 8192 (null = สูงสุด)"
|
||||
value={params.numCtx ?? ''}
|
||||
onChange={(e) => handleInputChange('numCtx', e.target.value)}
|
||||
className="bg-background/50 border-border/50 h-8 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="keepAliveSeconds" className="text-xs font-semibold text-foreground">
|
||||
Keep-Alive (วินาที)
|
||||
</Label>
|
||||
<Input
|
||||
id="keepAliveSeconds"
|
||||
type="number"
|
||||
placeholder="เช่น 600 (-1 = โหลดตลอดเวลา, 0 = ยกเลิกทันที)"
|
||||
value={params.keepAliveSeconds}
|
||||
onChange={(e) => handleInputChange('keepAliveSeconds', e.target.value)}
|
||||
className="bg-background/50 border-border/50 h-8 text-xs font-mono"
|
||||
/>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
ระยะเวลาที่โมเดลจะค้างอยู่ใน VRAM หลังจากสิ้นสุดการขอข้อมูลก่อนระบบจะเคลียร์ VRAM
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-foreground">
|
||||
โมเดลสำหรับโปรไฟล์ (Canonical Model)
|
||||
</Label>
|
||||
<div className="font-mono text-xs font-bold text-foreground bg-secondary/35 border border-border/50 p-2 rounded flex justify-between items-center select-none">
|
||||
<span>{params.canonicalModel}</span>
|
||||
<span className="text-[10px] text-muted-foreground">ระบบเปลี่ยนให้อัตโนมัติ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2.5 pt-4 border-t border-border/10">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleResetDraft}
|
||||
disabled={isResetting || isSaving || isApplying}
|
||||
className="h-8 text-xs border-border/50 bg-background/50"
|
||||
>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
รีเซ็ตแบบร่าง (Reset Draft)
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={isSaving || isResetting || isApplying}
|
||||
className="h-8 text-xs bg-secondary hover:bg-secondary/80 border border-border/30"
|
||||
>
|
||||
<Save className="mr-1.5 h-3.5 w-3.5 text-primary" />
|
||||
บันทึกแบบร่าง (Save Draft)
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApplyToProduction}
|
||||
disabled={isApplying || isSaving || isResetting}
|
||||
className="h-8 text-xs bg-primary hover:bg-primary/95 text-primary-foreground"
|
||||
>
|
||||
<CheckCircle className="mr-1.5 h-3.5 w-3.5" />
|
||||
ปรับใช้จริง (Apply to Production)
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
// File: frontend/components/admin/ai/SandboxTabs.tsx
|
||||
// Change Log:
|
||||
// - 2026-06-14: Created SandboxTabs component with 3-step testing (OCR -> AI Extract -> RAG Prep) (conforming to task T037)
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
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 { useProjects, useContracts } from '@/hooks/use-master-data';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Upload,
|
||||
Play,
|
||||
FileText,
|
||||
FileJson,
|
||||
Database,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SandboxTabsProps {
|
||||
promptType: string;
|
||||
selectedVersionNumber?: number;
|
||||
onActivateVersion?: (versionNumber: number) => void;
|
||||
}
|
||||
|
||||
interface ProjectOption {
|
||||
publicId: string;
|
||||
projectCode: string;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
interface ContractOption {
|
||||
publicId: string;
|
||||
contractCode: string;
|
||||
contractName: string;
|
||||
}
|
||||
|
||||
interface SandboxJobResult {
|
||||
ocrText?: string;
|
||||
answer?: string;
|
||||
status?: string;
|
||||
errorMessage?: string;
|
||||
ragChunks?: Array<{ text: string; summary: string }>;
|
||||
ragVectors?: unknown[];
|
||||
}
|
||||
|
||||
export default function SandboxTabs({
|
||||
promptType: _promptType,
|
||||
selectedVersionNumber,
|
||||
onActivateVersion,
|
||||
}: SandboxTabsProps) {
|
||||
// Master data state
|
||||
const [selectedProject, setSelectedProject] = useState<string>('');
|
||||
const [selectedContract, setSelectedContract] = useState<string>('');
|
||||
const { data: projectsData } = useProjects();
|
||||
const projects = Array.isArray(projectsData) ? (projectsData as ProjectOption[]) : [];
|
||||
const { data: contractsData } = useContracts(selectedProject);
|
||||
const contracts = Array.isArray(contractsData) ? (contractsData as ContractOption[]) : [];
|
||||
|
||||
// Sandbox states
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [ocrEngine, setOcrEngine] = useState<string>('auto');
|
||||
const [currentStep, setCurrentStep] = useState<number>(1);
|
||||
const [jobStatus, setJobStatus] = useState<'idle' | 'running' | 'completed' | 'failed'>('idle');
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [statusText, setStatusText] = useState<string>('');
|
||||
|
||||
// Results cache
|
||||
const [requestPublicId, setRequestPublicId] = useState<string | null>(null);
|
||||
const [ocrText, setOcrText] = useState<string>('');
|
||||
const [extractedMetadata, setExtractedMetadata] = useState<Record<string, unknown> | null>(null);
|
||||
const [ragChunks, setRagChunks] = useState<Array<{ text: string; summary: string }> | null>(null);
|
||||
const [ragVectorsCount, setRagVectorsCount] = useState<number>(0);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setFile(e.target.files[0]);
|
||||
setOcrText('');
|
||||
setExtractedMetadata(null);
|
||||
setRagChunks(null);
|
||||
setRequestPublicId(null);
|
||||
setCurrentStep(1);
|
||||
setJobStatus('idle');
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const pollJobStatus = (id: string, step: number, onSuccess: (result: SandboxJobResult) => void) => {
|
||||
let interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await adminAiService.getSandboxJobStatus(id);
|
||||
if (res.status === 'completed') {
|
||||
clearInterval(interval);
|
||||
setJobStatus('completed');
|
||||
setProgress(100);
|
||||
onSuccess(res as SandboxJobResult);
|
||||
} else if (res.status === 'failed') {
|
||||
clearInterval(interval);
|
||||
setJobStatus('failed');
|
||||
setProgress(0);
|
||||
toast.error(res.errorMessage || 'การประมวลผลล้มเหลว');
|
||||
} else if (res.status === 'processing') {
|
||||
setProgress(step === 1 ? 50 : 60);
|
||||
setStatusText('กำลังประมวลผล...');
|
||||
}
|
||||
} catch (_err) {
|
||||
clearInterval(interval);
|
||||
setJobStatus('failed');
|
||||
setProgress(0);
|
||||
toast.error('ไม่สามารถดึงสถานะงานได้');
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleRunOcr = async () => {
|
||||
if (!file) {
|
||||
toast.error('กรุณาเลือกไฟล์ PDF สำหรับทดสอบ');
|
||||
return;
|
||||
}
|
||||
setJobStatus('running');
|
||||
setProgress(15);
|
||||
setStatusText('กำลังอัปโหลดและส่งเอกสารเข้าคิว OCR...');
|
||||
try {
|
||||
const res = await adminAiService.submitSandboxOcr(file, ocrEngine);
|
||||
setRequestPublicId(res.requestPublicId);
|
||||
pollJobStatus(res.requestPublicId, 1, (result) => {
|
||||
setOcrText(result.ocrText || '');
|
||||
setCurrentStep(2);
|
||||
toast.success('ทำ OCR สำเร็จแล้ว สามารถทำการสกัดข้อมูลต่อได้');
|
||||
});
|
||||
} catch (_err) {
|
||||
setJobStatus('failed');
|
||||
toast.error('เกิดข้อผิดพลาดในการรัน OCR');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunExtract = async () => {
|
||||
if (!requestPublicId) {
|
||||
toast.error('กรุณาทำ OCR ก่อน');
|
||||
return;
|
||||
}
|
||||
if (!selectedProject) {
|
||||
toast.error('กรุณาเลือกโครงการสำหรับทดสอบ');
|
||||
return;
|
||||
}
|
||||
setJobStatus('running');
|
||||
setProgress(20);
|
||||
setStatusText('กำลังประมวลผลการสกัดข้อมูลเมตาดาต้า...');
|
||||
try {
|
||||
const res = await adminAiService.submitSandboxAiExtract(
|
||||
requestPublicId,
|
||||
selectedVersionNumber,
|
||||
selectedProject,
|
||||
selectedContract || undefined
|
||||
);
|
||||
pollJobStatus(res.requestPublicId, 2, (result) => {
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = result.answer ? JSON.parse(result.answer) : null;
|
||||
} catch {
|
||||
parsed = { error: 'ผลลัพธ์ไม่ใช่ JSON ที่ถูกต้อง', raw: result.answer };
|
||||
}
|
||||
setExtractedMetadata(parsed);
|
||||
setCurrentStep(3);
|
||||
toast.success('สกัดข้อมูลเมตาดาต้าสำเร็จ สามารถทดสอบ RAG Prep ต่อได้');
|
||||
});
|
||||
} catch (_err) {
|
||||
setJobStatus('failed');
|
||||
toast.error('เกิดข้อผิดพลาดในการสกัดข้อมูล');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunRagPrep = async () => {
|
||||
if (!ocrText) {
|
||||
toast.error('ไม่มีข้อความ OCR สำหรับทดสอบ');
|
||||
return;
|
||||
}
|
||||
setJobStatus('running');
|
||||
setProgress(30);
|
||||
setStatusText('กำลังประมวลผลการทำ Semantic Chunking และสร้างเวกเตอร์ RAG...');
|
||||
try {
|
||||
const res = await adminAiService.submitSandboxRagPrep(ocrText);
|
||||
pollJobStatus(res.jobId, 3, (result) => {
|
||||
setRagChunks(result.ragChunks || []);
|
||||
setRagVectorsCount(result.ragVectors ? result.ragVectors.length : 0);
|
||||
toast.success('วิเคราะห์การเตรียมข้อมูล RAG สำเร็จ');
|
||||
});
|
||||
} catch (_err) {
|
||||
setJobStatus('failed');
|
||||
toast.error('เกิดข้อผิดพลาดในการทำ RAG Prep');
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = () => {
|
||||
if (selectedVersionNumber && onActivateVersion) {
|
||||
onActivateVersion(selectedVersionNumber);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<Play className="h-4 w-4 text-primary" />
|
||||
รันบอร์ดทดลองการทำงาน (3-Step Sandbox Testing)
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
ทดสอบความถูกต้องของเวอร์ชันพรอมต์จำลองกระบวนการจริง (OCR → AI Extract → RAG Prep)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-5 space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-4 border-b border-border/10 pb-4">
|
||||
<div className="flex-1 min-w-[200px] space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">โครงการสำหรับสกัดบริบท</Label>
|
||||
<Select value={selectedProject} onValueChange={setSelectedProject}>
|
||||
<SelectTrigger className="h-8 text-xs bg-background/50 border-border/50">
|
||||
<SelectValue placeholder="เลือกโครงการ..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.publicId} value={p.publicId} className="text-xs">
|
||||
{p.projectName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[200px] space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">สัญญา (ถ้ามี)</Label>
|
||||
<Select value={selectedContract} onValueChange={setSelectedContract} disabled={!selectedProject}>
|
||||
<SelectTrigger className="h-8 text-xs bg-background/50 border-border/50">
|
||||
<SelectValue placeholder="เลือกสัญญา..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contracts.map((c) => (
|
||||
<SelectItem key={c.publicId} value={c.publicId} className="text-xs">
|
||||
{c.contractName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-[150px] space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">OCR Engine</Label>
|
||||
<Select value={ocrEngine} onValueChange={setOcrEngine}>
|
||||
<SelectTrigger className="h-8 text-xs bg-background/50 border-border/50">
|
||||
<SelectValue placeholder="เลือกเอนจิน..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto" className="text-xs">Auto (Baseline)</SelectItem>
|
||||
<SelectItem value="tesseract" className="text-xs">Tesseract (CPU)</SelectItem>
|
||||
<SelectItem value="np-dms-ocr" className="text-xs">Typhoon OCR (GPU)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 bg-background/40 p-4 border border-border/30 rounded-lg">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="p-2 bg-primary/10 rounded">
|
||||
<Upload className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-xs font-bold text-foreground">อัปโหลดไฟล์สำหรับทดสอบ Sandbox</Label>
|
||||
<p className="text-[10px] text-muted-foreground">เลือกไฟล์ PDF วิศวกรรม/ก่อสร้าง ขนาดไม่เกิน 50MB</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative overflow-hidden cursor-pointer bg-primary/90 hover:bg-primary/95 text-primary-foreground font-semibold px-4 py-2 rounded text-xs select-none flex items-center gap-2">
|
||||
<span>เลือกไฟล์เอกสาร...</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={handleFileChange}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono bg-secondary/20 border border-border/50 px-3 py-1.5 rounded">
|
||||
<FileText className="h-4 w-4 text-primary shrink-0" />
|
||||
<span className="truncate flex-1">{file.name}</span>
|
||||
<span>({(file.size / (1024 * 1024)).toFixed(2)} MB)</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status indicator */}
|
||||
{jobStatus === 'running' && (
|
||||
<div className="space-y-2.5 p-4 border border-primary/20 bg-primary/[0.02] rounded-lg">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="flex items-center font-semibold text-primary">
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
{statusText}
|
||||
</span>
|
||||
<span className="font-mono font-bold text-primary">{progress}%</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steps navigation and panels */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 pt-2">
|
||||
{/* Step buttons */}
|
||||
<div className="lg:col-span-3 flex lg:flex-col gap-2.5">
|
||||
<Button
|
||||
variant={currentStep === 1 ? 'default' : 'outline'}
|
||||
disabled={jobStatus === 'running' || !file}
|
||||
onClick={() => setCurrentStep(1)}
|
||||
className="w-full h-9 justify-start text-xs font-semibold"
|
||||
>
|
||||
<Badge className="mr-2 h-4 min-w-4 px-1 flex items-center justify-center text-[9px] bg-secondary text-secondary-foreground select-none">1</Badge>
|
||||
Step 1: Run OCR
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentStep === 2 ? 'default' : 'outline'}
|
||||
disabled={jobStatus === 'running' || !ocrText}
|
||||
onClick={() => setCurrentStep(2)}
|
||||
className="w-full h-9 justify-start text-xs font-semibold"
|
||||
>
|
||||
<Badge className="mr-2 h-4 min-w-4 px-1 flex items-center justify-center text-[9px] bg-secondary text-secondary-foreground select-none">2</Badge>
|
||||
Step 2: AI Extract
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentStep === 3 ? 'default' : 'outline'}
|
||||
disabled={jobStatus === 'running' || !extractedMetadata}
|
||||
onClick={() => setCurrentStep(3)}
|
||||
className="w-full h-9 justify-start text-xs font-semibold"
|
||||
>
|
||||
<Badge className="mr-2 h-4 min-w-4 px-1 flex items-center justify-center text-[9px] bg-secondary text-secondary-foreground select-none">3</Badge>
|
||||
Step 3: RAG Prep
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Step detail views */}
|
||||
<div className="lg:col-span-9 border border-border/30 rounded-lg p-4 bg-background/50 min-h-[300px] flex flex-col justify-between">
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-4 flex-1 flex flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-bold text-foreground flex items-center gap-1.5">
|
||||
<FileText className="h-4 w-4 text-primary" />
|
||||
Step 1: สกัดข้อความ OCR (OCR Extraction)
|
||||
</h4>
|
||||
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||
รันเอนจินสกัดข้อความเพื่อดึงตัวหนังสือดิบออกมาจากหน้าไฟล์ PDF ที่ส่งขึ้นไป สามารถดูผลลัพธ์ข้อความดิบเพื่อประเมินความคมชัดของ OCR
|
||||
</p>
|
||||
</div>
|
||||
{ocrText ? (
|
||||
<div className="flex-1 min-h-[150px] max-h-[250px] overflow-y-auto rounded bg-secondary/30 border border-border/50 p-3 font-mono text-[10px] whitespace-pre-wrap select-text leading-relaxed mt-3">
|
||||
{ocrText}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-[150px] flex items-center justify-center border border-dashed border-border/70 rounded mt-3 text-xs text-muted-foreground italic">
|
||||
ยังไม่มีข้อมูล OCR คลิก "เริ่มรัน OCR" ด้านล่าง
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end pt-4 border-t border-border/10 mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRunOcr}
|
||||
disabled={jobStatus === 'running' || !file}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
เริ่มรัน OCR (Run OCR)
|
||||
<ArrowRight className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-4 flex-1 flex flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-bold text-foreground flex items-center gap-1.5">
|
||||
<FileJson className="h-4 w-4 text-primary" />
|
||||
Step 2: สกัดข้อมูลอัจฉริยะ (AI Metadata Extraction)
|
||||
</h4>
|
||||
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||
ส่งข้อความ OCR พร้อมบริบท Master data (โครงการ/สัญญา) เข้าไปประมวลผลร่วมกับโมเดลหลักและเวอร์ชันพรอมต์ที่เลือก เพื่อแปลงเป็นโครงสร้างข้อมูล JSON อัจฉริยะ
|
||||
</p>
|
||||
</div>
|
||||
{extractedMetadata ? (
|
||||
<div className="flex-1 min-h-[150px] max-h-[250px] overflow-y-auto rounded bg-secondary/30 border border-border/50 p-3 font-mono text-[10px] text-emerald-400 select-text leading-relaxed mt-3">
|
||||
<pre>{JSON.stringify(extractedMetadata, null, 2)}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-[150px] flex items-center justify-center border border-dashed border-border/70 rounded mt-3 text-xs text-muted-foreground italic">
|
||||
ยังไม่มีผลลัพธ์การสกัดข้อมูล คลิก "เริ่มรันสกัดข้อมูล" ด้านล่าง
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center pt-4 border-t border-border/10 mt-4">
|
||||
{selectedVersionNumber && onActivateVersion && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleActivate}
|
||||
className="h-8 text-xs border-emerald-500/30 text-emerald-500 hover:bg-emerald-500/10"
|
||||
>
|
||||
<CheckCircle className="mr-1.5 h-3.5 w-3.5" />
|
||||
เปิดใช้งานเวอร์ชัน v{selectedVersionNumber} ทันที
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRunExtract}
|
||||
disabled={jobStatus === 'running' || !ocrText}
|
||||
className="h-8 text-xs bg-primary hover:bg-primary/95 text-primary-foreground"
|
||||
>
|
||||
เริ่มรันสกัดข้อมูล (Run AI Extract)
|
||||
<ArrowRight className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-4 flex-1 flex flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-bold text-foreground flex items-center gap-1.5">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
Step 3: เตรียมฐานข้อมูลค้นหา (RAG Prep Sandbox)
|
||||
</h4>
|
||||
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||
จำลองกระบวนการแบ่งข้อความออกเป็นส่วนๆ (Semantic Chunking) ตามความเหมาะสมทางภาษาและความหมายของเอกสาร พร้อมแสดงขนาดเวกเตอร์ Dense/Sparse ที่สกัดสำหรับใช้ใน Qdrant
|
||||
</p>
|
||||
</div>
|
||||
{ragChunks ? (
|
||||
<div className="flex-1 flex flex-col gap-3 mt-3 overflow-hidden">
|
||||
<div className="flex justify-between items-center bg-secondary/40 border border-border/50 px-3 py-2 rounded text-xs select-none">
|
||||
<span className="font-semibold text-foreground flex items-center gap-1">
|
||||
<CheckCircle className="h-4 w-4 text-emerald-500" />
|
||||
ทำเวกเตอร์สำเร็จ: {ragVectorsCount} เวกเตอร์
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px] border-border/50"> chunks: {ragChunks.length}</Badge>
|
||||
</div>
|
||||
<div className="flex-1 min-h-[120px] max-h-[200px] overflow-y-auto space-y-2 mt-1">
|
||||
{ragChunks.map((chunk, idx) => (
|
||||
<div key={idx} className="bg-background/80 border border-border/30 rounded p-2.5 text-[10px] space-y-1 hover:border-primary/20 transition-all select-text">
|
||||
<div className="flex justify-between items-center text-primary font-bold">
|
||||
<span>#Chunk {idx + 1}</span>
|
||||
<Badge className="text-[8px] py-0 px-1 select-none">{chunk.summary || 'หัวข้อหลัก'}</Badge>
|
||||
</div>
|
||||
<p className="leading-relaxed text-muted-foreground">{chunk.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-[150px] flex items-center justify-center border border-dashed border-border/70 rounded mt-3 text-xs text-muted-foreground italic">
|
||||
ยังไม่มีผลลัพธ์ RAG Prep คลิก "เริ่มทดสอบ RAG Prep" ด้านล่าง
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end pt-4 border-t border-border/10 mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRunRagPrep}
|
||||
disabled={jobStatus === 'running' || !ocrText}
|
||||
className="h-8 text-xs bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||
>
|
||||
เริ่มทดสอบ RAG Prep (Test RAG Prep)
|
||||
<CheckCircle className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// File: frontend/components/admin/ai/VersionHistory.tsx
|
||||
// Change Log:
|
||||
// - 2026-06-14: Created VersionHistory component with type filtering and nice badges (conforming to task T017)
|
||||
|
||||
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 { PromptVersion } from '@/lib/types/ai-prompts';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface VersionHistoryProps {
|
||||
versions: PromptVersion[];
|
||||
isLoading: boolean;
|
||||
onLoadTemplate: (version: PromptVersion) => void;
|
||||
onActivateVersion: (versionNumber: number) => void;
|
||||
onDeleteVersion: (versionNumber: number) => void;
|
||||
isActivating: boolean;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* คอมโพเนนต์แสดงประวัติเวอร์ชันของพรอมต์ตามประเภทที่กรองไว้
|
||||
* แสดงรายการเวอร์ชันพร้อมปุ่มพรีโหลด เปิดใช้งาน และลบเวอร์ชันที่ไม่ต้องการ
|
||||
*/
|
||||
export default function VersionHistory({
|
||||
versions,
|
||||
isLoading,
|
||||
onLoadTemplate,
|
||||
onActivateVersion,
|
||||
onDeleteVersion,
|
||||
isActivating,
|
||||
isDeleting,
|
||||
}: VersionHistoryProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-[300px] 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" />
|
||||
ประวัติเวอร์ชัน (Version History)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 px-3 sm:px-4 max-h-[500px] 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) => {
|
||||
const isActive = version.isActive === true;
|
||||
return (
|
||||
<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',
|
||||
isActive && 'border-emerald-500/20 bg-emerald-500/[0.02]'
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{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>
|
||||
</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>
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user