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 (
{props.hoverText || displayBadgeText}
{props.icon}
{displayBadgeText && displayBadgeText.length > 0 && (
{displayBadgeText}
)}
); } 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(null); let lockIcon = ; let privacyHoverText = "Private agents are only visible to you."; if (props.data.privacy_level === "public") { lockIcon = ; privacyHoverText = "Public agents are visible to everyone."; } else if (props.data.privacy_level === "protected") { lockIcon = ; privacyHoverText = "Protected agents are visible to anyone with a direct link."; } const userData = props.userProfile; const form = useForm>({ 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) => { 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 (
{props.editCard && ( )} {props.data.files && props.data.files.length > 0 && ( } text={`knowledge`} hoverText={`The agent has a custom knowledge base with ${props.data.files.length} documents. It can use them to give you answers.`} /> )} } 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) => ( ))} {props.data.input_tools.map((inputTool) => ( ))}
); } return ( {showLoginPrompt && ( )} { setShowModal(!showModal); window.history.pushState({}, `Khoj AI - Agents`, `/agents`); }} >
{getIconFromIconName(props.data.icon, props.data.color)} {props.data.name}
{props.editCard && (
{props.editCard && props.data.privacy_level !== "private" && ( )} {props.data.creator === userData?.username && ( )}
)} {(props.showChatButton ?? true) && (
{props.userProfile ? ( ) : ( )}
)}
{props.editCard ? ( Edit {props.data.name} ) : (
{getIconFromIconName(props.data.icon, props.data.color)}

{props.data.name}

{props.data.persona}
{makeBadgeFooter()}
)}
{makeBadgeFooter()}
); } interface AgentModificationFormProps { form: UseFormReturn>; onSubmit: (values: z.infer) => 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(null); const [error, setError] = useState(null); const [uploading, setUploading] = useState(false); const [progressValue, setProgressValue] = useState(0); const [uploadedFiles, setUploadedFiles] = useState([]); const [allFileOptions, setAllFileOptions] = useState([]); 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(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) { event.preventDefault(); setIsDragAndDropping(true); } function handleDragLeave(event: React.DragEvent) { event.preventDefault(); setIsDragAndDropping(false); } function handleDragAndDropFiles(event: React.DragEvent) { 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) { if (!event.target.files) return; uploadFiles(event.target.files); } const handleNext = (event: React.MouseEvent) => { event.preventDefault(); if (currentStep < formGroups.length - 1) { setCurrentStep(currentStep + 1); } }; const handlePrevious = (event: React.MouseEvent) => { 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 ( Upgrade to Futurist You need to be a Futurist subscriber to create more agents.{" "} Upgrade now. { setShowSubscribeDialog(false); }} > Cancel { window.location.href = "/settings"; }} > Continue ); } const renderFormField = (fieldName: string) => { switch (fieldName) { case "name": return ( ( Name What should this agent be called? Pick something descriptive & memorable. )} /> ); case "chat_model": return ( ( Chat Model {!props.isSubscribed ? (

Upgrade to the Futurist plan to access all models.

) : (

Which chat model would you like to use?

)}
)} /> ); case "privacy_level": return ( (
Privacy Level
Private: only visible to you.
Protected: visible to anyone with a link.
Public: visible to everyone.
All public agents will be reviewed by us before they are launched.
)} /> ); case "color": return ( ( Color )} /> ); case "icon": return ( ( Icon )} /> ); case "persona": return ( ( Personality What is the personality, thought process, or tuning of this agent? Get creative; this is how you can influence the agent constitution.