mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-04-07 19:38:19 +02:00
Chat UI small rework (#1504)
* Fat bar + configuration modal * Remove header
This commit is contained in:
parent
ba872a0f7f
commit
88db722ea4
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -67,7 +67,6 @@ export function DocumentSidebar({
|
||||
flex-col
|
||||
w-full
|
||||
h-screen
|
||||
${HEADER_PADDING}
|
||||
`}
|
||||
id="document-sidebar"
|
||||
>
|
||||
|
@ -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}
|
||||
|
210
web/src/app/chat/input/ChatInputBar.tsx
Normal file
210
web/src/app/chat/input/ChatInputBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
96
web/src/app/chat/input/ChatInputOption.tsx
Normal file
96
web/src/app/chat/input/ChatInputOption.tsx
Normal 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;
|
152
web/src/app/chat/input/SelectedFilterDisplay.tsx
Normal file
152
web/src/app/chat/input/SelectedFilterDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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,
|
||||
|
@ -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 || "")
|
||||
}
|
||||
>
|
||||
|
90
web/src/app/chat/modal/configuration/AssistantsTab.tsx
Normal file
90
web/src/app/chat/modal/configuration/AssistantsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
168
web/src/app/chat/modal/configuration/ConfigurationModal.tsx
Normal file
168
web/src/app/chat/modal/configuration/ConfigurationModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
260
web/src/app/chat/modal/configuration/FiltersTab.tsx
Normal file
260
web/src/app/chat/modal/configuration/FiltersTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
133
web/src/app/chat/modal/configuration/LlmTab.tsx
Normal file
133
web/src/app/chat/modal/configuration/LlmTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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
|
||||
|
@ -1,6 +0,0 @@
|
||||
export const SIDEBAR_TAB_COOKIE = "chatSidebarTab";
|
||||
|
||||
export enum Tabs {
|
||||
CHATS = "Chats",
|
||||
ASSISTANTS = "Assistants",
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -369,6 +369,7 @@ export function DefaultDropdown({
|
||||
side={side}
|
||||
sideOffset={5}
|
||||
matchWidth
|
||||
triggerMaxWidth
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -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()}
|
||||
|
130
web/src/components/UserDropdown.tsx
Normal file
130
web/src/components/UserDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
43
web/src/components/context/ChatContext.tsx
Normal file
43
web/src/components/context/ChatContext.tsx
Normal 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;
|
||||
};
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
*/
|
||||
|
Loading…
x
Reference in New Issue
Block a user