630 lines
23 KiB
TypeScript
630 lines
23 KiB
TypeScript
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,
|
|
FormTagInput
|
|
} 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";
|
|
import type { AIProviderInfo } from "@/types/ai";
|
|
|
|
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.array(z.string()),
|
|
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 [apiProviders, setApiProviders] = useState<AIProviderInfo[]>([]);
|
|
const [currentVersion, setCurrentVersion] = useState<number>(1);
|
|
|
|
const {
|
|
control,
|
|
handleSubmit,
|
|
setValue,
|
|
watch,
|
|
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",
|
|
});
|
|
|
|
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, promptData] = await Promise.all([
|
|
aiService.getProviders(),
|
|
aiService.getPrompt(id),
|
|
]);
|
|
|
|
setApiProviders(providerData);
|
|
|
|
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 || [],
|
|
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 || [];
|
|
|
|
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={(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}
|
|
/>
|
|
)}
|
|
/>
|
|
|
|
<Controller
|
|
name="model"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<FormSelect
|
|
label="Model"
|
|
value={field.value || ""}
|
|
onValueChange={field.onChange}
|
|
options={modelsOptions}
|
|
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 }) => (
|
|
<FormTagInput
|
|
label="Tags (Press enter to add)"
|
|
value={field.value || []}
|
|
onChange={field.onChange}
|
|
error={errors.tags?.message}
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default PromptEdit;
|