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,
|
Trash2,
|
||||||
Edit3,
|
Edit3,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
|
Play,
|
||||||
|
Activity,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
import { Layout } from "@/components/layout/Layout";
|
||||||
import {
|
import {
|
||||||
@ -260,6 +262,16 @@ const PromptManagement = (): ReactElement => {
|
|||||||
setIsVersionsOpen(true);
|
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" />,
|
icon: <Copy className="w-3.5 h-3.5 shrink-0" />,
|
||||||
label: "Clone Prompt",
|
label: "Clone Prompt",
|
||||||
@ -343,6 +355,7 @@ const PromptManagement = (): ReactElement => {
|
|||||||
onRollbackSuccess={loadPrompts}
|
onRollbackSuccess={loadPrompts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
<DeleteConfirmationModal
|
<DeleteConfirmationModal
|
||||||
isOpen={isDeleteOpen}
|
isOpen={isDeleteOpen}
|
||||||
onClose={() => setIsDeleteOpen(false)}
|
onClose={() => setIsDeleteOpen(false)}
|
||||||
|
|||||||
@ -47,6 +47,8 @@ const CompletionDetail = lazy(() => import("@/pages/tenant/CompletionDetail"));
|
|||||||
const PromptManagement = lazy(() => import("@/pages/tenant/PromptManagement"));
|
const PromptManagement = lazy(() => import("@/pages/tenant/PromptManagement"));
|
||||||
const PromptCreate = lazy(() => import("@/pages/tenant/PromptCreate"));
|
const PromptCreate = lazy(() => import("@/pages/tenant/PromptCreate"));
|
||||||
const PromptEdit = lazy(() => import("@/pages/tenant/PromptEdit"));
|
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 PromptTestCases = lazy(() => import("@/pages/tenant/PromptTestCases"));
|
||||||
const PromptTestCaseCreate = lazy(
|
const PromptTestCaseCreate = lazy(
|
||||||
() => import("@/pages/tenant/PromptTestCaseCreate"),
|
() => import("@/pages/tenant/PromptTestCaseCreate"),
|
||||||
@ -229,6 +231,14 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/tenant/ai/prompts/:id/edit",
|
path: "/tenant/ai/prompts/:id/edit",
|
||||||
element: <LazyRoute component={PromptEdit} />,
|
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",
|
path: "/tenant/ai/prompts/:id/test-cases",
|
||||||
element: <LazyRoute component={PromptTestCases} />,
|
element: <LazyRoute component={PromptTestCases} />,
|
||||||
|
|||||||
@ -218,6 +218,25 @@ class AIService {
|
|||||||
return unwrap<AIPrompt>(response);
|
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(
|
async executePrompt(
|
||||||
id: string,
|
id: string,
|
||||||
payload: { variables?: Record<string, unknown>; provider?: string; model?: string; temperature?: number; max_tokens?: number },
|
payload: { variables?: Record<string, unknown>; provider?: string; model?: string; temperature?: number; max_tokens?: number },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user