"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import type { Artifact, Skill } from "@/lib/types"; import { estimateToolCostAPI, submitJobAPI, getJobStatusAPI, type CostEstimate, } from "@/lib/api"; function detectMime(key: string): string { if (key.endsWith('.pdb') || key.endsWith('.cif')) return 'chemical/x-pdb'; if (key.endsWith('.fasta') || key.endsWith('.fa')) return 'application/fasta'; if (key.endsWith('.csv')) return 'text/csv'; if (key.endsWith('.json')) return 'application/json'; return 'application/octet-stream'; } type RunState = "configure" | "submitting" | "polling" | "completed" | "failed"; interface Props { skill: Skill; campaignId: string; onComplete: (artifacts: Artifact[]) => void; onClose: () => void; } export default function ToolRunPanel({ skill, campaignId, onComplete, onClose, }: Props) { const [runState, setRunState] = useState("configure"); const [estimate, setEstimate] = useState(null); const [estimateError, setEstimateError] = useState(null); const [callParams, setCallParams] = useState("{}"); const [file, setFile] = useState(null); const [provider, setProvider] = useState<"runpod" | "slurm">("runpod"); const [error, setError] = useState(null); const [artifacts, setArtifacts] = useState([]); const [elapsed, setElapsed] = useState(0); const pollRef = useRef | null>(null); const timerRef = useRef | null>(null); const jobIdRef = useRef(null); // Fetch cost estimate on mount useEffect(() => { let cancelled = false; estimateToolCostAPI(skill.id, skill.gpuType) .then((est) => { if (!cancelled) setEstimate(est); }) .catch((e) => { if (!cancelled) setEstimateError(e instanceof Error ? e.message : "estimate_failed"); }); return () => { cancelled = true; }; }, [skill.id, skill.gpuType]); // Clean up intervals on unmount useEffect(() => { return () => { if (pollRef.current) clearInterval(pollRef.current); if (timerRef.current) clearInterval(timerRef.current); }; }, []); const startPolling = useCallback( (jobId: string) => { setRunState("polling"); setElapsed(0); const start = Date.now(); timerRef.current = setInterval(() => { setElapsed(Math.floor((Date.now() - start) / 1000)); }, 1000); pollRef.current = setInterval(async () => { try { const status = await getJobStatusAPI(jobId); const hubStatus = String( status.hub?.status ?? "", ).toLowerCase(); if (hubStatus === "completed") { if (pollRef.current) clearInterval(pollRef.current); if (timerRef.current) clearInterval(timerRef.current); // Map backend artifact shape to frontend Artifact type. // Artifacts from Hub jobs are served by the backend, not the // Next.js local artifact store. Proxy via the backend URL. const hubOutputData = status.hub?.output_data && typeof status.hub.output_data === "object" ? (status.hub.output_data as Record) : undefined; const mapped: Artifact[] = (status.artifacts ?? []).map( (a, i) => ({ id: a.id ?? `artifact-${i}`, name: a.hub_output_key ?? `output-${i}`, bytes: 0, mime: detectMime(a.hub_output_key ?? ''), url: `/api/jobs/${encodeURIComponent(jobId!)}/artifacts/${encodeURIComponent(a.id ?? "")}`, createdAt: Date.now(), hubOutputData, }), ); setArtifacts(mapped); setRunState("completed"); onComplete(mapped); } else if ( hubStatus === "failed" || hubStatus === "error" || hubStatus === "cancelled" ) { if (pollRef.current) clearInterval(pollRef.current); if (timerRef.current) clearInterval(timerRef.current); setError( String( status.hub?.error ?? status.hub?.message ?? `Job ${hubStatus}`, ), ); setRunState("failed"); } } catch (e) { // Don't stop polling on transient network errors console.warn("Poll error:", e); } }, 3000); }, [onComplete], ); const handleRun = useCallback(async () => { if (!file) { setError("Please select an input file."); return; } // Validate JSON try { JSON.parse(callParams); } catch { setError("call_params is not valid JSON."); return; } setError(null); setRunState("submitting"); try { const formData = new FormData(); formData.set("endpoint_name", skill.id); formData.set("provider", provider); formData.set("call_params", callParams); formData.set("input_file", file); const result = await submitJobAPI(campaignId, formData); jobIdRef.current = result.campaign_job_id; startPolling(result.campaign_job_id); } catch (e) { setError(e instanceof Error ? e.message : "Submission failed"); setRunState("failed"); } }, [file, callParams, skill.id, provider, campaignId, startPolling]); const isRunning = runState === "submitting" || runState === "polling"; return (
e.stopPropagation()} > {/* Header */}

Run {skill.name}

{skill.id}

{/* Body */}
{/* Description */} {skill.description && (

{skill.description}

)} {/* Cost estimate */}

Cost Estimate

{estimate ? (

Cost

${estimate.estimated_usd.toFixed(2)}

GPU

{estimate.gpu_type || "CPU"}

Est. Time

{estimate.minutes_used.toFixed(1)} min

) : estimateError ? (

Estimate unavailable

) : (

Loading estimate...

)}
{/* Configuration (only visible before run) */} {runState === "configure" && ( <> {/* Provider */}

Provider

{(["runpod", "slurm"] as const).map((p) => ( ))}
{/* Input file */}

Input File

setFile(e.target.files?.[0] ?? null)} className="text-[11px] text-foreground file:mr-3 file:px-3 file:py-1.5 file:rounded-md file:border file:border-border file:bg-muted file:text-[11px] file:text-foreground file:cursor-pointer" />
{/* Call params */}

Parameters (JSON)