feat: implement prompt management features including editing, version history, cloning, status toggling, and deletion.

This commit is contained in:
Yashwin 2026-04-22 19:43:23 +05:30
parent bcd029950f
commit 7dc818ab71
13 changed files with 1734 additions and 337 deletions

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, type ReactNode } from 'react';
import type { ReactElement, InputHTMLAttributes } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { cn } from '@/lib/utils';
@ -7,7 +7,7 @@ interface FormFieldProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
required?: boolean;
error?: string;
helperText?: string;
helperText?: ReactNode;
}
export const FormField = ({

View File

@ -0,0 +1,102 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface FormSliderProps {
label: string;
min: number;
max: number;
step?: number;
value: number;
onChange: (value: number) => void;
helperText?: string;
error?: string;
className?: string;
}
export const FormSlider: React.FC<FormSliderProps> = ({
label,
min,
max,
step = 0.1,
value,
onChange,
helperText,
error,
className,
}) => {
// Calculate percentage for gradient track
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className={cn("space-y-2 pb-4", className)}>
<div className="flex items-center justify-between mb-1">
<label className="text-[13px] font-semibold text-[#0e1b2a]">
{label}
</label>
<div className="px-1.5 py-0.5 border border-gray-200 rounded text-[12px] font-medium text-gray-700 bg-white min-w-[36px] text-center shadow-sm">
{value}
</div>
</div>
<div className="relative h-6 flex flex-col justify-center">
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="form-range-slider w-full cursor-pointer appearance-none bg-transparent"
style={{
background: `linear-gradient(to right, #00cfd5 0%, #00cfd5 ${percentage}%, #eff6ff ${percentage}%, #eff6ff 100%)`,
height: '4px',
borderRadius: '4px',
}}
/>
<div className="flex justify-between mt-2 px-0.5">
<span className="text-[10px] font-medium text-gray-400">{min}</span>
<span className="text-[10px] font-medium text-gray-400">{max}</span>
</div>
</div>
{helperText && !error && (
<p className="text-[11px] text-gray-400 leading-tight mt-1">
{helperText}
</p>
)}
{error && (
<p className="text-[11px] text-red-500 leading-tight mt-1 font-medium">
{error}
</p>
)}
<style dangerouslySetInnerHTML={{ __html: `
.form-range-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: #0f172a;
border-radius: 50%;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
transition: transform 0.1s ease;
margin-top: 0px;
}
.form-range-slider::-moz-range-thumb {
width: 16px;
height: 16px;
background: #0f172a;
border-radius: 50%;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.form-range-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
`}} />
</div>
);
};

View File

@ -1,11 +1,11 @@
import type { ReactElement, TextareaHTMLAttributes } from 'react';
import type { ReactElement, TextareaHTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface FormTextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label: string;
required?: boolean;
error?: string;
helperText?: string;
helperText?: ReactNode;
}
export const FormTextArea = ({

View File

@ -34,6 +34,7 @@ export { ViewSupplierModal } from './ViewSupplierModal';
export { SupplierContactsModal } from './SupplierContactsModal';
export { SupplierScorecardsModal } from './SupplierScorecardsModal';
export { FormTextArea } from './FormTextArea';
export { FormSlider } from './FormSlider';
export { RichTextEditor } from './RichTextEditor';
export { FileUploadModal } from './FileUploadModal';
export type { FileUploadModalProps } from './FileUploadModal';

View File

@ -0,0 +1,136 @@
import { useEffect, useState } from "react";
import { Modal, DataTable, type Column, PrimaryButton, StatusBadge } from "@/components/shared";
import { aiService } from "@/services/ai-service";
import type { AIPrompt } from "@/types/ai";
import { showToast } from "@/utils/toast";
import { formatDate } from "@/utils/format-date";
import { RotateCcw } from "lucide-react";
interface PromptVersionsModalProps {
isOpen: boolean;
onClose: () => void;
prompt: AIPrompt | null;
onRollbackSuccess: () => void;
}
export const PromptVersionsModal = ({
isOpen,
onClose,
prompt,
onRollbackSuccess,
}: PromptVersionsModalProps) => {
const [versions, setVersions] = useState<AIPrompt[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isRollingBack, setIsRollingBack] = useState(false);
const loadVersions = async () => {
if (!prompt) return;
setIsLoading(true);
try {
const data = await aiService.getVersions(prompt.id);
setVersions(data);
} catch (err: any) {
showToast.error("Failed to load versions");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (isOpen && prompt) {
void loadVersions();
}
}, [isOpen, prompt]);
const handleRollback = async (version: number) => {
if (!prompt) return;
setIsRollingBack(true);
try {
await aiService.rollback(prompt.id, version);
showToast.success(`Successfully rolled back to version ${version}`);
onRollbackSuccess();
onClose();
} catch (err: any) {
showToast.error("Failed to rollback version");
} finally {
setIsRollingBack(false);
}
};
const columns: Column<AIPrompt>[] = [
{
key: "version",
label: "Version",
render: (row) => <span className="font-semibold text-gray-900">v{row.version}</span>,
},
{
key: "status",
label: "Status",
render: (row) => (
<StatusBadge variant={row.status === "active" ? "success" : "process"}>
{row.status || "draft"}
</StatusBadge>
),
},
{
key: "change_notes",
label: "Change Notes",
render: (row) => <span className="text-xs text-gray-500">{row.change_notes}</span>,
},
// {
// key: "created_by_email",
// label: "Created By",
// render: (row) => <span className="text-xs text-gray-500">{row.created_by_email}</span>,
// },
{
key: "updated_at",
label: "Created At",
render: (row) => (
<span className="text-xs text-gray-500">{formatDate(row.updated_at || row.created_at || "")}</span>
),
},
{
key: "actions",
label: "Actions",
align: "right",
render: (row) => (
<div className="flex justify-end gap-2">
{row.version !== prompt?.version && (
<PrimaryButton
size="small"
className="h-8 text-[11px] bg-[#112868] hover:bg-[#0a1b4a]"
onClick={() => handleRollback(row.version!)}
disabled={isRollingBack}
>
<RotateCcw className="w-3 h-3 mr-1" />
Rollback
</PrimaryButton>
)}
{row.version === prompt?.version && (
<span className="text-[11px] font-medium text-gray-400 py-1 px-2 border border-gray-100 rounded bg-gray-50">Current</span>
)}
</div>
),
},
];
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Version History - ${prompt?.name}`}
description="View previous versions of this prompt and rollback if needed."
maxWidth="lg"
>
<div className="border-t border-gray-100">
<DataTable
data={versions}
columns={columns}
keyExtractor={(item) => item.version?.toString() || item.id}
isLoading={isLoading}
emptyMessage="No version history available"
/>
</div>
</Modal>
);
};

View File

@ -452,9 +452,9 @@ const AIGateway = (): ReactElement => {
const promptColumns: Column<AIPrompt>[] = [
{ key: "name", label: "Prompt Name" },
{ key: "use_case", label: "Use Case" },
{ key: "provider", label: "Provider", render: (row) => row.provider || "-" },
{ key: "model", label: "Model", render: (row) => row.model || "-" },
{ key: "useCase", label: "Use Case" },
{ key: "provider", label: "Provider", render: (row) => row.defaultParameters?.provider || "-" },
{ key: "model", label: "Model", render: (row) => row.defaultParameters?.model || "-" },
{
key: "status",
label: "Status",

View File

@ -1,10 +1,23 @@
import { useEffect, useMemo, useRef, useState, type ReactElement } from "react";
import { Layout } from "@/components/layout/Layout";
import { FormField, FormSelect, PrimaryButton, SecondaryButton, StatusBadge } from "@/components/shared";
import { FormSelect, PrimaryButton, SecondaryButton, StatusBadge, FormSlider } from "@/components/shared";
import { aiService } from "@/services/ai-service";
import type { AIProviderInfo } from "@/types/ai";
import { showToast } from "@/utils/toast";
import { Bot, Send, User } from "lucide-react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
const playgroundSchema = z.object({
user: z.string().min(1, "Message is required"),
provider: z.string().optional(),
model: z.string().optional(),
temperature: z.number().min(0).max(2),
max_tokens: z.number().int().min(1).max(128000),
});
type PlaygroundFormData = z.infer<typeof playgroundSchema>;
const CompletionCreate = (): ReactElement => {
const [providers, setProviders] = useState<AIProviderInfo[]>([]);
@ -13,13 +26,25 @@ const CompletionCreate = (): ReactElement => {
const [isSending, setIsSending] = useState<boolean>(false);
const [isPlaygroundMode, setIsPlaygroundMode] = useState<boolean>(true);
const [form, setForm] = useState({
user: "",
provider: "gemini",
model: "",
temperature: "0.7",
max_tokens: "1024",
const {
control,
handleSubmit,
setValue,
watch,
formState: { errors }
} = useForm<PlaygroundFormData>({
resolver: zodResolver(playgroundSchema),
defaultValues: {
user: "",
provider: "gemini",
model: "",
temperature: 0.7,
max_tokens: 1024,
},
});
const formValues = watch();
const [lastSentUserMessage, setLastSentUserMessage] = useState<string>("");
const [displayedResponse, setDisplayedResponse] = useState<string>("");
const typingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@ -44,12 +69,12 @@ const CompletionCreate = (): ReactElement => {
const modelOptions = useMemo(
() =>
models
.filter((m) => !form.provider || m.provider === form.provider)
.filter((m) => !formValues.provider || m.provider === formValues.provider)
.map((m) => ({
value: m.id,
label: `${m.id}${m.isDefault ? " • default" : ""}`,
})),
[models, form.provider],
[models, formValues.provider],
);
const loadOptions = async (): Promise<void> => {
@ -77,13 +102,8 @@ const CompletionCreate = (): ReactElement => {
};
}, []);
const handleSend = async (): Promise<void> => {
const userMessage = form.user.trim();
if (!userMessage) {
showToast.error("Please enter a message before sending");
return;
}
const onSend = async (data: PlaygroundFormData): Promise<void> => {
const userMessage = data.user.trim();
if (typingIntervalRef.current) {
clearInterval(typingIntervalRef.current);
typingIntervalRef.current = null;
@ -96,17 +116,17 @@ const CompletionCreate = (): ReactElement => {
const result = isPlaygroundMode
? await aiService.playground({
messages: [{ role: "user", content: userMessage }],
provider: form.provider || undefined,
model: form.model || undefined,
temperature: Number(form.temperature),
max_tokens: Number(form.max_tokens),
provider: data.provider || undefined,
model: data.model || undefined,
temperature: data.temperature,
max_tokens: data.max_tokens,
})
: await aiService.createCompletion({
messages: [{ role: "user", content: userMessage }],
provider: form.provider || undefined,
model: form.model || undefined,
temperature: Number(form.temperature),
max_tokens: Number(form.max_tokens),
provider: data.provider || undefined,
model: data.model || undefined,
temperature: data.temperature,
max_tokens: data.max_tokens,
});
setResponseData({
@ -139,6 +159,7 @@ const CompletionCreate = (): ReactElement => {
showToast.success(
isPlaygroundMode ? "Playground response received" : "Completion created and saved to history",
);
setValue("user", "");
} catch (err: any) {
showToast.error(
err?.response?.data?.error?.message ||
@ -225,14 +246,25 @@ const CompletionCreate = (): ReactElement => {
<div className="p-2.5 md:p-3 border-t border-[rgba(0,0,0,0.08)] bg-white">
<div className="flex gap-2 items-center">
<input
value={form.user}
onChange={(e) => setForm((prev) => ({ ...prev, user: e.target.value }))}
placeholder="Type your message here..."
className="flex-1 h-10 md:h-11 px-3 rounded-lg border border-[rgba(0,0,0,0.12)] text-sm text-[#0f1724] placeholder:text-[#94a3b8] focus:outline-none focus:ring-2 focus:ring-[#112868]/20"
<Controller
name="user"
control={control}
render={({ field }) => (
<input
{...field}
placeholder="Type your message here..."
className="flex-1 h-10 md:h-11 px-3 rounded-lg border border-[rgba(0,0,0,0.12)] text-sm text-[#0f1724] placeholder:text-[#94a3b8] focus:outline-none focus:ring-2 focus:ring-[#112868]/20"
onKeyPress={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSubmit(onSend)();
}
}}
/>
)}
/>
<PrimaryButton
onClick={handleSend}
onClick={handleSubmit(onSend)}
disabled={isSending || isLoading}
className="h-10 md:h-11 px-3 md:px-4 min-w-[84px] md:min-w-[96px] shrink-0"
>
@ -240,6 +272,7 @@ const CompletionCreate = (): ReactElement => {
<span className="hidden sm:inline">{isSending ? "Sending..." : "Send"}</span>
</PrimaryButton>
</div>
{errors.user && <p className="text-[10px] text-red-500 mt-1">{errors.user.message}</p>}
</div>
</section>
@ -267,41 +300,66 @@ const CompletionCreate = (): ReactElement => {
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">Model Configuration</h3>
<FormSelect
label="Provider"
value={form.provider}
options={providerOptions}
onValueChange={(value) => setForm((prev) => ({ ...prev, provider: value, model: "" }))}
<Controller
name="provider"
control={control}
render={({ field }) => (
<FormSelect
{...field}
label="Provider"
options={providerOptions}
onValueChange={(value) => {
field.onChange(value);
setValue("model", "");
}}
/>
)}
/>
<FormSelect
label="Model"
value={form.model}
options={modelOptions}
placeholder="Provider default"
onValueChange={(value) => setForm((prev) => ({ ...prev, model: value }))}
<Controller
name="model"
control={control}
render={({ field }) => (
<FormSelect
{...field}
label="Model"
options={modelOptions}
placeholder="Provider default"
onValueChange={field.onChange}
/>
)}
/>
{!isPlaygroundMode && (
<FormField
label="Temperature"
type="number"
value={form.temperature}
onChange={(e) => setForm((prev) => ({ ...prev, temperature: e.target.value }))}
/>
)}
<input
type="range"
min="0"
max="2"
step="0.1"
value={Number(form.temperature)}
onChange={(e) => setForm((prev) => ({ ...prev, temperature: e.target.value }))}
className={`w-full ${isPlaygroundMode ? "mb-3 mt-0" : "-mt-2 mb-3"}`}
<Controller
name="temperature"
control={control}
render={({ field }) => (
<FormSlider
{...field}
label="Temperature"
min={0}
max={2}
step={0.1}
helperText="Lower values make output more deterministic"
error={errors.temperature?.message}
/>
)}
/>
<FormField
label="Max Tokens"
type="number"
value={form.max_tokens}
onChange={(e) => setForm((prev) => ({ ...prev, max_tokens: e.target.value }))}
<Controller
name="max_tokens"
control={control}
render={({ field }) => (
<FormSlider
{...field}
label="Max Tokens"
min={1}
max={5000}
step={1}
helperText="Maximum response token budget for generated output."
error={errors.max_tokens?.message}
/>
)}
/>
<div className="mt-2 mb-4 flex gap-2">

View File

@ -1,50 +1,113 @@
import { useEffect, useMemo, useState, type ReactElement } from "react";
import { useEffect, useState, type ReactElement } from "react";
import { useNavigate } from "react-router-dom";
import { Layout } from "@/components/layout/Layout";
import { FormField, FormSelect, FormTextArea, PrimaryButton, SecondaryButton } from "@/components/shared";
import {
FormField,
FormSelect,
FormTextArea,
PrimaryButton,
SecondaryButton,
FormSlider,
} from "@/components/shared";
import { Plus, Trash2 } 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";
import { cn } from "@/lib/utils";
type PromptVariable = {
name: string;
type: "string" | "number" | "boolean" | "array";
required: boolean;
default: string;
};
const variableSchema = z.object({
name: z.string().min(1, "Variable name is required").max(100),
type: z.enum(["string", "number", "boolean", "array"]),
required: z.boolean(),
default: z.string().optional(),
});
const promptSchema = z.object({
name: z.string().min(1, "Name is required").max(255),
description: z.string().optional(),
use_case: z.string().min(1, "Use case is required").max(100),
system_message: z.string().optional(),
user_template: z.string().min(1, "User template is required").max(50000),
provider: z.string().optional(),
model: z.string().optional(),
temperature: z.number().min(0).max(2),
max_tokens: z.number().int().min(1).max(128000),
tags: z.string().optional(),
is_default: z.boolean(),
variables: z.array(variableSchema),
});
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 [providers, setProviders] = useState<
Array<{ value: string; label: string }>
>([]);
const [models, setModels] = useState<Array<{ value: string; label: string }>>(
[],
);
const [form, setForm] = useState({
name: "",
description: "",
use_case: "",
system_message: "",
user_template: "",
provider: "",
model: "",
temperature: "0.3",
max_tokens: "2048",
tags: "",
is_default: false,
const {
control,
handleSubmit,
formState: { errors },
} = useForm<PromptFormData>({
resolver: zodResolver(promptSchema),
defaultValues: {
name: "",
description: "",
use_case: "",
system_message: "",
user_template: "",
provider: "",
model: "",
temperature: 0.7,
max_tokens: 2048,
tags: "",
is_default: true,
variables: [
{
name: "",
type: "string",
required: false,
default: "",
},
],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "variables",
});
const [variables, setVariables] = useState<PromptVariable[]>([
{ name: "focus_areas", type: "string", required: false, default: "regulatory obligations, deadlines, action items" },
]);
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, 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})`,
})),
);
} catch (err: unknown) {
showToast.error(
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message ||
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ||
"Failed to load provider metadata",
);
}
@ -52,236 +115,446 @@ const PromptCreate = (): ReactElement => {
void loadMeta();
}, []);
const parsedTags = useMemo(
() =>
form.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean),
[form.tags],
);
const onFormSubmit = async (data: PromptFormData): Promise<void> => {
const parsedTags = data.tags
? data.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
: [];
const addVariable = (): void => {
setVariables((prev) => [...prev, { name: "", type: "string", required: false, default: "" }]);
};
const updateVariable = (index: number, patch: Partial<PromptVariable>): void => {
setVariables((prev) => prev.map((item, idx) => (idx === index ? { ...item, ...patch } : item)));
};
const removeVariable = (index: number): void => {
setVariables((prev) => prev.filter((_, idx) => idx !== index));
};
const handleSubmit = async (): Promise<void> => {
if (!form.name.trim() || !form.use_case.trim() || !form.user_template.trim()) {
showToast.error("Name, use case, and user template are required");
return;
}
const sanitizedVariables = variables
.filter((v) => v.name.trim())
const sanitizedVariables = data.variables
?.filter((v) => v.name.trim())
.map((v) => ({
name: v.name.trim(),
type: v.type,
required: v.required,
...(v.default.trim() ? { default: v.default.trim() } : {}),
...(v.default?.trim() ? { default: v.default.trim() } : {}),
}));
setIsSubmitting(true);
try {
await aiService.createPrompt({
name: form.name.trim(),
description: form.description.trim() || undefined,
use_case: form.use_case.trim(),
system_message: form.system_message.trim() || undefined,
user_template: form.user_template,
model: form.model || undefined,
provider: form.provider || undefined,
temperature: Number(form.temperature),
max_tokens: Number(form.max_tokens),
name: data.name.trim(),
description: data.description?.trim() || undefined,
use_case: data.use_case.trim(),
system_message: data.system_message?.trim() || undefined,
user_template: data.user_template,
model: data.model || undefined,
provider: data.provider || undefined,
temperature: data.temperature,
max_tokens: data.max_tokens,
variables: sanitizedVariables,
tags: parsedTags,
is_default: form.is_default,
is_default: data.is_default,
});
showToast.success("Prompt created successfully");
navigate("/tenant/ai/prompts");
} catch (err: unknown) {
showToast.error(
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message ||
"Failed to create prompt",
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || "Failed to create prompt",
);
} finally {
setIsSubmitting(false);
}
};
const handleCreatePrompt = () => {
handleSubmit(onFormSubmit)();
};
return (
<Layout
currentPage="Create Prompt"
pageHeader={{
title: "Create Prompt",
description: "Create a reusable prompt template for AI workflows.",
}}
>
<div className="max-w-5xl mx-auto bg-white border border-[rgba(0,0,0,0.08)] rounded-lg">
<div className="p-5 md:p-6 space-y-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
label="Template Name"
required
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="e.g., Code Review Assitant"
helperText="Max 255 characters. Must be unique per tenant and version"
/>
<FormField
label="Use Case"
required
value={form.use_case}
onChange={(e) => setForm((prev) => ({ ...prev, use_case: e.target.value }))}
placeholder="doc_summary"
/>
</div>
<FormTextArea
label="Description"
value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
placeholder="Describe what this prompt template is used for..."
helperText="Optional template summary to explain the purpose and expected usage."
/>
<FormTextArea
label="System Message"
value={form.system_message}
onChange={(e) => setForm((prev) => ({ ...prev, system_message: e.target.value }))}
placeholder="You are a regulatory compliance expert..."
/>
<FormTextArea
label="User Template"
required
value={form.user_template}
onChange={(e) => setForm((prev) => ({ ...prev, user_template: e.target.value }))}
placeholder={"Summarize the following document:\n\n{{document_text}}\n\nFocus on: {{focus_areas}}"}
/>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<FormSelect
label="Provider"
value={form.provider}
onValueChange={(value) => setForm((prev) => ({ ...prev, provider: value }))}
options={providers}
placeholder="Select provider"
/>
<FormSelect
label="Model"
value={form.model}
onValueChange={(value) => setForm((prev) => ({ ...prev, model: value }))}
options={models}
placeholder="Select model"
/>
<FormField
label="Temperature"
type="number"
value={form.temperature}
onChange={(e) => setForm((prev) => ({ ...prev, temperature: e.target.value }))}
/>
<FormField
label="Max Tokens"
type="number"
value={form.max_tokens}
onChange={(e) => setForm((prev) => ({ ...prev, max_tokens: e.target.value }))}
/>
</div>
<FormField
label="Tags (comma separated)"
value={form.tags}
onChange={(e) => setForm((prev) => ({ ...prev, tags: e.target.value }))}
placeholder="compliance, document, summary"
/>
<label className="inline-flex items-center gap-2 text-sm text-[#334155]">
<input
type="checkbox"
checked={form.is_default}
onChange={(e) => setForm((prev) => ({ ...prev, is_default: e.target.checked }))}
className="w-4 h-4"
/>
Set as default prompt
</label>
<div className="border border-[rgba(0,0,0,0.08)] rounded-md p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-[#0f1724]">Variables</h3>
<button
type="button"
onClick={addVariable}
className="inline-flex items-center gap-1 text-xs font-medium text-[#112868]"
>
<Plus className="w-3.5 h-3.5" />
Add Variable
</button>
</div>
{variables.length === 0 && <p className="text-xs text-[#94a3b8]">No variables added.</p>}
{variables.map((variable, index) => (
<div key={index} className="grid grid-cols-1 md:grid-cols-5 gap-3 items-end">
<FormField
label="Name"
value={variable.name}
onChange={(e) => updateVariable(index, { name: e.target.value })}
placeholder="focus_areas"
/>
<FormSelect
label="Type"
value={variable.type}
onValueChange={(value) => updateVariable(index, { type: value as PromptVariable["type"] })}
options={[
{ value: "string", label: "string" },
{ value: "number", label: "number" },
{ value: "boolean", label: "boolean" },
{ value: "array", label: "array" },
]}
/>
<FormField
label="Default"
value={variable.default}
onChange={(e) => updateVariable(index, { default: e.target.value })}
placeholder="optional default"
/>
<label className="inline-flex items-center gap-2 text-sm text-[#334155] h-10">
<input
type="checkbox"
checked={variable.required}
onChange={(e) => updateVariable(index, { required: e.target.checked })}
className="w-4 h-4"
/>
Required
</label>
<button
type="button"
onClick={() => removeVariable(index)}
className="h-10 inline-flex items-center justify-center gap-1 text-xs font-medium text-[#ef4444] border border-[rgba(239,68,68,0.2)] rounded-md"
>
<Trash2 className="w-3.5 h-3.5" />
Remove
</button>
</div>
))}
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-[rgba(0,0,0,0.06)]">
<SecondaryButton onClick={() => navigate("/tenant/ai/prompts")}>Cancel</SecondaryButton>
<PrimaryButton onClick={handleSubmit} disabled={isSubmitting}>
action: (
<div className="flex gap-2">
<SecondaryButton onClick={() => navigate("/tenant/ai/prompts")}>
Cancel
</SecondaryButton>
<PrimaryButton onClick={handleCreatePrompt} disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Prompt"}
</PrimaryButton>
</div>
),
}}
>
<form
onSubmit={handleSubmit(onFormSubmit)}
className="flex items-start gap-6"
>
{/* LEFT SIDE */}
<div className="flex flex-col gap-6 flex-[2]">
{/* General Information */}
<div className="bg-white border border-gray-300 rounded-lg shadow-sm p-4 space-y-4">
<h2 className="text-sm font-semibold text-gray-800">
General Information
</h2>
<Controller
name="name"
control={control}
render={({ field }) => (
<FormField
{...field}
label="Template Name"
required
placeholder="e.g., Code Review Assistant"
helperText="Max 255 characters. Must be unique per tenant and version"
error={errors.name?.message}
/>
)}
/>
<Controller
name="description"
control={control}
render={({ field }) => (
<FormTextArea
{...field}
label="Description"
placeholder="Describe what this prompt template is used for..."
helperText="Optional template summary to explain the purpose and expected usage."
error={errors.description?.message}
/>
)}
/>
</div>
{/* Prompt Content */}
<div className="bg-white border border-gray-300 rounded-lg shadow-sm p-4 space-y-4">
<h2 className="text-sm font-semibold text-gray-800">
Prompt Content
</h2>
<Controller
name="system_message"
control={control}
render={({ field }) => (
<FormTextArea
{...field}
label="System Message"
placeholder="You are an expert software engineer..."
error={errors.system_message?.message}
helperText="Optional system prompt that is added before the template content to guide assistant behavior."
/>
)}
/>
<Controller
name="user_template"
control={control}
render={({ field }) => (
<FormTextArea
{...field}
label="User Template"
required
placeholder={
"Summarize the following document:\n{{document_text}}\nFocus on: {{focus_areas}}"
}
error={errors.user_template?.message}
helperText="Use {{variable_name}} for dynamic inputs"
/>
)}
/>
{/* Variables Section */}
<div className="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
<div className="p-4 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-800">
Variables Configuration
</h3>
<PrimaryButton
onClick={() =>
append({
name: "",
type: "string",
required: false,
default: "",
})
}
size="small"
className="h-8 py-0"
>
<Plus className="w-4 h-4 mr-1" />
Add Variable
</PrimaryButton>
</div>
<div className="p-4">
{fields.length > 0 && (
<div className="grid grid-cols-[1fr_120px_1fr_80px_40px] gap-4 px-2 mb-2">
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">
Variable Name
</span>
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-wider text-center">
Type
</span>
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">
Default Value
</span>
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-wider text-center">
Required
</span>
<span></span>
</div>
)}
{fields.length === 0 && (
<div className="text-center py-8 bg-gray-50 rounded-lg border border-dashed border-gray-200">
<p className="text-sm text-gray-400">
No variables configured yet.
</p>
</div>
)}
<div className="space-y-3">
{fields.map((field, index) => (
<div
key={field.id}
className="grid grid-cols-[1fr_120px_1fr_80px_40px] gap-4 items-start border-b border-gray-50 pb-3 last:border-0 last:pb-0"
>
<Controller
name={`variables.${index}.name`}
control={control}
render={({ field }) => (
<div className="space-y-1">
<input
{...field}
placeholder="e.g context"
className={cn(
"h-10 w-full px-3.5 py-1 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#112868]/20 focus-visible:border-[#112868]",
errors.variables?.[index]?.name &&
"border-red-500",
)}
/>
{errors.variables?.[index]?.name && (
<p className="text-[10px] text-red-500">
{errors.variables?.[index]?.name?.message}
</p>
)}
</div>
)}
/>
<Controller
name={`variables.${index}.type`}
control={control}
render={({ field }) => (
<div className="h-10 flex flex-col justify-center">
<FormSelect
label=""
className="pb-0"
value={field.value}
onValueChange={field.onChange}
options={[
{ value: "string", label: "String" },
{ value: "number", label: "Number" },
{ value: "boolean", label: "Boolean" },
{ value: "array", label: "Array" },
]}
error={errors.variables?.[index]?.type?.message}
/>
</div>
)}
/>
<Controller
name={`variables.${index}.default`}
control={control}
render={({ field }) => (
<div className="space-y-1">
<input
{...field}
placeholder="Enter default value"
className={cn(
"h-10 w-full px-3.5 py-1 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#112868]/20 focus-visible:border-[#112868]",
errors.variables?.[index]?.default &&
"border-red-500",
)}
/>
{errors.variables?.[index]?.default && (
<p className="text-[10px] text-red-500">
{errors.variables?.[index]?.default?.message}
</p>
)}
</div>
)}
/>
<div className="h-10 flex items-center justify-center">
<Controller
name={`variables.${index}.required`}
control={control}
render={({ field }) => (
<div
className={cn(
"w-10 h-5 rounded-full relative transition-colors duration-200 cursor-pointer",
field.value ? "bg-[#084cc8]" : "bg-gray-200",
)}
onClick={() => field.onChange(!field.value)}
>
<div
className={cn(
"absolute top-1 left-1 w-3 h-3 bg-white rounded-full transition-transform duration-200 shadow-sm",
field.value && "translate-x-5",
)}
/>
</div>
)}
/>
</div>
<button
type="button"
onClick={() => remove(index)}
className="h-10 flex items-center justify-center text-gray-400 hover:text-red-500 transition-colors"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
{/* RIGHT SIDE (Sidebar) */}
<div className="flex flex-col gap-6 flex-1 max-w-[320px] w-full">
{/* Settings */}
<div className="bg-white border border-gray-300 rounded-lg shadow-sm p-4 space-y-4">
<h2 className="text-sm font-semibold text-gray-800">Settings</h2>
<Controller
name="use_case"
control={control}
render={({ field }) => (
<FormField
{...field}
label="Use Case"
required
placeholder="e.g. document_analysis"
error={errors.use_case?.message}
/>
)}
/>
<div className="space-y-2 pt-2">
<label className="text-[13px] font-semibold text-[#0e1b2a]">Is Default</label>
<div className="flex items-center gap-3">
<Controller
name="is_default"
control={control}
render={({ field }) => (
<div
className={cn(
"w-10 h-5 rounded-full relative transition-colors duration-200 cursor-pointer",
field.value ? "bg-[#112868]" : "bg-gray-200",
)}
onClick={() => field.onChange(!field.value)}
>
<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",
)}
/>
</div>
)}
/>
<span className="text-[13px] text-gray-500 font-medium">Make default for this use case</span>
</div>
</div>
</div>
{/* Model Config */}
<div className="bg-white border border-gray-300 rounded-lg shadow-sm p-4 space-y-2">
<h2 className="text-sm font-semibold text-gray-800 mb-2">
Model Configuration
</h2>
<Controller
name="provider"
control={control}
render={({ field }) => (
<FormSelect
label="Provider"
value={field.value || ""}
onValueChange={field.onChange}
options={providers}
error={errors.provider?.message}
/>
)}
/>
<Controller
name="model"
control={control}
render={({ field }) => (
<FormSelect
label="Model"
value={field.value || ""}
onValueChange={field.onChange}
options={models}
error={errors.model?.message}
/>
)}
/>
<Controller
name="temperature"
control={control}
render={({ field }) => (
<FormSlider
{...field}
label="Temperature"
min={0}
max={2}
step={0.1}
helperText="Lower values make output more deterministic"
error={errors.temperature?.message}
/>
)}
/>
<Controller
name="max_tokens"
control={control}
render={({ field }) => (
<FormSlider
{...field}
label="Max Tokens"
min={1}
max={5000}
step={1}
helperText="Maximum response token budget for generated output."
error={errors.max_tokens?.message}
/>
)}
/>
</div>
{/* Organization */}
<div className="bg-white border border-gray-300 rounded-lg shadow-sm p-4 space-y-4">
<h2 className="text-sm font-semibold text-gray-800">
Organization
</h2>
<Controller
name="tags"
control={control}
render={({ field }) => (
<FormField
{...field}
label="Tags"
placeholder="e.g. analysis, legal, code"
helperText="Comma separated values"
error={errors.tags?.message}
/>
)}
/>
</div>
</div>
</form>
</Layout>
);
};

View File

@ -0,0 +1,625 @@
import { useEffect, useState, type ReactElement } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Layout } from "@/components/layout/Layout";
import {
FormField,
FormSelect,
FormTextArea,
PrimaryButton,
SecondaryButton,
FormSlider,
} from "@/components/shared";
import { Plus, Trash2, 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";
import { cn } from "@/lib/utils";
const variableSchema = z.object({
name: z.string().min(1, "Variable name is required").max(100),
type: z.enum(["string", "number", "boolean", "array"]),
required: z.boolean(),
default: z.string().optional(),
});
const promptSchema = z.object({
name: z.string().min(1, "Name is required").max(255),
description: z.string().optional(),
use_case: z.string().min(1, "Use case is required").max(100),
system_message: z.string().optional(),
user_template: z.string().min(1, "User template is required").max(50000),
provider: z.string().optional(),
model: z.string().optional(),
temperature: z.number().min(0).max(2),
max_tokens: z.number().int().min(1).max(128000),
tags: z.string().optional(),
is_default: z.boolean(),
variables: z.array(variableSchema),
change_notes: z.string().optional(),
});
type PromptFormData = z.infer<typeof promptSchema>;
const PromptEdit = (): ReactElement => {
const { id } = useParams<{ id: string }>();
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 [currentVersion, setCurrentVersion] = useState<number>(1);
const {
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<PromptFormData>({
resolver: zodResolver(promptSchema),
defaultValues: {
name: "",
description: "",
use_case: "",
system_message: "",
user_template: "",
provider: "",
model: "",
temperature: 0.7,
max_tokens: 2048,
tags: "",
is_default: false,
variables: [],
change_notes: "",
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "variables",
});
useEffect(() => {
const loadData = async (): Promise<void> => {
if (!id) return;
try {
setIsLoading(true);
const [providerData, modelData, 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})`,
})),
);
reset({
name: promptData.name,
description: promptData.description || "",
use_case: promptData.useCase,
system_message: promptData.systemMessage || "",
user_template: promptData.template,
provider: promptData.defaultParameters?.provider || "",
model: promptData.defaultParameters?.model || "",
temperature: promptData.defaultParameters?.temperature ?? 0.7,
max_tokens: promptData.defaultParameters?.max_tokens ?? 2048,
tags: (promptData.tags || []).join(", "),
is_default: promptData.isDefault || false,
variables: (promptData.variables || []).map((v) => ({
name: v.name,
type: v.type || "string",
required: !!v.required,
default: v.default ? String(v.default) : "",
})),
change_notes: "",
});
setCurrentVersion(promptData.version || 1);
} catch (err: unknown) {
showToast.error(
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ||
"Failed to load prompt data",
);
navigate("/tenant/ai/prompts");
} finally {
setIsLoading(false);
}
};
void loadData();
}, [id, reset, navigate]);
const onFormSubmit = async (data: PromptFormData): Promise<void> => {
if (!id) return;
const parsedTags = data.tags
? data.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
: [];
const sanitizedVariables = data.variables
?.filter((v) => v.name.trim())
.map((v) => ({
name: v.name.trim(),
type: v.type,
required: v.required,
...(v.default?.trim() ? { default: v.default.trim() } : {}),
}));
setIsSubmitting(true);
try {
await aiService.updatePrompt(id, {
name: data.name.trim(),
description: data.description?.trim() || undefined,
use_case: data.use_case.trim(),
system_message: data.system_message?.trim() || undefined,
user_template: data.user_template,
model: data.model || undefined,
provider: data.provider || undefined,
temperature: data.temperature,
max_tokens: data.max_tokens,
variables: sanitizedVariables,
tags: parsedTags,
is_default: data.is_default,
change_notes: data.change_notes,
});
showToast.success("Prompt updated successfully");
navigate("/tenant/ai/prompts");
} catch (err: unknown) {
showToast.error(
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || "Failed to update prompt",
);
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<Layout currentPage="Edit Prompt">
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-4 border-gray-200 border-t-[#112868] rounded-full animate-spin"></div>
<p className="text-sm text-gray-500">Loading prompt details...</p>
</div>
</div>
</Layout>
);
}
return (
<Layout
currentPage="Edit Prompt"
pageHeader={{
title: "Edit Prompt",
description: "Modify prompt template and configuration settings.",
action: (
<div className="flex gap-2">
<SecondaryButton onClick={() => navigate("/tenant/ai/prompts")}>
Cancel
</SecondaryButton>
<PrimaryButton onClick={handleSubmit(onFormSubmit)} disabled={isSubmitting}>
{isSubmitting ? "Updating..." : "Update Prompt"}
</PrimaryButton>
</div>
),
}}
>
<div className="mb-4">
<button
onClick={() => navigate("/tenant/ai/prompts")}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to list
</button>
</div>
<form
onSubmit={handleSubmit(onFormSubmit)}
className="flex items-start gap-6"
>
{/* LEFT SIDE */}
<div className="flex flex-col gap-6 flex-[2]">
{/* General Information */}
<div className="bg-white border border-gray-300 rounded-lg shadow-sm p-4 space-y-4">
<h2 className="text-sm font-semibold text-gray-800">
General Information
</h2>
<Controller
name="name"
control={control}
render={({ field }) => (
<FormField
{...field}
label="Template Name"
required
placeholder="e.g., Code Review Assistant"
helperText="Max 255 characters. Must be unique per tenant and version"
error={errors.name?.message}
/>
)}
/>
<Controller
name="description"
control={control}
render={({ field }) => (
<FormTextArea
{...field}
label="Description"
placeholder="Describe what this prompt template is used for..."
helperText="Optional template summary to explain the purpose and expected usage."
error={errors.description?.message}
/>
)}
/>
</div>
{/* Prompt Content */}
<div className="bg-white border border-gray-300 rounded-lg shadow-sm p-4 space-y-4">
<h2 className="text-sm font-semibold text-gray-800">
Prompt Content
</h2>
<Controller
name="system_message"
control={control}
render={({ field }) => (
<FormTextArea
{...field}
label="System Message"
placeholder="You are an expert software engineer..."
error={errors.system_message?.message}
helperText="Optional system prompt that is added before the template content to guide assistant behavior."
/>
)}
/>
<Controller
name="user_template"
control={control}
render={({ field }) => (
<FormTextArea
{...field}
label="User Template"
required
placeholder={
"Summarize the following document:\n{{document_text}}\nFocus on: {{focus_areas}}"
}
error={errors.user_template?.message}
helperText={
<>
Use <code className="text-[#112868] font-bold">{"{{variable_name}}"}</code> for dynamic inputs.
<p className="mt-1.5 text-amber-600 font-medium flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
Updating this template will create a new version (v{currentVersion + 1})
</p>
</>
}
/>
)}
/>
<Controller
name="change_notes"
control={control}
render={({ field }) => (
<FormTextArea
{...field}
label="Change Notes"
placeholder="e.g. Optimized the system message for better accuracy, added new variables..."
helperText="Describe what changed in this version. This will be visible in the version history."
error={errors.change_notes?.message}
/>
)}
/>
{/* Variables Section */}
<div className="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
<div className="p-4 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-800">
Variables Configuration
</h3>
<PrimaryButton
onClick={() =>
append({
name: "",
type: "string",
required: false,
default: "",
})
}
size="small"
className="h-8 py-0"
>
<Plus className="w-4 h-4 mr-1" />
Add Variable
</PrimaryButton>
</div>
<div className="p-4">
{fields.length > 0 && (
<div className="grid grid-cols-[1fr_120px_1fr_80px_40px] gap-4 px-2 mb-2">
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">
Variable Name
</span>
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-wider text-center">
Type
</span>
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">
Default Value
</span>
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-wider text-center">
Required
</span>
<span></span>
</div>
)}
<div className="space-y-3">
{fields.map((field, index) => (
<div
key={field.id}
className="grid grid-cols-[1fr_120px_1fr_80px_40px] gap-4 items-start border-b border-gray-50 pb-3 last:border-0 last:pb-0"
>
<Controller
name={`variables.${index}.name`}
control={control}
render={({ field }) => (
<div className="space-y-1">
<input
{...field}
placeholder="e.g context"
className={cn(
"h-10 w-full px-3.5 py-1 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#112868]/20 focus-visible:border-[#112868]",
errors.variables?.[index]?.name &&
"border-red-500",
)}
/>
{errors.variables?.[index]?.name && (
<p className="text-[10px] text-red-500">
{errors.variables?.[index]?.name?.message}
</p>
)}
</div>
)}
/>
<Controller
name={`variables.${index}.type`}
control={control}
render={({ field }) => (
<div className="h-10 flex flex-col justify-center">
<FormSelect
label=""
className="pb-0"
value={field.value}
onValueChange={field.onChange}
options={[
{ value: "string", label: "String" },
{ value: "number", label: "Number" },
{ value: "boolean", label: "Boolean" },
{ value: "array", label: "Array" },
]}
error={errors.variables?.[index]?.type?.message}
/>
</div>
)}
/>
<Controller
name={`variables.${index}.default`}
control={control}
render={({ field }) => (
<div className="space-y-1">
<input
{...field}
placeholder="Enter default value"
className={cn(
"h-10 w-full px-3.5 py-1 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#112868]/20 focus-visible:border-[#112868]",
errors.variables?.[index]?.default &&
"border-red-500",
)}
/>
{errors.variables?.[index]?.default && (
<p className="text-[10px] text-red-500">
{errors.variables?.[index]?.default?.message}
</p>
)}
</div>
)}
/>
<div className="h-10 flex items-center justify-center">
<Controller
name={`variables.${index}.required`}
control={control}
render={({ field }) => (
<div
className={cn(
"w-10 h-5 rounded-full relative transition-colors duration-200 cursor-pointer",
field.value ? "bg-[#084cc8]" : "bg-gray-200",
)}
onClick={() => field.onChange(!field.value)}
>
<div
className={cn(
"absolute top-1 left-1 w-3 h-3 bg-white rounded-full transition-transform duration-200 shadow-sm",
field.value && "translate-x-5",
)}
/>
</div>
)}
/>
</div>
<button
type="button"
onClick={() => remove(index)}
className="h-10 flex items-center justify-center text-gray-400 hover:text-red-500 transition-colors"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* RIGHT SIDE (Sidebar) */}
<div className="flex flex-col gap-6 flex-1 max-w-[320px] w-full">
{/* Settings */}
<div className="bg-white border border-gray-300 rounded-lg shadow-sm p-4 space-y-4">
<h2 className="text-sm font-semibold text-gray-800">Settings</h2>
<Controller
name="use_case"
control={control}
render={({ field }) => (
<FormField
{...field}
label="Use Case"
required
placeholder="e.g. document_analysis"
error={errors.use_case?.message}
/>
)}
/>
<div className="space-y-2 pt-2">
<label className="text-[13px] font-semibold text-[#0e1b2a]">Is Default</label>
<div className="flex items-center gap-3">
<Controller
name="is_default"
control={control}
render={({ field }) => (
<div
className={cn(
"w-10 h-5 rounded-full relative transition-colors duration-200 cursor-pointer",
field.value ? "bg-[#112868]" : "bg-gray-200",
)}
onClick={() => field.onChange(!field.value)}
>
<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",
)}
/>
</div>
)}
/>
<span className="text-[13px] text-gray-500 font-medium">Make default for this use case</span>
</div>
</div>
</div>
{/* Model Config */}
<div className="bg-white border border-gray-300 rounded-lg shadow-sm p-4 space-y-2">
<h2 className="text-sm font-semibold text-gray-800 mb-2">
Model Configuration
</h2>
<Controller
name="provider"
control={control}
render={({ field }) => (
<FormSelect
label="Provider"
value={field.value || ""}
onValueChange={field.onChange}
options={providers}
error={errors.provider?.message}
/>
)}
/>
<Controller
name="model"
control={control}
render={({ field }) => (
<FormSelect
label="Model"
value={field.value || ""}
onValueChange={field.onChange}
options={models}
error={errors.model?.message}
/>
)}
/>
<Controller
name="temperature"
control={control}
render={({ field }) => (
<FormSlider
{...field}
label="Temperature"
min={0}
max={2}
step={0.1}
helperText="Lower values make output more deterministic"
error={errors.temperature?.message}
/>
)}
/>
<Controller
name="max_tokens"
control={control}
render={({ field }) => (
<FormSlider
{...field}
label="Max Tokens"
min={1}
max={5000}
step={1}
helperText="Maximum response token budget."
error={errors.max_tokens?.message}
/>
)}
/>
</div>
{/* Organization */}
<div className="bg-white border border-gray-300 rounded-lg shadow-sm p-4 space-y-4">
<h2 className="text-sm font-semibold text-gray-800">
Organization
</h2>
<Controller
name="tags"
control={control}
render={({ field }) => (
<FormField
{...field}
label="Tags"
placeholder="e.g. analysis, legal, code"
helperText="Comma separated values"
error={errors.tags?.message}
/>
)}
/>
</div>
</div>
</form>
</Layout>
);
};
export default PromptEdit;

View File

@ -1,16 +1,23 @@
import { useEffect, useMemo, useState, type ReactElement } from "react";
import { useNavigate } from "react-router-dom";
import { Plus, Search } from "lucide-react";
import { Plus, Search, Copy, History, Trash2, Edit3 } from "lucide-react";
import { Layout } from "@/components/layout/Layout";
import { DataTable, type Column, Pagination, PrimaryButton, StatusBadge } from "@/components/shared";
import {
DataTable,
type Column,
Pagination,
PrimaryButton,
ActionDropdown,
DeleteConfirmationModal,
} from "@/components/shared";
import { aiService } from "@/services/ai-service";
import type { AIPrompt } from "@/types/ai";
import { showToast } from "@/utils/toast";
import { formatDate } from "@/utils/format-date";
import { useAppTheme } from "@/hooks/useAppTheme";
import { PromptVersionsModal } from "@/components/tenant/PromptVersionsModal";
import { cn } from "@/lib/utils";
const PromptManagement = (): ReactElement => {
const { primaryColor } = useAppTheme();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
@ -26,6 +33,12 @@ const PromptManagement = (): ReactElement => {
totalPages: 1,
});
// Modal states
const [selectedPrompt, setSelectedPrompt] = useState<AIPrompt | null>(null);
const [isVersionsOpen, setIsVersionsOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isActionLoading, setIsActionLoading] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
@ -52,8 +65,8 @@ const PromptManagement = (): ReactElement => {
});
} catch (err: unknown) {
const message =
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message ||
"Failed to load prompts";
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || "Failed to load prompts";
setError(message);
showToast.error(message);
} finally {
@ -65,6 +78,42 @@ const PromptManagement = (): ReactElement => {
void loadPrompts();
}, [page, limit, debouncedSearch]);
const handleStatusToggle = async (prompt: AIPrompt) => {
const newStatus = prompt.status === "active" ? "draft" : "active";
try {
await aiService.updatePromptStatus(prompt.id, newStatus);
showToast.success(`Prompt marked as ${newStatus}`);
void loadPrompts();
} catch (err: any) {
showToast.error("Failed to update status");
}
};
const handleDelete = async () => {
if (!selectedPrompt) return;
setIsActionLoading(true);
try {
await aiService.deletePrompt(selectedPrompt.id);
showToast.success("Prompt deleted successfully");
setIsDeleteOpen(false);
void loadPrompts();
} catch (err: any) {
showToast.error("Failed to delete prompt");
} finally {
setIsActionLoading(false);
}
};
const handleClone = async (prompt: AIPrompt) => {
try {
await aiService.clonePrompt(prompt.id); // Assuming this exists or I'll add it
showToast.success("Prompt cloned successfully");
void loadPrompts();
} catch (err: any) {
showToast.error("Failed to clone prompt");
}
};
const columns: Column<AIPrompt>[] = useMemo(
() => [
{
@ -72,20 +121,24 @@ const PromptManagement = (): ReactElement => {
label: "Name & Description",
render: (row) => (
<div className="min-w-0">
<p className="text-sm font-semibold text-[#0f1724] truncate">{row.name}</p>
<p className="text-xs text-[#64748b] truncate">{row.description || "No description"}</p>
<p className="text-sm font-semibold text-[#112868] hover:underline cursor-pointer" onClick={() => navigate(`/tenant/ai/prompts/${row.id}/edit`)}>
{row.name}
</p>
<p className="text-xs text-[#64748b] truncate max-w-[280px]">
{row.description || "No description"}
</p>
</div>
),
},
{
key: "use_case",
key: "useCase",
label: "Use Case",
render: (row) => {
const useCase = (row as any).useCase || row.use_case;
if (!useCase) return <span className="text-sm text-[#94a3b8]"></span>;
if (!row.useCase)
return <span className="text-sm text-[#94a3b8]"></span>;
return (
<span className="inline-flex items-center rounded-full border border-[#084cc8] bg-white px-3 py-1 text-xs font-medium text-[#084cc8]">
{String(useCase)}
<span className="inline-flex items-center rounded-full border border-[#e2e8f0] bg-[#f8fafc] px-2.5 py-0.5 text-[11px] font-medium text-[#1e293b]">
{row.useCase}
</span>
);
},
@ -94,9 +147,37 @@ const PromptManagement = (): ReactElement => {
key: "status",
label: "Status",
render: (row) => (
<StatusBadge variant={((row as any).status || "draft") === "active" ? "success" : "process"}>
{(row as any).status || "draft"}
</StatusBadge>
<div className="flex items-center gap-3">
<div
className={cn(
"w-9 h-[18px] rounded-full relative transition-colors duration-200 cursor-pointer shadow-inner",
row.status === "active" ? "bg-[#112868]" : "bg-gray-200",
)}
onClick={() => handleStatusToggle(row)}
>
<div
className={cn(
"absolute top-[2px] left-[2px] w-3.5 h-3.5 rounded-full transition-transform duration-200 shadow-sm",
row.status === "active" ? "translate-x-[18px] bg-[#00cfd5]" : "bg-white",
)}
/>
</div>
<span className={cn(
"text-[11px] font-medium uppercase tracking-wider",
row.status === "active" ? "text-green-600" : "text-gray-400"
)}>
{row.status || "draft"}
</span>
</div>
),
},
{
key: "version",
label: "Version",
render: (row) => (
<span className="text-xs font-semibold px-2 py-0.5 rounded bg-blue-50 text-blue-600 border border-blue-100">
v{row.version || 1}
</span>
),
},
{
@ -111,31 +192,72 @@ const PromptManagement = (): ReactElement => {
return (
<div className="flex flex-wrap gap-1">
{tags.map((tag, index) => (
{tags.slice(0, 2).map((tag, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
style={{
backgroundColor: `${primaryColor}1A`,
color: primaryColor,
}}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600"
>
{tag}
</span>
))}
{tags.length > 2 && (
<span className="text-[10px] text-gray-400 font-medium">+{tags.length - 2}</span>
)}
</div>
);
},
},
{
key: "Created At",
label: "Created At",
key: "Updated",
label: "Last Updated",
render: (row) => (
<span className="text-xs text-[#334155]">{formatDate((row as any).createdAt || "")}</span>
<span className="text-xs text-[#64748b]">
{formatDate(row.updatedAt || row.createdAt || "")}
</span>
),
},
{
key: "actions",
label: "",
align: "right",
render: (row) => (
<div className="flex justify-end pr-2">
<ActionDropdown
actions={[
{
icon: <Edit3 className="w-3.5 h-3.5 shrink-0" />,
label: "Edit Prompt",
onClick: () => navigate(`/tenant/ai/prompts/${row.id}/edit`),
},
{
icon: <History className="w-3.5 h-3.5 shrink-0" />,
label: "Version History",
onClick: () => {
setSelectedPrompt(row);
setIsVersionsOpen(true);
},
},
{
icon: <Copy className="w-3.5 h-3.5 shrink-0" />,
label: "Clone Prompt",
onClick: () => handleClone(row),
},
{
icon: <Trash2 className="w-3.5 h-3.5 shrink-0" />,
label: "Delete Prompt",
variant: "danger",
onClick: () => {
setSelectedPrompt(row);
setIsDeleteOpen(true);
},
},
]}
/>
</div>
),
},
],
[],
[navigate, loadPrompts],
);
return (
@ -145,23 +267,26 @@ const PromptManagement = (): ReactElement => {
title: "Prompt Management",
description: "Manage reusable AI prompts and versioned templates.",
action: (
<PrimaryButton onClick={() => navigate("/tenant/ai/prompts/create")} className="flex items-center gap-2">
<PrimaryButton
onClick={() => navigate("/tenant/ai/prompts/create")}
className="flex items-center gap-2 h-10 shadow-sm"
>
<Plus className="w-4 h-4" />
Create Prompt
</PrimaryButton>
),
}}
>
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden">
<div className="p-4 border-b border-[rgba(0,0,0,0.08)]">
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm overflow-hidden">
<div className="p-4 border-b border-[rgba(0,0,0,0.06)] bg-gray-50/50">
<div className="relative w-full md:w-80">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#94a3b8]" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search prompts by name or use case..."
className="w-full pl-9 pr-4 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[#112868]/10 focus:border-[#112868]"
placeholder="Search prompts..."
className="w-full pl-9 pr-4 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-lg text-sm transition-all focus:outline-none focus:ring-2 focus:ring-[#112868]/10 focus:border-[#112868]"
/>
</div>
</div>
@ -172,11 +297,11 @@ const PromptManagement = (): ReactElement => {
keyExtractor={(item) => item.id}
isLoading={isLoading}
error={error}
emptyMessage="No prompts found"
emptyMessage="No prompts found. Click 'Create Prompt' to get started."
/>
{pagination.total > 0 && (
<div className="border-t border-[rgba(0,0,0,0.08)] px-4 py-3">
<div className="border-t border-[rgba(0,0,0,0.08)] px-5 py-4 bg-gray-50/30">
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
@ -191,6 +316,23 @@ const PromptManagement = (): ReactElement => {
</div>
)}
</div>
<PromptVersionsModal
isOpen={isVersionsOpen}
onClose={() => setIsVersionsOpen(false)}
prompt={selectedPrompt}
onRollbackSuccess={loadPrompts}
/>
<DeleteConfirmationModal
isOpen={isDeleteOpen}
onClose={() => setIsDeleteOpen(false)}
onConfirm={handleDelete}
isLoading={isActionLoading}
title="Delete Prompt"
message="Are you sure you want to delete this prompt"
itemName={selectedPrompt?.name}
/>
</Layout>
);
};

View File

@ -42,6 +42,7 @@ 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"));
// Loading fallback component
const RouteLoader = (): ReactElement => (
@ -200,6 +201,10 @@ export const tenantAdminRoutes: RouteConfig[] = [
path: "/tenant/ai/prompts/create",
element: <LazyRoute component={PromptCreate} />,
},
{
path: "/tenant/ai/prompts/:id/edit",
element: <LazyRoute component={PromptEdit} />,
},
{
path: "/tenant/ai/knowledge",
element: <LazyRoute component={AIGateway} />,

View File

@ -140,6 +140,35 @@ class AIService {
return unwrap<AIPrompt>(response);
}
async getPrompt(id: string): Promise<AIPrompt> {
const response = await apiClient.get(`/ai/prompts/${id}`);
return unwrap<AIPrompt>(response);
}
async updatePrompt(id: string, payload: {
name: string;
description?: string;
use_case: string;
system_message?: string;
user_template: string;
provider?: string;
model?: string;
temperature?: number;
max_tokens?: number;
variables?: Array<{ name: string; type?: "string" | "number" | "boolean" | "array"; required?: boolean }>;
tags?: string[];
is_default?: boolean;
change_notes?: string;
}): Promise<AIPrompt> {
const response = await apiClient.put(`/ai/prompts/${id}`, payload);
return unwrap<AIPrompt>(response);
}
async clonePrompt(id: string): Promise<AIPrompt> {
const response = await apiClient.post(`/ai/prompts/${id}/clone`);
return unwrap<AIPrompt>(response);
}
async listPrompts(params: { page?: number; limit?: number; status?: string; search?: string } = {}): Promise<{
data: AIPrompt[];
pagination?: { page: number; limit: number; total: number; totalPages: number };
@ -156,6 +185,25 @@ class AIService {
return unwrap<AIPrompt>(response);
}
async deletePrompt(id: string): Promise<void> {
await apiClient.delete(`/ai/prompts/${id}`);
}
async getVersions(id: string): Promise<AIPrompt[]> {
const response = await apiClient.get(`/ai/prompts/${id}/versions`);
return unwrap<AIPrompt[]>(response);
}
async getVersion(id: string, version: number): Promise<AIPrompt> {
const response = await apiClient.get(`/ai/prompts/${id}/versions/${version}`);
return unwrap<AIPrompt>(response);
}
async rollback(id: string, version: number): Promise<AIPrompt> {
const response = await apiClient.post(`/ai/prompts/${id}/rollback`, { version });
return unwrap<AIPrompt>(response);
}
async executePrompt(
id: string,
payload: { variables?: Record<string, unknown>; provider?: string; model?: string; temperature?: number; max_tokens?: number },

View File

@ -104,19 +104,26 @@ export interface AIPrompt {
id: string;
name: string;
description?: string;
use_case: string;
system_message?: string;
user_template: string;
useCase: string;
systemMessage?: string;
template: string;
status?: "draft" | "active" | "archived" | "deprecated";
provider?: string;
model?: string;
temperature?: number;
max_tokens?: number;
defaultParameters?: {
provider?: string;
model?: string;
temperature?: number;
max_tokens?: number;
};
variables?: PromptVariable[];
tags?: string[];
isDefault?: boolean;
version?: number;
change_notes?: string;
created_at?: string;
updated_at?: string;
createdAt?: string;
updatedAt?: string;
created_by_email?:string;
}
export interface KnowledgeCollection {