Chat UI small rework (#1504)

* Fat bar + configuration modal

* Remove header
This commit is contained in:
Chris Weaver 2024-05-21 23:34:35 -07:00 committed by GitHub
parent ba872a0f7f
commit 88db722ea4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1640 additions and 522 deletions

View File

@ -43,7 +43,7 @@ import { useContext, useEffect, useRef, useState } from "react";
import { usePopup } from "@/components/admin/connectors/Popup";
import { SEARCH_PARAM_NAMES, shouldSubmitOnLoad } from "./searchParams";
import { useDocumentSelection } from "./useDocumentSelection";
import { useFilters } from "@/lib/hooks";
import { useFilters, useLlmOverride } from "@/lib/hooks";
import { computeAvailableFilters } from "@/lib/filters";
import { FeedbackType } from "./types";
import ResizableSection from "@/components/resizable/ResizableSection";
@ -62,13 +62,16 @@ import { SelectedDocuments } from "./modifiers/SelectedDocuments";
import { ChatFilters } from "./modifiers/ChatFilters";
import { AnswerPiecePacket, DanswerDocument } from "@/lib/search/interfaces";
import { buildFilters } from "@/lib/search/utils";
import { Tabs } from "./sessionSidebar/constants";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import Dropzone from "react-dropzone";
import { LLMProviderDescriptor } from "../admin/models/llm/interfaces";
import { checkLLMSupportsImageInput, getFinalLLM } from "@/lib/llm/utils";
import { InputBarPreviewImage } from "./images/InputBarPreviewImage";
import { Folder } from "./folders/interfaces";
import { ChatInputBar } from "./input/ChatInputBar";
import { ConfigurationModal } from "./modal/configuration/ConfigurationModal";
import { useChatContext } from "@/components/context/ChatContext";
import { UserDropdown } from "@/components/UserDropdown";
const MAX_INPUT_HEIGHT = 200;
const TEMP_USER_MESSAGE_ID = -1;
@ -76,32 +79,26 @@ const TEMP_ASSISTANT_MESSAGE_ID = -2;
const SYSTEM_MESSAGE_ID = -3;
export function ChatPage({
user,
chatSessions,
availableSources,
availableDocumentSets,
availablePersonas,
availableTags,
llmProviders,
defaultSelectedPersonaId,
documentSidebarInitialWidth,
defaultSidebarTab,
folders,
openedFolders,
defaultSelectedPersonaId,
}: {
user: User | null;
chatSessions: ChatSession[];
availableSources: ValidSources[];
availableDocumentSets: DocumentSet[];
availablePersonas: Persona[];
availableTags: Tag[];
llmProviders: LLMProviderDescriptor[];
defaultSelectedPersonaId?: number; // what persona to default to
documentSidebarInitialWidth?: number;
defaultSidebarTab?: Tabs;
folders: Folder[];
openedFolders: { [key: number]: boolean };
defaultSelectedPersonaId?: number;
}) {
const [configModalActiveTab, setConfigModalActiveTab] = useState<
string | null
>(null);
let {
user,
chatSessions,
availableSources,
availableDocumentSets,
availablePersonas,
llmProviders,
folders,
openedFolders,
} = useChatContext();
const router = useRouter();
const searchParams = useSearchParams();
const existingChatIdRaw = searchParams.get("chatId");
@ -145,6 +142,13 @@ export function ChatPage({
filterManager.setSelectedSources([]);
filterManager.setSelectedTags([]);
filterManager.setTimeRange(null);
// reset LLM overrides
llmOverrideManager.setLlmOverride({
name: "",
provider: "",
modelName: "",
});
llmOverrideManager.setTemperature(null);
// remove uploaded files
setCurrentMessageFileIds([]);
@ -386,6 +390,8 @@ export function ChatPage({
availableDocumentSets,
});
const llmOverrideManager = useLlmOverride();
// state for cancelling streaming
const [isCancelled, setIsCancelled] = useState(false);
const isCancelledRef = useRef(isCancelled);
@ -592,9 +598,13 @@ export function ChatPage({
.map((document) => document.db_doc_id as number),
queryOverride,
forceSearch,
modelProvider: llmOverrideManager.llmOverride.name || undefined,
modelVersion:
searchParams.get(SEARCH_PARAM_NAMES.MODEL_VERSION) || undefined,
llmOverrideManager.llmOverride.modelName ||
searchParams.get(SEARCH_PARAM_NAMES.MODEL_VERSION) ||
undefined,
temperature:
llmOverrideManager.temperature ||
parseFloat(searchParams.get(SEARCH_PARAM_NAMES.TEMPERATURE) || "") ||
undefined,
systemPromptOverride:
@ -767,6 +777,32 @@ export function ChatPage({
}
};
const handleImageUpload = (acceptedFiles: File[]) => {
const llmAcceptsImages = checkLLMSupportsImageInput(
...getFinalLLM(llmProviders, livePersona)
);
if (!llmAcceptsImages) {
setPopup({
type: "error",
message:
"The current Assistant does not support image input. Please select an assistant with Vision support.",
});
return;
}
uploadFilesForChat(acceptedFiles).then(([fileIds, error]) => {
if (error) {
setPopup({
type: "error",
message: error,
});
} else {
const newFileIds = [...currentMessageFileIds, ...fileIds];
setCurrentMessageFileIds(newFileIds);
}
});
};
// handle redirect if chat page is disabled
// NOTE: this must be done here, in a client component since
// settings are passed in via Context and therefore aren't
@ -779,9 +815,9 @@ export function ChatPage({
const retrievalDisabled = !personaIncludesRetrieval(livePersona);
return (
<>
<div className="absolute top-0 z-40 w-full">
{/* <div className="absolute top-0 z-40 w-full">
<Header user={user} />
</div>
</div> */}
<HealthCheckBanner />
<InstantSSRAutoRefresh />
@ -789,10 +825,6 @@ export function ChatPage({
<ChatSidebar
existingChats={chatSessions}
currentChatSession={selectedChatSession}
personas={availablePersonas}
onPersonaChange={onPersonaChange}
user={user}
defaultTab={defaultSidebarTab}
folders={folders}
openedFolders={openedFolders}
/>
@ -830,35 +862,18 @@ export function ChatPage({
/>
)}
{documentSidebarInitialWidth !== undefined ? (
<Dropzone
onDrop={(acceptedFiles) => {
const llmAcceptsImages = checkLLMSupportsImageInput(
...getFinalLLM(llmProviders, livePersona)
);
if (!llmAcceptsImages) {
setPopup({
type: "error",
message:
"The current Assistant does not support image input. Please select an assistant with Vision support.",
});
return;
}
<ConfigurationModal
activeTab={configModalActiveTab}
setActiveTab={setConfigModalActiveTab}
onClose={() => setConfigModalActiveTab(null)}
filterManager={filterManager}
selectedAssistant={livePersona}
setSelectedAssistant={onPersonaChange}
llmOverrideManager={llmOverrideManager}
/>
uploadFilesForChat(acceptedFiles).then(([fileIds, error]) => {
if (error) {
setPopup({
type: "error",
message: error,
});
} else {
const newFileIds = [...currentMessageFileIds, ...fileIds];
setCurrentMessageFileIds(newFileIds);
}
});
}}
noClick
>
{documentSidebarInitialWidth !== undefined ? (
<Dropzone onDrop={handleImageUpload} noClick>
{({ getRootProps }) => (
<>
<div
@ -869,27 +884,40 @@ export function ChatPage({
>
{/* <input {...getInputProps()} /> */}
<div
className={`w-full h-full ${HEADER_PADDING} flex flex-col overflow-y-auto overflow-x-hidden relative`}
className={`w-full h-full pt-2 flex flex-col overflow-y-auto overflow-x-hidden relative`}
ref={scrollableDivRef}
>
{livePersona && (
<div className="sticky top-0 left-80 z-10 w-full bg-background/90 flex">
<div className="ml-2 p-1 rounded mt-2 w-fit">
<div className="ml-2 p-1 rounded w-fit">
<ChatPersonaSelector
personas={availablePersonas}
selectedPersonaId={livePersona.id}
onPersonaChange={onPersonaChange}
userId={user?.id}
/>
</div>
{chatSessionId !== null && (
<div
onClick={() => setSharingModalVisible(true)}
className="ml-auto mr-6 my-auto border-border border p-2 rounded cursor-pointer hover:bg-hover-light"
>
<FiShare2 />
<div className="ml-auto mr-8 flex">
{chatSessionId !== null && (
<div
onClick={() => setSharingModalVisible(true)}
className={`
my-auto
p-2
rounded
cursor-pointer
hover:bg-hover-light
`}
>
<FiShare2 size="18" />
</div>
)}
<div className="ml-4 my-auto">
<UserDropdown user={user} />
</div>
)}
</div>
</div>
)}
@ -1166,143 +1194,23 @@ export function ChatPage({
</div>
</div>
<div className="absolute bottom-0 z-10 w-full bg-background border-t border-border">
<div className="w-full pb-4 pt-2">
{!retrievalDisabled && (
<div className="flex">
<div className="w-searchbar-xs 2xl:w-searchbar-sm 3xl:w-searchbar mx-auto px-4 pt-1 flex">
{selectedDocuments.length > 0 ? (
<SelectedDocuments
selectedDocuments={selectedDocuments}
/>
) : (
<ChatFilters
{...filterManager}
existingSources={finalAvailableSources}
availableDocumentSets={
finalAvailableDocumentSets
}
availableTags={availableTags}
/>
)}
</div>
</div>
)}
<div className="flex justify-center py-2 max-w-screen-lg mx-auto mb-2">
<div className="w-full shrink relative px-4 w-searchbar-xs 2xl:w-searchbar-sm 3xl:w-searchbar mx-auto">
<div
className={`
opacity-100
w-full
h-fit
flex
flex-col
border
border-border
rounded-lg
[&:has(textarea:focus)]::ring-1
[&:has(textarea:focus)]::ring-black
`}
>
{currentMessageFileIds.length > 0 && (
<div className="flex flex-wrap gap-y-2 px-1">
{currentMessageFileIds.map((fileId) => (
<div key={fileId} className="py-1">
<InputBarPreviewImage
fileId={fileId}
onDelete={() => {
setCurrentMessageFileIds(
currentMessageFileIds.filter(
(id) => id !== fileId
)
);
}}
/>
</div>
))}
</div>
)}
<textarea
ref={textareaRef}
className={`
m-0
w-full
shrink
resize-none
border-0
bg-transparent
${
(textareaRef?.current?.scrollHeight || 0) >
MAX_INPUT_HEIGHT
? "overflow-y-auto"
: ""
}
whitespace-normal
break-word
overscroll-contain
outline-none
placeholder-gray-400
overflow-hidden
resize-none
pl-4
pr-12
py-4
h-14`}
autoFocus
style={{ scrollbarWidth: "thin" }}
role="textarea"
aria-multiline
placeholder="Ask me anything..."
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
message &&
!isStreaming
) {
onSubmit();
event.preventDefault();
}
}}
suppressContentEditableWarning={true}
/>
</div>
<div className="absolute bottom-2.5 right-10">
<div
className={"cursor-pointer"}
onClick={() => {
if (!isStreaming) {
if (message) {
onSubmit();
}
} else {
setIsCancelled(true);
}
}}
>
{isStreaming ? (
<FiStopCircle
size={18}
className={
"text-emphasis w-9 h-9 p-2 rounded-lg hover:bg-hover"
}
/>
) : (
<FiSend
size={18}
className={
"text-emphasis w-9 h-9 p-2 rounded-lg " +
(message ? "bg-blue-200" : "")
}
/>
)}
</div>
</div>
</div>
</div>
<div className="absolute bottom-0 z-10 w-full">
<div className="w-full pb-4">
<ChatInputBar
message={message}
setMessage={setMessage}
onSubmit={onSubmit}
isStreaming={isStreaming}
setIsCancelled={setIsCancelled}
retrievalDisabled={retrievalDisabled}
filterManager={filterManager}
llmOverrideManager={llmOverrideManager}
selectedAssistant={livePersona}
fileIds={currentMessageFileIds}
setFileIds={setCurrentMessageFileIds}
handleFileUpload={handleImageUpload}
setConfigModalActiveTab={setConfigModalActiveTab}
/>
</div>
</div>
</div>

View File

@ -1,44 +1,58 @@
import { Persona } from "@/app/admin/assistants/interfaces";
import { FiCheck, FiChevronDown } from "react-icons/fi";
import { CustomDropdown } from "@/components/Dropdown";
import { FiCheck, FiChevronDown, FiPlusSquare, FiEdit } from "react-icons/fi";
import { CustomDropdown, DefaultDropdownElement } from "@/components/Dropdown";
import { useRouter } from "next/navigation";
import { Divider } from "@tremor/react";
import Link from "next/link";
function PersonaItem({
id,
name,
onSelect,
isSelected,
isOwner,
}: {
id: number;
name: string;
onSelect: (personaId: number) => void;
isSelected: boolean;
isOwner: boolean;
}) {
return (
<div
key={id}
className={`
flex
px-3
text-sm
py-2
my-0.5
rounded
mx-1
select-none
cursor-pointer
text-emphasis
bg-background
hover:bg-hover
`}
onClick={() => {
onSelect(id);
}}
>
{name}
{isSelected && (
<div className="ml-auto mr-1">
<FiCheck />
</div>
<div className="flex w-full">
<div
key={id}
className={`
flex
flex-grow
px-3
text-sm
py-2
my-0.5
rounded
mx-1
select-none
cursor-pointer
text-emphasis
bg-background
hover:bg-hover-light
${isSelected ? "bg-hover text-selected-emphasis" : ""}
`}
onClick={() => {
onSelect(id);
}}
>
{name}
{isSelected && (
<div className="ml-auto mr-1 my-auto">
<FiCheck />
</div>
)}
</div>
{isOwner && (
<Link href={`/assistants/edit/${id}`} className="mx-2 my-auto">
<FiEdit className="hover:bg-hover p-0.5 my-auto" size={20} />
</Link>
)}
</div>
);
@ -48,11 +62,15 @@ export function ChatPersonaSelector({
personas,
selectedPersonaId,
onPersonaChange,
userId,
}: {
personas: Persona[];
selectedPersonaId: number | null;
onPersonaChange: (persona: Persona | null) => void;
userId: string | undefined;
}) {
const router = useRouter();
const currentlySelectedPersona = personas.find(
(persona) => persona.id === selectedPersonaId
);
@ -66,16 +84,18 @@ export function ChatPersonaSelector({
border-border
bg-background
rounded-lg
shadow-lg
flex
flex-col
w-64
max-h-96
overflow-y-auto
flex
p-1
overscroll-contain`}
>
{personas.map((persona, ind) => {
{personas.map((persona) => {
const isSelected = persona.id === selectedPersonaId;
const isOwner = persona.owner?.id === userId;
return (
<PersonaItem
key={persona.id}
@ -90,13 +110,27 @@ export function ChatPersonaSelector({
}
}}
isSelected={isSelected}
isOwner={isOwner}
/>
);
})}
<div className="border-t border-border pt-2">
<DefaultDropdownElement
name={
<div className="flex items-center">
<FiPlusSquare className="mr-2" />
New Assistant
</div>
}
onSelect={() => router.push("/assistants/new")}
isSelected={false}
/>
</div>
</div>
}
>
<div className="select-none text-xl font-bold flex px-2 py-1.5 text-strong rounded cursor-pointer hover:bg-hover-light">
<div className="select-none text-xl text-strong font-bold flex px-2 py-1.5 rounded cursor-pointer hover:bg-hover-light">
<div className="my-auto">
{currentlySelectedPersona?.name || "Default"}
</div>

View File

@ -67,7 +67,6 @@ export function DocumentSidebar({
flex-col
w-full
h-screen
${HEADER_PADDING}
`}
id="document-sidebar"
>

View File

@ -230,7 +230,7 @@ export const FolderList = ({
}
return (
<div className="mt-1 pb-1 mb-1 overflow-y-auto">
<div className="mt-1 mb-1 overflow-y-auto">
{folders.map((folder) => (
<FolderItem
key={folder.folder_id}

View File

@ -0,0 +1,210 @@
import React, { useRef } from "react";
import { FiSend, FiFilter, FiPlusCircle, FiCpu } from "react-icons/fi";
import ChatInputOption from "./ChatInputOption";
import { FaBrain } from "react-icons/fa";
import { Persona } from "@/app/admin/assistants/interfaces";
import { FilterManager, LlmOverride, LlmOverrideManager } from "@/lib/hooks";
import { SelectedFilterDisplay } from "./SelectedFilterDisplay";
import { useChatContext } from "@/components/context/ChatContext";
import { getFinalLLM } from "@/lib/llm/utils";
import { InputBarPreviewImage } from "../images/InputBarPreviewImage";
export function ChatInputBar({
message,
setMessage,
onSubmit,
isStreaming,
setIsCancelled,
retrievalDisabled,
filterManager,
llmOverrideManager,
selectedAssistant,
fileIds,
setFileIds,
handleFileUpload,
setConfigModalActiveTab,
}: {
message: string;
setMessage: (message: string) => void;
onSubmit: () => void;
isStreaming: boolean;
setIsCancelled: (value: boolean) => void;
retrievalDisabled: boolean;
filterManager: FilterManager;
llmOverrideManager: LlmOverrideManager;
selectedAssistant: Persona;
fileIds: string[];
setFileIds: (fileIds: string[]) => void;
handleFileUpload: (files: File[]) => void;
setConfigModalActiveTab: (tab: string) => void;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { llmProviders } = useChatContext();
const [_, llmName] = getFinalLLM(llmProviders, selectedAssistant);
return (
<div>
<div className="flex justify-center pb-2 max-w-screen-lg mx-auto mb-2">
<div
className="
w-full
shrink
relative
px-4
w-searchbar-xs
2xl:w-searchbar-sm
3xl:w-searchbar
mx-auto
"
>
<div>
<SelectedFilterDisplay filterManager={filterManager} />
</div>
<div
className="
opacity-100
w-full
h-fit
flex
flex-col
border
border-border
rounded-lg
bg-background
[&:has(textarea:focus)]::ring-1
[&:has(textarea:focus)]::ring-black
"
>
{fileIds.length > 0 && (
<div className="flex flex-wrap gap-y-2 px-1">
{fileIds.map((fileId) => (
<div key={fileId} className="py-1">
<InputBarPreviewImage
fileId={fileId}
onDelete={() => {
setFileIds(fileIds.filter((id) => id !== fileId));
}}
/>
</div>
))}
</div>
)}
<textarea
ref={textareaRef}
className="
m-0
w-full
shrink
resize-none
border-0
bg-transparent
${
textareaRef.current &&
textareaRef.current.scrollHeight > 200
? 'overflow-y-auto'
: ''
}
whitespace-normal
break-word
overscroll-contain
outline-none
placeholder-gray-400
overflow-hidden
resize-none
pl-4
pr-12
py-4
h-14
"
autoFocus
style={{ scrollbarWidth: "thin" }}
role="textarea"
aria-multiline
placeholder="Send a message..."
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
message &&
!isStreaming
) {
onSubmit();
event.preventDefault();
}
}}
suppressContentEditableWarning={true}
/>
<div className="flex items-center space-x-3 px-4 pb-2">
<ChatInputOption
name={selectedAssistant ? selectedAssistant.name : "Assistants"}
icon={FaBrain}
onClick={() => setConfigModalActiveTab("assistants")}
/>
<ChatInputOption
name={
llmOverrideManager.llmOverride.modelName ||
(selectedAssistant
? selectedAssistant.llm_model_version_override || llmName
: llmName)
}
icon={FiCpu}
onClick={() => setConfigModalActiveTab("llms")}
/>
{!retrievalDisabled && (
<ChatInputOption
name="Filters"
icon={FiFilter}
onClick={() => setConfigModalActiveTab("filters")}
/>
)}
<ChatInputOption
name="File"
icon={FiPlusCircle}
onClick={() => {
const input = document.createElement("input");
input.type = "file";
input.onchange = (event: any) => {
const files = Array.from(
event?.target?.files || []
) as File[];
if (files.length > 0) {
handleFileUpload(files);
}
};
input.click();
}}
/>
</div>
<div className="absolute bottom-2.5 right-10">
<div
className="cursor-pointer"
onClick={() => {
if (!isStreaming) {
if (message) {
onSubmit();
}
} else {
setIsCancelled(true);
}
}}
>
<FiSend
size={18}
className={`text-emphasis w-9 h-9 p-2 rounded-lg ${
message ? "bg-blue-200" : ""
}`}
/>
</div>
</div>
</div>
</div>
</div>
{/* <div className="text-center text-sm text-subtle mt-2">
Press "/" for shortcuts and useful prompts
</div> */}
</div>
);
}

View File

@ -0,0 +1,96 @@
import React, { useState } from "react";
import { IconType } from "react-icons";
import { DefaultDropdownElement } from "../../../components/Dropdown";
import { Popover } from "../../../components/popover/Popover";
interface ChatInputOptionProps {
name: string;
icon: IconType;
onClick: () => void;
size?: number;
options?: { name: string; value: number; onClick?: () => void }[];
}
const ChatInputOption = ({
name,
icon: Icon,
onClick,
size = 16,
options,
}: ChatInputOptionProps) => {
const [isDropupVisible, setDropupVisible] = useState(false);
const handleClick = () => {
setDropupVisible(!isDropupVisible);
// onClick();
};
const dropdownContent = options ? (
<div
className={`
border
border
rounded-lg
flex
flex-col
bg-background
overflow-y-auto
overscroll-contain`}
>
{options.map((option) => (
<DefaultDropdownElement
key={option.value}
name={option.name}
onSelect={() => {
if (option.onClick) {
option.onClick();
setDropupVisible(false);
}
}}
isSelected={false}
/>
))}
</div>
) : null;
const option = (
<div className="relative w-fit">
<div
className="
cursor-pointer
flex
items-center
space-x-2
text-subtle
hover:bg-hover
hover:text-emphasis
p-1.5
rounded-md
"
onClick={handleClick}
title={name}
>
<Icon size={size} />
<span className="text-sm">{name}</span>
</div>
</div>
);
if (!dropdownContent) {
return <div onClick={onClick}>{option}</div>;
}
return (
<Popover
open={isDropupVisible}
onOpenChange={setDropupVisible}
content={option}
popover={dropdownContent}
side="top"
align="start"
sideOffset={5}
/>
);
};
export default ChatInputOption;

View File

@ -0,0 +1,152 @@
import { SourceIcon } from "@/components/SourceIcon";
import React from "react";
import { FiBookmark, FiTag, FiX } from "react-icons/fi";
import { FilterManager } from "@/lib/hooks";
import { DateRangePickerValue } from "@tremor/react";
const displayTimeRange = (timeRange: DateRangePickerValue) => {
if (timeRange.selectValue) {
return timeRange.selectValue;
}
if (timeRange.from && timeRange.to) {
return `${timeRange.from.toLocaleDateString()} to ${timeRange.to.toLocaleDateString()}`;
} else if (timeRange.from) {
return `From ${timeRange.from.toLocaleDateString()}`;
} else if (timeRange.to) {
return `Until ${timeRange.to.toLocaleDateString()}`;
} else {
return "No date range selected";
}
};
const SelectedFilter = ({
onClick,
children,
}: {
onClick: () => void;
children: JSX.Element | string;
}) => (
<div
className="
flex
text-xs
cursor-pointer
items-center
border
border-border
py-1
rounded-lg
px-2
w-fit
select-none
hover:bg-hover
bg-background
shadow-md
"
onClick={onClick}
>
{children}
<FiX className="ml-2" size={14} />
</div>
);
export function SelectedFilterDisplay({
filterManager,
}: {
filterManager: FilterManager;
}) {
const {
timeRange,
setTimeRange,
selectedSources,
setSelectedSources,
selectedDocumentSets,
setSelectedDocumentSets,
selectedTags,
setSelectedTags,
} = filterManager;
const anyFilters =
timeRange !== null ||
selectedSources.length > 0 ||
selectedDocumentSets.length > 0 ||
selectedTags.length > 0;
if (!anyFilters) {
return null;
}
return (
<div className="flex mb-2">
<div className="flex flex-wrap gap-x-2">
{timeRange &&
(timeRange.selectValue || timeRange.from || timeRange.to) && (
<SelectedFilter onClick={() => setTimeRange(null)}>
<div className="flex">{displayTimeRange(timeRange)}</div>
</SelectedFilter>
)}
{selectedSources.map((source) => (
<SelectedFilter
key={source.internalName}
onClick={() =>
setSelectedSources((prevSources) =>
prevSources.filter(
(s) => s.internalName !== source.internalName
)
)
}
>
<>
<SourceIcon sourceType={source.internalName} iconSize={16} />
<span className="ml-2">{source.displayName}</span>
</>
</SelectedFilter>
))}
{selectedDocumentSets.length > 0 &&
selectedDocumentSets.map((documentSetName) => (
<SelectedFilter
key={documentSetName}
onClick={() =>
setSelectedDocumentSets((prevSets) =>
prevSets.filter((s) => s !== documentSetName)
)
}
>
<>
<div>
<FiBookmark />
</div>
<span className="ml-2">{documentSetName}</span>
</>
</SelectedFilter>
))}
{selectedTags.length > 0 &&
selectedTags.map((tag) => (
<SelectedFilter
key={tag.tag_key + tag.tag_value}
onClick={() =>
setSelectedTags((prevTags) =>
prevTags.filter(
(t) =>
t.tag_key !== tag.tag_key || t.tag_value !== tag.tag_value
)
)
}
>
<>
<div>
<FiTag />
</div>
<span className="ml-1 max-w-[100px] text-ellipsis line-clamp-1 break-all">
{tag.tag_key}
<b>=</b>
{tag.tag_value}
</span>
</>
</SelectedFilter>
))}
</div>
</div>
);
}

View File

@ -57,6 +57,7 @@ export async function* sendMessage({
selectedDocumentIds,
queryOverride,
forceSearch,
modelProvider,
modelVersion,
temperature,
systemPromptOverride,
@ -72,6 +73,7 @@ export async function* sendMessage({
queryOverride?: string;
forceSearch?: boolean;
// LLM overrides
modelProvider?: string;
modelVersion?: string;
temperature?: number;
// prompt overrides
@ -117,6 +119,7 @@ export async function* sendMessage({
temperature || modelVersion
? {
temperature,
model_provider: modelProvider,
model_version: modelVersion,
}
: null,

View File

@ -13,7 +13,8 @@ export const ModalWrapper = ({
<div
onClick={() => onClose && onClose()}
className={
"fixed z-50 inset-0 overflow-y-auto bg-black bg-opacity-30 flex justify-center items-center " +
"fixed inset-0 bg-black bg-opacity-30 backdrop-blur-sm " +
"flex items-center justify-center z-50 " +
(bgClassName || "")
}
>

View File

@ -0,0 +1,90 @@
import { Persona } from "@/app/admin/assistants/interfaces";
import { Bubble } from "@/components/Bubble";
import { useChatContext } from "@/components/context/ChatContext";
import { getFinalLLM } from "@/lib/llm/utils";
import React from "react";
import { FiBookmark, FiImage, FiSearch } from "react-icons/fi";
export function AssistantsTab({
selectedAssistant,
onSelect,
}: {
selectedAssistant: Persona;
onSelect: (assistant: Persona) => void;
}) {
const { availablePersonas, llmProviders } = useChatContext();
const [_, llmName] = getFinalLLM(llmProviders, null);
return (
<div className="mb-4">
<h3 className="text-lg font-semibold">Choose Assistant</h3>
<div className="mt-3">
{availablePersonas.map((assistant) => (
<div
key={assistant.id}
className={`
cursor-pointer
p-3
border
rounded-md
mb-3
hover:bg-hover-light
${selectedAssistant.id === assistant.id ? "border-accent" : "border-border"}
`}
onClick={() => onSelect(assistant)}
>
<div className="font-bold text-emphasis mb-1">{assistant.name}</div>
<div className="text-sm text-subtle mb-1">
{assistant.description}
</div>
<div className="mt-2 flex flex-col gap-y-1">
{assistant.document_sets.length > 0 && (
<div className="text-xs text-subtle flex flex-wrap gap-1">
<p className="my-auto font-medium">Document Sets:</p>
{assistant.document_sets.map((set) => (
<Bubble key={set.id} isSelected={false}>
<div className="flex flex-row gap-0.5">
<FiBookmark className="mr-1 my-auto" />
{set.name}
</div>
</Bubble>
))}
</div>
)}
{assistant.tools.length > 0 && (
<div className="text-xs text-subtle flex flex-wrap gap-1">
<p className="my-auto font-medium">Tools:</p>
{assistant.tools.map((tool) => {
let toolName = tool.name;
let toolIcon = null;
if (tool.name === "SearchTool") {
toolName = "Search";
toolIcon = <FiSearch className="mr-1 my-auto" />;
} else if (tool.name === "ImageGenerationTool") {
toolName = "Image Generation";
toolIcon = <FiImage className="mr-1 my-auto" />;
}
return (
<Bubble key={tool.id} isSelected={false}>
<div className="flex flex-row gap-0.5">
{toolIcon}
{toolName}
</div>
</Bubble>
);
})}
</div>
)}
<div className="text-xs text-subtle">
<span className="font-medium">Default Model:</span>{" "}
<i>{assistant.llm_model_version_override || llmName}</i>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,168 @@
"use client";
import React, { useState, useEffect } from "react";
import { Modal } from "../../../../components/Modal";
import { FilterManager, LlmOverrideManager } from "@/lib/hooks";
import { FiltersTab } from "./FiltersTab";
import { FiCpu, FiFilter, FiX } from "react-icons/fi";
import { IconType } from "react-icons";
import { FaBrain } from "react-icons/fa";
import { AssistantsTab } from "./AssistantsTab";
import { Persona } from "@/app/admin/assistants/interfaces";
import { LlmTab } from "./LlmTab";
const TabButton = ({
label,
icon: Icon,
isActive,
onClick,
}: {
label: string;
icon: IconType;
isActive: boolean;
onClick: () => void;
}) => (
<button
onClick={onClick}
className={`
pb-4
pt-6
px-2
text-emphasis
font-bold
${isActive ? "border-b-2 border-accent" : ""}
hover:bg-hover-light
hover:text-strong
transition
duration-200
ease-in-out
flex
`}
>
<Icon className="inline-block mr-2 my-auto" size="16" />
<p className="my-auto">{label}</p>
</button>
);
export function ConfigurationModal({
activeTab,
setActiveTab,
onClose,
selectedAssistant,
setSelectedAssistant,
filterManager,
llmOverrideManager,
}: {
activeTab: string | null;
setActiveTab: (tab: string | null) => void;
onClose: () => void;
selectedAssistant: Persona;
setSelectedAssistant: (assistant: Persona) => void;
filterManager: FilterManager;
llmOverrideManager: LlmOverrideManager;
}) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose]);
if (!activeTab) return null;
return (
<Modal
onOutsideClick={onClose}
noPadding
className="
w-4/6
h-4/6
flex
flex-col
"
>
<div className="rounded flex flex-col overflow-hidden">
<div className="mb-4">
<div className="flex border-b border-border bg-background-emphasis">
<div className="flex px-6 gap-x-2">
<TabButton
label="Assistants"
icon={FaBrain}
isActive={activeTab === "assistants"}
onClick={() => setActiveTab("assistants")}
/>
<TabButton
label="Models"
icon={FiCpu}
isActive={activeTab === "llms"}
onClick={() => setActiveTab("llms")}
/>
<TabButton
label="Filters"
icon={FiFilter}
isActive={activeTab === "filters"}
onClick={() => setActiveTab("filters")}
/>
</div>
<button
className="
ml-auto
px-1
py-1
text-xs
font-medium
rounded
hover:bg-hover
focus:outline-none
focus:ring-2
focus:ring-offset-2
focus:ring-subtle
flex
items-center
h-fit
my-auto
mr-5
"
onClick={onClose}
>
<FiX size={24} />
</button>
</div>
</div>
<div className="flex flex-col overflow-y-auto">
<div className="px-8 pt-4">
{activeTab === "filters" && (
<FiltersTab filterManager={filterManager} />
)}
{activeTab === "llms" && (
<LlmTab
llmOverrideManager={llmOverrideManager}
currentAssistant={selectedAssistant}
/>
)}
{activeTab === "assistants" && (
<div>
<AssistantsTab
selectedAssistant={selectedAssistant}
onSelect={(assistant) => {
setSelectedAssistant(assistant);
onClose();
}}
/>
</div>
)}
</div>
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,260 @@
import { useChatContext } from "@/components/context/ChatContext";
import { FilterManager } from "@/lib/hooks";
import { listSourceMetadata } from "@/lib/sources";
import { useRef, useState } from "react";
import {
DateRangePicker,
DateRangePickerItem,
Divider,
Text,
} from "@tremor/react";
import { getXDaysAgo } from "@/lib/dateUtils";
import { DocumentSetSelectable } from "@/components/documentSet/DocumentSetSelectable";
import { Bubble } from "@/components/Bubble";
import { FiX } from "react-icons/fi";
export function FiltersTab({
filterManager,
}: {
filterManager: FilterManager;
}): JSX.Element {
const [filterValue, setFilterValue] = useState<string>("");
const inputRef = useRef<HTMLInputElement>(null);
const { availableSources, availableDocumentSets, availableTags } =
useChatContext();
const allSources = listSourceMetadata();
const availableSourceMetadata = allSources.filter((source) =>
availableSources.includes(source.internalName)
);
return (
<div className="overflow-hidden flex flex-col">
<div className="overflow-y-auto">
<div>
<div className="pb-4">
<h3 className="text-lg font-semibold">Time Range</h3>
<Text>
Choose the time range we should search over. If only one date is
selected, will only search after the specified date.
</Text>
<div className="mt-2">
<DateRangePicker
className="w-96"
value={{
from: filterManager.timeRange?.from,
to: filterManager.timeRange?.to,
selectValue: filterManager.timeRange?.selectValue,
}}
onValueChange={(value) =>
filterManager.setTimeRange({
from: value.from,
to: value.to,
selectValue: value.selectValue,
})
}
selectPlaceholder="Select range"
enableSelect
>
<DateRangePickerItem
key="Last 30 Days"
value="Last 30 Days"
from={getXDaysAgo(30)}
to={new Date()}
>
Last 30 Days
</DateRangePickerItem>
<DateRangePickerItem
key="Last 7 Days"
value="Last 7 Days"
from={getXDaysAgo(7)}
to={new Date()}
>
Last 7 Days
</DateRangePickerItem>
<DateRangePickerItem
key="Today"
value="Today"
from={getXDaysAgo(1)}
to={new Date()}
>
Today
</DateRangePickerItem>
</DateRangePicker>
</div>
</div>
<Divider />
<div className="mb-8">
<h3 className="text-lg font-semibold">Knowledge Sets</h3>
<Text>
Choose which knowledge sets we should search over. If multiple are
selected, we will search through all of them.
</Text>
<ul className="mt-3">
{availableDocumentSets.length > 0 ? (
availableDocumentSets.map((set) => {
const isSelected =
filterManager.selectedDocumentSets.includes(set.name);
return (
<DocumentSetSelectable
key={set.id}
documentSet={set}
isSelected={isSelected}
onSelect={() =>
filterManager.setSelectedDocumentSets((prev) =>
isSelected
? prev.filter((s) => s !== set.name)
: [...prev, set.name]
)
}
/>
);
})
) : (
<li>No knowledge sets available</li>
)}
</ul>
</div>
<Divider />
<div className="mb-4">
<h3 className="text-lg font-semibold">Sources</h3>
<Text>
Choose which sources we should search over. If multiple sources
are selected, we will search through all of them.
</Text>
<ul className="mt-3 flex gap-2">
{availableSourceMetadata.length > 0 ? (
availableSourceMetadata.map((sourceMetadata) => {
const isSelected = filterManager.selectedSources.some(
(selectedSource) =>
selectedSource.internalName ===
sourceMetadata.internalName
);
return (
<Bubble
key={sourceMetadata.internalName}
isSelected={isSelected}
onClick={() =>
filterManager.setSelectedSources((prev) =>
isSelected
? prev.filter(
(s) =>
s.internalName !== sourceMetadata.internalName
)
: [...prev, sourceMetadata]
)
}
showCheckbox={true}
>
<div className="flex items-center space-x-2">
{sourceMetadata?.icon({ size: 16 })}
<span>{sourceMetadata.displayName}</span>
</div>
</Bubble>
);
})
) : (
<li>No sources available</li>
)}
</ul>
</div>
<Divider />
<div className="mb-8">
<h3 className="text-lg font-semibold">Tags</h3>
<ul className="space-2 gap-2 flex flex-wrap mt-2">
{filterManager.selectedTags.length > 0 ? (
filterManager.selectedTags.map((tag) => (
<Bubble
key={tag.tag_key + tag.tag_value}
isSelected={true}
onClick={() =>
filterManager.setSelectedTags((prev) =>
prev.filter(
(t) =>
t.tag_key !== tag.tag_key ||
t.tag_value !== tag.tag_value
)
)
}
>
<div className="flex items-center space-x-2 text-sm">
<p>
{tag.tag_key}={tag.tag_value}
</p>{" "}
<FiX />
</div>
</Bubble>
))
) : (
<p className="text-xs italic">No selected tags</p>
)}
</ul>
<div className="w-96 mt-2">
<div>
<div className="mb-2 pt-2">
<input
ref={inputRef}
className="w-full border border-border py-0.5 px-2 rounded text-sm h-8"
placeholder="Find a tag"
value={filterValue}
onChange={(event) => setFilterValue(event.target.value)}
/>
</div>
<div className="max-h-48 flex flex-col gap-y-1 overflow-y-auto">
{availableTags.length > 0 ? (
availableTags
.filter(
(tag) =>
!filterManager.selectedTags.some(
(selectedTag) =>
selectedTag.tag_key === tag.tag_key &&
selectedTag.tag_value === tag.tag_value
) &&
(tag.tag_key.includes(filterValue) ||
tag.tag_value.includes(filterValue))
)
.slice(0, 12)
.map((tag) => (
<Bubble
key={tag.tag_key + tag.tag_value}
isSelected={filterManager.selectedTags.includes(tag)}
onClick={() =>
filterManager.setSelectedTags((prev) =>
filterManager.selectedTags.includes(tag)
? prev.filter(
(t) =>
t.tag_key !== tag.tag_key ||
t.tag_value !== tag.tag_value
)
: [...prev, tag]
)
}
>
<>
{tag.tag_key}={tag.tag_value}
</>
</Bubble>
))
) : (
<div className="text-sm px-2 py-2">
No matching tags found
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,133 @@
import { useChatContext } from "@/components/context/ChatContext";
import { LlmOverride, LlmOverrideManager } from "@/lib/hooks";
import React, { useCallback, useRef, useState } from "react";
import { debounce } from "lodash";
import { DefaultDropdown } from "@/components/Dropdown";
import { Text } from "@tremor/react";
import { Persona } from "@/app/admin/assistants/interfaces";
import { getFinalLLM } from "@/lib/llm/utils";
export function LlmTab({
llmOverrideManager,
currentAssistant,
}: {
llmOverrideManager: LlmOverrideManager;
currentAssistant: Persona;
}) {
const { llmProviders } = useChatContext();
const { llmOverride, setLlmOverride, temperature, setTemperature } =
llmOverrideManager;
const [localTemperature, setLocalTemperature] = useState<number>(
temperature || 0
);
const debouncedSetTemperature = useCallback(
debounce((value) => {
setTemperature(value);
}, 300),
[]
);
const handleTemperatureChange = (value: number) => {
setLocalTemperature(value);
debouncedSetTemperature(value);
};
const [_, defaultLlmName] = getFinalLLM(llmProviders, currentAssistant);
const llmOptions: { name: string; value: string }[] = [];
const structureValue = (
name: string,
provider: string,
modelName: string
) => {
return `${name}__${provider}__${modelName}`;
};
const destructureValue = (value: string): LlmOverride => {
const [displayName, provider, modelName] = value.split("__");
return {
name: displayName,
provider,
modelName,
};
};
llmProviders.forEach((llmProvider) => {
llmProvider.model_names.forEach((modelName) => {
llmOptions.push({
name: modelName,
value: structureValue(
llmProvider.name,
llmProvider.provider,
modelName
),
});
});
});
return (
<div className="mb-4">
<label className="block text-sm font-medium mb-2">Choose Model</label>
<Text className="mb-1">
Override the default model for the{" "}
<i className="font-medium">{currentAssistant.name}</i> assistant. The
override will apply only for this chat session.
</Text>
<Text className="mb-3">
Default Model: <i className="font-medium">{defaultLlmName}</i>.
</Text>
<div className="w-96">
<DefaultDropdown
options={llmOptions}
selected={structureValue(
llmOverride.name,
llmOverride.provider,
llmOverride.modelName
)}
onSelect={(value) =>
setLlmOverride(destructureValue(value as string))
}
/>
</div>
<label className="block text-sm font-medium mb-2 mt-4">Temperature</label>
<Text className="mb-8">
Adjust the temperature of the LLM. Higher temperature will make the LLM
generate more creative and diverse responses, while lower temperature
will make the LLM generate more conservative and focused responses.
</Text>
<div className="relative w-full">
<input
type="range"
onChange={(e) => handleTemperatureChange(parseFloat(e.target.value))}
className="
w-full
p-2
border
border-border
rounded-md
"
min="0"
max="2"
step="0.01"
value={localTemperature}
/>
<div
className="absolute text-sm"
style={{
left: `${(localTemperature || 0) * 50}%`,
transform: `translateX(-${Math.min(
Math.max((localTemperature || 0) * 50, 10),
90
)}%)`,
top: "-1.5rem",
}}
>
{localTemperature}
</div>
</div>
</div>
);
}

View File

@ -28,10 +28,10 @@ import { ChatPage } from "./ChatPage";
import { FullEmbeddingModelResponse } from "../admin/models/embedding/embeddingModels";
import { NoCompleteSourcesModal } from "@/components/initialSetup/search/NoCompleteSourceModal";
import { Settings } from "../admin/settings/interfaces";
import { SIDEBAR_TAB_COOKIE, Tabs } from "./sessionSidebar/constants";
import { fetchLLMProvidersSS } from "@/lib/llm/fetchLLMs";
import { LLMProviderDescriptor } from "../admin/models/llm/interfaces";
import { Folder } from "./folders/interfaces";
import { ChatProvider } from "@/components/context/ChatContext";
export default async function Page({
searchParams,
@ -151,10 +151,6 @@ export default async function Page({
? parseInt(documentSidebarCookieInitialWidth.value)
: undefined;
const defaultSidebarTab = cookies().get(SIDEBAR_TAB_COOKIE)?.value as
| Tabs
| undefined;
const hasAnyConnectors = ccPairs.length > 0;
const shouldShowWelcomeModal =
!hasCompletedWelcomeFlowSS() &&
@ -197,20 +193,24 @@ export default async function Page({
<NoCompleteSourcesModal ccPairs={ccPairs} />
)}
<ChatPage
user={user}
chatSessions={chatSessions}
availableSources={availableSources}
availableDocumentSets={documentSets}
availablePersonas={personas}
availableTags={tags}
llmProviders={llmProviders}
defaultSelectedPersonaId={defaultPersonaId}
documentSidebarInitialWidth={finalDocumentSidebarInitialWidth}
defaultSidebarTab={defaultSidebarTab}
folders={folders} // Pass folders to ChatPage
openedFolders={openedFolders} // Pass opened folders state to ChatPage
/>
<ChatProvider
value={{
user,
chatSessions,
availableSources,
availableDocumentSets: documentSets,
availablePersonas: personas,
availableTags: tags,
llmProviders,
folders,
openedFolders,
}}
>
<ChatPage
defaultSelectedPersonaId={defaultPersonaId}
documentSidebarInitialWidth={finalDocumentSidebarInitialWidth}
/>
</ChatProvider>
</>
);
}

View File

@ -108,9 +108,6 @@ export function ChatSessionDisplay({
<BasicSelectable fullWidth selected={isSelected}>
<>
<div className="flex relative">
<div className="my-auto mr-2">
<FiMessageSquare size={16} />
</div>
{isRenamingChat ? (
<input
value={chatName}
@ -184,6 +181,7 @@ export function ChatSessionDisplay({
}
requiresContentPadding
sideOffset={6}
triggerMaxWidth
/>
</div>
</div>

View File

@ -1,108 +1,35 @@
"use client";
import {
FiFolderPlus,
FiLogOut,
FiMessageSquare,
FiMoreHorizontal,
FiPlusSquare,
FiSearch,
FiTool,
} from "react-icons/fi";
import { useEffect, useRef, useState } from "react";
import { FiEdit, FiFolderPlus, FiPlusSquare } from "react-icons/fi";
import { useContext, useEffect, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { User } from "@/lib/types";
import { logout } from "@/lib/user";
import { BasicClickable, BasicSelectable } from "@/components/BasicClickable";
import { ChatSession } from "../interfaces";
import {
HEADER_PADDING,
NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA,
} from "@/lib/constants";
import { NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA } from "@/lib/constants";
import { ChatTab } from "./ChatTab";
import { AssistantsTab } from "./AssistantsTab";
import { Persona } from "@/app/admin/assistants/interfaces";
import Cookies from "js-cookie";
import { SIDEBAR_TAB_COOKIE, Tabs } from "./constants";
import { Folder } from "../folders/interfaces";
import { createFolder } from "../folders/FolderManagement";
import { usePopup } from "@/components/admin/connectors/Popup";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import React from "react";
export const ChatSidebar = ({
existingChats,
currentChatSession,
personas,
onPersonaChange,
user,
defaultTab,
folders,
openedFolders,
}: {
existingChats: ChatSession[];
currentChatSession: ChatSession | null | undefined;
personas: Persona[];
onPersonaChange: (persona: Persona | null) => void;
user: User | null;
defaultTab?: Tabs;
folders: Folder[];
openedFolders: { [key: number]: boolean };
}) => {
const router = useRouter();
const { popup, setPopup } = usePopup();
const [openTab, _setOpenTab] = useState(defaultTab || Tabs.CHATS);
const setOpenTab = (tab: Tabs) => {
Cookies.set(SIDEBAR_TAB_COOKIE, tab);
_setOpenTab(tab);
};
function TabOption({ tab }: { tab: Tabs }) {
return (
<div
className={
"font-bold p-1 rounded-lg hover:bg-hover cursor-pointer " +
(openTab === tab ? "bg-hover" : "")
}
onClick={() => {
setOpenTab(tab);
}}
>
{tab}
</div>
);
}
const [userInfoVisible, setUserInfoVisible] = useState(false);
const userInfoRef = useRef<HTMLDivElement>(null);
const handleLogout = () => {
logout().then((isSuccess) => {
if (!isSuccess) {
alert("Failed to logout");
}
router.push("/auth/login");
});
};
// hides logout popup on any click outside
const handleClickOutside = (event: MouseEvent) => {
if (
userInfoRef.current &&
!userInfoRef.current.contains(event.target as Node)
) {
setUserInfoVisible(false);
}
};
useEffect(() => {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const currentChatId = currentChatSession?.id;
// prevent the NextJS Router cache from causing the chat sidebar to not
@ -111,6 +38,12 @@ export const ChatSidebar = ({
router.refresh();
}, [currentChatId]);
const combinedSettings = useContext(SettingsContext);
if (!combinedSettings) {
return null;
}
const settings = combinedSettings.settings;
return (
<>
{popup}
@ -119,7 +52,6 @@ export const ChatSidebar = ({
flex-none
w-64
3xl:w-72
${HEADER_PADDING}
border-r
border-border
flex
@ -128,145 +60,74 @@ export const ChatSidebar = ({
transition-transform`}
id="chat-sidebar"
>
<div className="flex w-full px-3 mt-4 text-sm ">
<div className="flex w-full gap-x-4 pb-2 border-b border-border">
<TabOption tab={Tabs.CHATS} />
<TabOption tab={Tabs.ASSISTANTS} />
</div>
</div>
{openTab == Tabs.CHATS && (
<>
<div className="flex mt-5 items-center">
<Link
href={
"/chat" +
(NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA &&
currentChatSession
? `?assistantId=${currentChatSession.persona_id}`
: "")
}
className="ml-3 w-full"
>
<BasicClickable fullWidth>
<div className="flex items-center text-sm">
<FiPlusSquare className="mr-2" /> New Chat
</div>
</BasicClickable>
</Link>
<div className="ml-1.5 mr-3 h-full">
<BasicClickable
onClick={() =>
createFolder("New Folder")
.then((folderId) => {
console.log(`Folder created with ID: ${folderId}`);
router.refresh();
})
.catch((error) => {
console.error("Failed to create folder:", error);
setPopup({
message: `Failed to create folder: ${error.message}`,
type: "error",
});
})
}
>
<div className="flex items-center text-sm h-full">
<FiFolderPlus className="mx-1 my-auto" />
</div>
</BasicClickable>
<div className="pt-6 flex">
<Link
className="ml-4 w-full"
href={
settings && settings.default_page === "chat" ? "/chat" : "/search"
}
>
<div className="flex w-full">
<div className="h-[32px] w-[30px]">
<Image src="/logo.png" alt="Logo" width="1419" height="1520" />
</div>
<h1 className="flex text-2xl text-strong font-bold my-auto">
Danswer
</h1>
</div>
</Link>
</div>
<ChatTab
existingChats={existingChats}
currentChatId={currentChatId}
folders={folders}
openedFolders={openedFolders}
/>
</>
)}
{openTab == Tabs.ASSISTANTS && (
<>
<Link href="/assistants/new" className="mx-3 mt-5">
<BasicClickable fullWidth>
<div className="flex text-sm">
<FiPlusSquare className="my-auto mr-2" /> New Assistant
</div>
</BasicClickable>
</Link>
<AssistantsTab
personas={personas}
onPersonaChange={onPersonaChange}
user={user}
/>
</>
)}
<div
className="mt-auto py-2 border-t border-border px-3"
ref={userInfoRef}
>
<div className="relative text-strong">
{userInfoVisible && (
<div
className={
(user ? "translate-y-[-110%]" : "translate-y-[-115%]") +
" absolute top-0 bg-background border border-border z-30 w-full rounded text-strong text-sm"
}
>
<Link
href="/search"
className="flex py-3 px-4 cursor-pointer hover:bg-hover"
>
<FiSearch className="my-auto mr-2" />
Danswer Search
</Link>
<Link
href="/chat"
className="flex py-3 px-4 cursor-pointer hover:bg-hover"
>
<FiMessageSquare className="my-auto mr-2" />
Danswer Chat
</Link>
{(!user || user.role === "admin") && (
<Link
href="/admin/indexing/status"
className="flex py-3 px-4 cursor-pointer border-t border-border hover:bg-hover"
>
<FiTool className="my-auto mr-2" />
Admin Panel
</Link>
)}
{user && (
<div
onClick={handleLogout}
className="flex py-3 px-4 cursor-pointer border-t border-border rounded hover:bg-hover"
>
<FiLogOut className="my-auto mr-2" />
Log out
</div>
)}
<div className="flex mt-5 items-center">
<Link
href={
"/chat" +
(NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA &&
currentChatSession
? `?assistantId=${currentChatSession.persona_id}`
: "")
}
className="ml-3 w-full"
>
<BasicClickable fullWidth>
<div className="flex items-center text-sm">
<FiEdit className="ml-1 mr-2" /> New Chat
</div>
)}
<BasicSelectable fullWidth selected={false}>
<div
onClick={() => setUserInfoVisible(!userInfoVisible)}
className="flex h-8"
>
<div className="my-auto mr-2 bg-user rounded-lg px-1.5">
{user && user.email ? user.email[0].toUpperCase() : "A"}
</div>
<p className="my-auto">
{user ? user.email : "Anonymous Possum"}
</p>
<FiMoreHorizontal className="my-auto ml-auto mr-2" size={20} />
</BasicClickable>
</Link>
<div className="ml-1.5 mr-3 h-full">
<BasicClickable
onClick={() =>
createFolder("New Folder")
.then((folderId) => {
console.log(`Folder created with ID: ${folderId}`);
router.refresh();
})
.catch((error) => {
console.error("Failed to create folder:", error);
setPopup({
message: `Failed to create folder: ${error.message}`,
type: "error",
});
})
}
>
<div className="flex items-center text-sm h-full">
<FiFolderPlus className="mx-1 my-auto" />
</div>
</BasicSelectable>
</BasicClickable>
</div>
</div>
<div className="border-b border-border pb-4 mx-3" />
<ChatTab
existingChats={existingChats}
currentChatId={currentChatId}
folders={folders}
openedFolders={openedFolders}
/>
</div>
</>
);

View File

@ -50,14 +50,19 @@ export function ChatTab({
};
return (
<div className="mt-4 mb-1 ml-3 overflow-y-auto h-full">
<div className="border-b border-border pb-1 mr-3">
<FolderList
folders={folders}
currentChatId={currentChatId}
openedFolders={openedFolders}
/>
</div>
<div className="mb-1 ml-3 overflow-y-auto h-full">
{folders.length > 0 && (
<div className="py-2 mr-3 border-b border-border">
<div className="text-xs text-subtle flex pb-0.5 mb-1.5 mt-2 font-medium">
Folders
</div>
<FolderList
folders={folders}
currentChatId={currentChatId}
openedFolders={openedFolders}
/>
</div>
)}
<div
onDragOver={(event) => {
@ -66,7 +71,7 @@ export function ChatTab({
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDropToRemoveFromFolder}
className={`transition duration-300 ease-in-out mr-3 ${
className={`pt-1 transition duration-300 ease-in-out mr-3 ${
isDragOver ? "bg-hover" : ""
} rounded-md`}
>
@ -75,7 +80,7 @@ export function ChatTab({
if (chatSessions.length > 0) {
return (
<div key={dateRange}>
<div className="text-xs text-subtle flex pb-0.5 mb-1.5 mt-5 font-bold">
<div className="text-xs text-subtle flex pb-0.5 mb-1.5 mt-5 font-medium">
{dateRange}
</div>
{chatSessions

View File

@ -1,6 +0,0 @@
export const SIDEBAR_TAB_COOKIE = "chatSidebarTab";
export enum Tabs {
CHATS = "Chats",
ASSISTANTS = "Assistants",
}

View File

@ -1,11 +1,15 @@
import { CustomCheckbox } from "./CustomCheckbox";
export function Bubble({
isSelected,
onClick,
children,
showCheckbox = false,
}: {
isSelected: boolean;
onClick?: () => void;
children: string | JSX.Element;
showCheckbox?: boolean;
}) {
return (
<div
@ -24,6 +28,11 @@ export function Bubble({
onClick={onClick}
>
<div className="my-auto">{children}</div>
{showCheckbox && (
<div className="pl-2 my-auto">
<CustomCheckbox checked={isSelected} onChange={() => null} />
</div>
)}
</div>
);
}

View File

@ -369,6 +369,7 @@ export function DefaultDropdown({
side={side}
sideOffset={5}
matchWidth
triggerMaxWidth
/>
</div>
);

View File

@ -9,6 +9,7 @@ interface ModalProps {
width?: string;
titleSize?: string;
hideDividerForTitle?: boolean;
noPadding?: boolean;
}
export function Modal({
@ -19,12 +20,13 @@ export function Modal({
width,
titleSize,
hideDividerForTitle,
noPadding,
}: ModalProps) {
return (
<div>
<div
className={`
fixed inset-0 bg-black bg-opacity-50
fixed inset-0 bg-black bg-opacity-30 backdrop-blur-sm
flex items-center justify-center z-50
`}
onClick={onOutsideClick}
@ -32,7 +34,8 @@ export function Modal({
<div
className={`
bg-background rounded shadow-lg
relative ${width ?? "w-1/2"} text-sm p-8
relative ${width ?? "w-1/2"} text-sm
${noPadding ? "" : "p-8"}
${className}
`}
onClick={(event) => event.stopPropagation()}

View File

@ -0,0 +1,130 @@
import { useState, useRef, useEffect } from "react";
import {
FiSearch,
FiMessageSquare,
FiTool,
FiLogOut,
FiMoreHorizontal,
} from "react-icons/fi";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { User } from "@/lib/types";
import { logout } from "@/lib/user";
import { BasicSelectable } from "@/components/BasicClickable";
import { DefaultDropdown } from "./Dropdown";
import { Popover } from "./popover/Popover";
export function UserDropdown({
user,
hideChatAndSearch,
}: {
user: User | null;
hideChatAndSearch?: boolean;
}) {
const [userInfoVisible, setUserInfoVisible] = useState(false);
const userInfoRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const handleLogout = () => {
logout().then((isSuccess) => {
if (!isSuccess) {
alert("Failed to logout");
}
router.push("/auth/login");
});
};
const showAdminPanel = !user || user.role === "admin";
return (
<div className="relative" ref={userInfoRef}>
<Popover
open={userInfoVisible}
onOpenChange={setUserInfoVisible}
content={
<BasicSelectable selected={false}>
<div
onClick={() => setUserInfoVisible(!userInfoVisible)}
className="flex cursor-pointer"
>
<div className="my-auto bg-user rounded-lg px-2 text-base font-normal">
{user && user.email ? user.email[0].toUpperCase() : "A"}
</div>
</div>
</BasicSelectable>
}
popover={
<div
className={`
text-strong
text-sm
border
border-border
bg-background
rounded-lg
shadow-lg
flex
flex-col
w-full
max-h-96
overflow-y-auto
p-1
overscroll-contain
`}
>
{!hideChatAndSearch && (
<>
<Link
href="/search"
className="flex py-3 px-4 rounded cursor-pointer hover:bg-hover-light"
>
<FiSearch className="my-auto mr-2 text-lg" />
Danswer Search
</Link>
<Link
href="/chat"
className="flex py-3 px-4 rounded cursor-pointer hover:bg-hover-light"
>
<FiMessageSquare className="my-auto mr-2 text-lg" />
Danswer Chat
</Link>
</>
)}
{showAdminPanel && (
<>
{!hideChatAndSearch && (
<div className="border-t border-border my-1" />
)}
<Link
href="/admin/indexing/status"
className="flex py-3 px-4 cursor-pointer rounded hover:bg-hover-light"
>
<FiTool className="my-auto mr-2 text-lg" />
Admin Panel
</Link>
</>
)}
{user && (
<>
{(!hideChatAndSearch || showAdminPanel) && (
<div className="border-t border-border my-1" />
)}
<div
onClick={handleLogout}
className="mt-1 flex py-3 px-4 cursor-pointer hover:bg-hover-light"
>
<FiLogOut className="my-auto mr-2 text-lg" />
Log out
</div>
</>
)}
</div>
}
side="bottom"
align="end"
sideOffset={5}
alignOffset={-10}
/>
</div>
);
}

View File

@ -0,0 +1,43 @@
"use client";
import React, { createContext, useContext } from "react";
import {
CCPairBasicInfo,
DocumentSet,
Tag,
User,
ValidSources,
} from "@/lib/types";
import { ChatSession } from "@/app/chat/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
import { LLMProviderDescriptor } from "@/app/admin/models/llm/interfaces";
import { Folder } from "@/app/chat/folders/interfaces";
interface ChatContextProps {
user: User | null;
chatSessions: ChatSession[];
availableSources: ValidSources[];
availableDocumentSets: DocumentSet[];
availablePersonas: Persona[];
availableTags: Tag[];
llmProviders: LLMProviderDescriptor[];
folders: Folder[];
openedFolders: Record<string, boolean>;
}
const ChatContext = createContext<ChatContextProps | undefined>(undefined);
export const ChatProvider: React.FC<{
value: ChatContextProps;
children: React.ReactNode;
}> = ({ value, children }) => {
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
};
export const useChatContext = (): ChatContextProps => {
const context = useContext(ChatContext);
if (!context) {
throw new Error("useChatContext must be used within a ChatProvider");
}
return context;
};

View File

@ -10,6 +10,7 @@ import { CustomDropdown, DefaultDropdownElement } from "../Dropdown";
import { FiMessageSquare, FiSearch } from "react-icons/fi";
import { HeaderWrapper } from "./HeaderWrapper";
import { SettingsContext } from "../settings/SettingsProvider";
import { UserDropdown } from "../UserDropdown";
interface HeaderProps {
user: User | null;
@ -106,35 +107,7 @@ export function Header({ user }: HeaderProps) {
<div className="ml-auto h-full flex flex-col">
<div className="my-auto">
<CustomDropdown
dropdown={
<div
className={
"absolute right-0 mt-2 bg-background rounded border border-border " +
"w-48 overflow-hidden shadow-xl z-10 text-sm"
}
>
{/* Show connector option if (1) auth is disabled or (2) user is an admin */}
{(!user || user.role === "admin") && (
<Link href="/admin/indexing/status">
<DefaultDropdownElement name="Admin Panel" />
</Link>
)}
{user && (
<DefaultDropdownElement
name="Logout"
onSelect={handleLogout}
/>
)}
</div>
}
>
<div className="hover:bg-hover rounded p-1 w-fit">
<div className="my-auto bg-user text-sm rounded-lg px-1.5 select-none">
{user && user.email ? user.email[0].toUpperCase() : "A"}
</div>
</div>
</CustomDropdown>
<UserDropdown user={user} hideChatAndSearch />
</div>
</div>
</div>

View File

@ -12,8 +12,10 @@ export function Popover({
side,
align,
sideOffset,
alignOffset,
matchWidth,
requiresContentPadding,
triggerMaxWidth,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -22,8 +24,10 @@ export function Popover({
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
sideOffset?: number;
alignOffset?: number;
matchWidth?: boolean;
requiresContentPadding?: boolean;
triggerMaxWidth?: boolean;
}) {
/*
This Popover is needed when we want to put a popup / dropdown in a component
@ -36,7 +40,7 @@ export function Popover({
return (
<RadixPopover.Root open={open} onOpenChange={onOpenChange}>
<RadixPopover.Trigger style={{ width: "100%" }}>
<RadixPopover.Trigger style={triggerMaxWidth ? { width: "100%" } : {}}>
{/* NOTE: this weird `-mb-1.5` is needed to offset the Anchor, otherwise
the content will shift up by 1.5px when the Popover is open. */}
{open ? (
@ -57,6 +61,7 @@ export function Popover({
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
>
{popover}
</RadixPopover.Content>

View File

@ -79,7 +79,20 @@ export const useTimeRange = (initialValue?: DateRangePickerValue) => {
return useState<DateRangePickerValue | null>(null);
};
export function useFilters() {
export interface FilterManager {
timeRange: DateRangePickerValue | null;
setTimeRange: React.Dispatch<
React.SetStateAction<DateRangePickerValue | null>
>;
selectedSources: SourceMetadata[];
setSelectedSources: React.Dispatch<React.SetStateAction<SourceMetadata[]>>;
selectedDocumentSets: string[];
setSelectedDocumentSets: React.Dispatch<React.SetStateAction<string[]>>;
selectedTags: Tag[];
setSelectedTags: React.Dispatch<React.SetStateAction<Tag[]>>;
}
export function useFilters(): FilterManager {
const [timeRange, setTimeRange] = useTimeRange();
const [selectedSources, setSelectedSources] = useState<SourceMetadata[]>([]);
const [selectedDocumentSets, setSelectedDocumentSets] = useState<string[]>(
@ -109,6 +122,35 @@ export const useUsers = () => {
};
};
export interface LlmOverride {
name: string;
provider: string;
modelName: string;
}
export interface LlmOverrideManager {
llmOverride: LlmOverride;
setLlmOverride: React.Dispatch<React.SetStateAction<LlmOverride>>;
temperature: number | null;
setTemperature: React.Dispatch<React.SetStateAction<number | null>>;
}
export function useLlmOverride(): LlmOverrideManager {
const [llmOverride, setLlmOverride] = useState<LlmOverride>({
name: "",
provider: "",
modelName: "",
});
const [temperature, setTemperature] = useState<number | null>(null);
return {
llmOverride,
setLlmOverride,
temperature,
setTemperature,
};
}
/*
EE Only APIs
*/