feat: implement prompt execution module and associated UI components to allow testing AI templates with variable inputs
This commit is contained in:
parent
1144f83f3c
commit
b6e594611e
327
src/components/tenant/PromptExecuteModal.tsx
Normal file
327
src/components/tenant/PromptExecuteModal.tsx
Normal file
@ -0,0 +1,327 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import {
|
||||
Modal,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
FormSelect,
|
||||
FormSlider,
|
||||
FormTextArea,
|
||||
StatusBadge,
|
||||
MarkdownViewer,
|
||||
} from "@/components/shared";
|
||||
import { aiService } from "@/services/ai-service";
|
||||
import type { AIPrompt, AICompletion, AIProviderInfo } from "@/types/ai";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import { Play, Sparkles, Terminal, Sliders, AlertCircle, Cpu, Clock, DollarSign, Activity } from "lucide-react";
|
||||
|
||||
interface PromptExecuteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
prompt: AIPrompt | null;
|
||||
}
|
||||
|
||||
export const PromptExecuteModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
prompt,
|
||||
}: PromptExecuteModalProps) => {
|
||||
const [apiProviders, setApiProviders] = useState<AIProviderInfo[]>([]);
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
|
||||
// Form values for variables
|
||||
const [variables, setVariables] = useState<Record<string, string>>({});
|
||||
|
||||
// Form values for overrides
|
||||
const [provider, setProvider] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [temperature, setTemperature] = useState(0.7);
|
||||
const [maxTokens, setMaxTokens] = useState(2048);
|
||||
|
||||
// Execution result
|
||||
const [result, setResult] = useState<AICompletion | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load providers for override dropdowns
|
||||
const loadProviders = async () => {
|
||||
try {
|
||||
const data = await aiService.getProviders();
|
||||
setApiProviders(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load providers list", err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && prompt) {
|
||||
void loadProviders();
|
||||
|
||||
// Initialize dynamic variables form inputs
|
||||
const initialVars: Record<string, string> = {};
|
||||
(prompt.variables || []).forEach((v) => {
|
||||
initialVars[v.name] = String(v.default || "");
|
||||
});
|
||||
setVariables(initialVars);
|
||||
|
||||
// Initialize parameters overrides
|
||||
setProvider(prompt.defaultParameters?.provider || "");
|
||||
setModel(prompt.defaultParameters?.model || "");
|
||||
setTemperature(prompt.defaultParameters?.temperature ?? 0.7);
|
||||
setMaxTokens(prompt.defaultParameters?.max_tokens ?? 2048);
|
||||
|
||||
// Reset prior result states
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, prompt]);
|
||||
|
||||
// Handle provider changes
|
||||
const providersOptions = useMemo(
|
||||
() => apiProviders.map((p) => ({ value: p.name, label: p.displayName || p.name })),
|
||||
[apiProviders],
|
||||
);
|
||||
|
||||
const providerDetail = apiProviders.find((p) => p.name === provider);
|
||||
const modelsOptions = useMemo(
|
||||
() =>
|
||||
(providerDetail?.models || []).map((m) => ({
|
||||
value: m,
|
||||
label: m,
|
||||
})),
|
||||
[providerDetail],
|
||||
);
|
||||
|
||||
const handleVariableChange = (name: string, value: string) => {
|
||||
setVariables((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!prompt) return;
|
||||
|
||||
// Validate required fields
|
||||
const missingFields = (prompt.variables || [])
|
||||
.filter((v) => v.required && !variables[v.name]?.trim())
|
||||
.map((v) => v.name);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
showToast.error(`Please provide values for required variables: ${missingFields.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExecuting(true);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
variables: variables,
|
||||
provider: provider || undefined,
|
||||
model: model || undefined,
|
||||
temperature: temperature,
|
||||
max_tokens: maxTokens,
|
||||
};
|
||||
|
||||
const response = await aiService.executePrompt(prompt.id, payload);
|
||||
setResult(response);
|
||||
showToast.success("Prompt executed successfully!");
|
||||
} catch (err: any) {
|
||||
const errMsg = err?.response?.data?.error?.message || err.message || "Failed to execute prompt";
|
||||
setError(errMsg);
|
||||
showToast.error(errMsg);
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Run Template - ${prompt?.name}`}
|
||||
description="Inbound key variables to compile, render, and execute this prompt template."
|
||||
maxWidth="lg"
|
||||
>
|
||||
<div className="mx-2 space-y-6">
|
||||
{/* DYNAMIC VARIABLE INPUTS */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-1.5 border-b border-gray-100 pb-2">
|
||||
<Terminal className="w-4 h-4 text-[#112868]" />
|
||||
Template Variables Input
|
||||
</h3>
|
||||
|
||||
{prompt?.variables && prompt.variables.length > 0 ? (
|
||||
<div className="space-y-3.5">
|
||||
{prompt.variables.map((variable) => {
|
||||
const isRequired = !!variable.required;
|
||||
const value = variables[variable.name] || "";
|
||||
|
||||
return (
|
||||
<div key={variable.name} className="flex flex-col gap-1">
|
||||
<label className="text-[13px] font-semibold text-gray-700 flex items-center gap-1">
|
||||
{variable.name}
|
||||
{isRequired && <span className="text-red-500 font-bold">*</span>}
|
||||
<span className="text-[10px] text-gray-400 font-normal capitalize">
|
||||
({variable.type || "string"})
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<FormTextArea
|
||||
label=""
|
||||
value={value}
|
||||
onChange={(e) => handleVariableChange(variable.name, e.target.value)}
|
||||
placeholder={`Enter value for {{${variable.name}}}...`}
|
||||
rows={variable.name.toLowerCase().includes("text") || variable.name.toLowerCase().includes("doc") ? 3 : 1}
|
||||
className="pb-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-blue-50/50 border border-blue-100 rounded-lg p-3 text-xs text-blue-700">
|
||||
This prompt template does not require any dynamic input variables. It will execute with its static contents.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PARAMETERS OVERRIDES */}
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfig((open) => !open)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 text-xs font-bold text-gray-700 hover:bg-gray-100/75 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Sliders className="w-4 h-4 text-gray-500" />
|
||||
Runtime Config & Parameter Overrides
|
||||
</span>
|
||||
<span className="text-gray-400 font-medium text-[11px]">
|
||||
{showConfig ? "Hide Parameters" : "Customize Parameters"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showConfig && (
|
||||
<div className="p-4 bg-white border-t border-gray-200 space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormSelect
|
||||
label="Override Provider"
|
||||
value={provider}
|
||||
onValueChange={(val) => {
|
||||
setProvider(val);
|
||||
const pDetail = apiProviders.find((p) => p.name === val);
|
||||
if (pDetail && pDetail.defaultModel) {
|
||||
setModel(pDetail.defaultModel);
|
||||
} else if (pDetail && pDetail.models && pDetail.models.length > 0) {
|
||||
setModel(pDetail.models[0]);
|
||||
} else {
|
||||
setModel("");
|
||||
}
|
||||
}}
|
||||
options={providersOptions}
|
||||
className="pb-0"
|
||||
/>
|
||||
|
||||
<FormSelect
|
||||
label="Override Model"
|
||||
value={model}
|
||||
onValueChange={setModel}
|
||||
options={modelsOptions}
|
||||
className="pb-0"
|
||||
disabled={!provider}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-1">
|
||||
<FormSlider
|
||||
label="Temperature"
|
||||
value={temperature}
|
||||
onChange={setTemperature}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
helperText="Lower values make output deterministic"
|
||||
/>
|
||||
|
||||
<FormSlider
|
||||
label="Max Tokens"
|
||||
value={maxTokens}
|
||||
onChange={setMaxTokens}
|
||||
min={1}
|
||||
max={5000}
|
||||
step={1}
|
||||
helperText="Maximum completion token budget"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* EXECUTE ACTION ROW */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<SecondaryButton onClick={onClose} disabled={isExecuting}>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleExecute}
|
||||
disabled={isExecuting}
|
||||
className="flex items-center gap-1.5 bg-[#112868] hover:bg-[#0a1b4a]"
|
||||
>
|
||||
<Play className="w-4 h-4 fill-current shrink-0" />
|
||||
{isExecuting ? "Executing Prompt..." : "Execute Template"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{/* EXECUTION RESULTS CONTAINER */}
|
||||
{(result || error) && (
|
||||
<div className="border-t border-gray-200 pt-5 space-y-4">
|
||||
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Sparkles className="w-4 h-4 text-[#00cfd5]" />
|
||||
Execution Results Output
|
||||
</h3>
|
||||
|
||||
{/* Success Results */}
|
||||
{result && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusBadge variant="process">
|
||||
<Cpu className="w-3 h-3 mr-1 inline" />
|
||||
{result.provider} • {result.model}
|
||||
</StatusBadge>
|
||||
<StatusBadge variant="success">
|
||||
<Clock className="w-3 h-3 mr-1 inline" />
|
||||
{result.latency_ms} ms
|
||||
</StatusBadge>
|
||||
<StatusBadge variant="success">
|
||||
<DollarSign className="w-3 h-3 mr-1 inline" />
|
||||
USD {Number(result.cost ?? 0).toFixed(5)}
|
||||
</StatusBadge>
|
||||
<StatusBadge variant="process">
|
||||
<Activity className="w-3 h-3 mr-1 inline" />
|
||||
{result.usage?.total_tokens ?? 0} tokens
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#f8fafc] border border-gray-200 rounded-lg p-4 max-h-[300px] overflow-y-auto leading-relaxed shadow-inner">
|
||||
<MarkdownViewer content={result.content || ""} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Results */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2.5 bg-red-50 border border-red-100 rounded-lg p-4 text-red-800 text-xs shadow-sm leading-relaxed">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">Prompt Execution Failed</p>
|
||||
<p className="font-mono text-[11px] bg-red-100/30 p-2 rounded border border-red-200/50 mt-1">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
402
src/pages/tenant/PromptExecute.tsx
Normal file
402
src/pages/tenant/PromptExecute.tsx
Normal file
@ -0,0 +1,402 @@
|
||||
import { useEffect, useMemo, useState, type ReactElement } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import {
|
||||
FormSelect,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
FormSlider,
|
||||
FormTextArea,
|
||||
StatusBadge,
|
||||
MarkdownViewer,
|
||||
} from "@/components/shared";
|
||||
import { aiService } from "@/services/ai-service";
|
||||
import type { AIPrompt, AICompletion, AIProviderInfo } from "@/types/ai";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import {
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Sliders,
|
||||
AlertCircle,
|
||||
Cpu,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Activity,
|
||||
BookOpen,
|
||||
Tag
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PromptExecute = (): ReactElement => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isExecuting, setIsExecuting] = useState<boolean>(false);
|
||||
const [apiProviders, setApiProviders] = useState<AIProviderInfo[]>([]);
|
||||
const [prompt, setPrompt] = useState<AIPrompt | null>(null);
|
||||
|
||||
// Form values for variables
|
||||
const [variables, setVariables] = useState<Record<string, string>>({});
|
||||
|
||||
// Form values for overrides
|
||||
const [provider, setProvider] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [temperature, setTemperature] = useState(0.7);
|
||||
const [maxTokens, setMaxTokens] = useState(2048);
|
||||
|
||||
// Execution result
|
||||
const [result, setResult] = useState<AICompletion | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = async (): Promise<void> => {
|
||||
if (!id) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [providerData, promptData] = await Promise.all([
|
||||
aiService.getProviders(),
|
||||
aiService.getPrompt(id),
|
||||
]);
|
||||
|
||||
setApiProviders(providerData);
|
||||
setPrompt(promptData);
|
||||
|
||||
// Initialize dynamic variables form inputs
|
||||
const initialVars: Record<string, string> = {};
|
||||
(promptData.variables || []).forEach((v) => {
|
||||
initialVars[v.name] = String(v.default || "");
|
||||
});
|
||||
setVariables(initialVars);
|
||||
|
||||
// Initialize parameters overrides
|
||||
setProvider(promptData.defaultParameters?.provider || "");
|
||||
setModel(promptData.defaultParameters?.model || "");
|
||||
setTemperature(promptData.defaultParameters?.temperature ?? 0.7);
|
||||
setMaxTokens(promptData.defaultParameters?.max_tokens ?? 2048);
|
||||
} catch (err: unknown) {
|
||||
showToast.error(
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message || "Failed to load prompt configuration data",
|
||||
);
|
||||
navigate("/tenant/ai/prompts");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [id]);
|
||||
|
||||
// Handle provider changes
|
||||
const providersOptions = useMemo(
|
||||
() => apiProviders.map((p) => ({ value: p.name, label: p.displayName || p.name })),
|
||||
[apiProviders],
|
||||
);
|
||||
|
||||
const providerDetail = apiProviders.find((p) => p.name === provider);
|
||||
const modelsOptions = useMemo(
|
||||
() =>
|
||||
(providerDetail?.models || []).map((m) => ({
|
||||
value: m,
|
||||
label: m,
|
||||
})),
|
||||
[providerDetail],
|
||||
);
|
||||
|
||||
const handleVariableChange = (name: string, value: string) => {
|
||||
setVariables((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleExecute = async (): Promise<void> => {
|
||||
if (!prompt || !id) return;
|
||||
|
||||
// Validate required fields
|
||||
const missingFields = (prompt.variables || [])
|
||||
.filter((v) => v.required && !variables[v.name]?.trim())
|
||||
.map((v) => v.name);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
showToast.error(`Please provide values for required variables: ${missingFields.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExecuting(true);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
variables: variables,
|
||||
provider: provider || undefined,
|
||||
model: model || undefined,
|
||||
temperature: temperature,
|
||||
max_tokens: maxTokens,
|
||||
};
|
||||
|
||||
const response = await aiService.executePrompt(id, payload);
|
||||
setResult(response);
|
||||
showToast.success("Prompt template executed successfully!");
|
||||
} catch (err: any) {
|
||||
const errMsg = err?.response?.data?.error?.message || err.message || "Failed to execute prompt";
|
||||
setError(errMsg);
|
||||
showToast.error(errMsg);
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout currentPage="Execute Prompt">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-10 h-10 border-4 border-gray-200 border-t-[#112868] rounded-full animate-spin"></div>
|
||||
<p className="text-sm text-gray-500">Loading prompt details...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Execute Prompt"
|
||||
breadcrumbs={[
|
||||
{ label: "AI Prompts", path: "/tenant/ai/prompts" },
|
||||
{ label: "Execute Prompt" },
|
||||
]}
|
||||
pageHeader={{
|
||||
title: (
|
||||
<div className="flex items-center gap-3">
|
||||
<span>Execute Prompt</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-2.5 py-0.5 rounded-full text-sm font-semibold border",
|
||||
prompt?.status === "active"
|
||||
? "bg-[#E6FFFB] text-[#096DD9] border-[#91D5FF]"
|
||||
: "bg-[#FFF5E9] text-[#FA8C16] border-[#FFE7BA]",
|
||||
)}
|
||||
>
|
||||
{prompt?.status || "Draft"}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
description: "Compile template variables and execute the prompt.",
|
||||
action: (
|
||||
<div className="flex gap-2">
|
||||
<SecondaryButton onClick={() => navigate("/tenant/ai/prompts")}>
|
||||
Back to Prompts
|
||||
</SecondaryButton>
|
||||
<PrimaryButton onClick={handleExecute} disabled={isExecuting}>
|
||||
{isExecuting ? "Executing..." : "Execute Prompt"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-6">
|
||||
{/* LEFT COLUMN */}
|
||||
<div className="flex flex-col gap-6 flex-[2]">
|
||||
{/* Variables form */}
|
||||
<div className="bg-white border border-gray-300 rounded-lg shadow-sm p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-800 flex items-center gap-1.5 border-b border-gray-100 pb-3">
|
||||
<Terminal className="w-4 h-4 text-[#112868]" />
|
||||
Injected Variables Configuration
|
||||
</h3>
|
||||
|
||||
{prompt?.variables && prompt.variables.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{prompt.variables.map((variable) => {
|
||||
const isRequired = !!variable.required;
|
||||
const value = variables[variable.name] || "";
|
||||
|
||||
return (
|
||||
<div key={variable.name} className="flex flex-col gap-1.5">
|
||||
<label className="text-[13px] font-semibold text-gray-700 flex items-center gap-1">
|
||||
{variable.name}
|
||||
{isRequired && <span className="text-red-500 font-bold">*</span>}
|
||||
<span className="text-[10px] text-gray-400 font-normal capitalize">
|
||||
({variable.type || "string"})
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<FormTextArea
|
||||
label=""
|
||||
value={value}
|
||||
onChange={(e) => handleVariableChange(variable.name, e.target.value)}
|
||||
placeholder={`Enter value for {{${variable.name}}}...`}
|
||||
rows={variable.name.toLowerCase().includes("text") || variable.name.toLowerCase().includes("doc") ? 4 : 1}
|
||||
className="pb-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-blue-50/50 border border-blue-100 rounded-lg p-4 text-xs text-blue-700">
|
||||
This prompt template does not require any dynamic input variables. It will execute with its static contents.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results box */}
|
||||
{(result || error) && (
|
||||
<div className="bg-white border border-gray-300 rounded-lg shadow-sm p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-800 flex items-center gap-1.5 border-b border-gray-100 pb-3">
|
||||
<Sparkles className="w-4 h-4 text-[#00cfd5]" />
|
||||
Execution Results Output
|
||||
</h3>
|
||||
|
||||
{/* Success Result */}
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusBadge variant="process">
|
||||
<Cpu className="w-3.5 h-3.5 mr-1.5 inline" />
|
||||
{result.provider} • {result.model}
|
||||
</StatusBadge>
|
||||
<StatusBadge variant="success">
|
||||
<Clock className="w-3.5 h-3.5 mr-1.5 inline" />
|
||||
{result.latency_ms} ms
|
||||
</StatusBadge>
|
||||
<StatusBadge variant="success">
|
||||
<DollarSign className="w-3.5 h-3.5 mr-1.5 inline" />
|
||||
USD {Number(result.cost ?? 0).toFixed(6)}
|
||||
</StatusBadge>
|
||||
<StatusBadge variant="process">
|
||||
<Activity className="w-3.5 h-3.5 mr-1.5 inline" />
|
||||
{result.usage?.total_tokens ?? 0} tokens
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#f8fafc] border border-gray-200 rounded-lg p-5 max-h-[480px] overflow-y-auto leading-relaxed shadow-inner font-sans text-gray-800">
|
||||
<MarkdownViewer content={result.content || ""} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Result */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 bg-red-50 border border-red-100 rounded-lg p-4 text-red-800 text-xs shadow-sm leading-relaxed">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-sm">Prompt Execution Failed</p>
|
||||
<p className="font-mono text-[11px] bg-red-100/30 p-2.5 rounded border border-red-200/50 mt-1.5">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN */}
|
||||
<div className="flex flex-col gap-6 flex-1 max-w-[320px] w-full">
|
||||
{/* Metadata Card */}
|
||||
<div className="bg-white border border-gray-300 rounded-lg shadow-sm p-4 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-800 flex items-center gap-1.5">
|
||||
<BookOpen className="w-4 h-4 text-gray-500" />
|
||||
Template Details
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3 pt-1 text-xs">
|
||||
<div>
|
||||
<span className="text-gray-400 font-bold uppercase tracking-wider block text-[10px]">Name</span>
|
||||
<span className="font-semibold text-gray-800 text-[13px]">{prompt?.name}</span>
|
||||
</div>
|
||||
|
||||
{prompt?.description && (
|
||||
<div>
|
||||
<span className="text-gray-400 font-bold uppercase tracking-wider block text-[10px]">Description</span>
|
||||
<span className="text-gray-600">{prompt.description}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className="text-gray-400 font-bold uppercase tracking-wider block text-[10px]">Use Case</span>
|
||||
<span className="inline-flex items-center rounded-full border border-[#e2e8f0] bg-[#f8fafc] px-2.5 py-0.5 text-[11px] font-medium text-[#1e293b] mt-0.5">
|
||||
{prompt?.useCase}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{prompt?.tags && prompt.tags.length > 0 && (
|
||||
<div>
|
||||
<span className="text-gray-400 font-bold uppercase tracking-wider block text-[10px] mb-1">Tags</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{prompt.tags.map((t, idx) => (
|
||||
<span key={idx} className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600 border border-gray-150">
|
||||
<Tag className="w-2.5 h-2.5 mr-1" />
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration Card */}
|
||||
<div className="bg-white border border-gray-300 rounded-lg shadow-sm p-4 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-800 flex items-center gap-1.5">
|
||||
<Sliders className="w-4 h-4 text-gray-500" />
|
||||
Parameter Overrides
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3 pt-1">
|
||||
<FormSelect
|
||||
label="Override Provider"
|
||||
value={provider}
|
||||
onValueChange={(val) => {
|
||||
setProvider(val);
|
||||
const pDetail = apiProviders.find((p) => p.name === val);
|
||||
if (pDetail && pDetail.defaultModel) {
|
||||
setModel(pDetail.defaultModel);
|
||||
} else if (pDetail && pDetail.models && pDetail.models.length > 0) {
|
||||
setModel(pDetail.models[0]);
|
||||
} else {
|
||||
setModel("");
|
||||
}
|
||||
}}
|
||||
options={providersOptions}
|
||||
className="pb-0"
|
||||
/>
|
||||
|
||||
<FormSelect
|
||||
label="Override Model"
|
||||
value={model}
|
||||
onValueChange={setModel}
|
||||
options={modelsOptions}
|
||||
className="pb-0"
|
||||
disabled={!provider}
|
||||
/>
|
||||
|
||||
<FormSlider
|
||||
label="Temperature"
|
||||
value={temperature}
|
||||
onChange={setTemperature}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
helperText="Lower values make output deterministic"
|
||||
/>
|
||||
|
||||
<FormSlider
|
||||
label="Max Tokens"
|
||||
value={maxTokens}
|
||||
onChange={setMaxTokens}
|
||||
min={1}
|
||||
max={5000}
|
||||
step={1}
|
||||
helperText="Maximum completion token budget"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptExecute;
|
||||
522
src/pages/tenant/PromptExecutions.tsx
Normal file
522
src/pages/tenant/PromptExecutions.tsx
Normal file
@ -0,0 +1,522 @@
|
||||
import { useEffect, useState, type ReactElement } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import {
|
||||
DataTable,
|
||||
type Column,
|
||||
StatusBadge,
|
||||
Pagination,
|
||||
FilterDropdown,
|
||||
GradientStatCard,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
MarkdownViewer,
|
||||
} from "@/components/shared";
|
||||
import { aiService } from "@/services/ai-service";
|
||||
import type { AIPrompt, AICompletion } from "@/types/ai";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import { formatDate } from "@/utils/format-date";
|
||||
import {
|
||||
Play,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
DollarSign,
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Terminal,
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
FileCode,
|
||||
BookOpen,
|
||||
Tag,
|
||||
Activity
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PromptExecutions = (): ReactElement => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [prompt, setPrompt] = useState<AIPrompt | null>(null);
|
||||
const [executions, setExecutions] = useState<AICompletion[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [limit, setLimit] = useState<number>(5);
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
|
||||
const [summary, setSummary] = useState({
|
||||
usage_count: 0,
|
||||
success_rate: 0,
|
||||
avg_cost: 0,
|
||||
avg_latency: 0,
|
||||
failed_runs: 0,
|
||||
success_runs: 0,
|
||||
});
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
limit: 5,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
const loadData = async (): Promise<void> => {
|
||||
if (!id) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [promptData, executionsData] = await Promise.all([
|
||||
aiService.getPrompt(id),
|
||||
aiService.getPromptExecutions(id, {
|
||||
page,
|
||||
limit,
|
||||
status: statusFilter || undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
setPrompt(promptData);
|
||||
setExecutions(executionsData.data || []);
|
||||
setSummary(executionsData.summary);
|
||||
setPagination({
|
||||
page: executionsData.pagination?.page || page,
|
||||
limit: executionsData.pagination?.limit || limit,
|
||||
total: executionsData.pagination?.total || 0,
|
||||
totalPages: executionsData.pagination?.totalPages || 1,
|
||||
});
|
||||
} catch (err: any) {
|
||||
showToast.error("Failed to load prompt executions data");
|
||||
navigate("/tenant/ai/prompts");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [id, page, limit, statusFilter]);
|
||||
|
||||
// Reset page when filter changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [statusFilter]);
|
||||
|
||||
const toggleExpand = (itemId: string) => {
|
||||
setExpandedId((prev) => (prev === itemId ? null : itemId));
|
||||
};
|
||||
|
||||
const renderExpanded = (row: AICompletion): React.ReactNode => {
|
||||
const metadata = (row.metadata || {}) as any;
|
||||
const inputVars = metadata.input_variables || {};
|
||||
const fileProcess = metadata.file_processing;
|
||||
const promptVersion = metadata.prompt_version || "—";
|
||||
|
||||
return (
|
||||
<div className="bg-[#f8fafc] border border-gray-150 rounded-lg p-5 text-xs text-[#334155] space-y-4 shadow-inner">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Left Column: Metadata & Input Variables */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Terminal className="w-3.5 h-3.5 text-[#112868]" />
|
||||
Runtime Environment & Variables
|
||||
</h4>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-md p-3 space-y-2">
|
||||
<p className="flex justify-between">
|
||||
<span className="font-semibold text-gray-500">Prompt Version:</span>
|
||||
<span className="font-mono bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded font-semibold text-[10px]">
|
||||
v{promptVersion}
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex justify-between">
|
||||
<span className="font-semibold text-gray-500">Latency:</span>
|
||||
<span className="font-semibold text-gray-800">{row.latency_ms ?? "—"} ms</span>
|
||||
</p>
|
||||
<p className="flex justify-between">
|
||||
<span className="font-semibold text-gray-500">Cost:</span>
|
||||
<span className="font-semibold text-gray-800">USD {Number(row.cost ?? 0).toFixed(6)}</span>
|
||||
</p>
|
||||
<p className="flex justify-between">
|
||||
<span className="font-semibold text-gray-500">Tokens:</span>
|
||||
<span className="font-semibold text-gray-800">
|
||||
{row.prompt_tokens ?? 0} prompt · {row.completion_tokens ?? 0} completion ({row.total_tokens ?? 0} total)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-md p-3 space-y-2">
|
||||
<span className="font-semibold text-gray-500 block mb-1">Input Variables:</span>
|
||||
{Object.keys(inputVars).length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 max-h-28 overflow-y-auto pr-1">
|
||||
{Object.entries(inputVars).map(([key, val]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="bg-gray-50 border border-gray-150 rounded px-2 py-1 flex flex-col min-w-[100px]"
|
||||
>
|
||||
<span className="font-mono text-[9px] font-bold text-gray-400 uppercase">{key}</span>
|
||||
<span className="text-gray-800 font-medium whitespace-pre-line truncate max-w-[200px]" title={String(val)}>
|
||||
{String(val)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">No variables defined for this execution.</span>
|
||||
)}
|
||||
|
||||
{fileProcess && (
|
||||
<div className="mt-2.5 pt-2.5 border-t border-gray-100 space-y-1">
|
||||
<span className="font-semibold text-gray-500 block">Uploaded File Context:</span>
|
||||
<div className="bg-amber-50/50 border border-amber-100 rounded p-2 flex flex-col font-mono text-[10px]">
|
||||
<span className="text-gray-700 font-medium">Name: {fileProcess.fileName}</span>
|
||||
<span className="text-gray-500">Type: {fileProcess.mimeType} ({fileProcess.mode} mode)</span>
|
||||
<span className="text-gray-500">Size: {(fileProcess.fileSize / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Prompts Config */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-[#112868]" />
|
||||
System Message & Config
|
||||
</h4>
|
||||
<div className="bg-white border border-gray-200 rounded-md p-3 space-y-2">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-500 block mb-1">System Message:</span>
|
||||
<p className="bg-slate-50 border border-slate-100 p-2 rounded text-[11px] font-mono whitespace-pre-wrap max-h-24 overflow-y-auto leading-relaxed text-gray-600">
|
||||
{row.system_message || <span className="text-gray-400 italic">None</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 pt-1.5">
|
||||
<div className="bg-gray-50 p-2 rounded">
|
||||
<span className="text-[10px] text-gray-400 font-medium block">Temperature</span>
|
||||
<span className="text-xs font-bold text-gray-700">{row.temperature ?? "Default"}</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded">
|
||||
<span className="text-[10px] text-gray-400 font-medium block">Max Tokens</span>
|
||||
<span className="text-xs font-bold text-gray-700">{row.max_tokens ?? "Default"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Area: Full Prompt & Response or Error */}
|
||||
<div className="grid grid-cols-1 gap-4 pt-2">
|
||||
{/* Rendered Prompt */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-gray-500 flex items-center gap-1">
|
||||
<FileCode className="w-3.5 h-3.5 text-gray-400" />
|
||||
Rendered User Prompt
|
||||
</span>
|
||||
<div className="bg-slate-900 text-slate-100 font-mono p-3.5 rounded-md text-[11px] whitespace-pre-wrap max-h-40 overflow-y-auto border border-slate-800 leading-relaxed shadow-sm">
|
||||
{row.prompt}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Box / Error box */}
|
||||
{row.status === "completed" ? (
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-[#112868] flex items-center gap-1">
|
||||
<Sparkles className="w-3.5 h-3.5 text-[#00cfd5]" />
|
||||
Generated Response
|
||||
</span>
|
||||
<div className="bg-white border border-gray-250 p-4 rounded-md text-[12px] leading-relaxed max-h-60 overflow-y-auto text-gray-800 font-sans shadow-sm">
|
||||
<MarkdownViewer content={row.response || ""} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-red-600 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
Execution Failure Trace
|
||||
</span>
|
||||
<div className="bg-red-50 border border-red-100 text-red-700 p-4 rounded-md text-xs font-mono whitespace-pre-wrap max-h-40 overflow-y-auto leading-relaxed shadow-sm">
|
||||
{row.error_message || "Unknown gateway error occurred during processing."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const columns: Column<AICompletion>[] = [
|
||||
{
|
||||
key: "expand",
|
||||
label: "",
|
||||
width: "40px",
|
||||
render: (row) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand(row.id);
|
||||
}}
|
||||
className="text-gray-400 hover:text-[#112868] transition-colors"
|
||||
>
|
||||
{expandedId === row.id ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "date",
|
||||
label: "Date",
|
||||
render: (row) => (
|
||||
<span className="whitespace-nowrap font-medium text-gray-700">
|
||||
{formatDate(row.created_at || "")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
render: (row) => (
|
||||
<span className="inline-flex items-center rounded bg-gray-50 border border-gray-150 px-2 py-0.5 text-xs font-medium text-gray-600">
|
||||
{row.provider}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "model",
|
||||
label: "Model",
|
||||
render: (row) => (
|
||||
<span className="font-mono text-xs text-gray-500 line-clamp-1 max-w-[150px]" title={row.model}>
|
||||
{row.model}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "latency",
|
||||
label: "Latency",
|
||||
render: (row) => (
|
||||
<span className="font-medium text-gray-600">
|
||||
{row.latency_ms ? `${row.latency_ms}ms` : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "cost",
|
||||
label: "Cost",
|
||||
render: (row) => (
|
||||
<span className="font-semibold text-gray-600 text-xs">
|
||||
{row.cost ? `USD ${row.cost.toFixed(5)}` : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
render: (row) => (
|
||||
<StatusBadge variant={row.status === "completed" ? "success" : "failure"}>
|
||||
{row.status || "failed"}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading && !prompt) {
|
||||
return (
|
||||
<Layout currentPage="Execution History">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-10 h-10 border-4 border-gray-200 border-t-[#112868] rounded-full animate-spin"></div>
|
||||
<p className="text-sm text-gray-500">Loading execution history...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Execution History"
|
||||
breadcrumbs={[
|
||||
{ label: "AI Prompts", path: "/tenant/ai/prompts" },
|
||||
{ label: "Execution History" },
|
||||
]}
|
||||
pageHeader={{
|
||||
title: (
|
||||
<div className="flex items-center gap-3">
|
||||
<span>Execution History</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-2.5 py-0.5 rounded-full text-sm font-semibold border",
|
||||
prompt?.status === "active"
|
||||
? "bg-[#E6FFFB] text-[#096DD9] border-[#91D5FF]"
|
||||
: "bg-[#FFF5E9] text-[#FA8C16] border-[#FFE7BA]",
|
||||
)}
|
||||
>
|
||||
{prompt?.status || "Draft"}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
description: `Track execution metrics, variables, and failure traces for prompt "${prompt?.name}".`,
|
||||
action: (
|
||||
<div className="flex gap-2">
|
||||
<SecondaryButton onClick={() => navigate("/tenant/ai/prompts")}>
|
||||
Back to Prompts
|
||||
</SecondaryButton>
|
||||
<PrimaryButton onClick={() => navigate(`/tenant/ai/prompts/${id}/execute`)}>
|
||||
<Play className="w-4 h-4 mr-1.5 inline shrink-0 text-white" />
|
||||
Execute Prompt
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-6">
|
||||
{/* LEFT COLUMN: Stat cards, filters, and logs data table */}
|
||||
<div className="flex flex-col gap-6 flex-[3] w-full min-w-0">
|
||||
|
||||
{/* SUMMARY CARDS STRIP */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<GradientStatCard
|
||||
icon={Activity}
|
||||
value={summary.usage_count}
|
||||
label="Total Executions"
|
||||
/>
|
||||
<GradientStatCard
|
||||
icon={CheckCircle2}
|
||||
value={`${summary.success_rate}%`}
|
||||
label="Success Rate"
|
||||
badge={{ text: `${summary.success_runs} OK`, variant: "success" }}
|
||||
/>
|
||||
<GradientStatCard
|
||||
icon={Clock}
|
||||
value={`${summary.avg_latency}ms`}
|
||||
label="Avg Latency"
|
||||
/>
|
||||
<GradientStatCard
|
||||
icon={DollarSign}
|
||||
value={`USD ${summary.avg_cost.toFixed(5)}`}
|
||||
label="Avg Cost / Run"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* FILTER AND DATA TABLE */}
|
||||
<div className="bg-white border border-gray-300 rounded-lg shadow-sm p-4 space-y-4">
|
||||
|
||||
{/* FILTERS BAR */}
|
||||
<div className="flex items-center justify-between border-b border-gray-150 pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<FilterDropdown
|
||||
label="Status"
|
||||
options={[
|
||||
{ value: "completed", label: "Completed Only" },
|
||||
{ value: "failed", label: "Failed Only" },
|
||||
]}
|
||||
value={statusFilter}
|
||||
placeholder="All executions"
|
||||
onChange={(value) => setStatusFilter(value as string | null)}
|
||||
/>
|
||||
</div>
|
||||
{statusFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(null)}
|
||||
className="text-xs font-semibold text-gray-400 hover:text-[#112868] transition-colors"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* DATA TABLE */}
|
||||
<div className="overflow-hidden border border-gray-200 rounded-lg shadow-sm">
|
||||
<DataTable
|
||||
data={executions}
|
||||
columns={columns}
|
||||
keyExtractor={(item) => item.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No execution records found matching current query filters."
|
||||
expandableRows
|
||||
isRowExpanded={(row) => expandedId === row.id}
|
||||
onRowExpandToggle={(row) => toggleExpand(row.id)}
|
||||
renderExpandedRow={renderExpanded}
|
||||
onRowClick={(row) => toggleExpand(row.id)}
|
||||
/>
|
||||
|
||||
{pagination.total > 0 && (
|
||||
<div className="border-t border-gray-200 px-5 py-4 bg-gray-50/50">
|
||||
<Pagination
|
||||
currentPage={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
totalItems={pagination.total}
|
||||
limit={limit}
|
||||
onPageChange={setPage}
|
||||
onLimitChange={(newLimit) => {
|
||||
setLimit(newLimit);
|
||||
setPage(1);
|
||||
}}
|
||||
limitOptions={[
|
||||
{ value: "5", label: "5 per page" },
|
||||
{ value: "10", label: "10 per page" },
|
||||
{ value: "20", label: "20 per page" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN (Sidebar) */}
|
||||
<div className="flex flex-col gap-6 flex-1 max-w-[320px] w-full shrink-0">
|
||||
|
||||
{/* Template Details Card */}
|
||||
<div className="bg-white border border-gray-300 rounded-lg shadow-sm p-4 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-800 flex items-center gap-1.5 border-b border-gray-100 pb-2.5">
|
||||
<BookOpen className="w-4 h-4 text-gray-500" />
|
||||
Template Details
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3.5 pt-1 text-xs">
|
||||
<div>
|
||||
<span className="text-gray-400 font-bold uppercase tracking-wider block text-[10px]">Name</span>
|
||||
<span className="font-semibold text-gray-800 text-[13px]">{prompt?.name}</span>
|
||||
</div>
|
||||
|
||||
{prompt?.description && (
|
||||
<div>
|
||||
<span className="text-gray-400 font-bold uppercase tracking-wider block text-[10px]">Description</span>
|
||||
<span className="text-gray-600 leading-relaxed block mt-0.5">{prompt.description}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className="text-gray-400 font-bold uppercase tracking-wider block text-[10px]">Use Case</span>
|
||||
<span className="inline-flex items-center rounded-full border border-[#e2e8f0] bg-[#f8fafc] px-2.5 py-0.5 text-[11px] font-medium text-[#1e293b] mt-1">
|
||||
{prompt?.useCase}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{prompt?.tags && prompt.tags.length > 0 && (
|
||||
<div>
|
||||
<span className="text-gray-400 font-bold uppercase tracking-wider block text-[10px] mb-1">Tags</span>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{prompt.tags.map((t, idx) => (
|
||||
<span key={idx} className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600 border border-gray-150">
|
||||
<Tag className="w-2.5 h-2.5 mr-1" />
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptExecutions;
|
||||
@ -7,6 +7,8 @@ import {
|
||||
Trash2,
|
||||
Edit3,
|
||||
ClipboardCheck,
|
||||
Play,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import {
|
||||
@ -260,6 +262,16 @@ const PromptManagement = (): ReactElement => {
|
||||
setIsVersionsOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <Play className="w-3.5 h-3.5 shrink-0 text-emerald-600" />,
|
||||
label: "Run / Execute",
|
||||
onClick: () => navigate(`/tenant/ai/prompts/${row.id}/execute`),
|
||||
},
|
||||
{
|
||||
icon: <Activity className="w-3.5 h-3.5 shrink-0 text-blue-600" />,
|
||||
label: "Execution Logs",
|
||||
onClick: () => navigate(`/tenant/ai/prompts/${row.id}/executions`),
|
||||
},
|
||||
{
|
||||
icon: <Copy className="w-3.5 h-3.5 shrink-0" />,
|
||||
label: "Clone Prompt",
|
||||
@ -343,6 +355,7 @@ const PromptManagement = (): ReactElement => {
|
||||
onRollbackSuccess={loadPrompts}
|
||||
/>
|
||||
|
||||
|
||||
<DeleteConfirmationModal
|
||||
isOpen={isDeleteOpen}
|
||||
onClose={() => setIsDeleteOpen(false)}
|
||||
|
||||
@ -47,6 +47,8 @@ const CompletionDetail = lazy(() => import("@/pages/tenant/CompletionDetail"));
|
||||
const PromptManagement = lazy(() => import("@/pages/tenant/PromptManagement"));
|
||||
const PromptCreate = lazy(() => import("@/pages/tenant/PromptCreate"));
|
||||
const PromptEdit = lazy(() => import("@/pages/tenant/PromptEdit"));
|
||||
const PromptExecute = lazy(() => import("@/pages/tenant/PromptExecute"));
|
||||
const PromptExecutions = lazy(() => import("@/pages/tenant/PromptExecutions"));
|
||||
const PromptTestCases = lazy(() => import("@/pages/tenant/PromptTestCases"));
|
||||
const PromptTestCaseCreate = lazy(
|
||||
() => import("@/pages/tenant/PromptTestCaseCreate"),
|
||||
@ -229,6 +231,14 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
||||
path: "/tenant/ai/prompts/:id/edit",
|
||||
element: <LazyRoute component={PromptEdit} />,
|
||||
},
|
||||
{
|
||||
path: "/tenant/ai/prompts/:id/execute",
|
||||
element: <LazyRoute component={PromptExecute} />,
|
||||
},
|
||||
{
|
||||
path: "/tenant/ai/prompts/:id/executions",
|
||||
element: <LazyRoute component={PromptExecutions} />,
|
||||
},
|
||||
{
|
||||
path: "/tenant/ai/prompts/:id/test-cases",
|
||||
element: <LazyRoute component={PromptTestCases} />,
|
||||
|
||||
@ -218,6 +218,25 @@ class AIService {
|
||||
return unwrap<AIPrompt>(response);
|
||||
}
|
||||
|
||||
async getPromptExecutions(
|
||||
id: string,
|
||||
params: { page?: number; limit?: number; status?: string } = {}
|
||||
): Promise<{
|
||||
summary: {
|
||||
usage_count: number;
|
||||
success_rate: number;
|
||||
avg_cost: number;
|
||||
avg_latency: number;
|
||||
failed_runs: number;
|
||||
success_runs: number;
|
||||
};
|
||||
data: AICompletion[];
|
||||
pagination: { page: number; limit: number; total: number; totalPages: number };
|
||||
}> {
|
||||
const response = await apiClient.get(`/ai/prompts/${id}/executions`, { params });
|
||||
return unwrap(response);
|
||||
}
|
||||
|
||||
async executePrompt(
|
||||
id: string,
|
||||
payload: { variables?: Record<string, unknown>; provider?: string; model?: string; temperature?: number; max_tokens?: number },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user