diff --git a/src/interface/web/app/agents/page.tsx b/src/interface/web/app/agents/page.tsx index 8ea08041..b03d248c 100644 --- a/src/interface/web/app/agents/page.tsx +++ b/src/interface/web/app/agents/page.tsx @@ -171,7 +171,7 @@ function CreateAgentCard(props: CreateAgentCardProps) { ); } -interface AgentConfigurationOptions { +export interface AgentConfigurationOptions { input_tools: { [key: string]: string }; output_modes: { [key: string]: string }; } diff --git a/src/interface/web/app/chat/chat.module.css b/src/interface/web/app/chat/chat.module.css index c9868429..58a2a9bf 100644 --- a/src/interface/web/app/chat/chat.module.css +++ b/src/interface/web/app/chat/chat.module.css @@ -39,7 +39,7 @@ div.inputBox:focus { div.chatBodyFull { display: grid; grid-template-columns: 1fr; - height: 100%; + height: auto; } button.inputBox { @@ -83,7 +83,7 @@ div.titleBar { div.chatBoxBody { display: grid; height: 100%; - width: 95%; + width: 100%; margin: auto; } diff --git a/src/interface/web/app/chat/page.tsx b/src/interface/web/app/chat/page.tsx index 824a7e4c..7c96e2ad 100644 --- a/src/interface/web/app/chat/page.tsx +++ b/src/interface/web/app/chat/page.tsx @@ -30,6 +30,9 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/s import { AppSidebar } from "../components/appSidebar/appSidebar"; import { Separator } from "@/components/ui/separator"; import { KhojLogoType } from "../components/logo/khojLogo"; +import { Button } from "@/components/ui/button"; +import { Joystick } from "@phosphor-icons/react"; +import { ChatSidebar } from "../components/chatSidebar/chatSidebar"; interface ChatBodyDataProps { chatOptionsData: ChatOptions | null; @@ -43,6 +46,8 @@ interface ChatBodyDataProps { isLoggedIn: boolean; setImages: (images: string[]) => void; setTriggeredAbort: (triggeredAbort: boolean) => void; + isChatSideBarOpen: boolean; + onChatSideBarOpenChange: (open: boolean) => void; } function ChatBodyData(props: ChatBodyDataProps) { @@ -138,37 +143,44 @@ function ChatBodyData(props: ChatBodyDataProps) { } return ( - <> -
- +
+
+
+ +
+
+ setMessage(message)} + sendImage={(image) => setImages((prevImages) => [...prevImages, image])} + sendDisabled={processingMessage} + chatOptionsData={props.chatOptionsData} + conversationId={conversationId} + isMobileWidth={props.isMobileWidth} + setUploadedFiles={props.setUploadedFiles} + ref={chatInputRef} + isResearchModeEnabled={isInResearchMode} + setTriggeredAbort={props.setTriggeredAbort} + /> +
-
- setMessage(message)} - sendImage={(image) => setImages((prevImages) => [...prevImages, image])} - sendDisabled={processingMessage} - chatOptionsData={props.chatOptionsData} - conversationId={conversationId} - isMobileWidth={props.isMobileWidth} - setUploadedFiles={props.setUploadedFiles} - ref={chatInputRef} - isResearchModeEnabled={isInResearchMode} - setTriggeredAbort={props.setTriggeredAbort} - /> -
- + +
); } @@ -199,6 +211,7 @@ export default function Chat() { isLoading: authenticationLoading, } = useAuthenticatedData(); const isMobileWidth = useIsMobileWidth(); + const [isChatSideBarOpen, setIsChatSideBarOpen] = useState(false); useEffect(() => { fetch("/api/chat/options") @@ -432,6 +445,16 @@ export default function Chat() { )}
)} +
+ +
@@ -452,12 +475,14 @@ export default function Chat() { onConversationIdChange={handleConversationIdChange} setImages={setImages} setTriggeredAbort={setTriggeredAbort} + isChatSideBarOpen={isChatSideBarOpen} + onChatSideBarOpenChange={setIsChatSideBarOpen} /> </Suspense> </div> </div> </div> </SidebarInset> - </SidebarProvider> + </SidebarProvider > ); } diff --git a/src/interface/web/app/common/auth.ts b/src/interface/web/app/common/auth.ts index 738d273f..e00caa25 100644 --- a/src/interface/web/app/common/auth.ts +++ b/src/interface/web/app/common/auth.ts @@ -33,6 +33,8 @@ export function useAuthenticatedData() { export interface ModelOptions { id: number; name: string; + description: string; + strengths: string; } export interface SyncedContent { computer: boolean; @@ -99,6 +101,14 @@ export function useUserConfig(detailed: boolean = false) { return { userConfig, isLoadingUserConfig }; } +export function useChatModelOptions() { + const { data, error, isLoading } = useSWR<ModelOptions[]>(`/api/model/chat/options`, fetcher, { + revalidateOnFocus: false, + }); + + return { models: data, error, isLoading }; +} + export function isUserSubscribed(userConfig: UserConfig | null): boolean { return ( (userConfig?.subscription_state && diff --git a/src/interface/web/app/common/modelSelector.tsx b/src/interface/web/app/common/modelSelector.tsx new file mode 100644 index 00000000..ae0e6d4f --- /dev/null +++ b/src/interface/web/app/common/modelSelector.tsx @@ -0,0 +1,168 @@ +"use client" + +import * as React from "react" +import { useState, useEffect } from "react"; +import { PopoverProps } from "@radix-ui/react-popover" + +import { Check, CaretUpDown } from "@phosphor-icons/react"; + +import { cn } from "@/lib/utils" +import { useMutationObserver } from "@/app/common/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +import { ModelOptions, useChatModelOptions } from "./auth"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; +import { Skeleton } from "@/components/ui/skeleton"; + +export function ModelSelector({ ...props }: PopoverProps) { + const [open, setOpen] = React.useState(false) + const { models, isLoading, error } = useChatModelOptions(); + const [peekedModel, setPeekedModel] = useState<ModelOptions | undefined>(undefined); + const [selectedModel, setSelectedModel] = useState<ModelOptions | undefined>(undefined); + + useEffect(() => { + if (models && models.length > 0) { + setSelectedModel(models[0]) + } + + if (models && models.length > 0 && !selectedModel) { + setSelectedModel(models[0]) + } + + }, [models]); + + if (isLoading) { + return ( + <Skeleton className="w-full h-10" /> + ); + } + + if (error) { + return ( + <div className="text-sm text-error">{error.message}</div> + ); + } + + return ( + <div className="grid gap-2 w-[250px]"> + <Popover open={open} onOpenChange={setOpen} {...props}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + aria-label="Select a model" + className="w-full justify-between text-left" + > + <p className="truncate"> + {selectedModel ? selectedModel.name.substring(0,20) : "Select a model..."} + </p> + <CaretUpDown className="opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent align="end" className="w-[250px] p-0"> + <HoverCard> + <HoverCardContent + side="left" + align="start" + forceMount + className="min-h-[280px]" + > + <div className="grid gap-2"> + <h4 className="font-medium leading-none">{peekedModel?.name}</h4> + <div className="text-sm text-muted-foreground"> + {peekedModel?.description} + </div> + {peekedModel?.strengths ? ( + <div className="mt-4 grid gap-2"> + <h5 className="text-sm font-medium leading-none"> + Strengths + </h5> + <ul className="text-sm text-muted-foreground"> + {peekedModel.strengths} + </ul> + </div> + ) : null} + </div> + </HoverCardContent> + <div> + <HoverCardTrigger /> + <Command loop> + <CommandList className="h-[var(--cmdk-list-height)]"> + <CommandInput placeholder="Search Models..." /> + <CommandEmpty>No Models found.</CommandEmpty> + <CommandGroup key={"models"} heading={"Models"}> + {models && models.length > 0 && models + .map((model) => ( + <ModelItem + key={model.id} + model={model} + isSelected={selectedModel?.id === model.id} + onPeek={(model) => setPeekedModel(model)} + onSelect={() => { + setSelectedModel(model) + setOpen(false) + }} + /> + ))} + </CommandGroup> + </CommandList> + </Command> + </div> + </HoverCard> + </PopoverContent> + </Popover> + </div> + ) +} + +interface ModelItemProps { + model: ModelOptions, + isSelected: boolean, + onSelect: () => void, + onPeek: (model: ModelOptions) => void +} + +function ModelItem({ model, isSelected, onSelect, onPeek }: ModelItemProps) { + const ref = React.useRef<HTMLDivElement>(null) + + useMutationObserver(ref, (mutations) => { + mutations.forEach((mutation) => { + if ( + mutation.type === "attributes" && + mutation.attributeName === "aria-selected" && + ref.current?.getAttribute("aria-selected") === "true" + ) { + onPeek(model) + } + }) + }) + + return ( + <CommandItem + key={model.id} + onSelect={onSelect} + ref={ref} + className="data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground" + > + {model.name} + <Check + className={cn("ml-auto", isSelected ? "opacity-100" : "opacity-0")} + /> + </CommandItem> + ) +} diff --git a/src/interface/web/app/common/utils.ts b/src/interface/web/app/common/utils.ts index 8bf6db84..fe5043a2 100644 --- a/src/interface/web/app/common/utils.ts +++ b/src/interface/web/app/common/utils.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import useSWR from "swr"; +import * as React from "react" export interface LocationData { city?: string; @@ -69,6 +70,25 @@ export function useIsMobileWidth() { return isMobileWidth; } +export const useMutationObserver = ( + ref: React.MutableRefObject<HTMLElement | null>, + callback: MutationCallback, + options = { + attributes: true, + characterData: true, + childList: true, + subtree: true, + } +) => { + React.useEffect(() => { + if (ref.current) { + const observer = new MutationObserver(callback) + observer.observe(ref.current, options) + return () => observer.disconnect() + } + }, [ref, callback, options]) +} + export const convertBytesToText = (fileSize: number) => { if (fileSize < 1024) { return `${fileSize} B`; diff --git a/src/interface/web/app/components/allConversations/allConversations.tsx b/src/interface/web/app/components/allConversations/allConversations.tsx index 53ad20bf..94d05eba 100644 --- a/src/interface/web/app/components/allConversations/allConversations.tsx +++ b/src/interface/web/app/components/allConversations/allConversations.tsx @@ -184,7 +184,7 @@ interface FilesMenuProps { isMobileWidth: boolean; } -function FilesMenu(props: FilesMenuProps) { +export function FilesMenu(props: FilesMenuProps) { // Use SWR to fetch files const { data: files, error } = useSWR<string[]>("/api/content/computer", fetcher); const { data: selectedFiles, error: selectedFilesError } = useSWR( @@ -981,13 +981,6 @@ export default function AllConversations(props: SidePanelProps) { sideBarOpen={props.sideBarOpen} /> </div> - {props.sideBarOpen && ( - <FilesMenu - conversationId={props.conversationId} - uploadedFiles={props.uploadedFiles} - isMobileWidth={props.isMobileWidth} - /> - )} </> )} </div> diff --git a/src/interface/web/app/components/appSidebar/appSidebar.tsx b/src/interface/web/app/components/appSidebar/appSidebar.tsx index d80314b9..97b7664a 100644 --- a/src/interface/web/app/components/appSidebar/appSidebar.tsx +++ b/src/interface/web/app/components/appSidebar/appSidebar.tsx @@ -8,6 +8,7 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, + SidebarRail, } from "@/components/ui/sidebar"; import { KhojAgentLogo, @@ -150,6 +151,7 @@ export function AppSidebar(props: AppSidebarProps) { <SidebarFooter> <FooterMenu sideBarIsOpen={open} /> </SidebarFooter> + <SidebarRail /> </Sidebar> ); } diff --git a/src/interface/web/app/components/chatHistory/chatHistory.tsx b/src/interface/web/app/components/chatHistory/chatHistory.tsx index 829211be..f0b46e1e 100644 --- a/src/interface/web/app/components/chatHistory/chatHistory.tsx +++ b/src/interface/web/app/components/chatHistory/chatHistory.tsx @@ -314,7 +314,15 @@ export default function ChatHistory(props: ChatHistoryProps) { } return ( - <ScrollArea className={`h-[73vh] relative`} ref={scrollAreaRef}> + <ScrollArea + className={` + h-[calc(100svh-theme(spacing.44))] + sm:h-[calc(100svh-theme(spacing.44))] + md:h-[calc(100svh-theme(spacing.44))] + lg:h-[calc(100svh-theme(spacing.72))] + `} + ref={scrollAreaRef}> + <div> <div className={`${styles.chatHistory} ${props.customClassName}`}> <div ref={sentinelRef} style={{ height: "1px" }}> @@ -343,12 +351,12 @@ export default function ChatHistory(props: ChatHistoryProps) { index === data.chat.length - 2 ? latestUserMessageRef : // attach ref to the newest fetched message to handle scroll on fetch - // note: stabilize index selection against last page having less messages than fetchMessageCount - index === + // note: stabilize index selection against last page having less messages than fetchMessageCount + index === data.chat.length - - (currentPage - 1) * fetchMessageCount - ? latestFetchedMessageRef - : null + (currentPage - 1) * fetchMessageCount + ? latestFetchedMessageRef + : null } isMobileWidth={isMobileWidth} chatMessage={chatMessage} diff --git a/src/interface/web/app/components/chatSidebar/chatSidebar.tsx b/src/interface/web/app/components/chatSidebar/chatSidebar.tsx new file mode 100644 index 00000000..9d3588e0 --- /dev/null +++ b/src/interface/web/app/components/chatSidebar/chatSidebar.tsx @@ -0,0 +1,148 @@ +"use client" + +import * as React from "react" + +import { Bell } from "@phosphor-icons/react"; + +import { Button } from "@/components/ui/button"; + +import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar"; +import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Textarea } from "@/components/ui/textarea"; +import { ModelSelector } from "@/app/common/modelSelector"; +import { FilesMenu } from "../allConversations/allConversations"; +import { AgentConfigurationOptions } from "@/app/agents/page"; +import useSWR from "swr"; +import { Sheet, SheetContent } from "@/components/ui/sheet"; + +interface ChatSideBarProps { + conversationId: string; + isOpen: boolean; + isMobileWidth?: boolean; + onOpenChange: (open: boolean) => void; +} + +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +export function ChatSidebar({ ...props }: ChatSideBarProps) { + const { data: agentConfigurationOptions, error: agentConfigurationOptionsError } = + useSWR<AgentConfigurationOptions>("/api/agents/options", fetcher); + + if (props.isMobileWidth) { + return ( + <Sheet + open={props.isOpen} + onOpenChange={props.onOpenChange}> + <SheetContent + className="w-[300px] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" + > + <ChatSidebarInternal {...props} /> + </SheetContent> + </Sheet> + ); + } + + return ( + <ChatSidebarInternal {...props} /> + ); +} + + +function ChatSidebarInternal({ ...props }: ChatSideBarProps) { + const { data: agentConfigurationOptions, error: agentConfigurationOptionsError } = + useSWR<AgentConfigurationOptions>("/api/agents/options", fetcher); + + return ( + <Sidebar + collapsible="none" + className={`ml-auto rounded-lg p-2 transition-all transform duration-300 ease-in-out + ${props.isOpen + ? "translate-x-0 opacity-100 w-[300px]" + : "translate-x-full opacity-0 w-0"} + `} + variant="floating"> + <SidebarContent> + <SidebarHeader> + Chat Options + </SidebarHeader> + <SidebarGroup key={"test"} className="border-b last:border-none"> + <SidebarGroupContent className="gap-0"> + <SidebarMenu className="p-0 m-0"> + <SidebarMenuItem key={"item4"} className="list-none"> + <span>Custom Instructions</span> + <Textarea className="w-full h-32" /> + </SidebarMenuItem> + <SidebarMenuItem key={"item"} className="list-none"> + <SidebarMenuButton> + <Bell /> <span>Model</span> + </SidebarMenuButton> + <ModelSelector /> + </SidebarMenuItem> + <SidebarMenuItem key={"item1"} className="list-none"> + <SidebarMenuButton> + <Bell /> <span>Input Tools</span> + </SidebarMenuButton> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline">Input Tools</Button> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-56"> + <DropdownMenuLabel>Input Tool Options</DropdownMenuLabel> + <DropdownMenuSeparator /> + { + Object.entries(agentConfigurationOptions?.input_tools ?? {}).map(([key, value]) => { + return ( + <DropdownMenuCheckboxItem + checked={true} + onCheckedChange={() => { }} + > + {key} + </DropdownMenuCheckboxItem> + ); + } + ) + } + </DropdownMenuContent> + </DropdownMenu> + </SidebarMenuItem> + <SidebarMenuItem key={"item2"} className="list-none"> + <SidebarMenuButton> + <Bell /> <span>Output Tools</span> + </SidebarMenuButton> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline">Output Tools</Button> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-56"> + <DropdownMenuLabel>Output Tool Options</DropdownMenuLabel> + <DropdownMenuSeparator /> + { + Object.entries(agentConfigurationOptions?.output_modes ?? {}).map(([key, value]) => { + return ( + <DropdownMenuCheckboxItem + checked={true} + onCheckedChange={() => { }} + > + {key} + </DropdownMenuCheckboxItem> + ); + } + ) + } + </DropdownMenuContent> + </DropdownMenu> + </SidebarMenuItem> + <SidebarMenuItem key={"item3"} className="list-none"> + <FilesMenu + conversationId={props.conversationId} + uploadedFiles={[]} + isMobileWidth={props.isMobileWidth ?? false} + /> + </SidebarMenuItem> + </SidebarMenu> + </SidebarGroupContent> + </SidebarGroup> + </SidebarContent> + </Sidebar> + ) +} diff --git a/src/interface/web/app/share/chat/sharedChat.module.css b/src/interface/web/app/share/chat/sharedChat.module.css index 286e5ba4..844834f3 100644 --- a/src/interface/web/app/share/chat/sharedChat.module.css +++ b/src/interface/web/app/share/chat/sharedChat.module.css @@ -35,7 +35,6 @@ div.inputBox:focus { div.chatBodyFull { display: grid; grid-template-columns: 1fr; - height: 100%; } button.inputBox { @@ -78,7 +77,7 @@ div.titleBar { div.chatBoxBody { display: grid; height: 100%; - width: 95%; + width: 100%; margin: auto; } diff --git a/src/interface/web/components/ui/hover-card.tsx b/src/interface/web/components/ui/hover-card.tsx new file mode 100644 index 00000000..e54d91cf --- /dev/null +++ b/src/interface/web/components/ui/hover-card.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as HoverCardPrimitive from "@radix-ui/react-hover-card" + +import { cn } from "@/lib/utils" + +const HoverCard = HoverCardPrimitive.Root + +const HoverCardTrigger = HoverCardPrimitive.Trigger + +const HoverCardContent = React.forwardRef< + React.ElementRef<typeof HoverCardPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + <HoverCardPrimitive.Content + ref={ref} + align={align} + sideOffset={sideOffset} + className={cn( + "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> +)) +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/src/interface/web/package.json b/src/interface/web/package.json index 5aa04ff4..f899a0c2 100644 --- a/src/interface/web/package.json +++ b/src/interface/web/package.json @@ -27,6 +27,7 @@ "@radix-ui/react-collapsible": "^1.1.0", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-hover-card": "^1.1.4", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-menubar": "^1.1.1", "@radix-ui/react-navigation-menu": "^1.2.0", diff --git a/src/interface/web/tailwind.config.ts b/src/interface/web/tailwind.config.ts index 07bd0754..15391ae0 100644 --- a/src/interface/web/tailwind.config.ts +++ b/src/interface/web/tailwind.config.ts @@ -137,12 +137,23 @@ const config = { "0%": { opacity: "0", transform: "translateY(20px)" }, "100%": { opacity: "1", transform: "translateY(0)" }, }, + fadeInRight: { + "0%": { opacity: "0", transform: "translateX(20px)" }, + "100%": { opacity: "1", transform: "translateX(0)" }, + }, + fadeInLeft: { + "0%": { opacity: "0", transform: "translateX(-20px)" }, + "100%": { opacity: "1", transform: "translateX(0)" }, + }, + }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", "caret-blink": "caret-blink 1.25s ease-out infinite", "fade-in-up": "fadeInUp 0.3s ease-out", + "fade-in-right": "fadeInRight 0.3s ease-out", + "fade-in-left": "fadeInLeft 0.3s ease-out", }, }, }, diff --git a/src/interface/web/yarn.lock b/src/interface/web/yarn.lock index 14aec059..22cac901 100644 --- a/src/interface/web/yarn.lock +++ b/src/interface/web/yarn.lock @@ -716,7 +716,7 @@ "@radix-ui/react-primitive" "2.0.1" "@radix-ui/react-use-callback-ref" "1.1.0" -"@radix-ui/react-hover-card@^1.1.2": +"@radix-ui/react-hover-card@^1.1.2", "@radix-ui/react-hover-card@^1.1.4": version "1.1.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-hover-card/-/react-hover-card-1.1.4.tgz#f334fdc1814e14a81ecb4e88a72b92326c1f89a6" integrity sha512-QSUUnRA3PQ2UhvoCv3eYvMnCAgGQW+sTu86QPuNb+ZMi+ZENd6UWpiXbcWDQ4AEaKF9KKpCHBeaJz9Rw6lRlaQ==