Files
khoj/src/interface/web/app/agents/page.tsx
Debanjum Singh Solanky 13fb22f7e7 Update agent form data shown in edit card after save operaton on web app
Previously you had to refresh the page to see the updated data on
reopening the agents edit card after a save operation.

Now you see the latest saved agent data on reopening the agents edit
card. This should avoid confusion on whether the data was saved
correctly
2024-10-08 23:26:04 -07:00

1671 lines
81 KiB
TypeScript

"use client";
import styles from "./agents.module.css";
import useSWR from "swr";
import { useEffect, useRef, useState } from "react";
import {
useAuthenticatedData,
UserProfile,
ModelOptions,
useUserConfig,
UserConfig,
SubscriptionStates,
} from "../common/auth";
import { Button } from "@/components/ui/button";
import {
PaperPlaneTilt,
Lightning,
Plus,
Circle,
Info,
Check,
ShieldWarning,
Lock,
Book,
Brain,
Waveform,
CaretUpDown,
Globe,
LockOpen,
FloppyDisk,
DotsThreeCircleVertical,
DotsThreeVertical,
Pencil,
Trash,
} from "@phosphor-icons/react";
import { set, z } from "zod";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import LoginPrompt from "../components/loginPrompt/loginPrompt";
import { InlineLoading } from "../components/loading/loading";
import SidePanel from "../components/sidePanel/chatHistorySidePanel";
import {
getAvailableIcons,
getIconForSlashCommand,
getIconFromIconName,
} from "../common/iconUtils";
import { convertColorToTextClass, tailwindColors } from "../common/colorUtils";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useIsMobileWidth } from "../common/utils";
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 "../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 "../components/shareLink/shareLink";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
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>
);
}
const agentsFetcher = () =>
window
.fetch("/api/agents")
.then((res) => res.json())
.catch((err) => console.log(err));
// A generic fetcher function that uses the fetch API to make a request to a given URL and returns the response as JSON.
const fetcher = (url: string) => fetch(url).then((res) => res.json());
interface AgentCardProps {
data: AgentData;
userProfile: UserProfile | null;
isMobileWidth: boolean;
editCard: boolean;
filesOptions: string[];
modelOptions: ModelOptions[];
selectedChatModelOption: string;
isSubscribed: boolean;
setAgentChangeTriggered: (value: boolean) => void;
agentSlug: string;
inputToolOptions: { [key: string]: string };
outputModeOptions: { [key: string]: string };
}
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";
fetch(agentsApiUrl, {
method: method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(values),
})
.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 it can use 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-sm bg-gradient-to-b from-white 20% to-${props.data.color ? props.data.color : "gray"}-100/50 dark:from-[hsl(var(--background))] dark:to-${props.data.color ? props.data.color : "gray"}-950/50 rounded-xl hover:shadow-md`}
>
{showLoginPrompt && (
<LoginPrompt
loginRedirectMessage={`Sign in to start chatting with ${props.data.name}`}
onOpenChange={setShowLoginPrompt}
/>
)}
<CardHeader>
<CardTitle>
{!props.isMobileWidth ? (
<Dialog
open={showModal}
onOpenChange={() => {
setShowModal(!showModal);
window.history.pushState({}, `Khoj AI - Agents`, `/agents`);
}}
>
<DialogTrigger>
<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-14 h-14 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>
)}
<div className="float-right">
{props.userProfile ? (
<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={() => 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 overflow-y-scroll max-h-screen"}
>
<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]">
<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>
) : (
<Drawer
open={showModal}
onOpenChange={(open) => {
setShowModal(open);
window.history.pushState({}, `Khoj AI - Agents`, `/agents`);
}}
>
<DrawerTrigger>
<div className="flex items-center">
{getIconFromIconName(props.data.icon, props.data.color)}
{props.data.name}
</div>
</DrawerTrigger>
<div className="flex float-right">
{props.editCard && (
<div className="float-right">
<Popover>
<PopoverTrigger>
<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`}
>
<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>
)}
<div className="float-right">
{props.userProfile ? (
<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={() => 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 ? (
<DrawerContent className="whitespace-pre-line p-2">
<AgentModificationForm
form={form}
onSubmit={onSubmit}
create={false}
errors={errors}
filesOptions={props.filesOptions}
modelOptions={props.modelOptions}
slug={props.data.slug}
inputToolOptions={props.inputToolOptions}
outputModeOptions={props.outputModeOptions}
isSubscribed={props.isSubscribed}
/>
</DrawerContent>
) : (
<DrawerContent className="whitespace-pre-line p-2">
<DrawerHeader>
<DrawerTitle>{props.data.name}</DrawerTitle>
<DrawerDescription>Persona</DrawerDescription>
</DrawerHeader>
{props.data.persona}
<div className="flex flex-wrap items-center gap-1">
{makeBadgeFooter()}
</div>
<DrawerFooter>
<DrawerClose>Done</DrawerClose>
</DrawerFooter>
</DrawerContent>
)}
</Drawer>
)}
</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>
);
}
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 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;
}
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 [showSubscribeDialog, setShowSubscribeDialog] = useState(true);
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];
setAllFileOptions((prev) => [...prev, ...concatenatedFiles]);
}, []);
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 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 privacyOptions = ["public", "private", "protected"];
if (!props.isSubscribed && 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>
);
}
return (
<Form {...props.form}>
<form
onSubmit={props.form.handleSubmit((values) => {
props.onSubmit(values);
setIsSaving(true);
})}
className="space-y-6"
>
<FormField
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 placeholder="Biologist" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
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
placeholder="You are an excellent biologist, at the top of your field in marine biology."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={props.form.control}
name="chat_model"
render={({ field }) => (
<FormItem className="space-y-1 grid gap-2">
<FormLabel>Chat Model</FormLabel>
<FormDescription>
Which large language model should this agent use?
</FormDescription>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="text-left">
<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>
)}
/>
<FormField
control={props.form.control}
name="privacy_level"
render={({ field }) => (
<FormItem className="space-y-1 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]">
<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>
)}
/>
<div className="grid">
<FormLabel className="mb-2">Look & Feel</FormLabel>
<div className="flex gap-1 justify-left">
<FormField
control={props.form.control}
name="color"
render={({ field }) => (
<FormItem className="space-y-3">
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="w-[200px]">
<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>
)}
/>
<FormField
control={props.form.control}
name="icon"
render={({ field }) => (
<FormItem className="space-y-3">
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="w-[200px]">
<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>
)}
/>
</div>
</div>
<FormItem className="flex flex-col">
<FormLabel className="text-md">Advanced Settings</FormLabel>
<FormDescription>
These are optional settings that you can use to customize your agent.
</FormDescription>
</FormItem>
<FormField
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">
<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>
)}
/>
<FormField
control={props.form.control}
name="input_tools"
render={({ field }) => (
<FormItem className="flex flex-col gap-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">
<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",
)}
/>
<b>{key}</b>: {value}
</CommandItem>
),
)}
</CommandGroup>
</CommandList>
</Command>
</CollapsibleContent>
</Collapsible>
</FormItem>
)}
/>
<FormField
control={props.form.control}
name="output_modes"
render={({ field }) => (
<FormItem className="flex flex-col gap-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">
<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",
)}
/>
<b>{key}</b>: {value}
</CommandItem>
),
)}
</CommandGroup>
</CommandList>
</Command>
</CollapsibleContent>
</Collapsible>
</FormItem>
)}
/>
{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>
)}
<fieldset>
<Button
type="submit"
variant={"ghost"}
disabled={isSaving || !props.isSubscribed}
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>
{!!!props.create && props.form.getValues("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}
buttonClassName={`${colorOptionClassName}`}
includeIcon={true}
url={`${window.location.origin}/agents?agent=${props.slug}`}
/>
)}
</fieldset>
</form>
</Form>
);
}
interface CreateAgentCardProps {
data: AgentData;
userProfile: UserProfile | null;
isMobileWidth: boolean;
filesOptions: string[];
modelOptions: ModelOptions[];
selectedChatModelOption: string;
isSubscribed: boolean;
setAgentChangeTriggered: (value: boolean) => void;
inputToolOptions: { [key: string]: string };
outputModeOptions: { [key: string]: string };
}
function CreateAgentCard(props: CreateAgentCardProps) {
const [showModal, setShowModal] = useState(false);
const [errors, setErrors] = useState<string | null>(null);
const [showLoginPrompt, setShowLoginPrompt] = useState(true);
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.selectedChatModelOption,
files: [],
},
});
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.selectedChatModelOption,
files: [],
});
}, [props.selectedChatModelOption, props.data]);
const onSubmit = (values: z.infer<typeof EditAgentSchema>) => {
let agentsApiUrl = `/api/agents`;
fetch(agentsApiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(values),
})
.then((response) => {
if (response.status === 200) {
form.reset();
setShowModal(false);
setErrors(null);
props.setAgentChangeTriggered(true);
} else {
response.json().then((data) => {
console.error(data);
if (data.error) {
setErrors(data.error);
}
});
}
})
.catch((error) => {
console.error("Error:", error);
setErrors(error);
});
};
if (props.isMobileWidth) {
return (
<Drawer open={showModal} onOpenChange={setShowModal}>
<DrawerTrigger>
<div className="flex items-center">
<Plus />
Create Agent
</div>
</DrawerTrigger>
<DrawerContent className="p-2">
<DrawerHeader>
<DrawerTitle>Create Agent</DrawerTitle>
</DrawerHeader>
{!props.userProfile && showLoginPrompt && (
<LoginPrompt
loginRedirectMessage="Sign in to start chatting with a specialized agent"
onOpenChange={setShowLoginPrompt}
/>
)}
<AgentModificationForm
form={form}
onSubmit={onSubmit}
create={true}
errors={errors}
filesOptions={props.filesOptions}
modelOptions={props.modelOptions}
inputToolOptions={props.inputToolOptions}
outputModeOptions={props.outputModeOptions}
isSubscribed={props.isSubscribed}
/>
<DrawerFooter>
<DrawerClose>Dismiss</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogTrigger>
<div className="flex items-center text-md gap-2">
<Plus />
Create Agent
</div>
</DialogTrigger>
<DialogContent className={"lg:max-w-screen-lg overflow-y-scroll max-h-screen"}>
<DialogHeader>Create Agent</DialogHeader>
{!props.userProfile && showLoginPrompt && (
<LoginPrompt
loginRedirectMessage="Sign in to start chatting with a specialized agent"
onOpenChange={setShowLoginPrompt}
/>
)}
<AgentModificationForm
form={form}
onSubmit={onSubmit}
create={true}
errors={errors}
filesOptions={props.filesOptions}
modelOptions={props.modelOptions}
inputToolOptions={props.inputToolOptions}
outputModeOptions={props.outputModeOptions}
isSubscribed={props.isSubscribed}
/>
</DialogContent>
</Dialog>
);
}
interface AgentConfigurationOptions {
input_tools: { [key: string]: string };
output_modes: { [key: string]: string };
}
export default function Agents() {
const { data, error, mutate } = useSWR<AgentData[]>("agents", agentsFetcher, {
revalidateOnFocus: false,
});
const authenticatedData = useAuthenticatedData();
const { userConfig } = useUserConfig(true);
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const isMobileWidth = useIsMobileWidth();
const [personalAgents, setPersonalAgents] = useState<AgentData[]>([]);
const [publicAgents, setPublicAgents] = useState<AgentData[]>([]);
const [agentSlug, setAgentSlug] = useState<string | null>(null);
const { data: filesData, error: fileError } = useSWR<string[]>(
userConfig ? "/api/content/computer" : null,
fetcher,
);
const { data: agentConfigurationOptions, error: agentConfigurationOptionsError } =
useSWR<AgentConfigurationOptions>("/api/agents/options", fetcher);
const [agentChangeTriggered, setAgentChangeTriggered] = useState(false);
useEffect(() => {
if (agentChangeTriggered) {
mutate();
setAgentChangeTriggered(false);
}
}, [agentChangeTriggered]);
useEffect(() => {
if (data) {
const personalAgents = data.filter(
(agent) => agent.creator === authenticatedData?.username,
);
setPersonalAgents(personalAgents);
// Public agents are agents that are not private and not created by the user
const publicAgents = data.filter(
(agent) =>
agent.privacy_level !== "private" &&
agent.creator !== authenticatedData?.username,
);
setPublicAgents(publicAgents);
if (typeof window !== "undefined") {
const searchParams = new URLSearchParams(window.location.search);
const agentSlug = searchParams.get("agent");
// Search for the agent with the slug in the URL
if (agentSlug) {
setAgentSlug(agentSlug);
const selectedAgent = data.find((agent) => agent.slug === agentSlug);
if (!selectedAgent) {
// See if the agent is accessible as a protected agent.
fetch(`/api/agents/${agentSlug}`)
.then((res) => {
if (res.status === 404) {
throw new Error("Agent not found");
}
return res.json();
})
.then((agent: AgentData) => {
if (agent.privacy_level === "protected") {
setPublicAgents((prev) => [...prev, agent]);
}
});
}
}
}
}
}, [data]);
if (error) {
return (
<main className={styles.main}>
<div className={`${styles.titleBar} text-5xl`}>Agents</div>
<div className={styles.agentList}>Error loading agents</div>
</main>
);
}
if (!data) {
return (
<main className={styles.main}>
<div className={styles.agentList}>
<InlineLoading /> booting up your agents
</div>
</main>
);
}
const modelOptions: ModelOptions[] = userConfig?.chat_model_options || [];
const selectedChatModelOption: number = userConfig?.selected_chat_model_config || 0;
const isSubscribed: boolean =
(userConfig?.subscription_state &&
[
SubscriptionStates.SUBSCRIBED.valueOf(),
SubscriptionStates.TRIAL.valueOf(),
SubscriptionStates.UNSUBSCRIBED.valueOf(),
].includes(userConfig.subscription_state)) ||
false;
// The default model option should map to the item in the modelOptions array that has the same id as the selectedChatModelOption
const defaultModelOption = modelOptions.find(
(modelOption) => modelOption.id === selectedChatModelOption,
);
return (
<main className={`w-full mx-auto`}>
<div className={`grid w-full mx-auto`}>
<div className={`${styles.sidePanel} top-0`}>
<SidePanel
conversationId={null}
uploadedFiles={[]}
isMobileWidth={isMobileWidth}
/>
</div>
<div className={`${styles.pageLayout} w-full`}>
<div className={`pt-6 md:pt-8 flex justify-between`}>
<h1 className="text-3xl flex items-center">Agents</h1>
<div className="ml-auto float-right border p-2 pt-3 rounded-xl font-bold hover:bg-stone-100 dark:hover:bg-neutral-900">
<CreateAgentCard
data={{
slug: "",
name: "",
persona: "",
color: "",
icon: "",
privacy_level: "private",
managed_by_admin: false,
chat_model: "",
input_tools: [],
output_modes: [],
}}
userProfile={authenticatedData}
isMobileWidth={isMobileWidth}
filesOptions={filesData || []}
modelOptions={userConfig?.chat_model_options || []}
selectedChatModelOption={defaultModelOption?.name || ""}
isSubscribed={isSubscribed}
setAgentChangeTriggered={setAgentChangeTriggered}
inputToolOptions={agentConfigurationOptions?.input_tools || {}}
outputModeOptions={agentConfigurationOptions?.output_modes || {}}
/>
</div>
</div>
{showLoginPrompt && (
<LoginPrompt
loginRedirectMessage="Sign in to start chatting with a specialized agent"
onOpenChange={setShowLoginPrompt}
/>
)}
<Alert className="bg-secondary border-none my-4">
<AlertDescription>
<Lightning weight={"fill"} className="h-4 w-4 text-purple-400 inline" />
<span className="font-bold">How it works</span> Use any of these
specialized personas to tune your conversation to your needs.
</AlertDescription>
</Alert>
<div className="pt-6 md:pt-8">
<div className={`${styles.agentList}`}>
{personalAgents.map((agent) => (
<AgentCard
key={agent.slug}
data={agent}
userProfile={authenticatedData}
isMobileWidth={isMobileWidth}
filesOptions={filesData ?? []}
selectedChatModelOption={defaultModelOption?.name || ""}
isSubscribed={isSubscribed}
setAgentChangeTriggered={setAgentChangeTriggered}
modelOptions={userConfig?.chat_model_options || []}
editCard={true}
agentSlug={agentSlug || ""}
inputToolOptions={agentConfigurationOptions?.input_tools || {}}
outputModeOptions={
agentConfigurationOptions?.output_modes || {}
}
/>
))}
</div>
</div>
<div className="pt-6 md:pt-8">
<h2 className="text-2xl">Explore</h2>
<div className={`${styles.agentList}`}>
{publicAgents.map((agent) => (
<AgentCard
key={agent.slug}
data={agent}
userProfile={authenticatedData}
isMobileWidth={isMobileWidth}
editCard={false}
filesOptions={filesData ?? []}
selectedChatModelOption={defaultModelOption?.name || ""}
isSubscribed={isSubscribed}
setAgentChangeTriggered={setAgentChangeTriggered}
modelOptions={userConfig?.chat_model_options || []}
agentSlug={agentSlug || ""}
inputToolOptions={agentConfigurationOptions?.input_tools || {}}
outputModeOptions={
agentConfigurationOptions?.output_modes || {}
}
/>
))}
</div>
</div>
</div>
</div>
</main>
);
}