feat: add prompt test case management and integrate AI provider model resolution logic
This commit is contained in:
parent
178b8f9046
commit
816208fd9c
@ -9,7 +9,7 @@ interface LayoutProps {
|
||||
currentPage: string;
|
||||
breadcrumbs?: Array<{ label: string; path?: string }>;
|
||||
pageHeader?: {
|
||||
title: string;
|
||||
title: React.ReactNode;
|
||||
description?: string;
|
||||
tabs?: TabItem[];
|
||||
action?: React.ReactNode;
|
||||
|
||||
66
src/components/shared/FormTagInput.tsx
Normal file
66
src/components/shared/FormTagInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
96
src/components/shared/MarkdownViewer.tsx
Normal file
96
src/components/shared/MarkdownViewer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -10,7 +10,7 @@ export interface TabItem {
|
||||
}
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
title: React.ReactNode;
|
||||
description?: string;
|
||||
tabs?: TabItem[];
|
||||
action?: React.ReactNode;
|
||||
|
||||
@ -41,3 +41,5 @@ export type { FileUploadModalProps } from './FileUploadModal';
|
||||
export { FileShareModal } from './FileShareModal';
|
||||
export { ActiveOnlyToggle } from './ActiveOnlyToggle';
|
||||
export { SearchBox } from './SearchBox';
|
||||
export { FormTagInput } from './FormTagInput';
|
||||
export { MarkdownViewer } from './MarkdownViewer';
|
||||
|
||||
117
src/components/tenant/PromptTestCaseResultModal.tsx
Normal file
117
src/components/tenant/PromptTestCaseResultModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
151
src/components/tenant/PromptTestCaseResultsListModal.tsx
Normal file
151
src/components/tenant/PromptTestCaseResultsListModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -20,8 +20,7 @@ const playgroundSchema = z.object({
|
||||
type PlaygroundFormData = z.infer<typeof playgroundSchema>;
|
||||
|
||||
const CompletionCreate = (): ReactElement => {
|
||||
const [providers, setProviders] = useState<AIProviderInfo[]>([]);
|
||||
const [models, setModels] = useState<Array<{ id: string; provider: string; isDefault?: boolean }>>([]);
|
||||
const [apiProviders, setApiProviders] = useState<AIProviderInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isSending, setIsSending] = useState<boolean>(false);
|
||||
const [isPlaygroundMode, setIsPlaygroundMode] = useState<boolean>(true);
|
||||
@ -43,8 +42,6 @@ const CompletionCreate = (): ReactElement => {
|
||||
},
|
||||
});
|
||||
|
||||
const formValues = watch();
|
||||
|
||||
const [lastSentUserMessage, setLastSentUserMessage] = useState<string>("");
|
||||
const [displayedResponse, setDisplayedResponse] = useState<string>("");
|
||||
const typingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@ -61,30 +58,41 @@ const CompletionCreate = (): ReactElement => {
|
||||
fallbackUsed: false,
|
||||
});
|
||||
|
||||
const selectedProvider = watch("provider");
|
||||
|
||||
const providerOptions = useMemo(
|
||||
() => providers.map((p) => ({ value: p.name, label: p.displayName || p.name })),
|
||||
[providers],
|
||||
() => apiProviders.map((p) => ({ value: p.name, label: p.displayName || p.name })),
|
||||
[apiProviders],
|
||||
);
|
||||
|
||||
const providerDetail = apiProviders.find((p) => p.name === selectedProvider);
|
||||
const modelOptions = useMemo(
|
||||
() =>
|
||||
models
|
||||
.filter((m) => !formValues.provider || m.provider === formValues.provider)
|
||||
.map((m) => ({
|
||||
value: m.id,
|
||||
label: `${m.id}${m.isDefault ? " • default" : ""}`,
|
||||
(providerDetail?.models || []).map((m) => ({
|
||||
value: m,
|
||||
label: `${m}${m === providerDetail?.defaultModel ? " • default" : ""}`,
|
||||
})),
|
||||
[models, formValues.provider],
|
||||
[providerDetail],
|
||||
);
|
||||
|
||||
const loadOptions = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [providerData, modelData] = await Promise.all([aiService.getProviders(), aiService.getModels()]);
|
||||
setProviders(providerData);
|
||||
setModels(modelData);
|
||||
const providerData = await aiService.getProviders();
|
||||
setApiProviders(providerData);
|
||||
|
||||
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) {
|
||||
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 {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -310,7 +318,14 @@ const CompletionCreate = (): ReactElement => {
|
||||
options={providerOptions}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
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", "");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
FormTextArea,
|
||||
PrimaryButton,
|
||||
RichTextEditor,
|
||||
FormTagInput,
|
||||
} from "@/components/shared";
|
||||
import {
|
||||
documentService,
|
||||
@ -17,7 +18,7 @@ import {
|
||||
} from "@/services/document-service";
|
||||
import type { DocumentCategory } from "@/types/document";
|
||||
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 type { MyModule } from "@/types/module";
|
||||
|
||||
@ -292,58 +293,18 @@ const CreateDocument = (): ReactElement => {
|
||||
error={errors.department?.message}
|
||||
{...register("department")}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 pb-1">
|
||||
<label className="text-[13px] font-medium text-[#0e1b2a]">
|
||||
Tags (Press enter to add)
|
||||
</label>
|
||||
<Controller
|
||||
name="tags"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<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">
|
||||
{(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 = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
<FormTagInput
|
||||
label="Tags (Press enter to add)"
|
||||
value={field.value || []}
|
||||
onChange={field.onChange}
|
||||
error={errors.tags?.message}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.tags && (
|
||||
<p className="text-xs text-[#ef4444]">{errors.tags.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<Controller
|
||||
name="selectedModuleId"
|
||||
control={control}
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
FormSlider,
|
||||
FormTagInput,
|
||||
} from "@/components/shared";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
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 { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AIProviderInfo } from "@/types/ai";
|
||||
|
||||
const variableSchema = z.object({
|
||||
name: z.string().min(1, "Variable name is required").max(100),
|
||||
@ -34,7 +36,7 @@ const promptSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
temperature: z.number().min(0).max(2),
|
||||
max_tokens: z.number().int().min(1).max(128000),
|
||||
tags: z.string().optional(),
|
||||
tags: z.array(z.string()),
|
||||
is_default: z.boolean(),
|
||||
variables: z.array(variableSchema),
|
||||
});
|
||||
@ -44,16 +46,13 @@ type PromptFormData = z.infer<typeof promptSchema>;
|
||||
const PromptCreate = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [providers, setProviders] = useState<
|
||||
Array<{ value: string; label: string }>
|
||||
>([]);
|
||||
const [models, setModels] = useState<Array<{ value: string; label: string }>>(
|
||||
[],
|
||||
);
|
||||
const [apiProviders, setApiProviders] = useState<AIProviderInfo[]>([]);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<PromptFormData>({
|
||||
resolver: zodResolver(promptSchema),
|
||||
@ -67,7 +66,7 @@ const PromptCreate = (): ReactElement => {
|
||||
model: "",
|
||||
temperature: 0.7,
|
||||
max_tokens: 2048,
|
||||
tags: "",
|
||||
tags: [],
|
||||
is_default: true,
|
||||
variables: [
|
||||
{
|
||||
@ -85,25 +84,24 @@ const PromptCreate = (): ReactElement => {
|
||||
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(() => {
|
||||
const loadMeta = async (): Promise<void> => {
|
||||
try {
|
||||
const [providerData, modelData] = await Promise.all([
|
||||
aiService.getProviders(),
|
||||
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})`,
|
||||
})),
|
||||
);
|
||||
const providerData = await aiService.getProviders();
|
||||
setApiProviders(providerData);
|
||||
} catch (err: unknown) {
|
||||
showToast.error(
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
@ -116,12 +114,7 @@ const PromptCreate = (): ReactElement => {
|
||||
}, []);
|
||||
|
||||
const onFormSubmit = async (data: PromptFormData): Promise<void> => {
|
||||
const parsedTags = data.tags
|
||||
? data.tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const parsedTags = data.tags || [];
|
||||
|
||||
const sanitizedVariables = data.variables
|
||||
?.filter((v) => v.name.trim())
|
||||
@ -168,8 +161,15 @@ const PromptCreate = (): ReactElement => {
|
||||
<Layout
|
||||
currentPage="Create Prompt"
|
||||
pageHeader={{
|
||||
title: "Create Prompt",
|
||||
description: "Create a reusable prompt template for AI workflows.",
|
||||
title: (
|
||||
<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: (
|
||||
<div className="flex gap-2">
|
||||
<SecondaryButton onClick={() => navigate("/tenant/ai/prompts")}>
|
||||
@ -440,7 +440,9 @@ const PromptCreate = (): ReactElement => {
|
||||
/>
|
||||
|
||||
<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">
|
||||
<Controller
|
||||
name="is_default"
|
||||
@ -456,13 +458,17 @@ const PromptCreate = (): ReactElement => {
|
||||
<div
|
||||
className={cn(
|
||||
"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>
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
@ -480,8 +486,18 @@ const PromptCreate = (): ReactElement => {
|
||||
<FormSelect
|
||||
label="Provider"
|
||||
value={field.value || ""}
|
||||
onValueChange={field.onChange}
|
||||
options={providers}
|
||||
onValueChange={(val) => {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
@ -495,7 +511,7 @@ const PromptCreate = (): ReactElement => {
|
||||
label="Model"
|
||||
value={field.value || ""}
|
||||
onValueChange={field.onChange}
|
||||
options={models}
|
||||
options={modelsOptions}
|
||||
error={errors.model?.message}
|
||||
/>
|
||||
)}
|
||||
@ -543,11 +559,10 @@ const PromptCreate = (): ReactElement => {
|
||||
name="tags"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormField
|
||||
{...field}
|
||||
label="Tags"
|
||||
placeholder="e.g. analysis, legal, code"
|
||||
helperText="Comma separated values"
|
||||
<FormTagInput
|
||||
label="Tags (Press enter to add)"
|
||||
value={field.value || []}
|
||||
onChange={field.onChange}
|
||||
error={errors.tags?.message}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
FormSlider,
|
||||
FormTagInput
|
||||
} from "@/components/shared";
|
||||
import { Plus, Trash2, ArrowLeft } from "lucide-react";
|
||||
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 { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AIProviderInfo } from "@/types/ai";
|
||||
|
||||
const variableSchema = z.object({
|
||||
name: z.string().min(1, "Variable name is required").max(100),
|
||||
@ -34,7 +36,7 @@ const promptSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
temperature: z.number().min(0).max(2),
|
||||
max_tokens: z.number().int().min(1).max(128000),
|
||||
tags: z.string().optional(),
|
||||
tags: z.array(z.string()),
|
||||
is_default: z.boolean(),
|
||||
variables: z.array(variableSchema),
|
||||
change_notes: z.string().optional(),
|
||||
@ -47,17 +49,14 @@ const PromptEdit = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [providers, setProviders] = useState<
|
||||
Array<{ value: string; label: string }>
|
||||
>([]);
|
||||
const [models, setModels] = useState<Array<{ value: string; label: string }>>(
|
||||
[],
|
||||
);
|
||||
const [apiProviders, setApiProviders] = useState<AIProviderInfo[]>([]);
|
||||
const [currentVersion, setCurrentVersion] = useState<number>(1);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<PromptFormData>({
|
||||
@ -72,7 +71,7 @@ const PromptEdit = (): ReactElement => {
|
||||
model: "",
|
||||
temperature: 0.7,
|
||||
max_tokens: 2048,
|
||||
tags: "",
|
||||
tags: [],
|
||||
is_default: false,
|
||||
variables: [],
|
||||
change_notes: "",
|
||||
@ -84,29 +83,30 @@ const PromptEdit = (): ReactElement => {
|
||||
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(() => {
|
||||
const loadData = async (): Promise<void> => {
|
||||
if (!id) return;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const [providerData, modelData, promptData] = await Promise.all([
|
||||
const [providerData, promptData] = await Promise.all([
|
||||
aiService.getProviders(),
|
||||
aiService.getModels(),
|
||||
aiService.getPrompt(id),
|
||||
]);
|
||||
|
||||
setProviders(
|
||||
providerData.map((p) => ({
|
||||
value: p.name,
|
||||
label: p.displayName || p.name,
|
||||
})),
|
||||
);
|
||||
setModels(
|
||||
modelData.map((m) => ({
|
||||
value: m.id,
|
||||
label: `${m.id} (${m.provider})`,
|
||||
})),
|
||||
);
|
||||
setApiProviders(providerData);
|
||||
|
||||
reset({
|
||||
name: promptData.name,
|
||||
@ -118,7 +118,7 @@ const PromptEdit = (): ReactElement => {
|
||||
model: promptData.defaultParameters?.model || "",
|
||||
temperature: promptData.defaultParameters?.temperature ?? 0.7,
|
||||
max_tokens: promptData.defaultParameters?.max_tokens ?? 2048,
|
||||
tags: (promptData.tags || []).join(", "),
|
||||
tags: promptData.tags || [],
|
||||
is_default: promptData.isDefault || false,
|
||||
variables: (promptData.variables || []).map((v) => ({
|
||||
name: v.name,
|
||||
@ -145,12 +145,7 @@ const PromptEdit = (): ReactElement => {
|
||||
|
||||
const onFormSubmit = async (data: PromptFormData): Promise<void> => {
|
||||
if (!id) return;
|
||||
const parsedTags = data.tags
|
||||
? data.tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const parsedTags = data.tags || [];
|
||||
|
||||
const sanitizedVariables = data.variables
|
||||
?.filter((v) => v.name.trim())
|
||||
@ -543,8 +538,18 @@ const PromptEdit = (): ReactElement => {
|
||||
<FormSelect
|
||||
label="Provider"
|
||||
value={field.value || ""}
|
||||
onValueChange={field.onChange}
|
||||
options={providers}
|
||||
onValueChange={(val) => {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
@ -558,7 +563,7 @@ const PromptEdit = (): ReactElement => {
|
||||
label="Model"
|
||||
value={field.value || ""}
|
||||
onValueChange={field.onChange}
|
||||
options={models}
|
||||
options={modelsOptions}
|
||||
error={errors.model?.message}
|
||||
/>
|
||||
)}
|
||||
@ -606,11 +611,10 @@ const PromptEdit = (): ReactElement => {
|
||||
name="tags"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormField
|
||||
{...field}
|
||||
label="Tags"
|
||||
placeholder="e.g. analysis, legal, code"
|
||||
helperText="Comma separated values"
|
||||
<FormTagInput
|
||||
label="Tags (Press enter to add)"
|
||||
value={field.value || []}
|
||||
onChange={field.onChange}
|
||||
error={errors.tags?.message}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState, type ReactElement } from "react";
|
||||
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 {
|
||||
DataTable,
|
||||
@ -224,6 +224,11 @@ const PromptManagement = (): ReactElement => {
|
||||
<div className="flex justify-end pr-2">
|
||||
<ActionDropdown
|
||||
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" />,
|
||||
label: "Edit Prompt",
|
||||
|
||||
342
src/pages/tenant/PromptTestCaseCreate.tsx
Normal file
342
src/pages/tenant/PromptTestCaseCreate.tsx
Normal 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;
|
||||
326
src/pages/tenant/PromptTestCases.tsx
Normal file
326
src/pages/tenant/PromptTestCases.tsx
Normal 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;
|
||||
@ -30,19 +30,27 @@ const NotificationSettings = lazy(
|
||||
() => import("@/pages/tenant/NotificationSettings"),
|
||||
);
|
||||
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 FileView = lazy(() => import("@/pages/tenant/FileView"));
|
||||
const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard"));
|
||||
const SmtpSettings = lazy(() => import("@/pages/tenant/SmtpSettings"));
|
||||
const FailedEmails = lazy(() => import("@/pages/tenant/FailedEmails"));
|
||||
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 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 PromptTestCases = lazy(() => import("@/pages/tenant/PromptTestCases"));
|
||||
const PromptTestCaseCreate = lazy(
|
||||
() => import("@/pages/tenant/PromptTestCaseCreate"),
|
||||
);
|
||||
|
||||
// Loading fallback component
|
||||
const RouteLoader = (): ReactElement => (
|
||||
@ -205,6 +213,18 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
||||
path: "/tenant/ai/prompts/:id/edit",
|
||||
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",
|
||||
element: <LazyRoute component={AIGateway} />,
|
||||
|
||||
@ -9,6 +9,7 @@ import type {
|
||||
KnowledgeCollection,
|
||||
KnowledgeSearchItem,
|
||||
TenantAIConfig,
|
||||
AIPromptTestCase,
|
||||
} from "@/types/ai";
|
||||
|
||||
const unwrap = <T>(response: any): T => {
|
||||
@ -268,6 +269,34 @@ class AIService {
|
||||
const response = await apiClient.post("/ai/knowledge/search/context", payload);
|
||||
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();
|
||||
|
||||
@ -163,3 +163,18 @@ export interface AICostSummary {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user