mirror of
https://github.com/khoaliber/khoj.git
synced 2026-03-02 21:19:12 +00:00
- Add knowledge base as a separate section, apart from tools - This makes it easier to navigate the different components quickly
1339 lines
66 KiB
TypeScript
1339 lines
66 KiB
TypeScript
import styles from "./agentCard.module.css";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
import { UserProfile, ModelOptions, UserConfig } from "@/app/common/auth";
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
import {
|
|
PaperPlaneTilt,
|
|
Plus,
|
|
Circle,
|
|
Info,
|
|
Check,
|
|
ShieldWarning,
|
|
Lock,
|
|
Book,
|
|
Brain,
|
|
Waveform,
|
|
CaretUpDown,
|
|
Globe,
|
|
LockOpen,
|
|
FloppyDisk,
|
|
DotsThreeVertical,
|
|
Pencil,
|
|
Trash,
|
|
ArrowRight,
|
|
ArrowLeft,
|
|
} from "@phosphor-icons/react";
|
|
import { z, ZodError } from "zod";
|
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import LoginPrompt from "@/app/components/loginPrompt/loginPrompt";
|
|
import {
|
|
getAvailableIcons,
|
|
getIconForSlashCommand,
|
|
getIconFromIconName,
|
|
} from "@/app/common/iconUtils";
|
|
import { convertColorToTextClass, tailwindColors } from "@/app/common/colorUtils";
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from "@/components/ui/form";
|
|
import { useForm, UseFormReturn } from "react-hook-form";
|
|
import { Input } from "@/components/ui/input";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { DialogTitle } from "@radix-ui/react-dialog";
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import { uploadDataForIndexing } from "@/app/common/chatFunctions";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import ShareLink from "@/app/components/shareLink/shareLink";
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
|
export interface AgentData {
|
|
slug: string;
|
|
name: string;
|
|
persona: string;
|
|
color: string;
|
|
icon: string;
|
|
privacy_level: string;
|
|
files?: string[];
|
|
creator?: string;
|
|
managed_by_admin: boolean;
|
|
chat_model: string;
|
|
input_tools: string[];
|
|
output_modes: string[];
|
|
}
|
|
|
|
async function openChat(slug: string, userData: UserProfile | null) {
|
|
const unauthenticatedRedirectUrl = `/login?next=/agents?agent=${slug}`;
|
|
if (!userData) {
|
|
window.location.href = unauthenticatedRedirectUrl;
|
|
return;
|
|
}
|
|
|
|
const response = await fetch(`/api/chat/sessions?agent_slug=${encodeURIComponent(slug)}`, {
|
|
method: "POST",
|
|
});
|
|
const data = await response.json();
|
|
if (response.status == 200) {
|
|
window.location.href = `/chat?conversationId=${data.conversation_id}`;
|
|
} else if (response.status == 403 || response.status == 401) {
|
|
window.location.href = unauthenticatedRedirectUrl;
|
|
} else {
|
|
alert("Failed to start chat session");
|
|
}
|
|
}
|
|
|
|
function Badge(props: { icon: JSX.Element; text?: string; hoverText?: string }) {
|
|
// Always convert text to proper case (e.g., "public" -> "Public")
|
|
const displayBadgeText = props.text?.replace(/^\w/, (c) => c.toUpperCase()) || "";
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipContent asChild>
|
|
<div className="text-sm">{props.hoverText || displayBadgeText}</div>
|
|
</TooltipContent>
|
|
<TooltipTrigger className="cursor-text">
|
|
<div className="flex items-center space-x-2 rounded-full border-accent-500 border p-1.5">
|
|
<div className="text-muted-foreground">{props.icon}</div>
|
|
{displayBadgeText && displayBadgeText.length > 0 && (
|
|
<div className="text-muted-foreground text-sm">{displayBadgeText}</div>
|
|
)}
|
|
</div>
|
|
</TooltipTrigger>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
export const EditAgentSchema = z.object({
|
|
name: z.string({ required_error: "Name is required" }).min(1, "Name is required"),
|
|
persona: z
|
|
.string({ required_error: "Personality is required" })
|
|
.min(1, "Personality is required"),
|
|
color: z.string({ required_error: "Color is required" }).min(1, "Color is required"),
|
|
icon: z.string({ required_error: "Icon is required" }).min(1, "Icon is required"),
|
|
privacy_level: z
|
|
.string({ required_error: "Privacy level is required" })
|
|
.min(1, "Privacy level is required"),
|
|
chat_model: z
|
|
.string({ required_error: "Chat model is required" })
|
|
.min(1, "Chat model is required"),
|
|
files: z.array(z.string()).default([]).optional(),
|
|
input_tools: z.array(z.string()).default([]).optional(),
|
|
output_modes: z.array(z.string()).default([]).optional(),
|
|
});
|
|
|
|
interface AgentCardProps {
|
|
data: AgentData;
|
|
userProfile: UserProfile | null;
|
|
isMobileWidth: boolean;
|
|
editCard: boolean;
|
|
showChatButton?: boolean;
|
|
filesOptions: string[];
|
|
modelOptions: ModelOptions[];
|
|
selectedChatModelOption: string;
|
|
isSubscribed: boolean;
|
|
setAgentChangeTriggered: (value: boolean) => void;
|
|
agentSlug: string;
|
|
inputToolOptions: { [key: string]: string };
|
|
outputModeOptions: { [key: string]: string };
|
|
}
|
|
|
|
export function AgentCard(props: AgentCardProps) {
|
|
const [showModal, setShowModal] = useState(props.agentSlug === props.data.slug);
|
|
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
|
const [errors, setErrors] = useState<string | null>(null);
|
|
|
|
let lockIcon = <Lock />;
|
|
let privacyHoverText = "Private agents are only visible to you.";
|
|
|
|
if (props.data.privacy_level === "public") {
|
|
lockIcon = <Globe />;
|
|
privacyHoverText = "Public agents are visible to everyone.";
|
|
} else if (props.data.privacy_level === "protected") {
|
|
lockIcon = <LockOpen />;
|
|
privacyHoverText = "Protected agents are visible to anyone with a direct link.";
|
|
}
|
|
|
|
const userData = props.userProfile;
|
|
|
|
const form = useForm<z.infer<typeof EditAgentSchema>>({
|
|
resolver: zodResolver(EditAgentSchema),
|
|
defaultValues: {
|
|
name: props.data.name,
|
|
persona: props.data.persona,
|
|
color: props.data.color,
|
|
icon: props.data.icon,
|
|
privacy_level: props.data.privacy_level,
|
|
chat_model: props.data.chat_model,
|
|
files: props.data.files,
|
|
input_tools: props.data.input_tools,
|
|
output_modes: props.data.output_modes,
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
form.reset({
|
|
name: props.data.name,
|
|
persona: props.data.persona,
|
|
color: props.data.color,
|
|
icon: props.data.icon,
|
|
privacy_level: props.data.privacy_level,
|
|
chat_model: props.data.chat_model,
|
|
files: props.data.files,
|
|
input_tools: props.data.input_tools,
|
|
output_modes: props.data.output_modes,
|
|
});
|
|
}, [props.data]);
|
|
|
|
if (showModal) {
|
|
window.history.pushState(
|
|
{},
|
|
`Khoj AI - Agent ${props.data.slug}`,
|
|
`/agents?agent=${props.data.slug}`,
|
|
);
|
|
}
|
|
|
|
const onSubmit = (values: z.infer<typeof EditAgentSchema>) => {
|
|
let agentsApiUrl = `/api/agents`;
|
|
let method = props.editCard ? "PATCH" : "POST";
|
|
|
|
let valuesToSend: any = values;
|
|
|
|
if (props.editCard) {
|
|
valuesToSend = { ...values, slug: props.data.slug };
|
|
}
|
|
|
|
fetch(agentsApiUrl, {
|
|
method: method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(valuesToSend),
|
|
})
|
|
.then((response) => {
|
|
if (response.status === 200) {
|
|
form.reset();
|
|
setShowModal(false);
|
|
setErrors(null);
|
|
props.setAgentChangeTriggered(true);
|
|
} else {
|
|
response.json().then((data) => {
|
|
console.error(data);
|
|
form.clearErrors();
|
|
if (data.error) {
|
|
setErrors(data.error);
|
|
}
|
|
});
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("Error:", error);
|
|
setErrors(error);
|
|
form.clearErrors();
|
|
});
|
|
};
|
|
|
|
const stylingString = convertColorToTextClass(props.data.color);
|
|
|
|
function makeBadgeFooter() {
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
{props.editCard && (
|
|
<Badge
|
|
icon={lockIcon}
|
|
text={props.data.privacy_level}
|
|
hoverText={privacyHoverText}
|
|
/>
|
|
)}
|
|
{props.data.files && props.data.files.length > 0 && (
|
|
<Badge
|
|
icon={<Book />}
|
|
text={`knowledge`}
|
|
hoverText={`The agent has a custom knowledge base with ${props.data.files.length} documents. It can use them to give you answers.`}
|
|
/>
|
|
)}
|
|
<Badge
|
|
icon={<Brain />}
|
|
text={props.data.chat_model}
|
|
hoverText={`The agent uses the ${props.data.chat_model} model to chat with you.`}
|
|
/>
|
|
{props.data.output_modes.map((outputMode) => (
|
|
<Badge
|
|
key={outputMode}
|
|
icon={getIconForSlashCommand(outputMode)}
|
|
hoverText={`${outputMode}: ${props.outputModeOptions[outputMode]}`}
|
|
/>
|
|
))}
|
|
{props.data.input_tools.map((inputTool) => (
|
|
<Badge
|
|
key={inputTool}
|
|
icon={getIconForSlashCommand(inputTool)}
|
|
hoverText={`${inputTool}: ${props.inputToolOptions[inputTool]}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card className={`shadow-md rounded-xl hover:shadow-lg dark:bg-muted`}>
|
|
{showLoginPrompt && (
|
|
<LoginPrompt
|
|
loginRedirectMessage={`Sign in to start chatting with ${props.data.name}`}
|
|
onOpenChange={setShowLoginPrompt}
|
|
isMobileWidth={props.isMobileWidth}
|
|
/>
|
|
)}
|
|
<CardHeader>
|
|
<CardTitle>
|
|
<Dialog
|
|
open={showModal}
|
|
onOpenChange={() => {
|
|
setShowModal(!showModal);
|
|
window.history.pushState({}, `Khoj AI - Agents`, `/agents`);
|
|
}}
|
|
>
|
|
<DialogTrigger className="focus-visible:outline-none">
|
|
<div className="flex items-center relative top-2">
|
|
{getIconFromIconName(props.data.icon, props.data.color)}
|
|
{props.data.name}
|
|
</div>
|
|
</DialogTrigger>
|
|
<div className="flex float-right">
|
|
{props.editCard && (
|
|
<div className="float-right">
|
|
<Popover>
|
|
<PopoverTrigger>
|
|
<Button
|
|
className={`bg-[hsl(var(--background))] w-10 h-10 p-0 rounded-xl border dark:border-neutral-700 shadow-sm hover:bg-stone-100 dark:hover:bg-neutral-900`}
|
|
>
|
|
<DotsThreeVertical
|
|
className={`w-6 h-6 ${convertColorToTextClass(props.data.color)}`}
|
|
/>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="w-fit grid p-1"
|
|
side={"bottom"}
|
|
align={"end"}
|
|
>
|
|
<Button
|
|
className="items-center justify-start"
|
|
variant={"ghost"}
|
|
onClick={() => setShowModal(true)}
|
|
>
|
|
<Pencil className="w-4 h-4 mr-2" />
|
|
Edit
|
|
</Button>
|
|
{props.editCard &&
|
|
props.data.privacy_level !== "private" && (
|
|
<ShareLink
|
|
buttonTitle="Share"
|
|
title="Share Agent"
|
|
description="Share a link to this agent with others. They'll be able to chat with it, and ask questions to all of its knowledge base."
|
|
buttonVariant={"ghost" as const}
|
|
includeIcon={true}
|
|
url={`${window.location.origin}/agents?agent=${props.data.slug}`}
|
|
/>
|
|
)}
|
|
{props.data.creator === userData?.username && (
|
|
<Button
|
|
className="items-center justify-start"
|
|
variant={"destructive"}
|
|
onClick={() => {
|
|
fetch(`/api/agents/${props.data.slug}`, {
|
|
method: "DELETE",
|
|
}).then(() => {
|
|
props.setAgentChangeTriggered(true);
|
|
});
|
|
}}
|
|
>
|
|
<Trash className="w-4 h-4 mr-2" />
|
|
Delete
|
|
</Button>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
)}
|
|
{(props.showChatButton ?? true) && (
|
|
<div className="float-right ml-2">
|
|
{props.userProfile ? (
|
|
<Button
|
|
className={`bg-[hsl(var(--background))] w-10 h-10 p-0 rounded-xl border dark:border-neutral-700 shadow-sm hover:bg-stone-100 dark:hover:bg-neutral-900`}
|
|
onClick={() => openChat(props.data.slug, userData)}
|
|
>
|
|
<PaperPlaneTilt
|
|
className={`w-6 h-6 ${convertColorToTextClass(props.data.color)}`}
|
|
/>
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
className={`bg-[hsl(var(--background))] w-14 h-14 rounded-xl border dark:border-neutral-700 shadow-sm hover:bg-stone-100 dark:hover:bg-neutral-900`}
|
|
onClick={() => setShowLoginPrompt(true)}
|
|
>
|
|
<PaperPlaneTilt
|
|
className={`w-6 h-6 ${convertColorToTextClass(props.data.color)}`}
|
|
/>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{props.editCard ? (
|
|
<DialogContent
|
|
className={
|
|
"lg:max-w-screen-lg py-4 overflow-y-scroll h-4/6 rounded-lg flex flex-col"
|
|
}
|
|
>
|
|
<DialogTitle>
|
|
Edit <b>{props.data.name}</b>
|
|
</DialogTitle>
|
|
<AgentModificationForm
|
|
form={form}
|
|
onSubmit={onSubmit}
|
|
create={false}
|
|
errors={errors}
|
|
filesOptions={props.filesOptions}
|
|
modelOptions={props.modelOptions}
|
|
slug={props.data.slug}
|
|
inputToolOptions={props.inputToolOptions}
|
|
isSubscribed={props.isSubscribed}
|
|
outputModeOptions={props.outputModeOptions}
|
|
/>
|
|
</DialogContent>
|
|
) : (
|
|
<DialogContent className="whitespace-pre-line max-h-[80vh] max-w-[90vw] rounded-lg">
|
|
<DialogHeader>
|
|
<div className="flex items-center">
|
|
{getIconFromIconName(props.data.icon, props.data.color)}
|
|
<p className="font-bold text-lg">{props.data.name}</p>
|
|
</div>
|
|
</DialogHeader>
|
|
<div className="max-h-[60vh] overflow-y-scroll text-neutral-500 dark:text-white">
|
|
{props.data.persona}
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
{makeBadgeFooter()}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
className={`pt-6 pb-6 ${stylingString} bg-white dark:bg-[hsl(var(--background))] text-neutral-500 dark:text-white border-2 border-stone-100 shadow-sm rounded-xl hover:bg-stone-100 dark:hover:bg-neutral-900 dark:border-neutral-700`}
|
|
onClick={() => {
|
|
openChat(props.data.slug, userData);
|
|
setShowModal(false);
|
|
}}
|
|
>
|
|
<PaperPlaneTilt
|
|
className={`w-6 h-6 m-2 ${convertColorToTextClass(props.data.color)}`}
|
|
/>
|
|
Start Chatting
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
)}
|
|
</Dialog>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className={styles.agentPersonality}>
|
|
<button
|
|
className={`${styles.infoButton} text-neutral-500 dark:text-white`}
|
|
onClick={() => setShowModal(true)}
|
|
>
|
|
<p>{props.data.persona}</p>
|
|
</button>
|
|
</div>
|
|
</CardContent>
|
|
<CardFooter>
|
|
<div className="flex flex-wrap items-center gap-1">{makeBadgeFooter()}</div>
|
|
</CardFooter>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
interface AgentModificationFormProps {
|
|
form: UseFormReturn<z.infer<typeof EditAgentSchema>>;
|
|
onSubmit: (values: z.infer<typeof EditAgentSchema>) => void;
|
|
userConfig?: UserConfig;
|
|
create?: boolean;
|
|
errors?: string | null;
|
|
modelOptions: ModelOptions[];
|
|
filesOptions: string[];
|
|
inputToolOptions: { [key: string]: string };
|
|
outputModeOptions: { [key: string]: string };
|
|
slug?: string;
|
|
isSubscribed: boolean;
|
|
}
|
|
|
|
export function AgentModificationForm(props: AgentModificationFormProps) {
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
const iconOptions = getAvailableIcons();
|
|
const colorOptions = tailwindColors;
|
|
const colorOptionClassName = convertColorToTextClass(props.form.getValues("color"));
|
|
|
|
const [isDragAndDropping, setIsDragAndDropping] = useState(false);
|
|
const [warning, setWarning] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [progressValue, setProgressValue] = useState(0);
|
|
const [uploadedFiles, setUploadedFiles] = useState<string[]>([]);
|
|
const [allFileOptions, setAllFileOptions] = useState<string[]>([]);
|
|
const [currentStep, setCurrentStep] = useState(0);
|
|
|
|
const [showSubscribeDialog, setShowSubscribeDialog] = useState(false);
|
|
|
|
const privacyOptions = ["public", "private", "protected"];
|
|
|
|
const basicFields = [
|
|
{ name: "name", label: "Name" },
|
|
{ name: "persona", label: "Personality" },
|
|
];
|
|
|
|
const toolsFields = [
|
|
{ name: "input_tools", label: "Input Tools" },
|
|
{ name: "output_modes", label: "Output Modes" },
|
|
];
|
|
|
|
const knowledgeBaseFields = [{ name: "files", label: "Knowledge Base" }];
|
|
|
|
const customizationFields = [
|
|
{ name: "color", label: "Color" },
|
|
{ name: "icon", label: "Icon" },
|
|
{ name: "chat_model", label: "Chat Model" },
|
|
{ name: "privacy_level", label: "Privacy Level" },
|
|
];
|
|
|
|
const formGroups = [
|
|
{ fields: basicFields, label: "Basic Settings", tabName: "basic" },
|
|
{ fields: customizationFields, label: "Customization & Access", tabName: "customize" },
|
|
{ fields: knowledgeBaseFields, label: "Knowledge Base", tabName: "knowledge" },
|
|
{ fields: toolsFields, label: "Tools Settings", tabName: "tools" },
|
|
];
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!uploading) {
|
|
setProgressValue(0);
|
|
}
|
|
|
|
if (uploading) {
|
|
const interval = setInterval(() => {
|
|
setProgressValue((prev) => {
|
|
const increment = Math.floor(Math.random() * 5) + 1; // Generates a random number between 1 and 5
|
|
const nextValue = prev + increment;
|
|
return nextValue < 100 ? nextValue : 100; // Ensures progress does not exceed 100
|
|
});
|
|
}, 800);
|
|
return () => clearInterval(interval);
|
|
}
|
|
}, [uploading]);
|
|
|
|
useEffect(() => {
|
|
const currentFiles = props.form.getValues("files") || [];
|
|
const fileOptions = props.filesOptions || [];
|
|
const concatenatedFiles = [...currentFiles, ...fileOptions];
|
|
const fullAllFileOptions = [...allFileOptions, ...concatenatedFiles];
|
|
const dedupedAllFileOptions = Array.from(new Set(fullAllFileOptions));
|
|
setAllFileOptions(dedupedAllFileOptions);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (uploadedFiles.length > 0) {
|
|
handleAgentFileChange(uploadedFiles);
|
|
setAllFileOptions((prev) => [...prev, ...uploadedFiles]);
|
|
}
|
|
}, [uploadedFiles]);
|
|
|
|
useEffect(() => {
|
|
if (props.errors) {
|
|
setIsSaving(false);
|
|
}
|
|
}, [props.errors]);
|
|
|
|
function handleDragOver(event: React.DragEvent<HTMLDivElement>) {
|
|
event.preventDefault();
|
|
setIsDragAndDropping(true);
|
|
}
|
|
|
|
function handleDragLeave(event: React.DragEvent<HTMLDivElement>) {
|
|
event.preventDefault();
|
|
setIsDragAndDropping(false);
|
|
}
|
|
|
|
function handleDragAndDropFiles(event: React.DragEvent<HTMLDivElement>) {
|
|
event.preventDefault();
|
|
setIsDragAndDropping(false);
|
|
|
|
if (!event.dataTransfer.files) return;
|
|
|
|
uploadFiles(event.dataTransfer.files);
|
|
}
|
|
|
|
function uploadFiles(files: FileList) {
|
|
uploadDataForIndexing(files, setWarning, setUploading, setError, setUploadedFiles);
|
|
}
|
|
|
|
function openFileInput() {
|
|
if (fileInputRef && fileInputRef.current) {
|
|
fileInputRef.current.click();
|
|
}
|
|
}
|
|
|
|
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
if (!event.target.files) return;
|
|
|
|
uploadFiles(event.target.files);
|
|
}
|
|
|
|
const handleNext = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
event.preventDefault();
|
|
if (currentStep < formGroups.length - 1) {
|
|
setCurrentStep(currentStep + 1);
|
|
}
|
|
};
|
|
|
|
const handlePrevious = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
event.preventDefault();
|
|
if (currentStep > 0) {
|
|
setCurrentStep(currentStep - 1);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = (values: any) => {
|
|
props.onSubmit(values);
|
|
setIsSaving(true);
|
|
};
|
|
|
|
const handleAgentFileChange = (files: string[]) => {
|
|
for (const file of files) {
|
|
const currentFiles = props.form.getValues("files") || [];
|
|
const newFiles = currentFiles.includes(file)
|
|
? currentFiles.filter((item) => item !== file)
|
|
: [...currentFiles, file];
|
|
props.form.setValue("files", newFiles);
|
|
}
|
|
};
|
|
|
|
const areRequiredFieldsCompletedForCurrentStep = (formGroup: {
|
|
fields: { name: string }[];
|
|
}) => {
|
|
try {
|
|
EditAgentSchema.parse(props.form.getValues());
|
|
return true;
|
|
} catch (error) {
|
|
const errors: { [key: string]: string } = (error as ZodError).errors.reduce(
|
|
(acc: any, curr: any) => {
|
|
acc[curr.path[0]] = curr.message;
|
|
return acc;
|
|
},
|
|
{},
|
|
);
|
|
|
|
for (const field of formGroup.fields) {
|
|
if (errors[field.name]) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
};
|
|
|
|
if (showSubscribeDialog) {
|
|
return (
|
|
<AlertDialog open={true}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Upgrade to Futurist</AlertDialogTitle>
|
|
</AlertDialogHeader>
|
|
<AlertDialogDescription>
|
|
You need to be a Futurist subscriber to create more agents.{" "}
|
|
<a href="/settings">Upgrade now</a>.
|
|
</AlertDialogDescription>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel
|
|
onClick={() => {
|
|
setShowSubscribeDialog(false);
|
|
}}
|
|
>
|
|
Cancel
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => {
|
|
window.location.href = "/settings";
|
|
}}
|
|
>
|
|
Continue
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
);
|
|
}
|
|
|
|
const renderFormField = (fieldName: string) => {
|
|
switch (fieldName) {
|
|
case "name":
|
|
return (
|
|
<FormField
|
|
key={fieldName}
|
|
control={props.form.control}
|
|
name="name"
|
|
render={({ field }) => (
|
|
<FormItem className="space-y-0 grid gap-2">
|
|
<FormLabel>Name</FormLabel>
|
|
<FormDescription>
|
|
What should this agent be called? Pick something descriptive &
|
|
memorable.
|
|
</FormDescription>
|
|
<FormControl>
|
|
<Input
|
|
className="dark:bg-muted"
|
|
placeholder="Biologist"
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
case "chat_model":
|
|
return (
|
|
<FormField
|
|
key={fieldName}
|
|
control={props.form.control}
|
|
name="chat_model"
|
|
render={({ field }) => (
|
|
<FormItem className="my-3 grid gap-2">
|
|
<FormLabel>Chat Model</FormLabel>
|
|
<FormDescription>
|
|
{!props.isSubscribed ? (
|
|
<p className="text-secondary-foreground">
|
|
Upgrade to the <a href="/settings">Futurist plan</a> to
|
|
access all models.
|
|
</p>
|
|
) : (
|
|
<p>Which chat model would you like to use?</p>
|
|
)}
|
|
</FormDescription>
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
defaultValue={field.value}
|
|
disabled={!props.isSubscribed}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger className="text-left dark:bg-muted">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent className="items-start space-y-1 inline-flex flex-col">
|
|
{props.modelOptions.map((modelOption) => (
|
|
<SelectItem
|
|
key={modelOption.id}
|
|
value={modelOption.name}
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
{modelOption.name}
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
case "privacy_level":
|
|
return (
|
|
<FormField
|
|
key={fieldName}
|
|
control={props.form.control}
|
|
name="privacy_level"
|
|
render={({ field }) => (
|
|
<FormItem className="my-3 grid gap-2">
|
|
<FormLabel>
|
|
<div>Privacy Level</div>
|
|
</FormLabel>
|
|
<FormDescription>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant={"ghost" as const}
|
|
className="p-0 h-fit"
|
|
>
|
|
<span className="items-center flex gap-1 text-sm">
|
|
<Info className="inline" />
|
|
<p className="text-sm">Learn more</p>
|
|
</span>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent>
|
|
<b>Private</b>: only visible to you.
|
|
<br />
|
|
<b>Protected</b>: visible to anyone with a link.
|
|
<br />
|
|
<b>Public</b>: visible to everyone.
|
|
<br />
|
|
All public agents will be reviewed by us before they are
|
|
launched.
|
|
</PopoverContent>
|
|
</Popover>
|
|
</FormDescription>
|
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
<FormControl>
|
|
<SelectTrigger className="w-[200px] dark:bg-muted">
|
|
<SelectValue placeholder="private" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent className="items-center space-y-1 inline-flex flex-col">
|
|
{privacyOptions.map((privacyOption) => (
|
|
<SelectItem key={privacyOption} value={privacyOption}>
|
|
<div className="flex items-center space-x-2">
|
|
{privacyOption}
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
case "color":
|
|
return (
|
|
<FormField
|
|
key={fieldName}
|
|
control={props.form.control}
|
|
name="color"
|
|
render={({ field }) => (
|
|
<FormItem className="space-y-3">
|
|
<FormLabel>Color</FormLabel>
|
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
<FormControl>
|
|
<SelectTrigger className="w-[200px] dark:bg-muted">
|
|
<SelectValue placeholder="Color" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent className="items-center space-y-1 inline-flex flex-col">
|
|
{colorOptions.map((colorOption) => (
|
|
<SelectItem key={colorOption} value={colorOption}>
|
|
<div className="flex items-center space-x-2">
|
|
<Circle
|
|
className={`w-6 h-6 mr-2 ${convertColorToTextClass(colorOption)}`}
|
|
weight="fill"
|
|
/>
|
|
{colorOption}
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
case "icon":
|
|
return (
|
|
<FormField
|
|
key={fieldName}
|
|
control={props.form.control}
|
|
name="icon"
|
|
render={({ field }) => (
|
|
<FormItem className="space-y-3">
|
|
<FormLabel>Icon</FormLabel>
|
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
<FormControl>
|
|
<SelectTrigger className="w-[200px] dark:bg-muted">
|
|
<SelectValue placeholder="Icon" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent className="items-center space-y-1 inline-flex flex-col">
|
|
{iconOptions.map((iconOption) => (
|
|
<SelectItem key={iconOption} value={iconOption}>
|
|
<div className="flex items-center space-x-2">
|
|
{getIconFromIconName(
|
|
iconOption,
|
|
props.form.getValues("color"),
|
|
"w-6",
|
|
"h-6",
|
|
)}
|
|
{iconOption}
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
case "persona":
|
|
return (
|
|
<FormField
|
|
key={fieldName}
|
|
control={props.form.control}
|
|
name="persona"
|
|
render={({ field }) => (
|
|
<FormItem className="space-y-1 grid gap-2">
|
|
<FormLabel>Personality</FormLabel>
|
|
<FormDescription>
|
|
What is the personality, thought process, or tuning of this
|
|
agent? Get creative; this is how you can influence the agent
|
|
constitution.
|
|
</FormDescription>
|
|
<FormControl>
|
|
<Textarea
|
|
className="dark:bg-muted"
|
|
placeholder="You are an excellent biologist, at the top of your field in marine biology."
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
case "files":
|
|
return (
|
|
<FormField
|
|
key={fieldName}
|
|
control={props.form.control}
|
|
name="files"
|
|
render={({ field }) => (
|
|
<FormItem className="flex flex-col gap-2">
|
|
<FormLabel>Knowledge Base</FormLabel>
|
|
<FormDescription>
|
|
Which information should be part of its digital brain?{" "}
|
|
<a href="/settings">Manage data</a>.
|
|
</FormDescription>
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center justify-between text-sm gap-2 bg-muted p-2 rounded-lg">
|
|
<CaretUpDown />
|
|
{field.value && field.value.length > 0
|
|
? `${field.value.length} files selected`
|
|
: "Select files"}
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<Command>
|
|
<AlertDialog open={warning !== null || error != null}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Alert</AlertDialogTitle>
|
|
</AlertDialogHeader>
|
|
<AlertDialogDescription>
|
|
{warning || error}
|
|
</AlertDialogDescription>
|
|
<AlertDialogAction
|
|
className="bg-slate-400 hover:bg-slate-500"
|
|
onClick={() => {
|
|
setWarning(null);
|
|
setError(null);
|
|
setUploading(false);
|
|
}}
|
|
>
|
|
Close
|
|
</AlertDialogAction>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
<div
|
|
className={`flex flex-col h-full cursor-pointer`}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDragAndDropFiles}
|
|
onClick={openFileInput}
|
|
>
|
|
<input
|
|
type="file"
|
|
multiple
|
|
ref={fileInputRef}
|
|
style={{ display: "none" }}
|
|
onChange={handleFileChange}
|
|
/>
|
|
<div className="flex-none p-4">
|
|
{uploading && (
|
|
<Progress
|
|
indicatorColor="bg-slate-500"
|
|
className="w-full h-2 rounded-full"
|
|
value={progressValue}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={`flex-none p-4 bg-secondary border-b ${isDragAndDropping ? "animate-pulse" : ""} rounded-lg`}
|
|
>
|
|
<div className="flex items-center justify-center w-full h-16 border-2 border-dashed border-gray-300 rounded-lg">
|
|
{isDragAndDropping ? (
|
|
<div className="flex items-center justify-center w-full h-full">
|
|
<Waveform className="h-6 w-6 mr-2" />
|
|
<span>Drop files to upload</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center w-full h-full">
|
|
<Plus className="h-6 w-6 mr-2" />
|
|
<span>
|
|
Drag and drop files here
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<CommandInput placeholder="Select files..." />
|
|
<CommandList>
|
|
<CommandEmpty>No files found.</CommandEmpty>
|
|
<CommandGroup>
|
|
{allFileOptions.map((file) => (
|
|
<CommandItem
|
|
value={file}
|
|
key={file}
|
|
onSelect={() => {
|
|
const currentFiles =
|
|
props.form.getValues("files") ||
|
|
[];
|
|
const newFiles =
|
|
currentFiles.includes(file)
|
|
? currentFiles.filter(
|
|
(item) =>
|
|
item !== file,
|
|
)
|
|
: [...currentFiles, file];
|
|
props.form.setValue(
|
|
"files",
|
|
newFiles,
|
|
);
|
|
}}
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
field.value &&
|
|
field.value.includes(file)
|
|
? "opacity-100"
|
|
: "opacity-0",
|
|
)}
|
|
/>
|
|
{file}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
case "input_tools":
|
|
return (
|
|
<FormField
|
|
key={fieldName}
|
|
control={props.form.control}
|
|
name="input_tools"
|
|
render={({ field }) => (
|
|
<FormItem className="flex flex-col gap-2 my-2">
|
|
<FormLabel>Restrict Input Tools</FormLabel>
|
|
<FormDescription>
|
|
Which knowledge retrieval tools should this agent be limited to?
|
|
<br />
|
|
<b>Default:</b> No limitations.
|
|
</FormDescription>
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center justify-between text-sm gap-2 bg-muted p-2 rounded-lg">
|
|
<CaretUpDown />
|
|
{field.value && field.value.length > 0
|
|
? `${field.value.length} tools selected`
|
|
: "All tools"}
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<Command>
|
|
<CommandList>
|
|
<CommandGroup>
|
|
{Object.entries(props.inputToolOptions).map(
|
|
([key, value]) => (
|
|
<CommandItem
|
|
value={key}
|
|
key={key}
|
|
onSelect={() => {
|
|
const currentInputTools =
|
|
props.form.getValues(
|
|
"input_tools",
|
|
) || [];
|
|
const newInputTools =
|
|
currentInputTools.includes(
|
|
key,
|
|
)
|
|
? currentInputTools.filter(
|
|
(item) =>
|
|
item !== key,
|
|
)
|
|
: [
|
|
...currentInputTools,
|
|
key,
|
|
];
|
|
props.form.setValue(
|
|
"input_tools",
|
|
newInputTools,
|
|
);
|
|
}}
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
field.value &&
|
|
field.value.includes(
|
|
key,
|
|
)
|
|
? "opacity-100"
|
|
: "opacity-0",
|
|
)}
|
|
/>
|
|
<div
|
|
className={cn(
|
|
"flex items-center space-x-2",
|
|
)}
|
|
>
|
|
<p>
|
|
<b>{key}</b>
|
|
</p>
|
|
<p>{value}</p>
|
|
</div>
|
|
</CommandItem>
|
|
),
|
|
)}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
case "output_modes":
|
|
return (
|
|
<FormField
|
|
key={fieldName}
|
|
control={props.form.control}
|
|
name="output_modes"
|
|
render={({ field }) => (
|
|
<FormItem className="flex flex-col gap-2 my-2">
|
|
<FormLabel>Restrict Output Modes</FormLabel>
|
|
<FormDescription>
|
|
Which output modes should this agent be limited to?
|
|
<br />
|
|
<b>Default:</b> No limitations.
|
|
</FormDescription>
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center justify-between text-sm gap-2 bg-muted p-2 rounded-lg">
|
|
<CaretUpDown />
|
|
{field.value && field.value.length > 0
|
|
? `${field.value.length} modes selected`
|
|
: "All modes"}
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<Command>
|
|
<CommandList>
|
|
<CommandGroup>
|
|
{Object.entries(props.outputModeOptions).map(
|
|
([key, value]) => (
|
|
<CommandItem
|
|
value={key}
|
|
key={key}
|
|
onSelect={() => {
|
|
const currentOutputModes =
|
|
props.form.getValues(
|
|
"output_modes",
|
|
) || [];
|
|
const newOutputModes =
|
|
currentOutputModes.includes(
|
|
key,
|
|
)
|
|
? currentOutputModes.filter(
|
|
(item) =>
|
|
item !== key,
|
|
)
|
|
: [
|
|
...currentOutputModes,
|
|
key,
|
|
];
|
|
props.form.setValue(
|
|
"output_modes",
|
|
newOutputModes,
|
|
);
|
|
}}
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
field.value &&
|
|
field.value.includes(
|
|
key,
|
|
)
|
|
? "opacity-100"
|
|
: "opacity-0",
|
|
)}
|
|
/>
|
|
<div
|
|
className={cn(
|
|
"flex items-center space-x-2",
|
|
)}
|
|
>
|
|
<p>
|
|
<b>{key}</b>
|
|
</p>
|
|
<p>{value}</p>
|
|
</div>
|
|
</CommandItem>
|
|
),
|
|
)}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Form {...props.form}>
|
|
<form
|
|
onSubmit={props.form.handleSubmit(handleSubmit)}
|
|
className="space-y-6 h-full flex flex-col justify-between"
|
|
>
|
|
<Tabs defaultValue="basic" value={formGroups[currentStep].tabName}>
|
|
<TabsList className="grid grid-cols-2 md:grid-cols-4 gap-2 h-fit">
|
|
{formGroups.map((group) => (
|
|
<TabsTrigger
|
|
key={group.tabName}
|
|
value={group.tabName}
|
|
onClick={() =>
|
|
setCurrentStep(
|
|
formGroups.findIndex((g) => g.tabName === group.tabName),
|
|
)
|
|
}
|
|
>
|
|
{group.label}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
{formGroups.map((group) => (
|
|
<TabsContent key={group.tabName} value={group.tabName}>
|
|
{group.fields.map((field) => renderFormField(field.name))}
|
|
</TabsContent>
|
|
))}
|
|
</Tabs>
|
|
<div className="flex justify-between mt-4">
|
|
<Button
|
|
type="button"
|
|
variant={"outline"}
|
|
onClick={handlePrevious}
|
|
disabled={currentStep === 0}
|
|
className={`items-center ${isSaving ? "bg-stone-100 dark:bg-neutral-900" : ""} text-white ${colorOptionClassName}`}
|
|
>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Previous
|
|
</Button>
|
|
{currentStep < formGroups.length - 1 ? (
|
|
<Button
|
|
type="button"
|
|
variant={"outline"}
|
|
onClick={handleNext}
|
|
disabled={
|
|
!areRequiredFieldsCompletedForCurrentStep(formGroups[currentStep])
|
|
}
|
|
className={`items-center ${isSaving ? "bg-stone-100 dark:bg-neutral-900" : ""} text-white ${colorOptionClassName}`}
|
|
>
|
|
Next
|
|
<ArrowRight className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
type="submit"
|
|
variant={"outline"}
|
|
disabled={isSaving}
|
|
className={`items-center ${isSaving ? "bg-stone-100 dark:bg-neutral-900" : ""} text-white ${colorOptionClassName}`}
|
|
>
|
|
<FloppyDisk className="h-4 w-4 mr-2" />
|
|
{isSaving ? "Booting..." : "Save"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{props.errors && (
|
|
<Alert className="bg-secondary border-none my-4">
|
|
<AlertDescription className="flex items-center gap-1">
|
|
<ShieldWarning
|
|
weight="fill"
|
|
className="h-4 w-4 text-yellow-400 inline"
|
|
/>
|
|
<span>{props.errors}</span>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</form>
|
|
</Form>
|
|
);
|
|
}
|