feat: add prompt test case management and integrate AI provider model resolution logic

This commit is contained in:
Yashwin 2026-05-04 15:42:36 +05:30
parent 178b8f9046
commit 816208fd9c
17 changed files with 1319 additions and 155 deletions

View File

@ -9,7 +9,7 @@ interface LayoutProps {
currentPage: string; currentPage: string;
breadcrumbs?: Array<{ label: string; path?: string }>; breadcrumbs?: Array<{ label: string; path?: string }>;
pageHeader?: { pageHeader?: {
title: string; title: React.ReactNode;
description?: string; description?: string;
tabs?: TabItem[]; tabs?: TabItem[];
action?: React.ReactNode; action?: React.ReactNode;

View File

@ -0,0 +1,66 @@
import type { ReactElement } from "react";
import { X } from "lucide-react";
interface FormTagInputProps {
label?: string;
value: string[];
onChange: (value: string[]) => void;
error?: string;
placeholder?: string;
className?: string;
}
export const FormTagInput = ({
label,
value = [],
onChange,
error,
placeholder = "Type and press enter...",
}: FormTagInputProps): ReactElement => {
return (
<div className="flex flex-col gap-2 pb-1">
{label && (
<label className="text-[13px] font-medium text-[#0e1b2a]">
{label}
</label>
)}
<div className="flex flex-wrap gap-2 p-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md min-h-[40px] focus-within:ring-1 focus-within:ring-[#112868]/20 focus-within:border-[#112868]/20 transition-all">
{value.map((tag, tagIdx) => (
<span
key={tagIdx}
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-[#112868]/5 text-[#112868] rounded text-xs font-semibold border border-[#112868]/10"
>
{tag}
<button
type="button"
onClick={() => {
onChange(value.filter((_, i) => i !== tagIdx));
}}
className="hover:text-[#e02424] transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
type="text"
className="flex-1 outline-none text-sm min-w-[150px] bg-transparent"
placeholder={placeholder}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const val = e.currentTarget.value.trim();
if (val && !value.includes(val)) {
onChange([...value, val]);
e.currentTarget.value = "";
}
}
}}
/>
</div>
{error && (
<p className="text-xs text-[#ef4444]">{error}</p>
)}
</div>
);
};

View File

@ -0,0 +1,96 @@
import type { ReactElement } from "react";
interface MarkdownViewerProps {
content: string;
className?: string;
}
export const MarkdownViewer = ({
content,
className = "",
}: MarkdownViewerProps): ReactElement => {
if (!content) return <></>;
const parseBold = (text: string) => {
if (!text) return "";
const parts = text.split("**");
return parts.map((part, i) => {
if (i % 2 === 1) {
return (
<strong key={i} className="font-bold text-slate-900 select-text">
{part}
</strong>
);
}
return part;
});
};
const parseLine = (line: string, lineIndex: number) => {
// 1. Check for headings
if (line.startsWith("### ")) {
return (
<h3
key={lineIndex}
className="text-base font-bold text-slate-800 mt-3 mb-1 select-text"
>
{parseBold(line.slice(4))}
</h3>
);
}
if (line.startsWith("## ")) {
return (
<h2
key={lineIndex}
className="text-lg font-bold text-slate-800 mt-4 mb-2 select-text"
>
{parseBold(line.slice(3))}
</h2>
);
}
if (line.startsWith("# ")) {
return (
<h1
key={lineIndex}
className="text-xl font-bold text-slate-800 mt-4 mb-2 select-text"
>
{parseBold(line.slice(2))}
</h1>
);
}
// 2. Check for bullet lists
if (line.trim().startsWith("- ") || line.trim().startsWith("* ")) {
const cleanLine = line.trim().startsWith("- ")
? line.trim().slice(2)
: line.trim().slice(2);
return (
<li
key={lineIndex}
className="ml-5 text-xs text-slate-700 list-disc leading-relaxed select-text"
>
{parseBold(cleanLine)}
</li>
);
}
// 3. Regular paragraph
return (
<p
key={lineIndex}
className="text-xs text-slate-700 leading-relaxed min-h-[1em] mb-1 select-text"
>
{parseBold(line)}
</p>
);
};
// Split content by newline and process each line
const lines = content.split("\n");
return (
<div className={`flex flex-col gap-1 select-text ${className}`}>
{lines.map((line, index) => parseLine(line, index))}
</div>
);
};

View File

@ -10,7 +10,7 @@ export interface TabItem {
} }
interface PageHeaderProps { interface PageHeaderProps {
title: string; title: React.ReactNode;
description?: string; description?: string;
tabs?: TabItem[]; tabs?: TabItem[];
action?: React.ReactNode; action?: React.ReactNode;

View File

@ -41,3 +41,5 @@ export type { FileUploadModalProps } from './FileUploadModal';
export { FileShareModal } from './FileShareModal'; export { FileShareModal } from './FileShareModal';
export { ActiveOnlyToggle } from './ActiveOnlyToggle'; export { ActiveOnlyToggle } from './ActiveOnlyToggle';
export { SearchBox } from './SearchBox'; export { SearchBox } from './SearchBox';
export { FormTagInput } from './FormTagInput';
export { MarkdownViewer } from './MarkdownViewer';

View File

@ -0,0 +1,117 @@
import type { ReactElement } from "react";
import { Modal, MarkdownViewer } from "@/components/shared";
import { Cpu, DollarSign, Clock, CheckCircle } from "lucide-react";
interface PromptTestCaseResultModalProps {
isOpen: boolean;
onClose: () => void;
result: any;
}
export const PromptTestCaseResultModal = ({
isOpen,
onClose,
result,
}: PromptTestCaseResultModalProps): ReactElement | null => {
if (!result) return null;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Test Case Run Results"
description="Review LLM testing details, latency, and token consumption."
maxWidth="2xl"
>
<div className="p-6 flex flex-col gap-6 bg-slate-50/40 select-none">
{/* Performance & Usage Metrics Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Provider Card */}
<div className="bg-white border border-[rgba(0,0,0,0.06)] p-3.5 rounded-xl shadow-sm flex flex-col gap-1 justify-between min-h-[72px]">
<span className="text-[10px] text-slate-400 font-medium uppercase tracking-wider flex items-center gap-1">
<Cpu className="w-3.5 h-3.5 text-blue-500" /> Provider & Model
</span>
<span className="text-xs font-semibold text-slate-800 break-words mt-1">
{result.provider || "N/A"}
</span>
<span className="text-[10px] font-medium text-slate-500 break-words">
{result.model || "N/A"}
</span>
</div>
{/* Latency Card */}
<div className="bg-white border border-[rgba(0,0,0,0.06)] p-3.5 rounded-xl shadow-sm flex flex-col gap-1 justify-between min-h-[72px]">
<span className="text-[10px] text-slate-400 font-medium uppercase tracking-wider flex items-center gap-1">
<Clock className="w-3.5 h-3.5 text-indigo-500" /> Latency
</span>
<span className="text-sm md:text-base font-bold text-slate-800 mt-1">
{result.latency_ms ? `${(result.latency_ms / 1000).toFixed(2)}s` : "N/A"}
</span>
<span className="text-[10px] text-slate-400">Response time</span>
</div>
{/* Tokens Card */}
<div className="bg-white border border-[rgba(0,0,0,0.06)] p-3.5 rounded-xl shadow-sm flex flex-col gap-1 justify-between min-h-[72px]">
<span className="text-[10px] text-slate-400 font-medium uppercase tracking-wider">
Token Usage
</span>
<span className="text-sm md:text-base font-bold text-slate-800 mt-1">
{result.usage?.total_tokens || result.usage?.totalTokens || 0}
</span>
<span className="text-[10px] font-medium text-slate-500">
In: {result.usage?.prompt_tokens || result.usage?.promptTokens || 0} Out:{" "}
{result.usage?.completion_tokens || result.usage?.completionTokens || 0}
</span>
</div>
{/* Cost Card */}
<div className="bg-white border border-[rgba(0,0,0,0.06)] p-3.5 rounded-xl shadow-sm flex flex-col gap-1 justify-between min-h-[72px]">
<span className="text-[10px] text-slate-400 font-medium uppercase tracking-wider flex items-center gap-1">
<DollarSign className="w-3.5 h-3.5 text-green-500" /> Cost
</span>
<span className="text-sm md:text-base font-bold text-slate-800 mt-1">
{result.cost !== undefined ? `$${result.cost}` : "$0.00"}
</span>
<span className="text-[10px] text-slate-400">Total API cost</span>
</div>
</div>
{/* Prompt Input / Rendered Prompt */}
<div className="flex flex-col gap-2">
<span className="text-xs font-bold text-slate-700 tracking-wide uppercase select-none">
Rendered Prompt
</span>
<div className="bg-slate-100/80 border border-slate-200/60 p-4 rounded-xl max-h-40 overflow-y-auto font-mono text-xs text-slate-700 select-all leading-relaxed break-words whitespace-pre-wrap">
{result.renderedPrompt || result.prompt || "No rendered prompt available."}
</div>
</div>
{/* Expected Output */}
{result.expectedOutput && (
<div className="flex flex-col gap-2">
<span className="text-xs font-bold text-slate-700 tracking-wide uppercase flex items-center gap-1 select-none">
<CheckCircle className="w-4 h-4 text-emerald-600" /> Expected Output
</span>
<div className="bg-emerald-50/50 border border-emerald-200/40 p-4 rounded-xl max-h-40 overflow-y-auto text-xs text-slate-700 select-all">
<MarkdownViewer content={result.expectedOutput} />
</div>
</div>
)}
{/* Response Content */}
<div className="flex flex-col gap-2">
<span className="text-xs font-bold text-slate-700 tracking-wide uppercase select-none">
LLM Response Content
</span>
<div className="bg-white border border-slate-200/80 p-4 rounded-xl min-h-[140px] max-h-[300px] overflow-y-auto text-xs text-slate-800 select-all">
{result.content || result.response ? (
<MarkdownViewer content={result.content || result.response} />
) : (
"No output response content."
)}
</div>
</div>
</div>
</Modal>
);
};

View File

@ -0,0 +1,151 @@
import { useEffect, useState, type ReactElement } from "react";
import { Modal, DataTable, type Column, MarkdownViewer } from "@/components/shared";
import { aiService } from "@/services/ai-service";
import { formatDate } from "@/utils/format-date";
import { showToast } from "@/utils/toast";
interface PromptTestCaseResultsListModalProps {
isOpen: boolean;
onClose: () => void;
testCaseId: string;
testCaseName: string;
}
export const PromptTestCaseResultsListModal = ({
isOpen,
onClose,
testCaseId,
testCaseName,
}: PromptTestCaseResultsListModalProps): ReactElement | null => {
const [results, setResults] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({});
const loadResults = async () => {
if (!testCaseId) return;
setIsLoading(true);
setError(null);
try {
const data = await aiService.listPromptTestCaseResults(testCaseId, 10);
setResults(data || []);
} catch (err: any) {
const message =
err?.response?.data?.error?.message || "Failed to load test case results";
setError(message);
showToast.error(message);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (isOpen && testCaseId) {
void loadResults();
setExpandedRows({});
}
}, [isOpen, testCaseId]);
const toggleRowExpansion = (rowId: string) => {
setExpandedRows((prev) => ({
...prev,
[rowId]: !prev[rowId],
}));
};
const columns: Column<any>[] = [
{
key: "created_at",
label: "Timestamp",
render: (row) => (
<span className="text-xs text-slate-600 font-medium select-none">
{formatDate(row.created_at)}
</span>
),
},
{
key: "provider_model",
label: "Provider / Model",
render: (row) => (
<div className="flex flex-col select-none">
<span className="text-xs font-semibold text-slate-800">
{row.provider || "N/A"}
</span>
<span className="text-[10px] text-slate-500 font-medium">
{row.model || "N/A"}
</span>
</div>
),
},
{
key: "usage",
label: "Usage & Cost",
render: (row) => (
<div className="flex flex-col select-none">
<span className="text-xs font-medium text-slate-700">
{row.tokens_used ? `${row.tokens_used} tokens` : "0 tokens"}
</span>
<span className="text-[10px] text-slate-400 font-medium">
{row.cost_usd && Number(row.cost_usd) > 0 ? `$${row.cost_usd}` : "$0.00"}
</span>
</div>
),
},
{
key: "latency",
label: "Latency",
render: (row) => (
<span className="text-xs text-slate-600 font-medium select-none">
{row.latency_ms ? `${(row.latency_ms / 1000).toFixed(2)}s` : "N/A"}
</span>
),
},
{
key: "run_by",
label: "Run By",
render: (row) => (
<span className="text-xs text-slate-500 font-medium break-all select-none">
{row.run_by_email || row.run_by || "System"}
</span>
),
},
];
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Test Results - ${testCaseName || "Test Case"}`}
description="View the recent run history and response outputs."
maxWidth="2xl"
>
<div className="border-t border-slate-100 flex-1 min-h-0 select-none">
<DataTable
data={results}
columns={columns}
keyExtractor={(item) => item.id}
isLoading={isLoading}
error={error}
emptyMessage="No test results recorded yet."
expandableRows={true}
isRowExpanded={(item) => !!expandedRows[item.id]}
onRowExpandToggle={(item) => toggleRowExpansion(item.id)}
showExpandColumn={true}
expandedColSpan={columns.length + 1}
renderExpandedRow={(item) => (
<div className="bg-slate-50/70 border border-slate-200/50 p-4 rounded-xl text-xs text-slate-800 select-all">
<span className="text-xs font-bold text-slate-600 uppercase tracking-wide mb-2 block select-none">
Output
</span>
{item.output ? (
<MarkdownViewer content={item.output} />
) : (
"No output generated."
)}
</div>
)}
/>
</div>
</Modal>
);
};

View File

@ -20,8 +20,7 @@ const playgroundSchema = z.object({
type PlaygroundFormData = z.infer<typeof playgroundSchema>; type PlaygroundFormData = z.infer<typeof playgroundSchema>;
const CompletionCreate = (): ReactElement => { const CompletionCreate = (): ReactElement => {
const [providers, setProviders] = useState<AIProviderInfo[]>([]); const [apiProviders, setApiProviders] = useState<AIProviderInfo[]>([]);
const [models, setModels] = useState<Array<{ id: string; provider: string; isDefault?: boolean }>>([]);
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [isSending, setIsSending] = useState<boolean>(false); const [isSending, setIsSending] = useState<boolean>(false);
const [isPlaygroundMode, setIsPlaygroundMode] = useState<boolean>(true); const [isPlaygroundMode, setIsPlaygroundMode] = useState<boolean>(true);
@ -43,8 +42,6 @@ const CompletionCreate = (): ReactElement => {
}, },
}); });
const formValues = watch();
const [lastSentUserMessage, setLastSentUserMessage] = useState<string>(""); const [lastSentUserMessage, setLastSentUserMessage] = useState<string>("");
const [displayedResponse, setDisplayedResponse] = useState<string>(""); const [displayedResponse, setDisplayedResponse] = useState<string>("");
const typingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const typingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@ -61,30 +58,41 @@ const CompletionCreate = (): ReactElement => {
fallbackUsed: false, fallbackUsed: false,
}); });
const selectedProvider = watch("provider");
const providerOptions = useMemo( const providerOptions = useMemo(
() => providers.map((p) => ({ value: p.name, label: p.displayName || p.name })), () => apiProviders.map((p) => ({ value: p.name, label: p.displayName || p.name })),
[providers], [apiProviders],
); );
const providerDetail = apiProviders.find((p) => p.name === selectedProvider);
const modelOptions = useMemo( const modelOptions = useMemo(
() => () =>
models (providerDetail?.models || []).map((m) => ({
.filter((m) => !formValues.provider || m.provider === formValues.provider) value: m,
.map((m) => ({ label: `${m}${m === providerDetail?.defaultModel ? " • default" : ""}`,
value: m.id, })),
label: `${m.id}${m.isDefault ? " • default" : ""}`, [providerDetail],
})),
[models, formValues.provider],
); );
const loadOptions = async (): Promise<void> => { const loadOptions = async (): Promise<void> => {
setIsLoading(true); setIsLoading(true);
try { try {
const [providerData, modelData] = await Promise.all([aiService.getProviders(), aiService.getModels()]); const providerData = await aiService.getProviders();
setProviders(providerData); setApiProviders(providerData);
setModels(modelData);
if (providerData.length > 0) {
const initialProvider = "gemini";
const matchedProvider = providerData.find((p) => p.name === initialProvider) || providerData[0];
setValue("provider", matchedProvider.name);
if (matchedProvider.defaultModel) {
setValue("model", matchedProvider.defaultModel);
} else if (matchedProvider.models && matchedProvider.models.length > 0) {
setValue("model", matchedProvider.models[0]);
}
}
} catch (err: any) { } catch (err: any) {
showToast.error(err?.response?.data?.error?.message || "Failed to load provider/model options"); showToast.error(err?.response?.data?.error?.message || "Failed to load provider options");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -310,7 +318,14 @@ const CompletionCreate = (): ReactElement => {
options={providerOptions} options={providerOptions}
onValueChange={(value) => { onValueChange={(value) => {
field.onChange(value); field.onChange(value);
setValue("model", ""); const pDetail = apiProviders.find((p) => p.name === value);
if (pDetail && pDetail.defaultModel) {
setValue("model", pDetail.defaultModel);
} else if (pDetail && pDetail.models && pDetail.models.length > 0) {
setValue("model", pDetail.models[0]);
} else {
setValue("model", "");
}
}} }}
/> />
)} )}

View File

@ -10,6 +10,7 @@ import {
FormTextArea, FormTextArea,
PrimaryButton, PrimaryButton,
RichTextEditor, RichTextEditor,
FormTagInput,
} from "@/components/shared"; } from "@/components/shared";
import { import {
documentService, documentService,
@ -17,7 +18,7 @@ import {
} from "@/services/document-service"; } from "@/services/document-service";
import type { DocumentCategory } from "@/types/document"; import type { DocumentCategory } from "@/types/document";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
import { ArrowLeft, FileText, Info, Paperclip, X } from "lucide-react"; import { ArrowLeft, FileText, Info, Paperclip } from "lucide-react";
import { moduleService } from "@/services/module-service"; import { moduleService } from "@/services/module-service";
import type { MyModule } from "@/types/module"; import type { MyModule } from "@/types/module";
@ -292,58 +293,18 @@ const CreateDocument = (): ReactElement => {
error={errors.department?.message} error={errors.department?.message}
{...register("department")} {...register("department")}
/> />
<div className="flex flex-col gap-2 pb-1"> <Controller
<label className="text-[13px] font-medium text-[#0e1b2a]"> name="tags"
Tags (Press enter to add) control={control}
</label> render={({ field }) => (
<Controller <FormTagInput
name="tags" label="Tags (Press enter to add)"
control={control} value={field.value || []}
render={({ field }) => ( onChange={field.onChange}
<div className="flex flex-wrap gap-2 p-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md min-h-[40px] focus-within:ring-1 focus-within:ring-[#112868]/20 focus-within:border-[#112868]/20 transition-all"> error={errors.tags?.message}
{(field.value || []).map((tag, tagIdx) => ( />
<span
key={tagIdx}
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-[#112868]/5 text-[#112868] rounded text-xs font-semibold border border-[#112868]/10"
>
{tag}
<button
type="button"
onClick={() => {
field.onChange(
(field.value || []).filter(
(_, i) => i !== tagIdx,
),
);
}}
className="hover:text-[#e02424] transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
type="text"
className="flex-1 outline-none text-sm min-w-[150px] bg-transparent"
placeholder="Type and press enter..."
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const val = e.currentTarget.value.trim();
if (val && !(field.value || []).includes(val)) {
field.onChange([...(field.value || []), val]);
e.currentTarget.value = "";
}
}
}}
/>
</div>
)}
/>
{errors.tags && (
<p className="text-xs text-[#ef4444]">{errors.tags.message}</p>
)} )}
</div> />
<Controller <Controller
name="selectedModuleId" name="selectedModuleId"
control={control} control={control}

View File

@ -8,6 +8,7 @@ import {
PrimaryButton, PrimaryButton,
SecondaryButton, SecondaryButton,
FormSlider, FormSlider,
FormTagInput,
} from "@/components/shared"; } from "@/components/shared";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { aiService } from "@/services/ai-service"; import { aiService } from "@/services/ai-service";
@ -16,6 +17,7 @@ import { useForm, useFieldArray, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { AIProviderInfo } from "@/types/ai";
const variableSchema = z.object({ const variableSchema = z.object({
name: z.string().min(1, "Variable name is required").max(100), name: z.string().min(1, "Variable name is required").max(100),
@ -34,7 +36,7 @@ const promptSchema = z.object({
model: z.string().optional(), model: z.string().optional(),
temperature: z.number().min(0).max(2), temperature: z.number().min(0).max(2),
max_tokens: z.number().int().min(1).max(128000), max_tokens: z.number().int().min(1).max(128000),
tags: z.string().optional(), tags: z.array(z.string()),
is_default: z.boolean(), is_default: z.boolean(),
variables: z.array(variableSchema), variables: z.array(variableSchema),
}); });
@ -44,16 +46,13 @@ type PromptFormData = z.infer<typeof promptSchema>;
const PromptCreate = (): ReactElement => { const PromptCreate = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = useState<boolean>(false); const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [providers, setProviders] = useState< const [apiProviders, setApiProviders] = useState<AIProviderInfo[]>([]);
Array<{ value: string; label: string }>
>([]);
const [models, setModels] = useState<Array<{ value: string; label: string }>>(
[],
);
const { const {
control, control,
handleSubmit, handleSubmit,
setValue,
watch,
formState: { errors }, formState: { errors },
} = useForm<PromptFormData>({ } = useForm<PromptFormData>({
resolver: zodResolver(promptSchema), resolver: zodResolver(promptSchema),
@ -67,7 +66,7 @@ const PromptCreate = (): ReactElement => {
model: "", model: "",
temperature: 0.7, temperature: 0.7,
max_tokens: 2048, max_tokens: 2048,
tags: "", tags: [],
is_default: true, is_default: true,
variables: [ variables: [
{ {
@ -85,25 +84,24 @@ const PromptCreate = (): ReactElement => {
name: "variables", name: "variables",
}); });
const selectedProvider = watch("provider");
const providersOptions = apiProviders.map((p) => ({
value: p.name,
label: p.displayName || p.name,
}));
const providerDetail = apiProviders.find((p) => p.name === selectedProvider);
const modelsOptions = (providerDetail?.models || []).map((m) => ({
value: m,
label: m,
}));
useEffect(() => { useEffect(() => {
const loadMeta = async (): Promise<void> => { const loadMeta = async (): Promise<void> => {
try { try {
const [providerData, modelData] = await Promise.all([ const providerData = await aiService.getProviders();
aiService.getProviders(), setApiProviders(providerData);
aiService.getModels(),
]);
setProviders(
providerData.map((p) => ({
value: p.name,
label: p.displayName || p.name,
})),
);
setModels(
modelData.map((m) => ({
value: m.id,
label: `${m.id} (${m.provider})`,
})),
);
} catch (err: unknown) { } catch (err: unknown) {
showToast.error( showToast.error(
(err as { response?: { data?: { error?: { message?: string } } } }) (err as { response?: { data?: { error?: { message?: string } } } })
@ -116,12 +114,7 @@ const PromptCreate = (): ReactElement => {
}, []); }, []);
const onFormSubmit = async (data: PromptFormData): Promise<void> => { const onFormSubmit = async (data: PromptFormData): Promise<void> => {
const parsedTags = data.tags const parsedTags = data.tags || [];
? data.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
: [];
const sanitizedVariables = data.variables const sanitizedVariables = data.variables
?.filter((v) => v.name.trim()) ?.filter((v) => v.name.trim())
@ -168,8 +161,15 @@ const PromptCreate = (): ReactElement => {
<Layout <Layout
currentPage="Create Prompt" currentPage="Create Prompt"
pageHeader={{ pageHeader={{
title: "Create Prompt", title: (
description: "Create a reusable prompt template for AI workflows.", <div className="flex items-center gap-3">
<span>Create Prompt</span>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-sm font-semibold bg-[#FFF5E9] text-[#FA8C16] border border-[#FFE7BA]">
Draft
</span>
</div>
),
description: "Manage and reuse prompts for different use cases",
action: ( action: (
<div className="flex gap-2"> <div className="flex gap-2">
<SecondaryButton onClick={() => navigate("/tenant/ai/prompts")}> <SecondaryButton onClick={() => navigate("/tenant/ai/prompts")}>
@ -440,7 +440,9 @@ const PromptCreate = (): ReactElement => {
/> />
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<label className="text-[13px] font-semibold text-[#0e1b2a]">Is Default</label> <label className="text-[13px] font-semibold text-[#0e1b2a]">
Is Default
</label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Controller <Controller
name="is_default" name="is_default"
@ -456,13 +458,17 @@ const PromptCreate = (): ReactElement => {
<div <div
className={cn( className={cn(
"absolute top-1 left-1 w-3 h-3 rounded-full transition-transform duration-200 shadow-sm", "absolute top-1 left-1 w-3 h-3 rounded-full transition-transform duration-200 shadow-sm",
field.value ? "translate-x-5 bg-[#00cfd5]" : "bg-white", field.value
? "translate-x-5 bg-[#00cfd5]"
: "bg-white",
)} )}
/> />
</div> </div>
)} )}
/> />
<span className="text-[13px] text-gray-500 font-medium">Make default for this use case</span> <span className="text-[13px] text-gray-500 font-medium">
Make default for this use case
</span>
</div> </div>
</div> </div>
</div> </div>
@ -480,8 +486,18 @@ const PromptCreate = (): ReactElement => {
<FormSelect <FormSelect
label="Provider" label="Provider"
value={field.value || ""} value={field.value || ""}
onValueChange={field.onChange} onValueChange={(val) => {
options={providers} field.onChange(val);
const pDetail = apiProviders.find((p) => p.name === val);
if (pDetail && pDetail.defaultModel) {
setValue("model", pDetail.defaultModel);
} else if (pDetail && pDetail.models && pDetail.models.length > 0) {
setValue("model", pDetail.models[0]);
} else {
setValue("model", "");
}
}}
options={providersOptions}
error={errors.provider?.message} error={errors.provider?.message}
/> />
)} )}
@ -495,7 +511,7 @@ const PromptCreate = (): ReactElement => {
label="Model" label="Model"
value={field.value || ""} value={field.value || ""}
onValueChange={field.onChange} onValueChange={field.onChange}
options={models} options={modelsOptions}
error={errors.model?.message} error={errors.model?.message}
/> />
)} )}
@ -543,11 +559,10 @@ const PromptCreate = (): ReactElement => {
name="tags" name="tags"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<FormField <FormTagInput
{...field} label="Tags (Press enter to add)"
label="Tags" value={field.value || []}
placeholder="e.g. analysis, legal, code" onChange={field.onChange}
helperText="Comma separated values"
error={errors.tags?.message} error={errors.tags?.message}
/> />
)} )}

View File

@ -8,6 +8,7 @@ import {
PrimaryButton, PrimaryButton,
SecondaryButton, SecondaryButton,
FormSlider, FormSlider,
FormTagInput
} from "@/components/shared"; } from "@/components/shared";
import { Plus, Trash2, ArrowLeft } from "lucide-react"; import { Plus, Trash2, ArrowLeft } from "lucide-react";
import { aiService } from "@/services/ai-service"; import { aiService } from "@/services/ai-service";
@ -16,6 +17,7 @@ import { useForm, useFieldArray, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { AIProviderInfo } from "@/types/ai";
const variableSchema = z.object({ const variableSchema = z.object({
name: z.string().min(1, "Variable name is required").max(100), name: z.string().min(1, "Variable name is required").max(100),
@ -34,7 +36,7 @@ const promptSchema = z.object({
model: z.string().optional(), model: z.string().optional(),
temperature: z.number().min(0).max(2), temperature: z.number().min(0).max(2),
max_tokens: z.number().int().min(1).max(128000), max_tokens: z.number().int().min(1).max(128000),
tags: z.string().optional(), tags: z.array(z.string()),
is_default: z.boolean(), is_default: z.boolean(),
variables: z.array(variableSchema), variables: z.array(variableSchema),
change_notes: z.string().optional(), change_notes: z.string().optional(),
@ -47,17 +49,14 @@ const PromptEdit = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false); const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [providers, setProviders] = useState< const [apiProviders, setApiProviders] = useState<AIProviderInfo[]>([]);
Array<{ value: string; label: string }>
>([]);
const [models, setModels] = useState<Array<{ value: string; label: string }>>(
[],
);
const [currentVersion, setCurrentVersion] = useState<number>(1); const [currentVersion, setCurrentVersion] = useState<number>(1);
const { const {
control, control,
handleSubmit, handleSubmit,
setValue,
watch,
reset, reset,
formState: { errors }, formState: { errors },
} = useForm<PromptFormData>({ } = useForm<PromptFormData>({
@ -72,7 +71,7 @@ const PromptEdit = (): ReactElement => {
model: "", model: "",
temperature: 0.7, temperature: 0.7,
max_tokens: 2048, max_tokens: 2048,
tags: "", tags: [],
is_default: false, is_default: false,
variables: [], variables: [],
change_notes: "", change_notes: "",
@ -84,29 +83,30 @@ const PromptEdit = (): ReactElement => {
name: "variables", name: "variables",
}); });
const selectedProvider = watch("provider");
const providersOptions = apiProviders.map((p) => ({
value: p.name,
label: p.displayName || p.name,
}));
const providerDetail = apiProviders.find((p) => p.name === selectedProvider);
const modelsOptions = (providerDetail?.models || []).map((m) => ({
value: m,
label: m,
}));
useEffect(() => { useEffect(() => {
const loadData = async (): Promise<void> => { const loadData = async (): Promise<void> => {
if (!id) return; if (!id) return;
try { try {
setIsLoading(true); setIsLoading(true);
const [providerData, modelData, promptData] = await Promise.all([ const [providerData, promptData] = await Promise.all([
aiService.getProviders(), aiService.getProviders(),
aiService.getModels(),
aiService.getPrompt(id), aiService.getPrompt(id),
]); ]);
setProviders( setApiProviders(providerData);
providerData.map((p) => ({
value: p.name,
label: p.displayName || p.name,
})),
);
setModels(
modelData.map((m) => ({
value: m.id,
label: `${m.id} (${m.provider})`,
})),
);
reset({ reset({
name: promptData.name, name: promptData.name,
@ -118,7 +118,7 @@ const PromptEdit = (): ReactElement => {
model: promptData.defaultParameters?.model || "", model: promptData.defaultParameters?.model || "",
temperature: promptData.defaultParameters?.temperature ?? 0.7, temperature: promptData.defaultParameters?.temperature ?? 0.7,
max_tokens: promptData.defaultParameters?.max_tokens ?? 2048, max_tokens: promptData.defaultParameters?.max_tokens ?? 2048,
tags: (promptData.tags || []).join(", "), tags: promptData.tags || [],
is_default: promptData.isDefault || false, is_default: promptData.isDefault || false,
variables: (promptData.variables || []).map((v) => ({ variables: (promptData.variables || []).map((v) => ({
name: v.name, name: v.name,
@ -145,12 +145,7 @@ const PromptEdit = (): ReactElement => {
const onFormSubmit = async (data: PromptFormData): Promise<void> => { const onFormSubmit = async (data: PromptFormData): Promise<void> => {
if (!id) return; if (!id) return;
const parsedTags = data.tags const parsedTags = data.tags || [];
? data.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
: [];
const sanitizedVariables = data.variables const sanitizedVariables = data.variables
?.filter((v) => v.name.trim()) ?.filter((v) => v.name.trim())
@ -543,8 +538,18 @@ const PromptEdit = (): ReactElement => {
<FormSelect <FormSelect
label="Provider" label="Provider"
value={field.value || ""} value={field.value || ""}
onValueChange={field.onChange} onValueChange={(val) => {
options={providers} field.onChange(val);
const pDetail = apiProviders.find((p) => p.name === val);
if (pDetail && pDetail.defaultModel) {
setValue("model", pDetail.defaultModel);
} else if (pDetail && pDetail.models && pDetail.models.length > 0) {
setValue("model", pDetail.models[0]);
} else {
setValue("model", "");
}
}}
options={providersOptions}
error={errors.provider?.message} error={errors.provider?.message}
/> />
)} )}
@ -558,7 +563,7 @@ const PromptEdit = (): ReactElement => {
label="Model" label="Model"
value={field.value || ""} value={field.value || ""}
onValueChange={field.onChange} onValueChange={field.onChange}
options={models} options={modelsOptions}
error={errors.model?.message} error={errors.model?.message}
/> />
)} )}
@ -606,11 +611,10 @@ const PromptEdit = (): ReactElement => {
name="tags" name="tags"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<FormField <FormTagInput
{...field} label="Tags (Press enter to add)"
label="Tags" value={field.value || []}
placeholder="e.g. analysis, legal, code" onChange={field.onChange}
helperText="Comma separated values"
error={errors.tags?.message} error={errors.tags?.message}
/> />
)} )}

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type ReactElement } from "react"; import { useEffect, useMemo, useState, type ReactElement } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Plus, Search, Copy, History, Trash2, Edit3 } from "lucide-react"; import { Plus, Search, Copy, History, Trash2, Edit3, TestTube } from "lucide-react";
import { Layout } from "@/components/layout/Layout"; import { Layout } from "@/components/layout/Layout";
import { import {
DataTable, DataTable,
@ -224,6 +224,11 @@ const PromptManagement = (): ReactElement => {
<div className="flex justify-end pr-2"> <div className="flex justify-end pr-2">
<ActionDropdown <ActionDropdown
actions={[ actions={[
{
icon: <TestTube className="w-3.5 h-3.5 shrink-0" />,
label: "Prompt Test Cases",
onClick: () => navigate(`/tenant/ai/prompts/${row.id}/test-cases`),
},
{ {
icon: <Edit3 className="w-3.5 h-3.5 shrink-0" />, icon: <Edit3 className="w-3.5 h-3.5 shrink-0" />,
label: "Edit Prompt", label: "Edit Prompt",

View File

@ -0,0 +1,342 @@
import { useEffect, useState, type ReactElement } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Layout } from "@/components/layout/Layout";
import {
FormField,
FormTextArea,
FormTagInput,
PrimaryButton,
SecondaryButton,
} from "@/components/shared";
import { ArrowLeft } from "lucide-react";
import { aiService } from "@/services/ai-service";
import { showToast } from "@/utils/toast";
import { useForm, useFieldArray, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const variableSchema = z.object({
name: z.string().min(1, "Variable name is required").max(255),
value: z.string().optional(),
required: z.boolean().optional(),
});
const testCaseSchema = z.object({
name: z.string().min(1, "Name is required").max(255),
description: z.string().optional(),
expected_output: z.string().optional(),
tags: z.array(z.string()),
variables: z.array(variableSchema),
}).superRefine((data, ctx) => {
(data.variables || []).forEach((v, index) => {
if (v.required && (!v.value || !v.value.trim())) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${v.name} is a required variable`,
path: ["variables", index, "value"],
});
}
});
});
type TestCaseFormData = z.infer<typeof testCaseSchema>;
export const PromptTestCaseCreate = (): ReactElement => {
const { id: promptId } = useParams<{ id: string }>();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const {
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<TestCaseFormData>({
resolver: zodResolver(testCaseSchema),
defaultValues: {
name: "",
description: "",
expected_output: "",
tags: [],
variables: [],
},
});
const { fields } = useFieldArray({
control,
name: "variables",
});
// Fetch prompt metadata to autofill initial variable keys
useEffect(() => {
const loadPromptDetails = async () => {
if (!promptId) return;
try {
setIsLoading(true);
const prompt = await aiService.getPrompt(promptId);
let initialVariables: Array<{ name: string; value: string; required?: boolean }> = [];
if (prompt && Array.isArray(prompt.variables)) {
initialVariables = prompt.variables.map((v: any) => ({
name: v.name || "",
value: v.default || "",
required: !!v.required,
}));
}
reset({
name: "",
description: "",
expected_output: "",
tags: prompt.tags || [],
variables: initialVariables,
});
} catch (err: any) {
console.warn("Failed to prefetch prompt variables", err);
reset({
name: "",
description: "",
expected_output: "",
tags: [],
variables: [],
});
} finally {
setIsLoading(false);
}
};
void loadPromptDetails();
}, [promptId, reset]);
const onFormSubmit = async (data: TestCaseFormData) => {
if (!promptId) return;
// Convert array to Record
const variablesObject: Record<string, any> = {};
(data.variables || []).forEach((v) => {
if (v.name.trim()) {
variablesObject[v.name.trim()] = v.value || "";
}
});
setIsSubmitting(true);
try {
await aiService.createPromptTestCase(promptId, {
name: data.name.trim(),
description: data.description?.trim() || undefined,
variables: variablesObject,
expected_output: data.expected_output?.trim() || undefined,
tags: data.tags && data.tags.length > 0 ? data.tags : undefined,
});
showToast.success("Test case created successfully!");
navigate(`/tenant/ai/prompts/${promptId}/test-cases`);
} catch (err: any) {
const msg =
err?.response?.data?.error?.message || "Failed to create test case.";
showToast.error(msg);
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<Layout currentPage="Prompt Management">
<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 font-medium">
Loading prompt template details...
</p>
</div>
</div>
</Layout>
);
}
return (
<Layout
currentPage="Prompt Management"
pageHeader={{
title: "Create Test Cases",
description: "Define inputs and expected results to validate how your prompt performs.",
action: (
<div className="flex items-center gap-2">
<SecondaryButton
onClick={() => navigate(`/tenant/ai/prompts/${promptId}/test-cases`)}
className="h-10 px-5 min-w-[120px]"
>
Cancel
</SecondaryButton>
<PrimaryButton
onClick={handleSubmit(onFormSubmit)}
disabled={isSubmitting}
className="h-10 px-5 min-w-[120px]"
>
{isSubmitting ? "Creating..." : "Save Test Case"}
</PrimaryButton>
</div>
),
}}
>
<div className="mb-4">
<button
onClick={() => navigate(`/tenant/ai/prompts/${promptId}/test-cases`)}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-800 transition-colors font-medium select-none cursor-pointer"
>
<ArrowLeft className="w-3.5 h-3.5" />
Back to Test Cases
</button>
</div>
<form onSubmit={handleSubmit(onFormSubmit)} className="flex items-start gap-6 select-none">
{/* Left column containing Test Case Information */}
<div className="flex flex-col gap-6 flex-[2]">
{/* General Information Card */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm p-5 space-y-4">
<h2 className="text-sm font-semibold text-slate-800 select-none">
General Information
</h2>
<Controller
name="name"
control={control}
render={({ field }) => (
<FormField
{...field}
label="Name"
required
placeholder="e.g., Code Review - Security Flaws"
error={errors.name?.message}
helperText="A memorable name for identifying this test case in evaluations."
/>
)}
/>
<Controller
name="description"
control={control}
render={({ field }) => (
<FormTextArea
{...field}
label="Description"
placeholder="Testing the prompt's ability to catch SQL injection..."
error={errors.description?.message}
helperText="Optional details regarding the scenario being tested."
/>
)}
/>
</div>
{/* Input Variables Section */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm overflow-hidden select-none">
<div className="p-4 bg-white flex items-center justify-between border-b border-slate-100">
<h2 className="text-base font-semibold text-slate-800 select-none">
Input Variables
</h2>
</div>
{/* Column Header Row matching the image */}
<div className="px-5 py-2.5 bg-slate-50/60 border-b border-slate-100/80 flex items-center select-none text-slate-400 text-xs font-medium">
<div className="flex-1 max-w-[32%]">Variable Name</div>
<div className="flex-1 max-w-[55%] ml-4">Value</div>
<div className="w-20 ml-auto text-right pr-1">Required</div>
</div>
<div className="p-5">
<div className="space-y-4">
{fields.map((field, index) => (
<div
key={field.id}
className="flex items-start gap-4 pb-1 last:pb-0"
>
<div className="flex-1 max-w-[32%] h-11 flex items-center">
<span className="text-sm font-semibold text-slate-700">
{field.name}
</span>
</div>
<div className="flex-1 max-w-[55%]">
<Controller
name={`variables.${index}.value`}
control={control}
render={({ field }) => (
<FormTextArea
{...field}
label=""
placeholder={`Enter value for ${field.name}...`}
error={errors.variables?.[index]?.value?.message}
className="pb-0 min-h-[44px]"
/>
)}
/>
</div>
<div className="w-20 ml-auto flex items-center justify-end h-11">
<span
className={`text-[10px] px-2 py-0.5 rounded-md font-bold uppercase tracking-wider ${
field.required
? "bg-red-50 text-red-600 border border-red-100"
: "bg-slate-100 text-slate-500 border border-slate-100"
}`}
>
{field.required ? "Required" : "Optional"}
</span>
</div>
</div>
))}
{fields.length === 0 && (
<div className="text-center py-4 select-none">
<p className="text-xs text-slate-400 font-medium">
No input variables configured.
</p>
</div>
)}
</div>
</div>
</div>
{/* Expected Output Section */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm p-5 space-y-4">
<h2 className="text-sm font-semibold text-slate-800 select-none">
Expected Output
</h2>
<Controller
name="expected_output"
control={control}
render={({ field }) => (
<FormTextArea
{...field}
label="Expected Full Output (Optional)"
placeholder="What response do you expect from the LLM?"
error={errors.expected_output?.message}
helperText="Optional response string to compare against the model's actual output for evaluation."
/>
)}
/>
</div>
</div>
{/* Right column containing Organization & Advanced */}
<div className="flex flex-col gap-6 flex-1 max-w-[320px] w-full select-none">
{/* Organization / Tags Card */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm p-5 space-y-4">
<h2 className="text-sm font-semibold text-slate-800">Organization</h2>
<Controller
name="tags"
control={control}
render={({ field }) => (
<FormTagInput
label="Tags"
value={field.value || []}
onChange={field.onChange}
error={errors.tags?.message}
/>
)}
/>
</div>
</div>
</form>
</Layout>
);
};
export default PromptTestCaseCreate;

View File

@ -0,0 +1,326 @@
import { useEffect, useMemo, useState, type ReactElement } from "react";
import { useNavigate, useSearchParams, useParams } from "react-router-dom";
import {
Plus,
Play,
ArrowLeft,
Eye,
Loader2,
} from "lucide-react";
import { Layout } from "@/components/layout/Layout";
import {
DataTable,
type Column,
PrimaryButton,
ActionDropdown,
} from "@/components/shared";
import { aiService } from "@/services/ai-service";
import type { AIPrompt, AIPromptTestCase } from "@/types/ai";
import { showToast } from "@/utils/toast";
import { formatDate } from "@/utils/format-date";
import { PromptTestCaseResultModal } from "@/components/tenant/PromptTestCaseResultModal";
import { PromptTestCaseResultsListModal } from "@/components/tenant/PromptTestCaseResultsListModal";
const PromptTestCases = (): ReactElement => {
const navigate = useNavigate();
const { id } = useParams();
const [searchParams] = useSearchParams();
const promptId = id || searchParams.get("promptId") || "";
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [testCases, setTestCases] = useState<AIPromptTestCase[]>([]);
const [prompt, setPrompt] = useState<AIPrompt | null>(null);
const [runningCases, setRunningCases] = useState<Record<string, boolean>>({});
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({});
const toggleRowExpansion = (id: string) => {
setExpandedRows((prev) => ({
...prev,
[id]: !prev[id],
}));
};
const [selectedResult, setSelectedResult] = useState<any>(null);
const [isResultModalOpen, setIsResultModalOpen] = useState<boolean>(false);
const [selectedTestCaseId, setSelectedTestCaseId] = useState<string>("");
const [selectedTestCaseName, setSelectedTestCaseName] = useState<string>("");
const [isResultsListModalOpen, setIsResultsListModalOpen] = useState<boolean>(false);
const handleRunTestCase = async (testCaseId: string) => {
setRunningCases((prev) => ({ ...prev, [testCaseId]: true }));
try {
const response = await aiService.runPromptTestCase(testCaseId);
showToast.success("Test case executed successfully!");
if (response) {
setSelectedResult(response);
setIsResultModalOpen(true);
}
void loadData();
} catch (err: any) {
const message =
err?.response?.data?.error?.message || "Failed to run test case";
showToast.error(message);
} finally {
setRunningCases((prev) => ({ ...prev, [testCaseId]: false }));
}
};
// Load Prompt Details and Test Cases
const loadData = async (): Promise<void> => {
if (!promptId) {
setError("Prompt ID is missing.");
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
// 1. Fetch prompt details
try {
const p = await aiService.getPrompt(promptId);
setPrompt(p);
} catch (err) {
console.warn("Failed to load prompt details", err);
}
// 2. Fetch test cases
const cases = await aiService.listPromptTestCases(promptId);
setTestCases(cases || []);
} catch (err: unknown) {
const message =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || "Failed to load prompt test cases";
setError(message);
showToast.error(message);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void loadData();
}, [promptId]);
const columns: Column<AIPromptTestCase>[] = useMemo(
() => [
{
key: "name",
label: "Name & Description",
render: (row) => (
<div className="min-w-0">
<p className="text-sm font-semibold text-[#112868] hover:underline cursor-pointer select-none">
{row.name}
</p>
<p className="text-xs text-[#64748b] mt-0.5 line-clamp-2 max-w-[320px]">
{row.description || "No description"}
</p>
</div>
),
},
{
key: "template",
label: "Template",
render: () => (
<div className="min-w-0">
<p className="text-sm font-medium text-slate-800">
{prompt?.name || "AI Prompt"}
</p>
<p className="text-xs text-slate-500 mt-0.5">
v{prompt?.version || 1}.0
</p>
</div>
),
},
{
key: "tags",
label: "Tags",
render: (row) => {
const tags = row.tags || [];
if (!tags.length) {
return <span className="text-xs text-[#94a3b8]"></span>;
}
return (
<div className="flex flex-wrap gap-1.5 max-w-[150px]">
{tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-blue-50 text-blue-600 border border-blue-100"
>
{tag}
</span>
))}
</div>
);
},
},
{
key: "updatedAt",
label: "Last Updated",
render: (row) => (
<span className="text-xs text-[#64748b] select-none">
{formatDate(row.updated_at || row.created_at || "")}
</span>
),
},
{
key: "actions",
label: "Actions",
align: "right",
render: (row) => {
const isRunning = runningCases[row.id] || false;
return (
<div className="flex items-center justify-end gap-2 pr-2">
<button
onClick={() => handleRunTestCase(row.id)}
disabled={isRunning}
className="flex items-center gap-1 px-3 py-1.5 border border-[#112868]/20 rounded-md text-xs font-medium bg-white text-[#112868] hover:bg-[#112868]/5 transition-colors cursor-pointer disabled:opacity-50 select-none"
>
{isRunning ? (
<Loader2 className="w-3.5 h-3.5 animate-spin text-[#112868]" />
) : (
<Play className="w-3.5 h-3.5 fill-[#112868] text-[#112868]" />
)}
{isRunning ? "Running..." : "Run Test"}
</button>
<ActionDropdown
actions={[
{
icon: <Eye className="w-3.5 h-3.5 shrink-0" />,
label: "View Test Results",
onClick: () => {
setSelectedTestCaseId(row.id);
setSelectedTestCaseName(row.name);
setIsResultsListModalOpen(true);
},
},
// {
// icon: <Eye className="w-3.5 h-3.5 shrink-0" />,
// label: "View Test Case",
// onClick: () => showToast.info("View Test Case details..."),
// },
// {
// icon: <Edit className="w-3.5 h-3.5 shrink-0" />,
// label: "Edit Test Case",
// onClick: () => showToast.info("Edit Test Case..."),
// },
// {
// icon: <Trash2 className="w-3.5 h-3.5 shrink-0" />,
// label: "Delete Test Case",
// variant: "danger",
// onClick: () => showToast.info("Delete Test Case..."),
// },
]}
/>
</div>
);
},
},
],
[prompt, testCases, runningCases]
);
return (
<Layout
currentPage="Prompt Management"
pageHeader={{
title: "Prompt Test Cases",
description: "Manage and execute test cases for your prompt templates.",
action: (
<PrimaryButton
onClick={() => navigate(`/tenant/ai/prompts/${promptId}/test-cases/create`)}
className="flex items-center gap-2 h-10 shadow-sm bg-[#112868] text-white hover:bg-[#112868]/90"
>
<Plus className="w-4 h-4" />
Create Test Cases
</PrimaryButton>
),
}}
>
<div className="flex flex-col gap-5">
{/* Back navigation link */}
<div className="flex items-center">
<button
onClick={() => navigate("/tenant/ai/prompts")}
className="flex items-center gap-1 text-xs text-[#64748b] hover:text-[#112868] transition-colors cursor-pointer font-medium select-none"
>
<ArrowLeft className="w-3.5 h-3.5" />
Back to Prompts
</button>
</div>
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm overflow-hidden select-none">
<DataTable
data={testCases}
columns={columns}
keyExtractor={(item) => item.id}
isLoading={isLoading}
error={error}
emptyMessage="No test cases found."
expandableRows={true}
isRowExpanded={(item) => !!expandedRows[item.id]}
onRowExpandToggle={(item) => toggleRowExpansion(item.id)}
showExpandColumn={true}
expandedColSpan={columns.length + 1}
renderExpandedRow={(item: any) => (
<div className="bg-slate-50/70 border border-slate-200/50 p-4 rounded-xl text-xs text-slate-800 select-all flex flex-col gap-4">
<div>
<span className="text-xs font-bold text-slate-600 uppercase tracking-wide mb-2 block select-none">
Input Variables
</span>
{item.input_variables && Object.keys(item.input_variables).length > 0 ? (
<div className="bg-white border border-slate-200/60 p-3 rounded-lg flex flex-col gap-2">
{Object.entries(item.input_variables).map(([key, value]) => (
<div key={key} className="flex flex-col gap-0.5">
<span className="text-xs font-bold text-slate-600">{key}:</span>
<span className="text-xs text-slate-700 font-mono bg-slate-50 p-1.5 rounded border border-slate-100/80 break-words whitespace-pre-wrap">
{String(value)}
</span>
</div>
))}
</div>
) : (
<span className="text-slate-400 italic">No input variables defined.</span>
)}
</div>
<div>
<span className="text-xs font-bold text-slate-600 uppercase tracking-wide mb-2 block select-none">
Expected Output
</span>
{item.expected_output ? (
<div className="bg-white border border-slate-200/60 p-3 rounded-lg text-slate-700 font-mono p-1.5 break-words whitespace-pre-wrap">
{item.expected_output}
</div>
) : (
<span className="text-slate-400 italic">No expected output defined.</span>
)}
</div>
</div>
)}
/>
</div>
</div>
<PromptTestCaseResultModal
isOpen={isResultModalOpen}
onClose={() => setIsResultModalOpen(false)}
result={selectedResult}
/>
<PromptTestCaseResultsListModal
isOpen={isResultsListModalOpen}
onClose={() => setIsResultsListModalOpen(false)}
testCaseId={selectedTestCaseId}
testCaseName={selectedTestCaseName}
/>
</Layout>
);
};
export default PromptTestCases;

View File

@ -30,19 +30,27 @@ const NotificationSettings = lazy(
() => import("@/pages/tenant/NotificationSettings"), () => import("@/pages/tenant/NotificationSettings"),
); );
const Notifications = lazy(() => import("@/pages/tenant/Notifications")); const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
const NotificationTemplates = lazy(() => import("@/pages/tenant/NotificationTemplates")); const NotificationTemplates = lazy(
() => import("@/pages/tenant/NotificationTemplates"),
);
const FilesList = lazy(() => import("@/pages/tenant/FilesList")); const FilesList = lazy(() => import("@/pages/tenant/FilesList"));
const FileView = lazy(() => import("@/pages/tenant/FileView")); const FileView = lazy(() => import("@/pages/tenant/FileView"));
const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard")); const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard"));
const SmtpSettings = lazy(() => import("@/pages/tenant/SmtpSettings")); const SmtpSettings = lazy(() => import("@/pages/tenant/SmtpSettings"));
const FailedEmails = lazy(() => import("@/pages/tenant/FailedEmails")); const FailedEmails = lazy(() => import("@/pages/tenant/FailedEmails"));
const AIGateway = lazy(() => import("@/pages/tenant/AIGateway")); const AIGateway = lazy(() => import("@/pages/tenant/AIGateway"));
const CompletionHistory = lazy(() => import("@/pages/tenant/CompletionHistory")); const CompletionHistory = lazy(
() => import("@/pages/tenant/CompletionHistory"),
);
const CompletionCreate = lazy(() => import("@/pages/tenant/CompletionCreate")); const CompletionCreate = lazy(() => import("@/pages/tenant/CompletionCreate"));
const CompletionDetail = lazy(() => import("@/pages/tenant/CompletionDetail")); 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 PromptTestCases = lazy(() => import("@/pages/tenant/PromptTestCases"));
const PromptTestCaseCreate = lazy(
() => import("@/pages/tenant/PromptTestCaseCreate"),
);
// Loading fallback component // Loading fallback component
const RouteLoader = (): ReactElement => ( const RouteLoader = (): ReactElement => (
@ -205,6 +213,18 @@ 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/test-cases",
element: <LazyRoute component={PromptTestCases} />,
},
{
path: "/tenant/ai/prompts/:id/test-cases/create",
element: <LazyRoute component={PromptTestCaseCreate} />,
},
{
path: "/tenant/ai/prompt-test-cases",
element: <LazyRoute component={PromptTestCases} />,
},
{ {
path: "/tenant/ai/knowledge", path: "/tenant/ai/knowledge",
element: <LazyRoute component={AIGateway} />, element: <LazyRoute component={AIGateway} />,

View File

@ -9,6 +9,7 @@ import type {
KnowledgeCollection, KnowledgeCollection,
KnowledgeSearchItem, KnowledgeSearchItem,
TenantAIConfig, TenantAIConfig,
AIPromptTestCase,
} from "@/types/ai"; } from "@/types/ai";
const unwrap = <T>(response: any): T => { const unwrap = <T>(response: any): T => {
@ -268,6 +269,34 @@ class AIService {
const response = await apiClient.post("/ai/knowledge/search/context", payload); const response = await apiClient.post("/ai/knowledge/search/context", payload);
return unwrap<{ context?: string; matches?: KnowledgeSearchItem[] }>(response); return unwrap<{ context?: string; matches?: KnowledgeSearchItem[] }>(response);
} }
async createPromptTestCase(promptId: string, payload: {
name: string;
description?: string;
variables?: Record<string, any>;
expected_output?: string;
expected_contains?: string;
max_tokens?: number;
tags?: string[];
}): Promise<any> {
const response = await apiClient.post(`/ai/prompts/${promptId}/test-cases`, payload);
return unwrap<any>(response);
}
async listPromptTestCases(promptId: string): Promise<AIPromptTestCase[]> {
const response = await apiClient.get(`/ai/prompts/${promptId}/test-cases`);
return unwrap<AIPromptTestCase[]>(response);
}
async runPromptTestCase(testCaseId: string): Promise<any> {
const response = await apiClient.post(`/ai/prompts/test-cases/${testCaseId}/run`, {});
return unwrap<any>(response);
}
async listPromptTestCaseResults(testCaseId: string, limit = 10): Promise<any[]> {
const response = await apiClient.get(`/ai/prompts/test-cases/${testCaseId}/results`, { params: { limit } });
return unwrap<any[]>(response);
}
} }
export const aiService = new AIService(); export const aiService = new AIService();

View File

@ -163,3 +163,18 @@ export interface AICostSummary {
cost: number; cost: number;
}>; }>;
} }
export interface AIPromptTestCase {
id: string;
prompt_id: string;
tenant_id: string;
name: string;
description?: string;
input_variables?: Record<string, any>;
expected_output?: string;
tags?: string[];
created_by?: string;
created_at?: string;
updated_at?: string;
}