diff --git a/src/components/tenant/PromptExecuteModal.tsx b/src/components/tenant/PromptExecuteModal.tsx new file mode 100644 index 0000000..e40fbde --- /dev/null +++ b/src/components/tenant/PromptExecuteModal.tsx @@ -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([]); + const [isExecuting, setIsExecuting] = useState(false); + const [showConfig, setShowConfig] = useState(false); + + // Form values for variables + const [variables, setVariables] = useState>({}); + + // 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(null); + const [error, setError] = useState(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 = {}; + (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 ( + +
+ {/* DYNAMIC VARIABLE INPUTS */} +
+

+ + Template Variables Input +

+ + {prompt?.variables && prompt.variables.length > 0 ? ( +
+ {prompt.variables.map((variable) => { + const isRequired = !!variable.required; + const value = variables[variable.name] || ""; + + return ( +
+ + + 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" + /> +
+ ); + })} +
+ ) : ( +
+ This prompt template does not require any dynamic input variables. It will execute with its static contents. +
+ )} +
+ + {/* PARAMETERS OVERRIDES */} +
+ + + {showConfig && ( +
+
+ { + 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" + /> + + +
+ +
+ + + +
+
+ )} +
+ + {/* EXECUTE ACTION ROW */} +
+ + Cancel + + + + {isExecuting ? "Executing Prompt..." : "Execute Template"} + +
+ + {/* EXECUTION RESULTS CONTAINER */} + {(result || error) && ( +
+

+ + Execution Results Output +

+ + {/* Success Results */} + {result && ( +
+
+ + + {result.provider} • {result.model} + + + + {result.latency_ms} ms + + + + USD {Number(result.cost ?? 0).toFixed(5)} + + + + {result.usage?.total_tokens ?? 0} tokens + +
+ +
+ +
+
+ )} + + {/* Error Results */} + {error && ( +
+ +
+

Prompt Execution Failed

+

+ {error} +

+
+
+ )} +
+ )} +
+
+ ); +}; diff --git a/src/pages/tenant/PromptExecute.tsx b/src/pages/tenant/PromptExecute.tsx new file mode 100644 index 0000000..45c3a10 --- /dev/null +++ b/src/pages/tenant/PromptExecute.tsx @@ -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(true); + const [isExecuting, setIsExecuting] = useState(false); + const [apiProviders, setApiProviders] = useState([]); + const [prompt, setPrompt] = useState(null); + + // Form values for variables + const [variables, setVariables] = useState>({}); + + // 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(null); + const [error, setError] = useState(null); + + const loadData = async (): Promise => { + 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 = {}; + (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 => { + 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 ( + +
+
+
+

Loading prompt details...

+
+
+
+ ); + } + + return ( + + Execute Prompt + + {prompt?.status || "Draft"} + + + ), + description: "Compile template variables and execute the prompt.", + action: ( +
+ navigate("/tenant/ai/prompts")}> + Back to Prompts + + + {isExecuting ? "Executing..." : "Execute Prompt"} + +
+ ), + }} + > +
+ {/* LEFT COLUMN */} +
+ {/* Variables form */} +
+

+ + Injected Variables Configuration +

+ + {prompt?.variables && prompt.variables.length > 0 ? ( +
+ {prompt.variables.map((variable) => { + const isRequired = !!variable.required; + const value = variables[variable.name] || ""; + + return ( +
+ + + 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" + /> +
+ ); + })} +
+ ) : ( +
+ This prompt template does not require any dynamic input variables. It will execute with its static contents. +
+ )} +
+ + {/* Results box */} + {(result || error) && ( +
+

+ + Execution Results Output +

+ + {/* Success Result */} + {result && ( +
+
+ + + {result.provider} • {result.model} + + + + {result.latency_ms} ms + + + + USD {Number(result.cost ?? 0).toFixed(6)} + + + + {result.usage?.total_tokens ?? 0} tokens + +
+ +
+ +
+
+ )} + + {/* Error Result */} + {error && ( +
+ +
+

Prompt Execution Failed

+

+ {error} +

+
+
+ )} +
+ )} +
+ + {/* RIGHT COLUMN */} +
+ {/* Metadata Card */} +
+

+ + Template Details +

+ +
+
+ Name + {prompt?.name} +
+ + {prompt?.description && ( +
+ Description + {prompt.description} +
+ )} + +
+ Use Case + + {prompt?.useCase} + +
+ + {prompt?.tags && prompt.tags.length > 0 && ( +
+ Tags +
+ {prompt.tags.map((t, idx) => ( + + + {t} + + ))} +
+
+ )} +
+
+ + {/* Configuration Card */} +
+

+ + Parameter Overrides +

+ +
+ { + 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" + /> + + + + + + +
+
+
+
+
+ ); +}; + +export default PromptExecute; diff --git a/src/pages/tenant/PromptExecutions.tsx b/src/pages/tenant/PromptExecutions.tsx new file mode 100644 index 0000000..8356a2c --- /dev/null +++ b/src/pages/tenant/PromptExecutions.tsx @@ -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(null); + const [executions, setExecutions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [expandedId, setExpandedId] = useState(null); + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(5); + const [statusFilter, setStatusFilter] = useState(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 => { + 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 ( +
+
+ {/* Left Column: Metadata & Input Variables */} +
+

+ + Runtime Environment & Variables +

+ +
+

+ Prompt Version: + + v{promptVersion} + +

+

+ Latency: + {row.latency_ms ?? "—"} ms +

+

+ Cost: + USD {Number(row.cost ?? 0).toFixed(6)} +

+

+ Tokens: + + {row.prompt_tokens ?? 0} prompt · {row.completion_tokens ?? 0} completion ({row.total_tokens ?? 0} total) + +

+
+ +
+ Input Variables: + {Object.keys(inputVars).length > 0 ? ( +
+ {Object.entries(inputVars).map(([key, val]) => ( +
+ {key} + + {String(val)} + +
+ ))} +
+ ) : ( + No variables defined for this execution. + )} + + {fileProcess && ( +
+ Uploaded File Context: +
+ Name: {fileProcess.fileName} + Type: {fileProcess.mimeType} ({fileProcess.mode} mode) + Size: {(fileProcess.fileSize / 1024).toFixed(1)} KB +
+
+ )} +
+
+ + {/* Right Column: Prompts Config */} +
+

+ + System Message & Config +

+
+
+ System Message: +

+ {row.system_message || None} +

+
+
+
+ Temperature + {row.temperature ?? "Default"} +
+
+ Max Tokens + {row.max_tokens ?? "Default"} +
+
+
+
+
+ + {/* Bottom Area: Full Prompt & Response or Error */} +
+ {/* Rendered Prompt */} +
+ + + Rendered User Prompt + +
+ {row.prompt} +
+
+ + {/* Response Box / Error box */} + {row.status === "completed" ? ( +
+ + + Generated Response + +
+ +
+
+ ) : ( +
+ + + Execution Failure Trace + +
+ {row.error_message || "Unknown gateway error occurred during processing."} +
+
+ )} +
+
+ ); + }; + + const columns: Column[] = [ + { + key: "expand", + label: "", + width: "40px", + render: (row) => ( + + ), + }, + { + key: "date", + label: "Date", + render: (row) => ( + + {formatDate(row.created_at || "")} + + ), + }, + { + key: "provider", + label: "Provider", + render: (row) => ( + + {row.provider} + + ), + }, + { + key: "model", + label: "Model", + render: (row) => ( + + {row.model} + + ), + }, + { + key: "latency", + label: "Latency", + render: (row) => ( + + {row.latency_ms ? `${row.latency_ms}ms` : "—"} + + ), + }, + { + key: "cost", + label: "Cost", + render: (row) => ( + + {row.cost ? `USD ${row.cost.toFixed(5)}` : "—"} + + ), + }, + { + key: "status", + label: "Status", + render: (row) => ( + + {row.status || "failed"} + + ), + }, + ]; + + if (isLoading && !prompt) { + return ( + +
+
+
+

Loading execution history...

+
+
+
+ ); + } + + return ( + + Execution History + + {prompt?.status || "Draft"} + + + ), + description: `Track execution metrics, variables, and failure traces for prompt "${prompt?.name}".`, + action: ( +
+ navigate("/tenant/ai/prompts")}> + Back to Prompts + + navigate(`/tenant/ai/prompts/${id}/execute`)}> + + Execute Prompt + +
+ ), + }} + > +
+ {/* LEFT COLUMN: Stat cards, filters, and logs data table */} +
+ + {/* SUMMARY CARDS STRIP */} +
+ + + + +
+ + {/* FILTER AND DATA TABLE */} +
+ + {/* FILTERS BAR */} +
+
+ setStatusFilter(value as string | null)} + /> +
+ {statusFilter && ( + + )} +
+ + {/* DATA TABLE */} +
+ 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 && ( +
+ { + setLimit(newLimit); + setPage(1); + }} + limitOptions={[ + { value: "5", label: "5 per page" }, + { value: "10", label: "10 per page" }, + { value: "20", label: "20 per page" }, + ]} + /> +
+ )} +
+
+
+ + {/* RIGHT COLUMN (Sidebar) */} +
+ + {/* Template Details Card */} +
+

+ + Template Details +

+ +
+
+ Name + {prompt?.name} +
+ + {prompt?.description && ( +
+ Description + {prompt.description} +
+ )} + +
+ Use Case + + {prompt?.useCase} + +
+ + {prompt?.tags && prompt.tags.length > 0 && ( +
+ Tags +
+ {prompt.tags.map((t, idx) => ( + + + {t} + + ))} +
+
+ )} +
+
+
+
+
+ ); +}; + +export default PromptExecutions; diff --git a/src/pages/tenant/PromptManagement.tsx b/src/pages/tenant/PromptManagement.tsx index 86fe066..9f3a033 100644 --- a/src/pages/tenant/PromptManagement.tsx +++ b/src/pages/tenant/PromptManagement.tsx @@ -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: , + label: "Run / Execute", + onClick: () => navigate(`/tenant/ai/prompts/${row.id}/execute`), + }, + { + icon: , + label: "Execution Logs", + onClick: () => navigate(`/tenant/ai/prompts/${row.id}/executions`), + }, { icon: , label: "Clone Prompt", @@ -343,6 +355,7 @@ const PromptManagement = (): ReactElement => { onRollbackSuccess={loadPrompts} /> + setIsDeleteOpen(false)} diff --git a/src/routes/tenant-admin-routes.tsx b/src/routes/tenant-admin-routes.tsx index 2c59c0c..249ce8d 100644 --- a/src/routes/tenant-admin-routes.tsx +++ b/src/routes/tenant-admin-routes.tsx @@ -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: , }, + { + path: "/tenant/ai/prompts/:id/execute", + element: , + }, + { + path: "/tenant/ai/prompts/:id/executions", + element: , + }, { path: "/tenant/ai/prompts/:id/test-cases", element: , diff --git a/src/services/ai-service.ts b/src/services/ai-service.ts index fac228c..6e3c1dd 100644 --- a/src/services/ai-service.ts +++ b/src/services/ai-service.ts @@ -218,6 +218,25 @@ class AIService { return unwrap(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; provider?: string; model?: string; temperature?: number; max_tokens?: number },