Add a ride hand side bar for chat controls

This commit is contained in:
sabaimran
2025-01-17 16:45:50 -08:00
parent 00843f4f24
commit 2fa212061d
15 changed files with 465 additions and 51 deletions

View File

@@ -171,7 +171,7 @@ function CreateAgentCard(props: CreateAgentCardProps) {
);
}
interface AgentConfigurationOptions {
export interface AgentConfigurationOptions {
input_tools: { [key: string]: string };
output_modes: { [key: string]: string };
}

View File

@@ -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;
}

View File

@@ -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 (
<>
<div className={false ? styles.chatBody : styles.chatBodyFull}>
<ChatHistory
conversationId={conversationId}
setTitle={props.setTitle}
setAgent={setAgentMetadata}
pendingMessage={processingMessage ? message : ""}
incomingMessages={props.streamedMessages}
setIncomingMessages={props.setStreamedMessages}
customClassName={chatHistoryCustomClassName}
/>
<div className="flex flex-row h-full w-full">
<div className="flex flex-col h-full w-full">
<div className={false ? styles.chatBody : styles.chatBodyFull}>
<ChatHistory
conversationId={conversationId}
setTitle={props.setTitle}
setAgent={setAgentMetadata}
pendingMessage={processingMessage ? message : ""}
incomingMessages={props.streamedMessages}
setIncomingMessages={props.setStreamedMessages}
customClassName={chatHistoryCustomClassName}
/>
</div>
<div
className={`${styles.inputBox} p-1 md:px-2 shadow-md bg-background align-middle items-center justify-center dark:bg-neutral-700 dark:border-0 dark:shadow-sm rounded-2xl md:rounded-xl h-fit ${chatHistoryCustomClassName} mr-auto ml-auto`}
>
<ChatInputArea
agentColor={agentMetadata?.color}
isLoggedIn={props.isLoggedIn}
sendMessage={(message) => 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}
/>
</div>
</div>
<div
className={`${styles.inputBox} p-1 md:px-2 shadow-md bg-background align-middle items-center justify-center dark:bg-neutral-700 dark:border-0 dark:shadow-sm rounded-2xl md:rounded-xl h-fit ${chatHistoryCustomClassName} mr-auto ml-auto`}
>
<ChatInputArea
agentColor={agentMetadata?.color}
isLoggedIn={props.isLoggedIn}
sendMessage={(message) => 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}
/>
</div>
</>
<ChatSidebar
conversationId={conversationId}
isOpen={props.isChatSideBarOpen}
onOpenChange={props.onChatSideBarOpenChange}
isMobileWidth={props.isMobileWidth} />
</div>
);
}
@@ -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() {
)}
</div>
)}
<div className="flex justify-end items-start gap-2 text-sm ml-auto">
<Button
variant="ghost"
size="icon"
className="h-12 w-12 data-[state=open]:bg-accent"
onClick={() => setIsChatSideBarOpen(!isChatSideBarOpen)}
>
<Joystick className="w-6 h-6" />
</Button>
</div>
</header>
<div className={`${styles.main} ${styles.chatLayout}`}>
<title>
@@ -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 >
);
}

View File

@@ -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 &&

View File

@@ -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>
)
}

View File

@@ -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`;

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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 }

View File

@@ -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",

View File

@@ -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",
},
},
},

View File

@@ -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==