เพิ่ม AI Inference (Run) ที่ Frontend
This commit is contained in:
parent
7686d89fe0
commit
2eba6a099e
34
web/src/components/FileUploadAndSubmit.jsx
Normal file
34
web/src/components/FileUploadAndSubmit.jsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// ใน component นี้ เราจะจัดการแค่ UI/Input/Button และเรียก onSubmit() ที่มาจาก parent
|
||||||
|
export default function FileUploadAndSubmit({ file, setFile, isPending, onSubmit, selectedModelId }) {
|
||||||
|
|
||||||
|
// กำหนดว่าปุ่มควรถูก Disable หรือไม่
|
||||||
|
const isDisabled = isPending || !selectedModelId || !file;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
{/* File Upload */}
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">อัปโหลดไฟล์ DICOM / NIfTI</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="file-input file-input-bordered w-full"
|
||||||
|
accept=".nii,.nii.gz,.zip,.dcm"
|
||||||
|
onChange={(e) => setFile(e.target.files[0])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`btn btn-primary w-full ${isPending ? 'loading' : ''}`}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
{isPending ? 'กำลังประมวลผล...' : 'เริ่มประมวลผล'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
web/src/components/ModelSelector.jsx
Normal file
28
web/src/components/ModelSelector.jsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function ModelSelector({ activeModels, isLoading, selectedModelId, setSelectedModelId, isPending }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">เลือก Model</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered w-full"
|
||||||
|
value={selectedModelId}
|
||||||
|
onChange={(e) => setSelectedModelId(e.target.value)}
|
||||||
|
disabled={isLoading || isPending}
|
||||||
|
>
|
||||||
|
<option value="">-- เลือก Model --</option>
|
||||||
|
{isLoading ? (
|
||||||
|
<option disabled>กำลังโหลด...</option>
|
||||||
|
) : (
|
||||||
|
activeModels?.map(model => (
|
||||||
|
<option key={model.id} value={model.id}>
|
||||||
|
{model.name} (v{model.model_version})
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
web/src/components/ResultDisplay.jsx
Normal file
25
web/src/components/ResultDisplay.jsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function ResultDisplay({ result }) {
|
||||||
|
// หากไม่มีผลลัพธ์ (result เป็น null) ก็ไม่ต้องแสดงผลอะไร
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 alert alert-success shadow-lg">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold">Inference Success!</h3>
|
||||||
|
<p className="text-sm">ได้รับผลลัพธ์จากเซิร์ฟเวอร์แล้ว (JSON Payload)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* แสดงผลลัพธ์ JSON ในรูปแบบที่อ่านง่าย */}
|
||||||
|
<pre className="text-sm mt-2 bg-base-100 p-2 rounded max-h-60 overflow-auto border border-base-300
|
||||||
|
text-base-content whitespace-pre-wrap break-words">
|
||||||
|
{/* text-base-content: เพื่อให้สีตัวอักษรเป็นสีพื้นฐานของ Theme (ไม่ใช่สีขาว) */}
|
||||||
|
{/* whitespace-pre-wrap: บังคับให้ข้อความขึ้นบรรทัดใหม่แม้จะอยู่ใน <pre> */}
|
||||||
|
{JSON.stringify(result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
web/src/pages/data/InferenceRun.jsx
Normal file
71
web/src/pages/data/InferenceRun.jsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
// Import Components ย่อยจากโฟลเดอร์ components/
|
||||||
|
import ModelSelector from '../../components/ModelSelector';
|
||||||
|
import FileUploadAndSubmit from '../../components/FileUploadAndSubmit';
|
||||||
|
import ResultDisplay from '../../components/ResultDisplay';
|
||||||
|
|
||||||
|
// Import Hooks จากโฟลเดอร์ services/
|
||||||
|
import { useActiveModelList } from '../../services/modelRegistryApi';
|
||||||
|
import { useRunInferenceMutation } from '../../services/inferenceApi';
|
||||||
|
|
||||||
|
export default function InferenceRun() {
|
||||||
|
// 1. Hooks สำหรับดึงข้อมูลและเรียกใช้งาน AI
|
||||||
|
const { data: activeModels, isLoading } = useActiveModelList();
|
||||||
|
const { mutateAsync: runInference, isPending } = useRunInferenceMutation();
|
||||||
|
|
||||||
|
// 2. State สำหรับ Component
|
||||||
|
const [selectedModelId, setSelectedModelId] = useState('');
|
||||||
|
const [file, setFile] = useState(null);
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
|
||||||
|
// 3. Logic เมื่อกดปุ่ม Submit
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setResult(null); // เคลียร์ผลลัพธ์เก่า
|
||||||
|
|
||||||
|
if (!selectedModelId || !file) return alert('กรุณาเลือก Model และอัปโหลดไฟล์');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// เรียก Mutation Hook: ส่ง modelId แทน targetUrl
|
||||||
|
const res = await runInference({
|
||||||
|
formData,
|
||||||
|
modelId: selectedModelId // Backend คาดหวัง String ใน URL Path
|
||||||
|
});
|
||||||
|
setResult(res);
|
||||||
|
} catch (error) {
|
||||||
|
// Error handling ถูกจัดการด้วย Toast ใน Hook แล้ว
|
||||||
|
console.error('Inference run failed in page component:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">AI Inference (Run)</h2>
|
||||||
|
<p className="mb-6 text-gray-500">เลือก Model ที่ต้องการ และอัปโหลดไฟล์เพื่อสั่งรันการประมวลผล</p>
|
||||||
|
|
||||||
|
{/* Component 1: ตัวเลือก Model */}
|
||||||
|
<ModelSelector
|
||||||
|
activeModels={activeModels}
|
||||||
|
isLoading={isLoading}
|
||||||
|
selectedModelId={selectedModelId}
|
||||||
|
setSelectedModelId={setSelectedModelId}
|
||||||
|
isPending={isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Component 2: อัปโหลดไฟล์และปุ่ม Submit */}
|
||||||
|
<FileUploadAndSubmit
|
||||||
|
file={file}
|
||||||
|
setFile={setFile}
|
||||||
|
isPending={isPending}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
selectedModelId={selectedModelId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Component 3: แสดงผลลัพธ์ */}
|
||||||
|
<ResultDisplay result={result} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import Dashboard from '../pages/Dashboard';
|
import Dashboard from '../pages/Dashboard';
|
||||||
import BlankPage from '../pages/BlankPage';
|
import BlankPage from '../pages/BlankPage';
|
||||||
import ModelRegistry from '../pages/data/ModelRegistry';
|
import ModelRegistry from '../pages/data/ModelRegistry';
|
||||||
|
import InferenceRun from '../pages/data/InferenceRun';
|
||||||
|
|
||||||
// Array ของเส้นทางย่อยภายใต้ /dashboard/
|
// Array ของเส้นทางย่อยภายใต้ /dashboard/
|
||||||
const pageRoutes = [
|
const pageRoutes = [
|
||||||
@ -15,6 +16,12 @@ const pageRoutes = [
|
|||||||
path: 'model-registry',
|
path: 'model-registry',
|
||||||
element: <ModelRegistry />,
|
element: <ModelRegistry />,
|
||||||
},
|
},
|
||||||
|
// --- AI Inference (Run) ---
|
||||||
|
{
|
||||||
|
// Path: /dashboard/inference-run
|
||||||
|
path: 'inference-run',
|
||||||
|
element: <InferenceRun />,
|
||||||
|
},
|
||||||
// Fallback สำหรับเส้นทางที่ไม่ตรงกับเมนูใดๆ
|
// Fallback สำหรับเส้นทางที่ไม่ตรงกับเมนูใดๆ
|
||||||
{
|
{
|
||||||
path: '*',
|
path: '*',
|
||||||
|
|||||||
38
web/src/services/inferenceApi.js
Normal file
38
web/src/services/inferenceApi.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import axiosClient from './axiosClient';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { addToast } from '../features/toast/toastSlice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook สำหรับสั่งรัน AI Inference ผ่าน Django DRF Proxy
|
||||||
|
* Endpoint: POST /api/v1/models/{modelId}/run-inference/ ⬅️ (ใหม่!)
|
||||||
|
*/
|
||||||
|
export const useRunInferenceMutation = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ formData, modelId }) => {
|
||||||
|
|
||||||
|
// ใช้ Template String เพื่อฝัง modelId เข้าไปใน URL
|
||||||
|
const response = await axiosClient.post(
|
||||||
|
`/api/v1/models/${modelId}/run-inference/`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
// ต้องระบุ Content-Type สำหรับการอัปโหลดไฟล์
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
// ตั้งค่า Timeout เผื่อการประมวลผล AI ที่นาน (10 นาที)
|
||||||
|
timeout: 600000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
dispatch(addToast({ message: 'เริ่มกระบวนการ Inference แล้ว!', type: 'success' }));
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const msg = error.response?.data?.detail || 'การประมวลผลล้มเหลว';
|
||||||
|
dispatch(addToast({ message: `Inference Failed: ${msg}`, type: 'error' }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -113,4 +113,38 @@ export const useUpdateModel = () => {
|
|||||||
dispatch(addToast({ message: `การแก้ไขล้มเหลว: ${errorMessage}`, type: 'error' }));
|
dispatch(addToast({ message: `การแก้ไขล้มเหลว: ${errorMessage}`, type: 'error' }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useActiveModelList = () => {
|
||||||
|
// Note: useModelList() จะส่ง data: undefined หรือ error มาเมื่อ 401
|
||||||
|
const { data, isLoading, isError } = useModelList();
|
||||||
|
|
||||||
|
const activeModels = data
|
||||||
|
// การตรวจสอบ data?. เพื่อความปลอดภัยก่อนเข้าสู่ filter
|
||||||
|
?.filter(model => model.status === 'ACTIVE')
|
||||||
|
?.map(model => {
|
||||||
|
|
||||||
|
// การตรวจสอบ Optional Chaining (?. ) ก่อนเรียกใช้ properties
|
||||||
|
// และเพิ่มการตรวจสอบค่าว่าง (|| '') เพื่อป้องกัน undefined/null
|
||||||
|
const base_url = model.base_url || '';
|
||||||
|
const endpoint_path = model.endpoint_path || '';
|
||||||
|
|
||||||
|
// 1. ลบ / ท้ายสุดของ base_url ออก (ถ้ามี)
|
||||||
|
const baseUrl = base_url.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
// 2. ลบ / หน้าสุดของ endpoint_path ออก (ถ้ามี)
|
||||||
|
// Error: Cannot read properties of undefined (reading 'startsWith') เกิดที่นี่
|
||||||
|
const endpointPath = endpoint_path.startsWith('/')
|
||||||
|
? endpoint_path.substring(1)
|
||||||
|
: endpoint_path;
|
||||||
|
|
||||||
|
const fullInferenceUrl = `${baseUrl}/${endpointPath}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...model,
|
||||||
|
fullInferenceUrl,
|
||||||
|
};
|
||||||
|
}) || []; // ถ้า data เป็น undefined/null ให้คืนค่า Array ว่าง
|
||||||
|
|
||||||
|
return { data: activeModels, isLoading, isError };
|
||||||
};
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user