diff --git a/src/interface/web/app/agents/page.tsx b/src/interface/web/app/agents/page.tsx index 45b485fb..697d4f80 100644 --- a/src/interface/web/app/agents/page.tsx +++ b/src/interface/web/app/agents/page.tsx @@ -4,107 +4,32 @@ import styles from "./agents.module.css"; import useSWR from "swr"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, 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, - DotsThreeVertical, - Pencil, - Trash, - ArrowRight, - ArrowLeft, -} from "@phosphor-icons/react"; -import { set, 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 { Lightning, Plus } from "@phosphor-icons/react"; +import { z } from "zod"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "@/components/ui/dialog"; 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 { + AgentCard, + EditAgentSchema, + AgentModificationForm, +} from "@/app/components/agentCard/agentCard"; -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 { useForm } from "react-hook-form"; 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; @@ -121,49 +46,6 @@ export interface AgentData { 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}
- )} -
-
-
-
- ); -} - const agentsFetcher = () => window .fetch("/api/agents") @@ -173,1150 +55,6 @@ const agentsFetcher = () => // 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(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.userProfile ? ( - - ) : ( - - )} -
-
- {props.editCard ? ( - - - Edit {props.data.name} - - - - ) : ( - - -
- {getIconFromIconName(props.data.icon, props.data.color)} -

{props.data.name}

-
-
-
- {props.data.persona} -
-
- {makeBadgeFooter()} -
- - - -
- )} -
-
-
- -
- -
-
- -
{makeBadgeFooter()}
-
-
- ); -} - -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>; - 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; -} - -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(true); - - const privacyOptions = ["public", "private", "protected"]; - - const basicFields = [ - { name: "name", label: "Name" }, - { name: "persona", label: "Personality" }, - ]; - - const advancedFields = [ - { name: "files", label: "Knowledge Base" }, - { name: "input_tools", label: "Input Tools" }, - { name: "output_modes", label: "Output Modes" }, - ]; - - 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" }, - { fields: customizationFields, label: "Customization & Access" }, - { fields: advancedFields, label: "Advanced Settings" }, - ]; - - 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 (!props.isSubscribed && 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 - - Which large language model should this agent 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 - Choose a color for your agent. - - - - )} - /> - ); - case "icon": - return ( - ( - - Icon - Choose an icon for your agent. - - - - )} - /> - ); - 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. - - -