diff --git a/src/interface/web/app/agents/page.tsx b/src/interface/web/app/agents/page.tsx index b5c1524f..fef3e932 100644 --- a/src/interface/web/app/agents/page.tsx +++ b/src/interface/web/app/agents/page.tsx @@ -2,18 +2,43 @@ import styles from "./agents.module.css"; -import Image from "next/image"; import useSWR from "swr"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; -import { useAuthenticatedData, UserProfile } from "../common/auth"; +import { + useAuthenticatedData, + UserProfile, + ModelOptions, + useUserConfig, + UserConfig, + SubscriptionStates, +} from "../common/auth"; import { Button } from "@/components/ui/button"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { PaperPlaneTilt, Lightning, Plus } from "@phosphor-icons/react"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +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, @@ -34,18 +59,73 @@ import { import LoginPrompt from "../components/loginPrompt/loginPrompt"; import { InlineLoading } from "../components/loading/loading"; import SidePanel from "../components/sidePanel/chatHistorySidePanel"; -import { getIconFromIconName } from "../common/iconUtils"; -import { convertColorToTextClass } from "../common/colorUtils"; +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, + AlertDialogContent, + AlertDialogDescription, + 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; - avatar: 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) { @@ -66,26 +146,86 @@ async function openChat(slug: string, userData: UserProfile | null) { } } +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") .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 searchParams = new URLSearchParams(window.location.search); - const agentSlug = searchParams.get("agent"); - const [showModal, setShowModal] = useState(agentSlug === props.data.slug); + 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, + }, + }); + if (showModal) { window.history.pushState( {}, @@ -94,8 +234,85 @@ function AgentCard(props: AgentCardProps) { ); } + const onSubmit = (values: z.infer) => { + 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) => { + console.log(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 it can use 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 (
- {getIconFromIconName(props.data.icon, props.data.color) || ( - {props.data.name} - )} + {getIconFromIconName(props.data.icon, props.data.color)} {props.data.name}
-
- {props.userProfile ? ( - - ) : ( - - )} -
- - -
- {getIconFromIconName(props.data.icon, props.data.color) || ( - {props.data.name} - )} -

{props.data.name}

+
+ {props.editCard && ( +
+ + + + + + + {props.editCard && + props.data.privacy_level !== "private" && ( + + )} + {props.data.creator === userData?.username && ( + + )} + +
- -
- {props.data.persona} + )} +
+ {props.userProfile ? ( + + ) : ( + + )}
- - - - +
+ {props.editCard ? ( + + + Edit {props.data.name} + + + + ) : ( + + +
+ {getIconFromIconName(props.data.icon, props.data.color)} +

{props.data.name}

+
+
+
+ {props.data.persona} +
+
+ {makeBadgeFooter()} +
+ + + +
+ )} ) : (
- {getIconFromIconName(props.data.icon, props.data.color) || ( - {props.data.name} - )} + {getIconFromIconName(props.data.icon, props.data.color)} {props.data.name}
-
- {props.userProfile ? ( - - ) : ( - +
+ {props.editCard && ( +
+ + + + + + + {props.editCard && + props.data.privacy_level !== "private" && ( + + )} + {props.data.creator === userData?.username && ( + + )} + + +
)} +
+ {props.userProfile ? ( + + ) : ( + + )} +
- - - {props.data.name} - Full Prompt - - {props.data.persona} - - Done - - + {props.editCard ? ( + + + + ) : ( + + + {props.data.name} + Persona + + {props.data.persona} +
+ {makeBadgeFooter()} +
+ + Done + +
+ )} )} @@ -249,18 +610,847 @@ function AgentCard(props: AgentCardProps) {
+ +
{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; + create?: boolean; + errors?: string | null; + modelOptions: ModelOptions[]; + filesOptions: string[]; + inputToolOptions: { [key: string]: string }; + outputModeOptions: { [key: string]: string }; + slug?: string; +} + +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 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(() => { + if (uploadedFiles.length > 0) { + handleAgentFileChange(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 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"]; + + return ( +
+ { + props.onSubmit(values); + setIsSaving(true); + })} + className="space-y-6" + > + ( + + Name + + What should this agent be called? Pick something descriptive & + memorable. + + + + + + + )} + /> + + ( + + Personality + + What is the personality, thought process, or tuning of this agent? + Get creative; this is how you can influence the agent constitution. + + +