feat: implement prompt management features including editing, version history, cloning, status toggling, and deletion.
This commit is contained in:
parent
bcd029950f
commit
7dc818ab71
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import type { ReactElement, InputHTMLAttributes } from 'react';
|
import type { ReactElement, InputHTMLAttributes } from 'react';
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@ -7,7 +7,7 @@ interface FormFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
|||||||
label: string;
|
label: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
helperText?: string;
|
helperText?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FormField = ({
|
export const FormField = ({
|
||||||
|
|||||||
102
src/components/shared/FormSlider.tsx
Normal file
102
src/components/shared/FormSlider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,11 +1,11 @@
|
|||||||
import type { ReactElement, TextareaHTMLAttributes } from 'react';
|
import type { ReactElement, TextareaHTMLAttributes, ReactNode } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface FormTextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
interface FormTextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
label: string;
|
label: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
helperText?: string;
|
helperText?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FormTextArea = ({
|
export const FormTextArea = ({
|
||||||
|
|||||||
@ -34,6 +34,7 @@ export { ViewSupplierModal } from './ViewSupplierModal';
|
|||||||
export { SupplierContactsModal } from './SupplierContactsModal';
|
export { SupplierContactsModal } from './SupplierContactsModal';
|
||||||
export { SupplierScorecardsModal } from './SupplierScorecardsModal';
|
export { SupplierScorecardsModal } from './SupplierScorecardsModal';
|
||||||
export { FormTextArea } from './FormTextArea';
|
export { FormTextArea } from './FormTextArea';
|
||||||
|
export { FormSlider } from './FormSlider';
|
||||||
export { RichTextEditor } from './RichTextEditor';
|
export { RichTextEditor } from './RichTextEditor';
|
||||||
export { FileUploadModal } from './FileUploadModal';
|
export { FileUploadModal } from './FileUploadModal';
|
||||||
export type { FileUploadModalProps } from './FileUploadModal';
|
export type { FileUploadModalProps } from './FileUploadModal';
|
||||||
|
|||||||
136
src/components/tenant/PromptVersionsModal.tsx
Normal file
136
src/components/tenant/PromptVersionsModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -452,9 +452,9 @@ const AIGateway = (): ReactElement => {
|
|||||||
|
|
||||||
const promptColumns: Column<AIPrompt>[] = [
|
const promptColumns: Column<AIPrompt>[] = [
|
||||||
{ key: "name", label: "Prompt Name" },
|
{ key: "name", label: "Prompt Name" },
|
||||||
{ key: "use_case", label: "Use Case" },
|
{ key: "useCase", label: "Use Case" },
|
||||||
{ key: "provider", label: "Provider", render: (row) => row.provider || "-" },
|
{ key: "provider", label: "Provider", render: (row) => row.defaultParameters?.provider || "-" },
|
||||||
{ key: "model", label: "Model", render: (row) => row.model || "-" },
|
{ key: "model", label: "Model", render: (row) => row.defaultParameters?.model || "-" },
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
label: "Status",
|
label: "Status",
|
||||||
|
|||||||
@ -1,10 +1,23 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type ReactElement } from "react";
|
import { useEffect, useMemo, useRef, useState, type ReactElement } from "react";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
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 { aiService } from "@/services/ai-service";
|
||||||
import type { AIProviderInfo } from "@/types/ai";
|
import type { AIProviderInfo } from "@/types/ai";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import { Bot, Send, User } from "lucide-react";
|
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 CompletionCreate = (): ReactElement => {
|
||||||
const [providers, setProviders] = useState<AIProviderInfo[]>([]);
|
const [providers, setProviders] = useState<AIProviderInfo[]>([]);
|
||||||
@ -13,13 +26,25 @@ const CompletionCreate = (): ReactElement => {
|
|||||||
const [isSending, setIsSending] = useState<boolean>(false);
|
const [isSending, setIsSending] = useState<boolean>(false);
|
||||||
const [isPlaygroundMode, setIsPlaygroundMode] = useState<boolean>(true);
|
const [isPlaygroundMode, setIsPlaygroundMode] = useState<boolean>(true);
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const {
|
||||||
user: "",
|
control,
|
||||||
provider: "gemini",
|
handleSubmit,
|
||||||
model: "",
|
setValue,
|
||||||
temperature: "0.7",
|
watch,
|
||||||
max_tokens: "1024",
|
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 [lastSentUserMessage, setLastSentUserMessage] = useState<string>("");
|
||||||
const [displayedResponse, setDisplayedResponse] = useState<string>("");
|
const [displayedResponse, setDisplayedResponse] = useState<string>("");
|
||||||
const typingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const typingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@ -44,12 +69,12 @@ const CompletionCreate = (): ReactElement => {
|
|||||||
const modelOptions = useMemo(
|
const modelOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
models
|
models
|
||||||
.filter((m) => !form.provider || m.provider === form.provider)
|
.filter((m) => !formValues.provider || m.provider === formValues.provider)
|
||||||
.map((m) => ({
|
.map((m) => ({
|
||||||
value: m.id,
|
value: m.id,
|
||||||
label: `${m.id}${m.isDefault ? " • default" : ""}`,
|
label: `${m.id}${m.isDefault ? " • default" : ""}`,
|
||||||
})),
|
})),
|
||||||
[models, form.provider],
|
[models, formValues.provider],
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadOptions = async (): Promise<void> => {
|
const loadOptions = async (): Promise<void> => {
|
||||||
@ -77,13 +102,8 @@ const CompletionCreate = (): ReactElement => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSend = async (): Promise<void> => {
|
const onSend = async (data: PlaygroundFormData): Promise<void> => {
|
||||||
const userMessage = form.user.trim();
|
const userMessage = data.user.trim();
|
||||||
if (!userMessage) {
|
|
||||||
showToast.error("Please enter a message before sending");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typingIntervalRef.current) {
|
if (typingIntervalRef.current) {
|
||||||
clearInterval(typingIntervalRef.current);
|
clearInterval(typingIntervalRef.current);
|
||||||
typingIntervalRef.current = null;
|
typingIntervalRef.current = null;
|
||||||
@ -96,17 +116,17 @@ const CompletionCreate = (): ReactElement => {
|
|||||||
const result = isPlaygroundMode
|
const result = isPlaygroundMode
|
||||||
? await aiService.playground({
|
? await aiService.playground({
|
||||||
messages: [{ role: "user", content: userMessage }],
|
messages: [{ role: "user", content: userMessage }],
|
||||||
provider: form.provider || undefined,
|
provider: data.provider || undefined,
|
||||||
model: form.model || undefined,
|
model: data.model || undefined,
|
||||||
temperature: Number(form.temperature),
|
temperature: data.temperature,
|
||||||
max_tokens: Number(form.max_tokens),
|
max_tokens: data.max_tokens,
|
||||||
})
|
})
|
||||||
: await aiService.createCompletion({
|
: await aiService.createCompletion({
|
||||||
messages: [{ role: "user", content: userMessage }],
|
messages: [{ role: "user", content: userMessage }],
|
||||||
provider: form.provider || undefined,
|
provider: data.provider || undefined,
|
||||||
model: form.model || undefined,
|
model: data.model || undefined,
|
||||||
temperature: Number(form.temperature),
|
temperature: data.temperature,
|
||||||
max_tokens: Number(form.max_tokens),
|
max_tokens: data.max_tokens,
|
||||||
});
|
});
|
||||||
|
|
||||||
setResponseData({
|
setResponseData({
|
||||||
@ -139,6 +159,7 @@ const CompletionCreate = (): ReactElement => {
|
|||||||
showToast.success(
|
showToast.success(
|
||||||
isPlaygroundMode ? "Playground response received" : "Completion created and saved to history",
|
isPlaygroundMode ? "Playground response received" : "Completion created and saved to history",
|
||||||
);
|
);
|
||||||
|
setValue("user", "");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast.error(
|
showToast.error(
|
||||||
err?.response?.data?.error?.message ||
|
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="p-2.5 md:p-3 border-t border-[rgba(0,0,0,0.08)] bg-white">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<input
|
<Controller
|
||||||
value={form.user}
|
name="user"
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, user: e.target.value }))}
|
control={control}
|
||||||
placeholder="Type your message here..."
|
render={({ field }) => (
|
||||||
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"
|
<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
|
<PrimaryButton
|
||||||
onClick={handleSend}
|
onClick={handleSubmit(onSend)}
|
||||||
disabled={isSending || isLoading}
|
disabled={isSending || isLoading}
|
||||||
className="h-10 md:h-11 px-3 md:px-4 min-w-[84px] md:min-w-[96px] shrink-0"
|
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>
|
<span className="hidden sm:inline">{isSending ? "Sending..." : "Send"}</span>
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
|
{errors.user && <p className="text-[10px] text-red-500 mt-1">{errors.user.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -267,41 +300,66 @@ const CompletionCreate = (): ReactElement => {
|
|||||||
|
|
||||||
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">Model Configuration</h3>
|
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">Model Configuration</h3>
|
||||||
|
|
||||||
<FormSelect
|
<Controller
|
||||||
label="Provider"
|
name="provider"
|
||||||
value={form.provider}
|
control={control}
|
||||||
options={providerOptions}
|
render={({ field }) => (
|
||||||
onValueChange={(value) => setForm((prev) => ({ ...prev, provider: value, model: "" }))}
|
<FormSelect
|
||||||
|
{...field}
|
||||||
|
label="Provider"
|
||||||
|
options={providerOptions}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
setValue("model", "");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<FormSelect
|
|
||||||
label="Model"
|
<Controller
|
||||||
value={form.model}
|
name="model"
|
||||||
options={modelOptions}
|
control={control}
|
||||||
placeholder="Provider default"
|
render={({ field }) => (
|
||||||
onValueChange={(value) => setForm((prev) => ({ ...prev, model: value }))}
|
<FormSelect
|
||||||
|
{...field}
|
||||||
|
label="Model"
|
||||||
|
options={modelOptions}
|
||||||
|
placeholder="Provider default"
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{!isPlaygroundMode && (
|
|
||||||
<FormField
|
<Controller
|
||||||
label="Temperature"
|
name="temperature"
|
||||||
type="number"
|
control={control}
|
||||||
value={form.temperature}
|
render={({ field }) => (
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, temperature: e.target.value }))}
|
<FormSlider
|
||||||
/>
|
{...field}
|
||||||
)}
|
label="Temperature"
|
||||||
<input
|
min={0}
|
||||||
type="range"
|
max={2}
|
||||||
min="0"
|
step={0.1}
|
||||||
max="2"
|
helperText="Lower values make output more deterministic"
|
||||||
step="0.1"
|
error={errors.temperature?.message}
|
||||||
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"}`}
|
|
||||||
/>
|
/>
|
||||||
<FormField
|
|
||||||
label="Max Tokens"
|
<Controller
|
||||||
type="number"
|
name="max_tokens"
|
||||||
value={form.max_tokens}
|
control={control}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, max_tokens: e.target.value }))}
|
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">
|
<div className="mt-2 mb-4 flex gap-2">
|
||||||
|
|||||||
@ -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 { useNavigate } from "react-router-dom";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
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 { Plus, Trash2 } from "lucide-react";
|
||||||
import { aiService } from "@/services/ai-service";
|
import { aiService } from "@/services/ai-service";
|
||||||
import { showToast } from "@/utils/toast";
|
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 = {
|
const variableSchema = z.object({
|
||||||
name: string;
|
name: z.string().min(1, "Variable name is required").max(100),
|
||||||
type: "string" | "number" | "boolean" | "array";
|
type: z.enum(["string", "number", "boolean", "array"]),
|
||||||
required: boolean;
|
required: z.boolean(),
|
||||||
default: string;
|
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 PromptCreate = (): ReactElement => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
const [providers, setProviders] = useState<Array<{ value: string; label: string }>>([]);
|
const [providers, setProviders] = useState<
|
||||||
const [models, setModels] = useState<Array<{ value: string; label: string }>>([]);
|
Array<{ value: string; label: string }>
|
||||||
|
>([]);
|
||||||
|
const [models, setModels] = useState<Array<{ value: string; label: string }>>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const {
|
||||||
name: "",
|
control,
|
||||||
description: "",
|
handleSubmit,
|
||||||
use_case: "",
|
formState: { errors },
|
||||||
system_message: "",
|
} = useForm<PromptFormData>({
|
||||||
user_template: "",
|
resolver: zodResolver(promptSchema),
|
||||||
provider: "",
|
defaultValues: {
|
||||||
model: "",
|
name: "",
|
||||||
temperature: "0.3",
|
description: "",
|
||||||
max_tokens: "2048",
|
use_case: "",
|
||||||
tags: "",
|
system_message: "",
|
||||||
is_default: false,
|
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(() => {
|
useEffect(() => {
|
||||||
const loadMeta = async (): Promise<void> => {
|
const loadMeta = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const [providerData, modelData] = await Promise.all([aiService.getProviders(), aiService.getModels()]);
|
const [providerData, modelData] = await Promise.all([
|
||||||
setProviders(providerData.map((p) => ({ value: p.name, label: p.displayName || p.name })));
|
aiService.getProviders(),
|
||||||
setModels(modelData.map((m) => ({ value: m.id, label: `${m.id} (${m.provider})` })));
|
aiService.getModels(),
|
||||||
|
]);
|
||||||
|
setProviders(
|
||||||
|
providerData.map((p) => ({
|
||||||
|
value: p.name,
|
||||||
|
label: p.displayName || p.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setModels(
|
||||||
|
modelData.map((m) => ({
|
||||||
|
value: m.id,
|
||||||
|
label: `${m.id} (${m.provider})`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showToast.error(
|
showToast.error(
|
||||||
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message ||
|
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||||
|
?.response?.data?.error?.message ||
|
||||||
"Failed to load provider metadata",
|
"Failed to load provider metadata",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -52,236 +115,446 @@ const PromptCreate = (): ReactElement => {
|
|||||||
void loadMeta();
|
void loadMeta();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const parsedTags = useMemo(
|
const onFormSubmit = async (data: PromptFormData): Promise<void> => {
|
||||||
() =>
|
const parsedTags = data.tags
|
||||||
form.tags
|
? data.tags
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((tag) => tag.trim())
|
.map((tag) => tag.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean)
|
||||||
[form.tags],
|
: [];
|
||||||
);
|
|
||||||
|
|
||||||
const addVariable = (): void => {
|
const sanitizedVariables = data.variables
|
||||||
setVariables((prev) => [...prev, { name: "", type: "string", required: false, default: "" }]);
|
?.filter((v) => v.name.trim())
|
||||||
};
|
|
||||||
|
|
||||||
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())
|
|
||||||
.map((v) => ({
|
.map((v) => ({
|
||||||
name: v.name.trim(),
|
name: v.name.trim(),
|
||||||
type: v.type,
|
type: v.type,
|
||||||
required: v.required,
|
required: v.required,
|
||||||
...(v.default.trim() ? { default: v.default.trim() } : {}),
|
...(v.default?.trim() ? { default: v.default.trim() } : {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await aiService.createPrompt({
|
await aiService.createPrompt({
|
||||||
name: form.name.trim(),
|
name: data.name.trim(),
|
||||||
description: form.description.trim() || undefined,
|
description: data.description?.trim() || undefined,
|
||||||
use_case: form.use_case.trim(),
|
use_case: data.use_case.trim(),
|
||||||
system_message: form.system_message.trim() || undefined,
|
system_message: data.system_message?.trim() || undefined,
|
||||||
user_template: form.user_template,
|
user_template: data.user_template,
|
||||||
model: form.model || undefined,
|
model: data.model || undefined,
|
||||||
provider: form.provider || undefined,
|
provider: data.provider || undefined,
|
||||||
temperature: Number(form.temperature),
|
temperature: data.temperature,
|
||||||
max_tokens: Number(form.max_tokens),
|
max_tokens: data.max_tokens,
|
||||||
variables: sanitizedVariables,
|
variables: sanitizedVariables,
|
||||||
tags: parsedTags,
|
tags: parsedTags,
|
||||||
is_default: form.is_default,
|
is_default: data.is_default,
|
||||||
});
|
});
|
||||||
showToast.success("Prompt created successfully");
|
showToast.success("Prompt created successfully");
|
||||||
navigate("/tenant/ai/prompts");
|
navigate("/tenant/ai/prompts");
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showToast.error(
|
showToast.error(
|
||||||
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message ||
|
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||||
"Failed to create prompt",
|
?.response?.data?.error?.message || "Failed to create prompt",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreatePrompt = () => {
|
||||||
|
handleSubmit(onFormSubmit)();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
currentPage="Create Prompt"
|
currentPage="Create Prompt"
|
||||||
pageHeader={{
|
pageHeader={{
|
||||||
title: "Create Prompt",
|
title: "Create Prompt",
|
||||||
description: "Create a reusable prompt template for AI workflows.",
|
description: "Create a reusable prompt template for AI workflows.",
|
||||||
}}
|
action: (
|
||||||
>
|
<div className="flex gap-2">
|
||||||
<div className="max-w-5xl mx-auto bg-white border border-[rgba(0,0,0,0.08)] rounded-lg">
|
<SecondaryButton onClick={() => navigate("/tenant/ai/prompts")}>
|
||||||
<div className="p-5 md:p-6 space-y-5">
|
Cancel
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
</SecondaryButton>
|
||||||
<FormField
|
<PrimaryButton onClick={handleCreatePrompt} disabled={isSubmitting}>
|
||||||
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}>
|
|
||||||
{isSubmitting ? "Creating..." : "Create Prompt"}
|
{isSubmitting ? "Creating..." : "Create Prompt"}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</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>
|
||||||
</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>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
625
src/pages/tenant/PromptEdit.tsx
Normal file
625
src/pages/tenant/PromptEdit.tsx
Normal 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;
|
||||||
@ -1,16 +1,23 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactElement } from "react";
|
import { useEffect, useMemo, useState, type ReactElement } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Plus, Search } from "lucide-react";
|
import { Plus, Search, Copy, History, Trash2, Edit3 } from "lucide-react";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
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 { aiService } from "@/services/ai-service";
|
||||||
import type { AIPrompt } from "@/types/ai";
|
import type { AIPrompt } from "@/types/ai";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import { formatDate } from "@/utils/format-date";
|
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 PromptManagement = (): ReactElement => {
|
||||||
const { primaryColor } = useAppTheme();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -26,6 +33,12 @@ const PromptManagement = (): ReactElement => {
|
|||||||
totalPages: 1,
|
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(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDebouncedSearch(search);
|
setDebouncedSearch(search);
|
||||||
@ -52,8 +65,8 @@ const PromptManagement = (): ReactElement => {
|
|||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message =
|
const message =
|
||||||
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message ||
|
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||||
"Failed to load prompts";
|
?.response?.data?.error?.message || "Failed to load prompts";
|
||||||
setError(message);
|
setError(message);
|
||||||
showToast.error(message);
|
showToast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
@ -65,6 +78,42 @@ const PromptManagement = (): ReactElement => {
|
|||||||
void loadPrompts();
|
void loadPrompts();
|
||||||
}, [page, limit, debouncedSearch]);
|
}, [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(
|
const columns: Column<AIPrompt>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@ -72,20 +121,24 @@ const PromptManagement = (): ReactElement => {
|
|||||||
label: "Name & Description",
|
label: "Name & Description",
|
||||||
render: (row) => (
|
render: (row) => (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-semibold text-[#0f1724] truncate">{row.name}</p>
|
<p className="text-sm font-semibold text-[#112868] hover:underline cursor-pointer" onClick={() => navigate(`/tenant/ai/prompts/${row.id}/edit`)}>
|
||||||
<p className="text-xs text-[#64748b] truncate">{row.description || "No description"}</p>
|
{row.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#64748b] truncate max-w-[280px]">
|
||||||
|
{row.description || "No description"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "use_case",
|
key: "useCase",
|
||||||
label: "Use Case",
|
label: "Use Case",
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
const useCase = (row as any).useCase || row.use_case;
|
if (!row.useCase)
|
||||||
if (!useCase) return <span className="text-sm text-[#94a3b8]">—</span>;
|
return <span className="text-sm text-[#94a3b8]">—</span>;
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center rounded-full border border-[#084cc8] bg-white px-3 py-1 text-xs font-medium text-[#084cc8]">
|
<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]">
|
||||||
{String(useCase)}
|
{row.useCase}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -94,9 +147,37 @@ const PromptManagement = (): ReactElement => {
|
|||||||
key: "status",
|
key: "status",
|
||||||
label: "Status",
|
label: "Status",
|
||||||
render: (row) => (
|
render: (row) => (
|
||||||
<StatusBadge variant={((row as any).status || "draft") === "active" ? "success" : "process"}>
|
<div className="flex items-center gap-3">
|
||||||
{(row as any).status || "draft"}
|
<div
|
||||||
</StatusBadge>
|
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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -104,38 +185,79 @@ const PromptManagement = (): ReactElement => {
|
|||||||
label: "Tags",
|
label: "Tags",
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
const tags = ((row as any).tags || []) as string[];
|
const tags = ((row as any).tags || []) as string[];
|
||||||
|
|
||||||
if (!tags.length) {
|
if (!tags.length) {
|
||||||
return <span className="text-xs text-[#94a3b8]">—</span>;
|
return <span className="text-xs text-[#94a3b8]">—</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{tags.map((tag, index) => (
|
{tags.slice(0, 2).map((tag, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
|
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600"
|
||||||
style={{
|
|
||||||
backgroundColor: `${primaryColor}1A`,
|
|
||||||
color: primaryColor,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
{tags.length > 2 && (
|
||||||
|
<span className="text-[10px] text-gray-400 font-medium">+{tags.length - 2}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "Created At",
|
key: "Updated",
|
||||||
label: "Created At",
|
label: "Last Updated",
|
||||||
render: (row) => (
|
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 (
|
return (
|
||||||
@ -145,23 +267,26 @@ const PromptManagement = (): ReactElement => {
|
|||||||
title: "Prompt Management",
|
title: "Prompt Management",
|
||||||
description: "Manage reusable AI prompts and versioned templates.",
|
description: "Manage reusable AI prompts and versioned templates.",
|
||||||
action: (
|
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" />
|
<Plus className="w-4 h-4" />
|
||||||
Create Prompt
|
Create Prompt
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden">
|
<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.08)]">
|
<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">
|
<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]" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#94a3b8]" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Search prompts by name or use case..."
|
placeholder="Search prompts..."
|
||||||
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]"
|
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>
|
||||||
</div>
|
</div>
|
||||||
@ -172,11 +297,11 @@ const PromptManagement = (): ReactElement => {
|
|||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
emptyMessage="No prompts found"
|
emptyMessage="No prompts found. Click 'Create Prompt' to get started."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{pagination.total > 0 && (
|
{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
|
<Pagination
|
||||||
currentPage={pagination.page}
|
currentPage={pagination.page}
|
||||||
totalPages={pagination.totalPages}
|
totalPages={pagination.totalPages}
|
||||||
@ -191,6 +316,23 @@ const PromptManagement = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -42,6 +42,7 @@ const CompletionCreate = lazy(() => import("@/pages/tenant/CompletionCreate"));
|
|||||||
const CompletionDetail = lazy(() => import("@/pages/tenant/CompletionDetail"));
|
const CompletionDetail = lazy(() => import("@/pages/tenant/CompletionDetail"));
|
||||||
const PromptManagement = lazy(() => import("@/pages/tenant/PromptManagement"));
|
const PromptManagement = lazy(() => import("@/pages/tenant/PromptManagement"));
|
||||||
const PromptCreate = lazy(() => import("@/pages/tenant/PromptCreate"));
|
const PromptCreate = lazy(() => import("@/pages/tenant/PromptCreate"));
|
||||||
|
const PromptEdit = lazy(() => import("@/pages/tenant/PromptEdit"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -200,6 +201,10 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/tenant/ai/prompts/create",
|
path: "/tenant/ai/prompts/create",
|
||||||
element: <LazyRoute component={PromptCreate} />,
|
element: <LazyRoute component={PromptCreate} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/tenant/ai/prompts/:id/edit",
|
||||||
|
element: <LazyRoute component={PromptEdit} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/tenant/ai/knowledge",
|
path: "/tenant/ai/knowledge",
|
||||||
element: <LazyRoute component={AIGateway} />,
|
element: <LazyRoute component={AIGateway} />,
|
||||||
|
|||||||
@ -140,6 +140,35 @@ class AIService {
|
|||||||
return unwrap<AIPrompt>(response);
|
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<{
|
async listPrompts(params: { page?: number; limit?: number; status?: string; search?: string } = {}): Promise<{
|
||||||
data: AIPrompt[];
|
data: AIPrompt[];
|
||||||
pagination?: { page: number; limit: number; total: number; totalPages: number };
|
pagination?: { page: number; limit: number; total: number; totalPages: number };
|
||||||
@ -156,6 +185,25 @@ class AIService {
|
|||||||
return unwrap<AIPrompt>(response);
|
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(
|
async executePrompt(
|
||||||
id: string,
|
id: string,
|
||||||
payload: { variables?: Record<string, unknown>; provider?: string; model?: string; temperature?: number; max_tokens?: number },
|
payload: { variables?: Record<string, unknown>; provider?: string; model?: string; temperature?: number; max_tokens?: number },
|
||||||
|
|||||||
@ -104,19 +104,26 @@ export interface AIPrompt {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
use_case: string;
|
useCase: string;
|
||||||
system_message?: string;
|
systemMessage?: string;
|
||||||
user_template: string;
|
template: string;
|
||||||
status?: "draft" | "active" | "archived" | "deprecated";
|
status?: "draft" | "active" | "archived" | "deprecated";
|
||||||
provider?: string;
|
defaultParameters?: {
|
||||||
model?: string;
|
provider?: string;
|
||||||
temperature?: number;
|
model?: string;
|
||||||
max_tokens?: number;
|
temperature?: number;
|
||||||
|
max_tokens?: number;
|
||||||
|
};
|
||||||
variables?: PromptVariable[];
|
variables?: PromptVariable[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
isDefault?: boolean;
|
||||||
version?: number;
|
version?: number;
|
||||||
|
change_notes?: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
created_by_email?:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KnowledgeCollection {
|
export interface KnowledgeCollection {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user