mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-10-09 12:47:13 +02:00
3406 lines
127 KiB
TypeScript
3406 lines
127 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
redirect,
|
|
usePathname,
|
|
useRouter,
|
|
useSearchParams,
|
|
} from "next/navigation";
|
|
import {
|
|
BackendChatSession,
|
|
BackendMessage,
|
|
ChatFileType,
|
|
ChatSession,
|
|
ChatSessionSharedStatus,
|
|
FileDescriptor,
|
|
FileChatDisplay,
|
|
Message,
|
|
MessageResponseIDInfo,
|
|
RetrievalType,
|
|
StreamingError,
|
|
ToolCallMetadata,
|
|
SubQuestionDetail,
|
|
constructSubQuestions,
|
|
DocumentsResponse,
|
|
AgenticMessageResponseIDInfo,
|
|
UserKnowledgeFilePacket,
|
|
} from "./interfaces";
|
|
|
|
import Prism from "prismjs";
|
|
import Cookies from "js-cookie";
|
|
import { HistorySidebar } from "./sessionSidebar/HistorySidebar";
|
|
import { Persona } from "../admin/assistants/interfaces";
|
|
import { HealthCheckBanner } from "@/components/health/healthcheck";
|
|
import {
|
|
buildChatUrl,
|
|
buildLatestMessageChain,
|
|
createChatSession,
|
|
getCitedDocumentsFromMessage,
|
|
getHumanAndAIMessageFromMessageNumber,
|
|
getLastSuccessfulMessageId,
|
|
handleChatFeedback,
|
|
nameChatSession,
|
|
PacketType,
|
|
personaIncludesRetrieval,
|
|
processRawChatHistory,
|
|
removeMessage,
|
|
sendMessage,
|
|
SendMessageParams,
|
|
setMessageAsLatest,
|
|
updateLlmOverrideForChatSession,
|
|
updateParentChildren,
|
|
uploadFilesForChat,
|
|
useScrollonStream,
|
|
} from "./lib";
|
|
import {
|
|
Dispatch,
|
|
SetStateAction,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { usePopup } from "@/components/admin/connectors/Popup";
|
|
import { SEARCH_PARAM_NAMES, shouldSubmitOnLoad } from "./searchParams";
|
|
import { LlmDescriptor, useFilters, useLlmManager } from "@/lib/hooks";
|
|
import { ChatState, FeedbackType, RegenerationState } from "./types";
|
|
import { DocumentResults } from "./documentSidebar/DocumentResults";
|
|
import { OnyxInitializingLoader } from "@/components/OnyxInitializingLoader";
|
|
import { FeedbackModal } from "./modal/FeedbackModal";
|
|
import { ShareChatSessionModal } from "./modal/ShareChatSessionModal";
|
|
import { FiArrowDown } from "react-icons/fi";
|
|
import { ChatIntro } from "./ChatIntro";
|
|
import { AIMessage, HumanMessage } from "./message/Messages";
|
|
import { StarterMessages } from "../../components/assistants/StarterMessage";
|
|
import {
|
|
AnswerPiecePacket,
|
|
OnyxDocument,
|
|
DocumentInfoPacket,
|
|
StreamStopInfo,
|
|
StreamStopReason,
|
|
SubQueryPiece,
|
|
SubQuestionPiece,
|
|
AgentAnswerPiece,
|
|
RefinedAnswerImprovement,
|
|
MinimalOnyxDocument,
|
|
} from "@/lib/search/interfaces";
|
|
import { buildFilters } from "@/lib/search/utils";
|
|
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
|
import Dropzone from "react-dropzone";
|
|
import {
|
|
getFinalLLM,
|
|
modelSupportsImageInput,
|
|
structureValue,
|
|
} from "@/lib/llm/utils";
|
|
import { ChatInputBar } from "./input/ChatInputBar";
|
|
import { useChatContext } from "@/components/context/ChatContext";
|
|
import { ChatPopup } from "./ChatPopup";
|
|
import FunctionalHeader from "@/components/chat/Header";
|
|
import { useSidebarVisibility } from "@/components/chat/hooks";
|
|
import {
|
|
PRO_SEARCH_TOGGLED_COOKIE_NAME,
|
|
SIDEBAR_TOGGLED_COOKIE_NAME,
|
|
} from "@/components/resizable/constants";
|
|
import FixedLogo from "@/components/logo/FixedLogo";
|
|
import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal";
|
|
import { SEARCH_TOOL_ID, SEARCH_TOOL_NAME } from "./tools/constants";
|
|
import { useUser } from "@/components/user/UserProvider";
|
|
import { ApiKeyModal } from "@/components/llm/ApiKeyModal";
|
|
import BlurBackground from "../../components/chat/BlurBackground";
|
|
import { NoAssistantModal } from "@/components/modals/NoAssistantModal";
|
|
import { useAssistants } from "@/components/context/AssistantsContext";
|
|
import TextView from "@/components/chat/TextView";
|
|
import { Modal } from "@/components/Modal";
|
|
import { useSendMessageToParent } from "@/lib/extension/utils";
|
|
import {
|
|
CHROME_MESSAGE,
|
|
SUBMIT_MESSAGE_TYPES,
|
|
} from "@/lib/extension/constants";
|
|
|
|
import { getSourceMetadata } from "@/lib/sources";
|
|
import { UserSettingsModal } from "./modal/UserSettingsModal";
|
|
import { AgenticMessage } from "./message/AgenticMessage";
|
|
import AssistantModal from "../assistants/mine/AssistantModal";
|
|
import { useSidebarShortcut } from "@/lib/browserUtilities";
|
|
import { FilePickerModal } from "./my-documents/components/FilePicker";
|
|
|
|
import { SourceMetadata } from "@/lib/search/interfaces";
|
|
import { ValidSources } from "@/lib/types";
|
|
import {
|
|
FileResponse,
|
|
FolderResponse,
|
|
useDocumentsContext,
|
|
} from "./my-documents/DocumentsContext";
|
|
import { ChatSearchModal } from "./chat_search/ChatSearchModal";
|
|
import { ErrorBanner } from "./message/Resubmit";
|
|
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
|
|
|
const TEMP_USER_MESSAGE_ID = -1;
|
|
const TEMP_ASSISTANT_MESSAGE_ID = -2;
|
|
const SYSTEM_MESSAGE_ID = -3;
|
|
|
|
export enum UploadIntent {
|
|
ATTACH_TO_MESSAGE, // For files uploaded via ChatInputBar (paste, drag/drop)
|
|
ADD_TO_DOCUMENTS, // For files uploaded via FilePickerModal or similar (just add to repo)
|
|
}
|
|
|
|
export function ChatPage({
|
|
toggle,
|
|
documentSidebarInitialWidth,
|
|
sidebarVisible,
|
|
firstMessage,
|
|
initialFolders,
|
|
initialFiles,
|
|
}: {
|
|
toggle: (toggled?: boolean) => void;
|
|
documentSidebarInitialWidth?: number;
|
|
sidebarVisible: boolean;
|
|
firstMessage?: string;
|
|
initialFolders?: any;
|
|
initialFiles?: any;
|
|
}) {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
|
|
const {
|
|
chatSessions,
|
|
ccPairs,
|
|
tags,
|
|
documentSets,
|
|
llmProviders,
|
|
folders,
|
|
shouldShowWelcomeModal,
|
|
refreshChatSessions,
|
|
proSearchToggled,
|
|
} = useChatContext();
|
|
|
|
const {
|
|
selectedFiles,
|
|
selectedFolders,
|
|
addSelectedFile,
|
|
addSelectedFolder,
|
|
clearSelectedItems,
|
|
setSelectedFiles,
|
|
folders: userFolders,
|
|
files: allUserFiles,
|
|
uploadFile,
|
|
currentMessageFiles,
|
|
setCurrentMessageFiles,
|
|
} = useDocumentsContext();
|
|
|
|
const defaultAssistantIdRaw = searchParams?.get(
|
|
SEARCH_PARAM_NAMES.PERSONA_ID
|
|
);
|
|
const defaultAssistantId = defaultAssistantIdRaw
|
|
? parseInt(defaultAssistantIdRaw)
|
|
: undefined;
|
|
|
|
// Function declarations need to be outside of blocks in strict mode
|
|
function useScreenSize() {
|
|
const [screenSize, setScreenSize] = useState({
|
|
width: typeof window !== "undefined" ? window.innerWidth : 0,
|
|
height: typeof window !== "undefined" ? window.innerHeight : 0,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const handleResize = () => {
|
|
setScreenSize({
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
});
|
|
};
|
|
|
|
window.addEventListener("resize", handleResize);
|
|
return () => window.removeEventListener("resize", handleResize);
|
|
}, []);
|
|
|
|
return screenSize;
|
|
}
|
|
|
|
// 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
|
|
// available in server-side components
|
|
const settings = useContext(SettingsContext);
|
|
const enterpriseSettings = settings?.enterpriseSettings;
|
|
|
|
const [toggleDocSelection, setToggleDocSelection] = useState(false);
|
|
const [documentSidebarVisible, setDocumentSidebarVisible] = useState(false);
|
|
const [proSearchEnabled, setProSearchEnabled] = useState(proSearchToggled);
|
|
const toggleProSearch = () => {
|
|
Cookies.set(
|
|
PRO_SEARCH_TOGGLED_COOKIE_NAME,
|
|
String(!proSearchEnabled).toLocaleLowerCase()
|
|
);
|
|
setProSearchEnabled(!proSearchEnabled);
|
|
};
|
|
|
|
const isInitialLoad = useRef(true);
|
|
const [userSettingsToggled, setUserSettingsToggled] = useState(false);
|
|
|
|
const { assistants: availableAssistants, pinnedAssistants } = useAssistants();
|
|
|
|
const [showApiKeyModal, setShowApiKeyModal] = useState(
|
|
!shouldShowWelcomeModal
|
|
);
|
|
|
|
const { user, isAdmin } = useUser();
|
|
const slackChatId = searchParams?.get("slackChatId");
|
|
const existingChatIdRaw = searchParams?.get("chatId");
|
|
|
|
const [showHistorySidebar, setShowHistorySidebar] = useState(false);
|
|
|
|
const existingChatSessionId = existingChatIdRaw ? existingChatIdRaw : null;
|
|
|
|
const selectedChatSession = chatSessions.find(
|
|
(chatSession) => chatSession.id === existingChatSessionId
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (user?.is_anonymous_user) {
|
|
Cookies.set(
|
|
SIDEBAR_TOGGLED_COOKIE_NAME,
|
|
String(!sidebarVisible).toLocaleLowerCase()
|
|
);
|
|
toggle(false);
|
|
}
|
|
}, [user]);
|
|
|
|
const processSearchParamsAndSubmitMessage = (searchParamsString: string) => {
|
|
const newSearchParams = new URLSearchParams(searchParamsString);
|
|
const message = newSearchParams?.get("user-prompt");
|
|
|
|
filterManager.buildFiltersFromQueryString(
|
|
newSearchParams.toString(),
|
|
availableSources,
|
|
documentSets.map((ds) => ds.name),
|
|
tags
|
|
);
|
|
|
|
const fileDescriptorString = newSearchParams?.get(SEARCH_PARAM_NAMES.FILES);
|
|
const overrideFileDescriptors: FileDescriptor[] = fileDescriptorString
|
|
? JSON.parse(decodeURIComponent(fileDescriptorString))
|
|
: [];
|
|
|
|
newSearchParams.delete(SEARCH_PARAM_NAMES.SEND_ON_LOAD);
|
|
|
|
router.replace(`?${newSearchParams.toString()}`, { scroll: false });
|
|
|
|
// If there's a message, submit it
|
|
if (message) {
|
|
setSubmittedMessage(message);
|
|
onSubmit({ messageOverride: message, overrideFileDescriptors });
|
|
}
|
|
};
|
|
|
|
const chatSessionIdRef = useRef<string | null>(existingChatSessionId);
|
|
|
|
// Only updates on session load (ie. rename / switching chat session)
|
|
// Useful for determining which session has been loaded (i.e. still on `new, empty session` or `previous session`)
|
|
const loadedIdSessionRef = useRef<string | null>(existingChatSessionId);
|
|
|
|
const existingChatSessionAssistantId = selectedChatSession?.persona_id;
|
|
const [selectedAssistant, setSelectedAssistant] = useState<
|
|
Persona | undefined
|
|
>(
|
|
// NOTE: look through available assistants here, so that even if the user
|
|
// has hidden this assistant it still shows the correct assistant when
|
|
// going back to an old chat session
|
|
existingChatSessionAssistantId !== undefined
|
|
? availableAssistants.find(
|
|
(assistant) => assistant.id === existingChatSessionAssistantId
|
|
)
|
|
: defaultAssistantId !== undefined
|
|
? availableAssistants.find(
|
|
(assistant) => assistant.id === defaultAssistantId
|
|
)
|
|
: undefined
|
|
);
|
|
// Gather default temperature settings
|
|
const search_param_temperature = searchParams?.get(
|
|
SEARCH_PARAM_NAMES.TEMPERATURE
|
|
);
|
|
|
|
const setSelectedAssistantFromId = (assistantId: number) => {
|
|
// NOTE: also intentionally look through available assistants here, so that
|
|
// even if the user has hidden an assistant they can still go back to it
|
|
// for old chats
|
|
setSelectedAssistant(
|
|
availableAssistants.find((assistant) => assistant.id === assistantId)
|
|
);
|
|
};
|
|
|
|
const [alternativeAssistant, setAlternativeAssistant] =
|
|
useState<Persona | null>(null);
|
|
|
|
const [presentingDocument, setPresentingDocument] =
|
|
useState<MinimalOnyxDocument | null>(null);
|
|
|
|
// Current assistant is decided based on this ordering
|
|
// 1. Alternative assistant (assistant selected explicitly by user)
|
|
// 2. Selected assistant (assistnat default in this chat session)
|
|
// 3. First pinned assistants (ordered list of pinned assistants)
|
|
// 4. Available assistants (ordered list of available assistants)
|
|
// Relevant test: `live_assistant.spec.ts`
|
|
const liveAssistant: Persona | undefined = useMemo(
|
|
() =>
|
|
alternativeAssistant ||
|
|
selectedAssistant ||
|
|
pinnedAssistants[0] ||
|
|
availableAssistants[0],
|
|
[
|
|
alternativeAssistant,
|
|
selectedAssistant,
|
|
pinnedAssistants,
|
|
availableAssistants,
|
|
]
|
|
);
|
|
|
|
const llmManager = useLlmManager(
|
|
llmProviders,
|
|
selectedChatSession,
|
|
liveAssistant
|
|
);
|
|
|
|
const noAssistants = liveAssistant == null || liveAssistant == undefined;
|
|
|
|
const availableSources: ValidSources[] = useMemo(() => {
|
|
return ccPairs.map((ccPair) => ccPair.source);
|
|
}, [ccPairs]);
|
|
|
|
const sources: SourceMetadata[] = useMemo(() => {
|
|
const uniqueSources = Array.from(new Set(availableSources));
|
|
return uniqueSources.map((source) => getSourceMetadata(source));
|
|
}, [availableSources]);
|
|
|
|
const stopGenerating = () => {
|
|
const currentSession = currentSessionId();
|
|
const controller = abortControllers.get(currentSession);
|
|
if (controller) {
|
|
controller.abort();
|
|
setAbortControllers((prev) => {
|
|
const newControllers = new Map(prev);
|
|
newControllers.delete(currentSession);
|
|
return newControllers;
|
|
});
|
|
}
|
|
|
|
const lastMessage = messageHistory[messageHistory.length - 1];
|
|
if (
|
|
lastMessage &&
|
|
lastMessage.type === "assistant" &&
|
|
lastMessage.toolCall &&
|
|
lastMessage.toolCall.tool_result === undefined
|
|
) {
|
|
const newCompleteMessageMap = new Map(
|
|
currentMessageMap(completeMessageDetail)
|
|
);
|
|
const updatedMessage = { ...lastMessage, toolCall: null };
|
|
newCompleteMessageMap.set(lastMessage.messageId, updatedMessage);
|
|
updateCompleteMessageDetail(currentSession, newCompleteMessageMap);
|
|
}
|
|
|
|
updateChatState("input", currentSession);
|
|
};
|
|
|
|
// this is for "@"ing assistants
|
|
|
|
// this is used to track which assistant is being used to generate the current message
|
|
// for example, this would come into play when:
|
|
// 1. default assistant is `Onyx`
|
|
// 2. we "@"ed the `GPT` assistant and sent a message
|
|
// 3. while the `GPT` assistant message is generating, we "@" the `Paraphrase` assistant
|
|
const [alternativeGeneratingAssistant, setAlternativeGeneratingAssistant] =
|
|
useState<Persona | null>(null);
|
|
|
|
// used to track whether or not the initial "submit on load" has been performed
|
|
// this only applies if `?submit-on-load=true` or `?submit-on-load=1` is in the URL
|
|
// NOTE: this is required due to React strict mode, where all `useEffect` hooks
|
|
// are run twice on initial load during development
|
|
const submitOnLoadPerformed = useRef<boolean>(false);
|
|
|
|
const { popup, setPopup } = usePopup();
|
|
|
|
// fetch messages for the chat session
|
|
const [isFetchingChatMessages, setIsFetchingChatMessages] = useState(
|
|
existingChatSessionId !== null
|
|
);
|
|
|
|
const [isReady, setIsReady] = useState(false);
|
|
|
|
useEffect(() => {
|
|
Prism.highlightAll();
|
|
setIsReady(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const priorChatSessionId = chatSessionIdRef.current;
|
|
const loadedSessionId = loadedIdSessionRef.current;
|
|
chatSessionIdRef.current = existingChatSessionId;
|
|
loadedIdSessionRef.current = existingChatSessionId;
|
|
|
|
textAreaRef.current?.focus();
|
|
|
|
// only clear things if we're going from one chat session to another
|
|
const isChatSessionSwitch = existingChatSessionId !== priorChatSessionId;
|
|
if (isChatSessionSwitch) {
|
|
// de-select documents
|
|
|
|
// reset all filters
|
|
filterManager.setSelectedDocumentSets([]);
|
|
filterManager.setSelectedSources([]);
|
|
filterManager.setSelectedTags([]);
|
|
filterManager.setTimeRange(null);
|
|
|
|
// remove uploaded files
|
|
setCurrentMessageFiles([]);
|
|
|
|
// if switching from one chat to another, then need to scroll again
|
|
// if we're creating a brand new chat, then don't need to scroll
|
|
if (chatSessionIdRef.current !== null) {
|
|
clearSelectedDocuments();
|
|
setHasPerformedInitialScroll(false);
|
|
}
|
|
}
|
|
|
|
async function initialSessionFetch() {
|
|
if (existingChatSessionId === null) {
|
|
setIsFetchingChatMessages(false);
|
|
if (defaultAssistantId !== undefined) {
|
|
setSelectedAssistantFromId(defaultAssistantId);
|
|
} else {
|
|
setSelectedAssistant(undefined);
|
|
}
|
|
updateCompleteMessageDetail(null, new Map());
|
|
setChatSessionSharedStatus(ChatSessionSharedStatus.Private);
|
|
|
|
// if we're supposed to submit on initial load, then do that here
|
|
if (
|
|
shouldSubmitOnLoad(searchParams) &&
|
|
!submitOnLoadPerformed.current
|
|
) {
|
|
submitOnLoadPerformed.current = true;
|
|
await onSubmit();
|
|
}
|
|
return;
|
|
}
|
|
|
|
setIsFetchingChatMessages(true);
|
|
const response = await fetch(
|
|
`/api/chat/get-chat-session/${existingChatSessionId}`
|
|
);
|
|
|
|
const session = await response.json();
|
|
const chatSession = session as BackendChatSession;
|
|
setSelectedAssistantFromId(chatSession.persona_id);
|
|
|
|
const newMessageMap = processRawChatHistory(chatSession.messages);
|
|
const newMessageHistory = buildLatestMessageChain(newMessageMap);
|
|
|
|
// Update message history except for edge where where
|
|
// last message is an error and we're on a new chat.
|
|
// This corresponds to a "renaming" of chat, which occurs after first message
|
|
// stream
|
|
if (
|
|
(messageHistory[messageHistory.length - 1]?.type !== "error" ||
|
|
loadedSessionId != null) &&
|
|
!currentChatAnswering()
|
|
) {
|
|
const latestMessageId =
|
|
newMessageHistory[newMessageHistory.length - 1]?.messageId;
|
|
|
|
setSelectedMessageForDocDisplay(
|
|
latestMessageId !== undefined ? latestMessageId : null
|
|
);
|
|
|
|
updateCompleteMessageDetail(chatSession.chat_session_id, newMessageMap);
|
|
}
|
|
|
|
setChatSessionSharedStatus(chatSession.shared_status);
|
|
|
|
// go to bottom. If initial load, then do a scroll,
|
|
// otherwise just appear at the bottom
|
|
|
|
scrollInitialized.current = false;
|
|
|
|
if (!hasPerformedInitialScroll) {
|
|
if (isInitialLoad.current) {
|
|
setHasPerformedInitialScroll(true);
|
|
isInitialLoad.current = false;
|
|
}
|
|
clientScrollToBottom();
|
|
|
|
setTimeout(() => {
|
|
setHasPerformedInitialScroll(true);
|
|
}, 100);
|
|
} else if (isChatSessionSwitch) {
|
|
setHasPerformedInitialScroll(true);
|
|
clientScrollToBottom(true);
|
|
}
|
|
|
|
setIsFetchingChatMessages(false);
|
|
|
|
// if this is a seeded chat, then kick off the AI message generation
|
|
if (
|
|
newMessageHistory.length === 1 &&
|
|
newMessageHistory[0] !== undefined &&
|
|
!submitOnLoadPerformed.current &&
|
|
searchParams?.get(SEARCH_PARAM_NAMES.SEEDED) === "true"
|
|
) {
|
|
submitOnLoadPerformed.current = true;
|
|
const seededMessage = newMessageHistory[0].message;
|
|
await onSubmit({
|
|
isSeededChat: true,
|
|
messageOverride: seededMessage,
|
|
});
|
|
// force re-name if the chat session doesn't have one
|
|
if (!chatSession.description) {
|
|
await nameChatSession(existingChatSessionId);
|
|
refreshChatSessions();
|
|
}
|
|
} else if (newMessageHistory.length === 2 && !chatSession.description) {
|
|
await nameChatSession(existingChatSessionId);
|
|
refreshChatSessions();
|
|
}
|
|
}
|
|
|
|
initialSessionFetch();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [existingChatSessionId, searchParams?.get(SEARCH_PARAM_NAMES.PERSONA_ID)]);
|
|
|
|
useEffect(() => {
|
|
const userFolderId = searchParams?.get(SEARCH_PARAM_NAMES.USER_FOLDER_ID);
|
|
const allMyDocuments = searchParams?.get(
|
|
SEARCH_PARAM_NAMES.ALL_MY_DOCUMENTS
|
|
);
|
|
|
|
if (userFolderId) {
|
|
const userFolder = userFolders.find(
|
|
(folder) => folder.id === parseInt(userFolderId)
|
|
);
|
|
if (userFolder) {
|
|
addSelectedFolder(userFolder);
|
|
}
|
|
} else if (allMyDocuments === "true" || allMyDocuments === "1") {
|
|
// Clear any previously selected folders
|
|
|
|
clearSelectedItems();
|
|
|
|
// Add all user folders to the current context
|
|
userFolders.forEach((folder) => {
|
|
addSelectedFolder(folder);
|
|
});
|
|
}
|
|
}, [
|
|
userFolders,
|
|
searchParams?.get(SEARCH_PARAM_NAMES.USER_FOLDER_ID),
|
|
searchParams?.get(SEARCH_PARAM_NAMES.ALL_MY_DOCUMENTS),
|
|
addSelectedFolder,
|
|
clearSelectedItems,
|
|
]);
|
|
|
|
const [message, setMessage] = useState(
|
|
searchParams?.get(SEARCH_PARAM_NAMES.USER_PROMPT) || ""
|
|
);
|
|
|
|
const [completeMessageDetail, setCompleteMessageDetail] = useState<
|
|
Map<string | null, Map<number, Message>>
|
|
>(new Map());
|
|
|
|
const updateCompleteMessageDetail = (
|
|
sessionId: string | null,
|
|
messageMap: Map<number, Message>
|
|
) => {
|
|
setCompleteMessageDetail((prevState) => {
|
|
const newState = new Map(prevState);
|
|
newState.set(sessionId, messageMap);
|
|
return newState;
|
|
});
|
|
};
|
|
|
|
const currentMessageMap = (
|
|
messageDetail: Map<string | null, Map<number, Message>>
|
|
) => {
|
|
return (
|
|
messageDetail.get(chatSessionIdRef.current) || new Map<number, Message>()
|
|
);
|
|
};
|
|
const currentSessionId = (): string => {
|
|
return chatSessionIdRef.current!;
|
|
};
|
|
|
|
const upsertToCompleteMessageMap = ({
|
|
messages,
|
|
completeMessageMapOverride,
|
|
chatSessionId,
|
|
replacementsMap = null,
|
|
makeLatestChildMessage = false,
|
|
}: {
|
|
messages: Message[];
|
|
// if calling this function repeatedly with short delay, stay may not update in time
|
|
// and result in weird behavior
|
|
completeMessageMapOverride?: Map<number, Message> | null;
|
|
chatSessionId?: string;
|
|
replacementsMap?: Map<number, number> | null;
|
|
makeLatestChildMessage?: boolean;
|
|
}) => {
|
|
// deep copy
|
|
const frozenCompleteMessageMap =
|
|
completeMessageMapOverride || currentMessageMap(completeMessageDetail);
|
|
const newCompleteMessageMap = structuredClone(frozenCompleteMessageMap);
|
|
|
|
if (messages[0] !== undefined && newCompleteMessageMap.size === 0) {
|
|
const systemMessageId = messages[0].parentMessageId || SYSTEM_MESSAGE_ID;
|
|
const firstMessageId = messages[0].messageId;
|
|
const dummySystemMessage: Message = {
|
|
messageId: systemMessageId,
|
|
message: "",
|
|
type: "system",
|
|
files: [],
|
|
toolCall: null,
|
|
parentMessageId: null,
|
|
childrenMessageIds: [firstMessageId],
|
|
latestChildMessageId: firstMessageId,
|
|
};
|
|
newCompleteMessageMap.set(
|
|
dummySystemMessage.messageId,
|
|
dummySystemMessage
|
|
);
|
|
messages[0].parentMessageId = systemMessageId;
|
|
}
|
|
|
|
messages.forEach((message) => {
|
|
const idToReplace = replacementsMap?.get(message.messageId);
|
|
if (idToReplace) {
|
|
removeMessage(idToReplace, newCompleteMessageMap);
|
|
}
|
|
|
|
// update childrenMessageIds for the parent
|
|
if (
|
|
!newCompleteMessageMap.has(message.messageId) &&
|
|
message.parentMessageId !== null
|
|
) {
|
|
updateParentChildren(message, newCompleteMessageMap, true);
|
|
}
|
|
newCompleteMessageMap.set(message.messageId, message);
|
|
});
|
|
// if specified, make these new message the latest of the current message chain
|
|
if (makeLatestChildMessage) {
|
|
const currentMessageChain = buildLatestMessageChain(
|
|
frozenCompleteMessageMap
|
|
);
|
|
const latestMessage = currentMessageChain[currentMessageChain.length - 1];
|
|
if (messages[0] !== undefined && latestMessage) {
|
|
newCompleteMessageMap.get(
|
|
latestMessage.messageId
|
|
)!.latestChildMessageId = messages[0].messageId;
|
|
}
|
|
}
|
|
|
|
const newCompleteMessageDetail = {
|
|
sessionId: chatSessionId || currentSessionId(),
|
|
messageMap: newCompleteMessageMap,
|
|
};
|
|
|
|
updateCompleteMessageDetail(
|
|
chatSessionId || currentSessionId(),
|
|
newCompleteMessageMap
|
|
);
|
|
console.log(newCompleteMessageDetail);
|
|
return newCompleteMessageDetail;
|
|
};
|
|
|
|
const messageHistory = buildLatestMessageChain(
|
|
currentMessageMap(completeMessageDetail)
|
|
);
|
|
|
|
const [submittedMessage, setSubmittedMessage] = useState(firstMessage || "");
|
|
|
|
const [chatState, setChatState] = useState<Map<string | null, ChatState>>(
|
|
new Map([[chatSessionIdRef.current, firstMessage ? "loading" : "input"]])
|
|
);
|
|
|
|
const [regenerationState, setRegenerationState] = useState<
|
|
Map<string | null, RegenerationState | null>
|
|
>(new Map([[null, null]]));
|
|
|
|
const [abortControllers, setAbortControllers] = useState<
|
|
Map<string | null, AbortController>
|
|
>(new Map());
|
|
|
|
// Updates "null" session values to new session id for
|
|
// regeneration, chat, and abort controller state, messagehistory
|
|
const updateStatesWithNewSessionId = (newSessionId: string) => {
|
|
const updateState = (
|
|
setState: Dispatch<SetStateAction<Map<string | null, any>>>,
|
|
defaultValue?: any
|
|
) => {
|
|
setState((prevState) => {
|
|
const newState = new Map(prevState);
|
|
const existingState = newState.get(null);
|
|
if (existingState !== undefined) {
|
|
newState.set(newSessionId, existingState);
|
|
newState.delete(null);
|
|
} else if (defaultValue !== undefined) {
|
|
newState.set(newSessionId, defaultValue);
|
|
}
|
|
return newState;
|
|
});
|
|
};
|
|
|
|
updateState(setRegenerationState);
|
|
updateState(setChatState);
|
|
updateState(setAbortControllers);
|
|
|
|
// Update completeMessageDetail
|
|
setCompleteMessageDetail((prevState) => {
|
|
const newState = new Map(prevState);
|
|
const existingMessages = newState.get(null);
|
|
if (existingMessages) {
|
|
newState.set(newSessionId, existingMessages);
|
|
newState.delete(null);
|
|
}
|
|
return newState;
|
|
});
|
|
|
|
// Update chatSessionIdRef
|
|
chatSessionIdRef.current = newSessionId;
|
|
};
|
|
|
|
const updateChatState = (newState: ChatState, sessionId?: string | null) => {
|
|
setChatState((prevState) => {
|
|
const newChatState = new Map(prevState);
|
|
newChatState.set(
|
|
sessionId !== undefined ? sessionId : currentSessionId(),
|
|
newState
|
|
);
|
|
return newChatState;
|
|
});
|
|
};
|
|
|
|
const currentChatState = (): ChatState => {
|
|
return chatState.get(currentSessionId()) || "input";
|
|
};
|
|
|
|
const currentChatAnswering = () => {
|
|
return (
|
|
currentChatState() == "toolBuilding" ||
|
|
currentChatState() == "streaming" ||
|
|
currentChatState() == "loading"
|
|
);
|
|
};
|
|
|
|
const updateRegenerationState = (
|
|
newState: RegenerationState | null,
|
|
sessionId?: string | null
|
|
) => {
|
|
const newRegenerationState = new Map(regenerationState);
|
|
newRegenerationState.set(
|
|
sessionId !== undefined && sessionId != null
|
|
? sessionId
|
|
: currentSessionId(),
|
|
newState
|
|
);
|
|
|
|
setRegenerationState((prevState) => {
|
|
const newRegenerationState = new Map(prevState);
|
|
newRegenerationState.set(
|
|
sessionId !== undefined && sessionId != null
|
|
? sessionId
|
|
: currentSessionId(),
|
|
newState
|
|
);
|
|
return newRegenerationState;
|
|
});
|
|
};
|
|
|
|
const resetRegenerationState = (sessionId?: string | null) => {
|
|
updateRegenerationState(null, sessionId);
|
|
};
|
|
|
|
const currentRegenerationState = (): RegenerationState | null => {
|
|
return regenerationState.get(currentSessionId()) || null;
|
|
};
|
|
|
|
const [canContinue, setCanContinue] = useState<Map<string | null, boolean>>(
|
|
new Map([[null, false]])
|
|
);
|
|
|
|
const updateCanContinue = (newState: boolean, sessionId?: string | null) => {
|
|
setCanContinue((prevState) => {
|
|
const newCanContinueState = new Map(prevState);
|
|
newCanContinueState.set(
|
|
sessionId !== undefined ? sessionId : currentSessionId(),
|
|
newState
|
|
);
|
|
return newCanContinueState;
|
|
});
|
|
};
|
|
|
|
const currentCanContinue = (): boolean => {
|
|
return canContinue.get(currentSessionId()) || false;
|
|
};
|
|
|
|
const currentSessionChatState = currentChatState();
|
|
const currentSessionRegenerationState = currentRegenerationState();
|
|
|
|
// for document display
|
|
// NOTE: -1 is a special designation that means the latest AI message
|
|
const [selectedMessageForDocDisplay, setSelectedMessageForDocDisplay] =
|
|
useState<number | null>(null);
|
|
|
|
const { aiMessage, humanMessage } = selectedMessageForDocDisplay
|
|
? getHumanAndAIMessageFromMessageNumber(
|
|
messageHistory,
|
|
selectedMessageForDocDisplay
|
|
)
|
|
: { aiMessage: null, humanMessage: null };
|
|
|
|
const [chatSessionSharedStatus, setChatSessionSharedStatus] =
|
|
useState<ChatSessionSharedStatus>(ChatSessionSharedStatus.Private);
|
|
|
|
useEffect(() => {
|
|
if (messageHistory.length === 0 && chatSessionIdRef.current === null) {
|
|
// Select from available assistants so shared assistants appear.
|
|
setSelectedAssistant(
|
|
availableAssistants.find((persona) => persona.id === defaultAssistantId)
|
|
);
|
|
}
|
|
}, [defaultAssistantId, availableAssistants, messageHistory.length]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
submittedMessage &&
|
|
currentSessionChatState === "loading" &&
|
|
messageHistory.length == 0
|
|
) {
|
|
window.parent.postMessage(
|
|
{ type: CHROME_MESSAGE.LOAD_NEW_CHAT_PAGE },
|
|
"*"
|
|
);
|
|
}
|
|
}, [submittedMessage, currentSessionChatState]);
|
|
// just choose a conservative default, this will be updated in the
|
|
// background on initial load / on persona change
|
|
const [maxTokens, setMaxTokens] = useState<number>(4096);
|
|
|
|
// fetch # of allowed document tokens for the selected Persona
|
|
useEffect(() => {
|
|
async function fetchMaxTokens() {
|
|
const response = await fetch(
|
|
`/api/chat/max-selected-document-tokens?persona_id=${liveAssistant?.id}`
|
|
);
|
|
if (response.ok) {
|
|
const maxTokens = (await response.json()).max_tokens as number;
|
|
setMaxTokens(maxTokens);
|
|
}
|
|
}
|
|
fetchMaxTokens();
|
|
}, [liveAssistant]);
|
|
|
|
const filterManager = useFilters();
|
|
const [isChatSearchModalOpen, setIsChatSearchModalOpen] = useState(false);
|
|
|
|
const [currentFeedback, setCurrentFeedback] = useState<
|
|
[FeedbackType, number] | null
|
|
>(null);
|
|
|
|
const [sharingModalVisible, setSharingModalVisible] =
|
|
useState<boolean>(false);
|
|
|
|
const [aboveHorizon, setAboveHorizon] = useState(false);
|
|
|
|
const scrollableDivRef = useRef<HTMLDivElement>(null);
|
|
const lastMessageRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLDivElement>(null);
|
|
const endDivRef = useRef<HTMLDivElement>(null);
|
|
const endPaddingRef = useRef<HTMLDivElement>(null);
|
|
|
|
const previousHeight = useRef<number>(
|
|
inputRef.current?.getBoundingClientRect().height!
|
|
);
|
|
const scrollDist = useRef<number>(0);
|
|
|
|
const handleInputResize = () => {
|
|
setTimeout(() => {
|
|
if (
|
|
inputRef.current &&
|
|
lastMessageRef.current &&
|
|
!waitForScrollRef.current
|
|
) {
|
|
const newHeight: number =
|
|
inputRef.current?.getBoundingClientRect().height!;
|
|
const heightDifference = newHeight - previousHeight.current;
|
|
if (
|
|
previousHeight.current &&
|
|
heightDifference != 0 &&
|
|
endPaddingRef.current &&
|
|
scrollableDivRef &&
|
|
scrollableDivRef.current
|
|
) {
|
|
endPaddingRef.current.style.transition = "height 0.3s ease-out";
|
|
endPaddingRef.current.style.height = `${Math.max(
|
|
newHeight - 50,
|
|
0
|
|
)}px`;
|
|
|
|
if (autoScrollEnabled) {
|
|
scrollableDivRef?.current.scrollBy({
|
|
left: 0,
|
|
top: Math.max(heightDifference, 0),
|
|
behavior: "smooth",
|
|
});
|
|
}
|
|
}
|
|
previousHeight.current = newHeight;
|
|
}
|
|
}, 100);
|
|
};
|
|
|
|
const clientScrollToBottom = (fast?: boolean) => {
|
|
waitForScrollRef.current = true;
|
|
|
|
setTimeout(() => {
|
|
if (!endDivRef.current || !scrollableDivRef.current) {
|
|
console.error("endDivRef or scrollableDivRef not found");
|
|
return;
|
|
}
|
|
|
|
const rect = endDivRef.current.getBoundingClientRect();
|
|
const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
|
|
|
|
if (isVisible) return;
|
|
|
|
// Check if all messages are currently rendered
|
|
// If all messages are already rendered, scroll immediately
|
|
endDivRef.current.scrollIntoView({
|
|
behavior: fast ? "auto" : "smooth",
|
|
});
|
|
|
|
setHasPerformedInitialScroll(true);
|
|
}, 50);
|
|
|
|
// Reset waitForScrollRef after 1.5 seconds
|
|
setTimeout(() => {
|
|
waitForScrollRef.current = false;
|
|
}, 1500);
|
|
};
|
|
|
|
const debounceNumber = 100; // time for debouncing
|
|
|
|
const [hasPerformedInitialScroll, setHasPerformedInitialScroll] = useState(
|
|
existingChatSessionId === null
|
|
);
|
|
|
|
// handle re-sizing of the text area
|
|
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
|
useEffect(() => {
|
|
handleInputResize();
|
|
}, [message]);
|
|
|
|
// used for resizing of the document sidebar
|
|
const masterFlexboxRef = useRef<HTMLDivElement>(null);
|
|
const [maxDocumentSidebarWidth, setMaxDocumentSidebarWidth] = useState<
|
|
number | null
|
|
>(null);
|
|
const adjustDocumentSidebarWidth = () => {
|
|
if (masterFlexboxRef.current && document.documentElement.clientWidth) {
|
|
// numbers below are based on the actual width the center section for different
|
|
// screen sizes. `1700` corresponds to the custom "3xl" tailwind breakpoint
|
|
// NOTE: some buffer is needed to account for scroll bars
|
|
if (document.documentElement.clientWidth > 1700) {
|
|
setMaxDocumentSidebarWidth(masterFlexboxRef.current.clientWidth - 950);
|
|
} else if (document.documentElement.clientWidth > 1420) {
|
|
setMaxDocumentSidebarWidth(masterFlexboxRef.current.clientWidth - 760);
|
|
} else {
|
|
setMaxDocumentSidebarWidth(masterFlexboxRef.current.clientWidth - 660);
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (
|
|
(!personaIncludesRetrieval &&
|
|
(!selectedDocuments || selectedDocuments.length === 0) &&
|
|
documentSidebarVisible) ||
|
|
chatSessionIdRef.current == undefined
|
|
) {
|
|
setDocumentSidebarVisible(false);
|
|
}
|
|
clientScrollToBottom();
|
|
}, [chatSessionIdRef.current]);
|
|
|
|
const loadNewPageLogic = (event: MessageEvent) => {
|
|
if (event.data.type === SUBMIT_MESSAGE_TYPES.PAGE_CHANGE) {
|
|
try {
|
|
const url = new URL(event.data.href);
|
|
processSearchParamsAndSubmitMessage(url.searchParams.toString());
|
|
} catch (error) {
|
|
console.error("Error parsing URL:", error);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Equivalent to `loadNewPageLogic`
|
|
useEffect(() => {
|
|
if (searchParams?.get(SEARCH_PARAM_NAMES.SEND_ON_LOAD)) {
|
|
processSearchParamsAndSubmitMessage(searchParams.toString());
|
|
}
|
|
}, [searchParams, router]);
|
|
|
|
useEffect(() => {
|
|
adjustDocumentSidebarWidth();
|
|
window.addEventListener("resize", adjustDocumentSidebarWidth);
|
|
window.addEventListener("message", loadNewPageLogic);
|
|
|
|
return () => {
|
|
window.removeEventListener("message", loadNewPageLogic);
|
|
window.removeEventListener("resize", adjustDocumentSidebarWidth);
|
|
};
|
|
}, []);
|
|
|
|
if (!documentSidebarInitialWidth && maxDocumentSidebarWidth) {
|
|
documentSidebarInitialWidth = Math.min(700, maxDocumentSidebarWidth);
|
|
}
|
|
class CurrentMessageFIFO {
|
|
private stack: PacketType[] = [];
|
|
isComplete: boolean = false;
|
|
error: string | null = null;
|
|
|
|
push(packetBunch: PacketType) {
|
|
this.stack.push(packetBunch);
|
|
}
|
|
|
|
nextPacket(): PacketType | undefined {
|
|
return this.stack.shift();
|
|
}
|
|
|
|
isEmpty(): boolean {
|
|
return this.stack.length === 0;
|
|
}
|
|
}
|
|
|
|
async function updateCurrentMessageFIFO(
|
|
stack: CurrentMessageFIFO,
|
|
params: SendMessageParams
|
|
) {
|
|
try {
|
|
for await (const packet of sendMessage(params)) {
|
|
if (params.signal?.aborted) {
|
|
throw new Error("AbortError");
|
|
}
|
|
stack.push(packet);
|
|
}
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error) {
|
|
if (error.name === "AbortError") {
|
|
console.debug("Stream aborted");
|
|
} else {
|
|
stack.error = error.message;
|
|
}
|
|
} else {
|
|
stack.error = String(error);
|
|
}
|
|
} finally {
|
|
stack.isComplete = true;
|
|
}
|
|
}
|
|
|
|
const resetInputBar = () => {
|
|
setMessage("");
|
|
setCurrentMessageFiles([]);
|
|
|
|
// Reset selectedFiles if they're under the context limit, but preserve selectedFolders.
|
|
// If under the context limit, the files will be included in the chat history
|
|
// so we don't need to keep them around.
|
|
if (selectedDocumentTokens < maxTokens) {
|
|
setSelectedFiles([]);
|
|
}
|
|
|
|
if (endPaddingRef.current) {
|
|
endPaddingRef.current.style.height = `95px`;
|
|
}
|
|
};
|
|
|
|
const continueGenerating = () => {
|
|
onSubmit({
|
|
messageOverride:
|
|
"Continue Generating (pick up exactly where you left off)",
|
|
});
|
|
};
|
|
const [uncaughtError, setUncaughtError] = useState<string | null>(null);
|
|
const [agenticGenerating, setAgenticGenerating] = useState(false);
|
|
|
|
const autoScrollEnabled =
|
|
(user?.preferences?.auto_scroll && !agenticGenerating) ?? false;
|
|
|
|
useScrollonStream({
|
|
chatState: currentSessionChatState,
|
|
scrollableDivRef,
|
|
scrollDist,
|
|
endDivRef,
|
|
debounceNumber,
|
|
mobile: settings?.isMobile,
|
|
enableAutoScroll: autoScrollEnabled,
|
|
});
|
|
|
|
// Track whether a message has been sent during this page load, keyed by chat session id
|
|
const [sessionHasSentLocalUserMessage, setSessionHasSentLocalUserMessage] =
|
|
useState<Map<string | null, boolean>>(new Map());
|
|
|
|
// Update the local state for a session once the user sends a message
|
|
const markSessionMessageSent = (sessionId: string | null) => {
|
|
setSessionHasSentLocalUserMessage((prev) => {
|
|
const newMap = new Map(prev);
|
|
newMap.set(sessionId, true);
|
|
return newMap;
|
|
});
|
|
};
|
|
const currentSessionHasSentLocalUserMessage = useMemo(
|
|
() => (sessionId: string | null) => {
|
|
return sessionHasSentLocalUserMessage.size === 0
|
|
? undefined
|
|
: sessionHasSentLocalUserMessage.get(sessionId) || false;
|
|
},
|
|
[sessionHasSentLocalUserMessage]
|
|
);
|
|
|
|
const { height: screenHeight } = useScreenSize();
|
|
|
|
const getContainerHeight = useMemo(() => {
|
|
return () => {
|
|
if (!currentSessionHasSentLocalUserMessage(chatSessionIdRef.current)) {
|
|
return undefined;
|
|
}
|
|
if (autoScrollEnabled) return undefined;
|
|
|
|
if (screenHeight < 600) return "40vh";
|
|
if (screenHeight < 1200) return "50vh";
|
|
return "60vh";
|
|
};
|
|
}, [autoScrollEnabled, screenHeight, currentSessionHasSentLocalUserMessage]);
|
|
|
|
const reset = () => {
|
|
setMessage("");
|
|
setCurrentMessageFiles([]);
|
|
clearSelectedItems();
|
|
setLoadingError(null);
|
|
};
|
|
|
|
const onSubmit = async ({
|
|
messageIdToResend,
|
|
messageOverride,
|
|
queryOverride,
|
|
forceSearch,
|
|
isSeededChat,
|
|
alternativeAssistantOverride = null,
|
|
modelOverride,
|
|
regenerationRequest,
|
|
overrideFileDescriptors,
|
|
}: {
|
|
messageIdToResend?: number;
|
|
messageOverride?: string;
|
|
queryOverride?: string;
|
|
forceSearch?: boolean;
|
|
isSeededChat?: boolean;
|
|
alternativeAssistantOverride?: Persona | null;
|
|
modelOverride?: LlmDescriptor;
|
|
regenerationRequest?: RegenerationRequest | null;
|
|
overrideFileDescriptors?: FileDescriptor[];
|
|
} = {}) => {
|
|
navigatingAway.current = false;
|
|
let frozenSessionId = currentSessionId();
|
|
updateCanContinue(false, frozenSessionId);
|
|
setUncaughtError(null);
|
|
setLoadingError(null);
|
|
|
|
// Mark that we've sent a message for this session in the current page load
|
|
markSessionMessageSent(frozenSessionId);
|
|
|
|
// Check if the last message was an error and remove it before proceeding with a new message
|
|
// Ensure this isn't a regeneration or resend, as those operations should preserve the history leading up to the point of regeneration/resend.
|
|
let currentMap = currentMessageMap(completeMessageDetail);
|
|
let currentHistory = buildLatestMessageChain(currentMap);
|
|
let lastMessage = currentHistory[currentHistory.length - 1];
|
|
|
|
if (
|
|
lastMessage &&
|
|
lastMessage.type === "error" &&
|
|
!messageIdToResend &&
|
|
!regenerationRequest
|
|
) {
|
|
const newMap = new Map(currentMap);
|
|
const parentId = lastMessage.parentMessageId;
|
|
|
|
// Remove the error message itself
|
|
newMap.delete(lastMessage.messageId);
|
|
|
|
// Remove the parent message + update the parent of the parent to no longer
|
|
// link to the parent
|
|
if (parentId !== null && parentId !== undefined) {
|
|
const parentOfError = newMap.get(parentId);
|
|
if (parentOfError) {
|
|
const grandparentId = parentOfError.parentMessageId;
|
|
if (grandparentId !== null && grandparentId !== undefined) {
|
|
const grandparent = newMap.get(grandparentId);
|
|
if (grandparent) {
|
|
// Update grandparent to no longer link to parent
|
|
const updatedGrandparent = {
|
|
...grandparent,
|
|
childrenMessageIds: (
|
|
grandparent.childrenMessageIds || []
|
|
).filter((id) => id !== parentId),
|
|
latestChildMessageId:
|
|
grandparent.latestChildMessageId === parentId
|
|
? null
|
|
: grandparent.latestChildMessageId,
|
|
};
|
|
newMap.set(grandparentId, updatedGrandparent);
|
|
}
|
|
}
|
|
// Remove the parent message
|
|
newMap.delete(parentId);
|
|
}
|
|
}
|
|
// Update the state immediately so subsequent logic uses the cleaned map
|
|
updateCompleteMessageDetail(frozenSessionId, newMap);
|
|
console.log("Removed previous error message ID:", lastMessage.messageId);
|
|
|
|
// update state for the new world (with the error message removed)
|
|
currentHistory = buildLatestMessageChain(newMap);
|
|
currentMap = newMap;
|
|
lastMessage = currentHistory[currentHistory.length - 1];
|
|
}
|
|
|
|
if (currentChatState() != "input") {
|
|
if (currentChatState() == "uploading") {
|
|
setPopup({
|
|
message: "Please wait for the content to upload",
|
|
type: "error",
|
|
});
|
|
} else {
|
|
setPopup({
|
|
message: "Please wait for the response to complete",
|
|
type: "error",
|
|
});
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
setAlternativeGeneratingAssistant(alternativeAssistantOverride);
|
|
|
|
clientScrollToBottom();
|
|
|
|
let currChatSessionId: string;
|
|
const isNewSession = chatSessionIdRef.current === null;
|
|
|
|
const searchParamBasedChatSessionName =
|
|
searchParams?.get(SEARCH_PARAM_NAMES.TITLE) || null;
|
|
|
|
if (isNewSession) {
|
|
currChatSessionId = await createChatSession(
|
|
liveAssistant?.id || 0,
|
|
searchParamBasedChatSessionName
|
|
);
|
|
} else {
|
|
currChatSessionId = chatSessionIdRef.current as string;
|
|
}
|
|
frozenSessionId = currChatSessionId;
|
|
// update the selected model for the chat session if one is specified so that
|
|
// it persists across page reloads. Do not `await` here so that the message
|
|
// request can continue and this will just happen in the background.
|
|
// NOTE: only set the model override for the chat session once we send a
|
|
// message with it. If the user switches models and then starts a new
|
|
// chat session, it is unexpected for that model to be used when they
|
|
// return to this session the next day.
|
|
let finalLLM = modelOverride || llmManager.currentLlm;
|
|
updateLlmOverrideForChatSession(
|
|
currChatSessionId,
|
|
structureValue(
|
|
finalLLM.name || "",
|
|
finalLLM.provider || "",
|
|
finalLLM.modelName || ""
|
|
)
|
|
);
|
|
|
|
updateStatesWithNewSessionId(currChatSessionId);
|
|
|
|
const controller = new AbortController();
|
|
|
|
setAbortControllers((prev) =>
|
|
new Map(prev).set(currChatSessionId, controller)
|
|
);
|
|
|
|
const messageToResend = messageHistory.find(
|
|
(message) => message.messageId === messageIdToResend
|
|
);
|
|
if (messageIdToResend) {
|
|
updateRegenerationState(
|
|
{ regenerating: true, finalMessageIndex: messageIdToResend },
|
|
currentSessionId()
|
|
);
|
|
}
|
|
const messageToResendParent =
|
|
messageToResend?.parentMessageId !== null &&
|
|
messageToResend?.parentMessageId !== undefined
|
|
? currentMap.get(messageToResend.parentMessageId)
|
|
: null;
|
|
const messageToResendIndex = messageToResend
|
|
? messageHistory.indexOf(messageToResend)
|
|
: null;
|
|
|
|
if (!messageToResend && messageIdToResend !== undefined) {
|
|
setPopup({
|
|
message:
|
|
"Failed to re-send message - please refresh the page and try again.",
|
|
type: "error",
|
|
});
|
|
resetRegenerationState(currentSessionId());
|
|
updateChatState("input", frozenSessionId);
|
|
return;
|
|
}
|
|
let currMessage = messageToResend ? messageToResend.message : message;
|
|
if (messageOverride) {
|
|
currMessage = messageOverride;
|
|
}
|
|
|
|
setSubmittedMessage(currMessage);
|
|
|
|
updateChatState("loading");
|
|
|
|
const currMessageHistory =
|
|
messageToResendIndex !== null
|
|
? currentHistory.slice(0, messageToResendIndex)
|
|
: currentHistory;
|
|
|
|
let parentMessage =
|
|
messageToResendParent ||
|
|
(currMessageHistory.length > 0
|
|
? currMessageHistory[currMessageHistory.length - 1]
|
|
: null) ||
|
|
(currentMap.size === 1 ? Array.from(currentMap.values())[0] : null);
|
|
|
|
let currentAssistantId;
|
|
if (alternativeAssistantOverride) {
|
|
currentAssistantId = alternativeAssistantOverride.id;
|
|
} else if (alternativeAssistant) {
|
|
currentAssistantId = alternativeAssistant.id;
|
|
} else {
|
|
if (liveAssistant) {
|
|
currentAssistantId = liveAssistant.id;
|
|
} else {
|
|
currentAssistantId = 0; // Fallback if no assistant is live
|
|
}
|
|
}
|
|
|
|
resetInputBar();
|
|
let messageUpdates: Message[] | null = null;
|
|
|
|
let answer = "";
|
|
let second_level_answer = "";
|
|
|
|
const stopReason: StreamStopReason | null = null;
|
|
let query: string | null = null;
|
|
let retrievalType: RetrievalType =
|
|
selectedDocuments.length > 0
|
|
? RetrievalType.SelectedDocs
|
|
: RetrievalType.None;
|
|
let documents: OnyxDocument[] = selectedDocuments;
|
|
let aiMessageImages: FileDescriptor[] | null = null;
|
|
let agenticDocs: OnyxDocument[] | null = null;
|
|
let error: string | null = null;
|
|
let stackTrace: string | null = null;
|
|
|
|
let sub_questions: SubQuestionDetail[] = [];
|
|
let is_generating: boolean = false;
|
|
let second_level_generating: boolean = false;
|
|
let finalMessage: BackendMessage | null = null;
|
|
let toolCall: ToolCallMetadata | null = null;
|
|
let isImprovement: boolean | undefined = undefined;
|
|
let isStreamingQuestions = true;
|
|
let includeAgentic = false;
|
|
let secondLevelMessageId: number | null = null;
|
|
let isAgentic: boolean = false;
|
|
let files: FileDescriptor[] = [];
|
|
|
|
let initialFetchDetails: null | {
|
|
user_message_id: number;
|
|
assistant_message_id: number;
|
|
frozenMessageMap: Map<number, Message>;
|
|
} = null;
|
|
try {
|
|
const mapKeys = Array.from(currentMap.keys());
|
|
const lastSuccessfulMessageId =
|
|
getLastSuccessfulMessageId(currMessageHistory);
|
|
|
|
const stack = new CurrentMessageFIFO();
|
|
|
|
updateCurrentMessageFIFO(stack, {
|
|
signal: controller.signal,
|
|
message: currMessage,
|
|
alternateAssistantId: currentAssistantId,
|
|
fileDescriptors: overrideFileDescriptors || currentMessageFiles,
|
|
parentMessageId:
|
|
regenerationRequest?.parentMessage.messageId ||
|
|
lastSuccessfulMessageId,
|
|
chatSessionId: currChatSessionId,
|
|
filters: buildFilters(
|
|
filterManager.selectedSources,
|
|
filterManager.selectedDocumentSets,
|
|
filterManager.timeRange,
|
|
filterManager.selectedTags
|
|
),
|
|
selectedDocumentIds: selectedDocuments
|
|
.filter(
|
|
(document) =>
|
|
document.db_doc_id !== undefined && document.db_doc_id !== null
|
|
)
|
|
.map((document) => document.db_doc_id as number),
|
|
queryOverride,
|
|
forceSearch,
|
|
userFolderIds: selectedFolders.map((folder) => folder.id),
|
|
userFileIds: selectedFiles
|
|
.filter((file) => file.id !== undefined && file.id !== null)
|
|
.map((file) => file.id),
|
|
|
|
regenerate: regenerationRequest !== undefined,
|
|
modelProvider:
|
|
modelOverride?.name || llmManager.currentLlm.name || undefined,
|
|
modelVersion:
|
|
modelOverride?.modelName ||
|
|
llmManager.currentLlm.modelName ||
|
|
searchParams?.get(SEARCH_PARAM_NAMES.MODEL_VERSION) ||
|
|
undefined,
|
|
temperature: llmManager.temperature || undefined,
|
|
systemPromptOverride:
|
|
searchParams?.get(SEARCH_PARAM_NAMES.SYSTEM_PROMPT) || undefined,
|
|
useExistingUserMessage: isSeededChat,
|
|
useLanggraph:
|
|
settings?.settings.pro_search_enabled &&
|
|
proSearchEnabled &&
|
|
retrievalEnabled,
|
|
});
|
|
|
|
const delay = (ms: number) => {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
};
|
|
|
|
await delay(50);
|
|
while (!stack.isComplete || !stack.isEmpty()) {
|
|
if (stack.isEmpty()) {
|
|
await delay(0.5);
|
|
}
|
|
|
|
if (!stack.isEmpty() && !controller.signal.aborted) {
|
|
const packet = stack.nextPacket();
|
|
if (!packet) {
|
|
continue;
|
|
}
|
|
console.log("Packet:", JSON.stringify(packet));
|
|
|
|
if (!initialFetchDetails) {
|
|
if (!Object.hasOwn(packet, "user_message_id")) {
|
|
console.error(
|
|
"First packet should contain message response info "
|
|
);
|
|
if (Object.hasOwn(packet, "error")) {
|
|
const error = (packet as StreamingError).error;
|
|
setLoadingError(error);
|
|
updateChatState("input");
|
|
return;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const messageResponseIDInfo = packet as MessageResponseIDInfo;
|
|
|
|
const user_message_id = messageResponseIDInfo.user_message_id!;
|
|
const assistant_message_id =
|
|
messageResponseIDInfo.reserved_assistant_message_id;
|
|
|
|
// we will use tempMessages until the regenerated message is complete
|
|
messageUpdates = [
|
|
{
|
|
messageId: regenerationRequest
|
|
? regenerationRequest?.parentMessage?.messageId!
|
|
: user_message_id,
|
|
message: currMessage,
|
|
type: "user",
|
|
files: files,
|
|
toolCall: null,
|
|
parentMessageId: parentMessage?.messageId || SYSTEM_MESSAGE_ID,
|
|
},
|
|
];
|
|
|
|
if (parentMessage && !regenerationRequest) {
|
|
messageUpdates.push({
|
|
...parentMessage,
|
|
childrenMessageIds: (
|
|
parentMessage.childrenMessageIds || []
|
|
).concat([user_message_id]),
|
|
latestChildMessageId: user_message_id,
|
|
});
|
|
}
|
|
|
|
const { messageMap: currentFrozenMessageMap } =
|
|
upsertToCompleteMessageMap({
|
|
messages: messageUpdates,
|
|
chatSessionId: currChatSessionId,
|
|
completeMessageMapOverride: currentMap,
|
|
});
|
|
currentMap = currentFrozenMessageMap;
|
|
|
|
initialFetchDetails = {
|
|
frozenMessageMap: currentMap,
|
|
assistant_message_id,
|
|
user_message_id,
|
|
};
|
|
|
|
resetRegenerationState();
|
|
} else {
|
|
const { user_message_id, frozenMessageMap } = initialFetchDetails;
|
|
if (Object.hasOwn(packet, "agentic_message_ids")) {
|
|
const agenticMessageIds = (packet as AgenticMessageResponseIDInfo)
|
|
.agentic_message_ids;
|
|
const level1MessageId = agenticMessageIds.find(
|
|
(item) => item.level === 1
|
|
)?.message_id;
|
|
if (level1MessageId) {
|
|
secondLevelMessageId = level1MessageId;
|
|
includeAgentic = true;
|
|
}
|
|
}
|
|
|
|
setChatState((prevState) => {
|
|
if (prevState.get(chatSessionIdRef.current!) === "loading") {
|
|
return new Map(prevState).set(
|
|
chatSessionIdRef.current!,
|
|
"streaming"
|
|
);
|
|
}
|
|
return prevState;
|
|
});
|
|
|
|
if (Object.hasOwn(packet, "level")) {
|
|
if ((packet as any).level === 1) {
|
|
second_level_generating = true;
|
|
}
|
|
}
|
|
if (Object.hasOwn(packet, "user_files")) {
|
|
const userFiles = (packet as UserKnowledgeFilePacket).user_files;
|
|
// Ensure files are unique by id
|
|
const newUserFiles = userFiles.filter(
|
|
(newFile) =>
|
|
!files.some((existingFile) => existingFile.id === newFile.id)
|
|
);
|
|
files = files.concat(newUserFiles);
|
|
}
|
|
if (Object.hasOwn(packet, "is_agentic")) {
|
|
isAgentic = (packet as any).is_agentic;
|
|
}
|
|
|
|
if (Object.hasOwn(packet, "refined_answer_improvement")) {
|
|
isImprovement = (packet as RefinedAnswerImprovement)
|
|
.refined_answer_improvement;
|
|
}
|
|
|
|
if (Object.hasOwn(packet, "stream_type")) {
|
|
if ((packet as any).stream_type == "main_answer") {
|
|
is_generating = false;
|
|
second_level_generating = true;
|
|
}
|
|
}
|
|
|
|
// // Continuously refine the sub_questions based on the packets that we receive
|
|
if (
|
|
Object.hasOwn(packet, "stop_reason") &&
|
|
Object.hasOwn(packet, "level_question_num")
|
|
) {
|
|
if ((packet as StreamStopInfo).stream_type == "main_answer") {
|
|
updateChatState("streaming", frozenSessionId);
|
|
}
|
|
if (
|
|
(packet as StreamStopInfo).stream_type == "sub_questions" &&
|
|
(packet as StreamStopInfo).level_question_num == undefined
|
|
) {
|
|
isStreamingQuestions = false;
|
|
}
|
|
sub_questions = constructSubQuestions(
|
|
sub_questions,
|
|
packet as StreamStopInfo
|
|
);
|
|
} else if (Object.hasOwn(packet, "sub_question")) {
|
|
updateChatState("toolBuilding", frozenSessionId);
|
|
isAgentic = true;
|
|
is_generating = true;
|
|
sub_questions = constructSubQuestions(
|
|
sub_questions,
|
|
packet as SubQuestionPiece
|
|
);
|
|
setAgenticGenerating(true);
|
|
} else if (Object.hasOwn(packet, "sub_query")) {
|
|
sub_questions = constructSubQuestions(
|
|
sub_questions,
|
|
packet as SubQueryPiece
|
|
);
|
|
} else if (
|
|
Object.hasOwn(packet, "answer_piece") &&
|
|
Object.hasOwn(packet, "answer_type") &&
|
|
(packet as AgentAnswerPiece).answer_type === "agent_sub_answer"
|
|
) {
|
|
sub_questions = constructSubQuestions(
|
|
sub_questions,
|
|
packet as AgentAnswerPiece
|
|
);
|
|
} else if (Object.hasOwn(packet, "answer_piece")) {
|
|
// Mark every sub_question's is_generating as false
|
|
sub_questions = sub_questions.map((subQ) => ({
|
|
...subQ,
|
|
is_generating: false,
|
|
}));
|
|
|
|
if (
|
|
Object.hasOwn(packet, "level") &&
|
|
(packet as any).level === 1
|
|
) {
|
|
second_level_answer += (packet as AnswerPiecePacket)
|
|
.answer_piece;
|
|
} else {
|
|
answer += (packet as AnswerPiecePacket).answer_piece;
|
|
}
|
|
} else if (
|
|
Object.hasOwn(packet, "top_documents") &&
|
|
Object.hasOwn(packet, "level_question_num") &&
|
|
(packet as DocumentsResponse).level_question_num != undefined
|
|
) {
|
|
const documentsResponse = packet as DocumentsResponse;
|
|
sub_questions = constructSubQuestions(
|
|
sub_questions,
|
|
documentsResponse
|
|
);
|
|
|
|
if (
|
|
documentsResponse.level_question_num === 0 &&
|
|
documentsResponse.level == 0
|
|
) {
|
|
documents = (packet as DocumentsResponse).top_documents;
|
|
} else if (
|
|
documentsResponse.level_question_num === 0 &&
|
|
documentsResponse.level == 1
|
|
) {
|
|
agenticDocs = (packet as DocumentsResponse).top_documents;
|
|
}
|
|
} else if (Object.hasOwn(packet, "top_documents")) {
|
|
documents = (packet as DocumentInfoPacket).top_documents;
|
|
retrievalType = RetrievalType.Search;
|
|
|
|
if (documents && documents.length > 0) {
|
|
// point to the latest message (we don't know the messageId yet, which is why
|
|
// we have to use -1)
|
|
setSelectedMessageForDocDisplay(user_message_id);
|
|
}
|
|
} else if (Object.hasOwn(packet, "tool_name")) {
|
|
// Will only ever be one tool call per message
|
|
toolCall = {
|
|
tool_name: (packet as ToolCallMetadata).tool_name,
|
|
tool_args: (packet as ToolCallMetadata).tool_args,
|
|
tool_result: (packet as ToolCallMetadata).tool_result,
|
|
};
|
|
|
|
if (!toolCall.tool_name.includes("agent")) {
|
|
if (
|
|
!toolCall.tool_result ||
|
|
toolCall.tool_result == undefined
|
|
) {
|
|
updateChatState("toolBuilding", frozenSessionId);
|
|
} else {
|
|
updateChatState("streaming", frozenSessionId);
|
|
}
|
|
|
|
// This will be consolidated in upcoming tool calls udpate,
|
|
// but for now, we need to set query as early as possible
|
|
if (toolCall.tool_name == SEARCH_TOOL_NAME) {
|
|
query = toolCall.tool_args["query"];
|
|
}
|
|
} else {
|
|
toolCall = null;
|
|
}
|
|
} else if (Object.hasOwn(packet, "file_ids")) {
|
|
aiMessageImages = (packet as FileChatDisplay).file_ids.map(
|
|
(fileId) => {
|
|
return {
|
|
id: fileId,
|
|
type: ChatFileType.IMAGE,
|
|
};
|
|
}
|
|
);
|
|
} else if (
|
|
Object.hasOwn(packet, "error") &&
|
|
(packet as any).error != null
|
|
) {
|
|
if (
|
|
sub_questions.length > 0 &&
|
|
sub_questions
|
|
.filter((q) => q.level === 0)
|
|
.every((q) => q.is_stopped === true)
|
|
) {
|
|
setUncaughtError((packet as StreamingError).error);
|
|
updateChatState("input");
|
|
setAgenticGenerating(false);
|
|
setAlternativeGeneratingAssistant(null);
|
|
setSubmittedMessage("");
|
|
|
|
throw new Error((packet as StreamingError).error);
|
|
} else {
|
|
error = (packet as StreamingError).error;
|
|
stackTrace = (packet as StreamingError).stack_trace;
|
|
}
|
|
} else if (Object.hasOwn(packet, "message_id")) {
|
|
finalMessage = packet as BackendMessage;
|
|
} else if (Object.hasOwn(packet, "stop_reason")) {
|
|
const stop_reason = (packet as StreamStopInfo).stop_reason;
|
|
if (stop_reason === StreamStopReason.CONTEXT_LENGTH) {
|
|
updateCanContinue(true, frozenSessionId);
|
|
}
|
|
}
|
|
|
|
// on initial message send, we insert a dummy system message
|
|
// set this as the parent here if no parent is set
|
|
parentMessage =
|
|
parentMessage || frozenMessageMap?.get(SYSTEM_MESSAGE_ID)!;
|
|
|
|
const updateFn = (messages: Message[]) => {
|
|
const replacementsMap = regenerationRequest
|
|
? new Map([
|
|
[
|
|
regenerationRequest?.parentMessage?.messageId,
|
|
regenerationRequest?.parentMessage?.messageId,
|
|
],
|
|
[
|
|
regenerationRequest?.messageId,
|
|
initialFetchDetails?.assistant_message_id,
|
|
],
|
|
] as [number, number][])
|
|
: null;
|
|
|
|
const newMessageDetails = upsertToCompleteMessageMap({
|
|
messages: messages,
|
|
replacementsMap: replacementsMap,
|
|
// Pass the latest map state
|
|
completeMessageMapOverride: currentMap,
|
|
chatSessionId: frozenSessionId!,
|
|
});
|
|
currentMap = newMessageDetails.messageMap;
|
|
return newMessageDetails;
|
|
};
|
|
|
|
const systemMessageId = Math.min(...mapKeys);
|
|
updateFn([
|
|
{
|
|
messageId: regenerationRequest
|
|
? regenerationRequest?.parentMessage?.messageId!
|
|
: initialFetchDetails.user_message_id!,
|
|
message: currMessage,
|
|
type: "user",
|
|
files: files,
|
|
toolCall: null,
|
|
// in the frontend, every message should have a parent ID
|
|
parentMessageId: lastSuccessfulMessageId ?? systemMessageId,
|
|
childrenMessageIds: [
|
|
...(regenerationRequest?.parentMessage?.childrenMessageIds ||
|
|
[]),
|
|
initialFetchDetails.assistant_message_id!,
|
|
],
|
|
latestChildMessageId: initialFetchDetails.assistant_message_id,
|
|
},
|
|
{
|
|
isStreamingQuestions: isStreamingQuestions,
|
|
is_generating: is_generating,
|
|
isImprovement: isImprovement,
|
|
messageId: initialFetchDetails.assistant_message_id!,
|
|
message: error || answer,
|
|
second_level_message: second_level_answer,
|
|
type: error ? "error" : "assistant",
|
|
retrievalType,
|
|
query: finalMessage?.rephrased_query || query,
|
|
documents: documents,
|
|
citations: finalMessage?.citations || {},
|
|
files: finalMessage?.files || aiMessageImages || [],
|
|
toolCall: finalMessage?.tool_call || toolCall,
|
|
parentMessageId: regenerationRequest
|
|
? regenerationRequest?.parentMessage?.messageId!
|
|
: initialFetchDetails.user_message_id,
|
|
alternateAssistantID: alternativeAssistant?.id,
|
|
stackTrace: stackTrace,
|
|
overridden_model: finalMessage?.overridden_model,
|
|
stopReason: stopReason,
|
|
sub_questions: sub_questions,
|
|
second_level_generating: second_level_generating,
|
|
agentic_docs: agenticDocs,
|
|
is_agentic: isAgentic,
|
|
},
|
|
...(includeAgentic
|
|
? [
|
|
{
|
|
messageId: secondLevelMessageId!,
|
|
message: second_level_answer,
|
|
type: "assistant" as const,
|
|
files: [],
|
|
toolCall: null,
|
|
parentMessageId:
|
|
initialFetchDetails.assistant_message_id!,
|
|
},
|
|
]
|
|
: []),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
console.log("Error:", e);
|
|
const errorMsg = e.message;
|
|
const newMessageDetails = upsertToCompleteMessageMap({
|
|
messages: [
|
|
{
|
|
messageId:
|
|
initialFetchDetails?.user_message_id || TEMP_USER_MESSAGE_ID,
|
|
message: currMessage,
|
|
type: "user",
|
|
files: currentMessageFiles,
|
|
toolCall: null,
|
|
parentMessageId: parentMessage?.messageId || SYSTEM_MESSAGE_ID,
|
|
},
|
|
{
|
|
messageId:
|
|
initialFetchDetails?.assistant_message_id ||
|
|
TEMP_ASSISTANT_MESSAGE_ID,
|
|
message: errorMsg,
|
|
type: "error",
|
|
files: aiMessageImages || [],
|
|
toolCall: null,
|
|
parentMessageId:
|
|
initialFetchDetails?.user_message_id || TEMP_USER_MESSAGE_ID,
|
|
},
|
|
],
|
|
completeMessageMapOverride: currentMap,
|
|
});
|
|
currentMap = newMessageDetails.messageMap;
|
|
}
|
|
console.log("Finished streaming");
|
|
setAgenticGenerating(false);
|
|
resetRegenerationState(currentSessionId());
|
|
|
|
updateChatState("input");
|
|
if (isNewSession) {
|
|
console.log("Setting up new session");
|
|
if (finalMessage) {
|
|
setSelectedMessageForDocDisplay(finalMessage.message_id);
|
|
}
|
|
|
|
if (!searchParamBasedChatSessionName) {
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
await nameChatSession(currChatSessionId);
|
|
refreshChatSessions();
|
|
}
|
|
|
|
// NOTE: don't switch pages if the user has navigated away from the chat
|
|
if (
|
|
currChatSessionId === chatSessionIdRef.current ||
|
|
chatSessionIdRef.current === null
|
|
) {
|
|
const newUrl = buildChatUrl(searchParams, currChatSessionId, null);
|
|
// newUrl is like /chat?chatId=10
|
|
// current page is like /chat
|
|
|
|
if (pathname == "/chat" && !navigatingAway.current) {
|
|
router.push(newUrl, { scroll: false });
|
|
}
|
|
}
|
|
}
|
|
if (
|
|
finalMessage?.context_docs &&
|
|
finalMessage.context_docs.top_documents.length > 0 &&
|
|
retrievalType === RetrievalType.Search
|
|
) {
|
|
setSelectedMessageForDocDisplay(finalMessage.message_id);
|
|
}
|
|
setAlternativeGeneratingAssistant(null);
|
|
setSubmittedMessage("");
|
|
};
|
|
|
|
const onFeedback = async (
|
|
messageId: number,
|
|
feedbackType: FeedbackType,
|
|
feedbackDetails: string,
|
|
predefinedFeedback: string | undefined
|
|
) => {
|
|
if (chatSessionIdRef.current === null) {
|
|
return;
|
|
}
|
|
|
|
const response = await handleChatFeedback(
|
|
messageId,
|
|
feedbackType,
|
|
feedbackDetails,
|
|
predefinedFeedback
|
|
);
|
|
|
|
if (response.ok) {
|
|
setPopup({
|
|
message: "Thanks for your feedback!",
|
|
type: "success",
|
|
});
|
|
} else {
|
|
const responseJson = await response.json();
|
|
const errorMsg = responseJson.detail || responseJson.message;
|
|
setPopup({
|
|
message: `Failed to submit feedback - ${errorMsg}`,
|
|
type: "error",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleMessageSpecificFileUpload = async (acceptedFiles: File[]) => {
|
|
const [_, llmModel] = getFinalLLM(
|
|
llmProviders,
|
|
liveAssistant ?? null,
|
|
llmManager.currentLlm
|
|
);
|
|
const llmAcceptsImages = modelSupportsImageInput(llmProviders, llmModel);
|
|
|
|
const imageFiles = acceptedFiles.filter((file) =>
|
|
file.type.startsWith("image/")
|
|
);
|
|
|
|
if (imageFiles.length > 0 && !llmAcceptsImages) {
|
|
setPopup({
|
|
type: "error",
|
|
message:
|
|
"The current model does not support image input. Please select a model with Vision support.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
updateChatState("uploading", currentSessionId());
|
|
|
|
for (let file of acceptedFiles) {
|
|
const formData = new FormData();
|
|
formData.append("files", file);
|
|
const response: FileResponse[] = await uploadFile(formData, null);
|
|
|
|
if (response.length > 0 && response[0] !== undefined) {
|
|
const uploadedFile = response[0];
|
|
|
|
const newFileDescriptor: FileDescriptor = {
|
|
// Use file_id (storage ID) if available, otherwise fallback to DB id
|
|
// Ensure it's a string as FileDescriptor expects
|
|
id: uploadedFile.file_id
|
|
? String(uploadedFile.file_id)
|
|
: String(uploadedFile.id),
|
|
type: uploadedFile.chat_file_type
|
|
? uploadedFile.chat_file_type
|
|
: ChatFileType.PLAIN_TEXT,
|
|
name: uploadedFile.name,
|
|
isUploading: false, // Mark as successfully uploaded
|
|
};
|
|
|
|
setCurrentMessageFiles((prev) => [...prev, newFileDescriptor]);
|
|
} else {
|
|
setPopup({
|
|
type: "error",
|
|
message: "Failed to upload file",
|
|
});
|
|
}
|
|
}
|
|
|
|
updateChatState("input", currentSessionId());
|
|
};
|
|
|
|
// Used to maintain a "time out" for history sidebar so our existing refs can have time to process change
|
|
const [untoggled, setUntoggled] = useState(false);
|
|
const [loadingError, setLoadingError] = useState<string | null>(null);
|
|
|
|
const explicitlyUntoggle = () => {
|
|
setShowHistorySidebar(false);
|
|
|
|
setUntoggled(true);
|
|
setTimeout(() => {
|
|
setUntoggled(false);
|
|
}, 200);
|
|
};
|
|
const toggleSidebar = () => {
|
|
if (user?.is_anonymous_user) {
|
|
return;
|
|
}
|
|
Cookies.set(
|
|
SIDEBAR_TOGGLED_COOKIE_NAME,
|
|
String(!sidebarVisible).toLocaleLowerCase()
|
|
),
|
|
{
|
|
path: "/",
|
|
};
|
|
|
|
toggle();
|
|
};
|
|
const removeToggle = () => {
|
|
setShowHistorySidebar(false);
|
|
toggle(false);
|
|
};
|
|
|
|
const waitForScrollRef = useRef(false);
|
|
const sidebarElementRef = useRef<HTMLDivElement>(null);
|
|
|
|
useSidebarVisibility({
|
|
sidebarVisible,
|
|
sidebarElementRef,
|
|
showDocSidebar: showHistorySidebar,
|
|
setShowDocSidebar: setShowHistorySidebar,
|
|
setToggled: removeToggle,
|
|
mobile: settings?.isMobile,
|
|
isAnonymousUser: user?.is_anonymous_user,
|
|
});
|
|
|
|
// Virtualization + Scrolling related effects and functions
|
|
const scrollInitialized = useRef(false);
|
|
|
|
const imageFileInMessageHistory = useMemo(() => {
|
|
return messageHistory
|
|
.filter((message) => message.type === "user")
|
|
.some((message) =>
|
|
message.files.some((file) => file.type === ChatFileType.IMAGE)
|
|
);
|
|
}, [messageHistory]);
|
|
|
|
useSendMessageToParent();
|
|
|
|
useEffect(() => {
|
|
if (liveAssistant) {
|
|
const hasSearchTool = liveAssistant.tools.some(
|
|
(tool) =>
|
|
tool.in_code_tool_id === SEARCH_TOOL_ID &&
|
|
liveAssistant.user_file_ids?.length == 0 &&
|
|
liveAssistant.user_folder_ids?.length == 0
|
|
);
|
|
setRetrievalEnabled(hasSearchTool);
|
|
if (!hasSearchTool) {
|
|
filterManager.clearFilters();
|
|
}
|
|
}
|
|
}, [liveAssistant]);
|
|
|
|
const [retrievalEnabled, setRetrievalEnabled] = useState(() => {
|
|
if (liveAssistant) {
|
|
return liveAssistant.tools.some(
|
|
(tool) =>
|
|
tool.in_code_tool_id === SEARCH_TOOL_ID &&
|
|
liveAssistant.user_file_ids?.length == 0 &&
|
|
liveAssistant.user_folder_ids?.length == 0
|
|
);
|
|
}
|
|
return false;
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!retrievalEnabled) {
|
|
setDocumentSidebarVisible(false);
|
|
}
|
|
}, [retrievalEnabled]);
|
|
|
|
const [stackTraceModalContent, setStackTraceModalContent] = useState<
|
|
string | null
|
|
>(null);
|
|
|
|
const innerSidebarElementRef = useRef<HTMLDivElement>(null);
|
|
const [settingsToggled, setSettingsToggled] = useState(false);
|
|
|
|
const [selectedDocuments, setSelectedDocuments] = useState<OnyxDocument[]>(
|
|
[]
|
|
);
|
|
const [selectedDocumentTokens, setSelectedDocumentTokens] = useState(0);
|
|
|
|
const currentPersona = alternativeAssistant || liveAssistant;
|
|
|
|
const HORIZON_DISTANCE = 800;
|
|
const handleScroll = useCallback(() => {
|
|
const scrollDistance =
|
|
endDivRef?.current?.getBoundingClientRect()?.top! -
|
|
inputRef?.current?.getBoundingClientRect()?.top!;
|
|
scrollDist.current = scrollDistance;
|
|
setAboveHorizon(scrollDist.current > HORIZON_DISTANCE);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleSlackChatRedirect = async () => {
|
|
if (!slackChatId) return;
|
|
|
|
// Set isReady to false before starting retrieval to display loading text
|
|
setIsReady(false);
|
|
|
|
try {
|
|
const response = await fetch("/api/chat/seed-chat-session-from-slack", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
chat_session_id: slackChatId,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to seed chat from Slack");
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
router.push(data.redirect_url);
|
|
} catch (error) {
|
|
console.error("Error seeding chat from Slack:", error);
|
|
setPopup({
|
|
message: "Failed to load chat from Slack",
|
|
type: "error",
|
|
});
|
|
}
|
|
};
|
|
|
|
handleSlackChatRedirect();
|
|
}, [searchParams, router]);
|
|
|
|
useEffect(() => {
|
|
llmManager.updateImageFilesPresent(imageFileInMessageHistory);
|
|
}, [imageFileInMessageHistory]);
|
|
|
|
const pathname = usePathname();
|
|
useEffect(() => {
|
|
return () => {
|
|
// Cleanup which only runs when the component unmounts (i.e. when you navigate away).
|
|
const currentSession = currentSessionId();
|
|
const controller = abortControllersRef.current.get(currentSession);
|
|
if (controller) {
|
|
controller.abort();
|
|
navigatingAway.current = true;
|
|
setAbortControllers((prev) => {
|
|
const newControllers = new Map(prev);
|
|
newControllers.delete(currentSession);
|
|
return newControllers;
|
|
});
|
|
}
|
|
};
|
|
}, [pathname]);
|
|
|
|
const navigatingAway = useRef(false);
|
|
// Keep a ref to abortControllers to ensure we always have the latest value
|
|
const abortControllersRef = useRef(abortControllers);
|
|
useEffect(() => {
|
|
abortControllersRef.current = abortControllers;
|
|
}, [abortControllers]);
|
|
useEffect(() => {
|
|
const calculateTokensAndUpdateSearchMode = async () => {
|
|
if (selectedFiles.length > 0 || selectedFolders.length > 0) {
|
|
try {
|
|
// Prepare the query parameters for the API call
|
|
const fileIds = selectedFiles.map((file: FileResponse) => file.id);
|
|
const folderIds = selectedFolders.map(
|
|
(folder: FolderResponse) => folder.id
|
|
);
|
|
|
|
// Build the query string
|
|
const queryParams = new URLSearchParams();
|
|
fileIds.forEach((id) =>
|
|
queryParams.append("file_ids", id.toString())
|
|
);
|
|
folderIds.forEach((id) =>
|
|
queryParams.append("folder_ids", id.toString())
|
|
);
|
|
|
|
// Make the API call to get token estimate
|
|
const response = await fetch(
|
|
`/api/user/file/token-estimate?${queryParams.toString()}`
|
|
);
|
|
|
|
if (!response.ok) {
|
|
console.error("Failed to fetch token estimate");
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
console.error("Error calculating tokens:", error);
|
|
}
|
|
}
|
|
};
|
|
|
|
calculateTokensAndUpdateSearchMode();
|
|
}, [selectedFiles, selectedFolders, llmManager.currentLlm]);
|
|
|
|
useSidebarShortcut(router, toggleSidebar);
|
|
|
|
const [sharedChatSession, setSharedChatSession] =
|
|
useState<ChatSession | null>();
|
|
|
|
const handleResubmitLastMessage = () => {
|
|
// Grab the last user-type message
|
|
const lastUserMsg = messageHistory
|
|
.slice()
|
|
.reverse()
|
|
.find((m) => m.type === "user");
|
|
if (!lastUserMsg) {
|
|
setPopup({
|
|
message: "No previously-submitted user message found.",
|
|
type: "error",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// We call onSubmit, passing a `messageOverride`
|
|
onSubmit({
|
|
messageIdToResend: lastUserMsg.messageId,
|
|
messageOverride: lastUserMsg.message,
|
|
});
|
|
};
|
|
|
|
const showShareModal = (chatSession: ChatSession) => {
|
|
setSharedChatSession(chatSession);
|
|
};
|
|
const [showAssistantsModal, setShowAssistantsModal] = useState(false);
|
|
|
|
const toggleDocumentSidebar = () => {
|
|
if (!documentSidebarVisible) {
|
|
setDocumentSidebarVisible(true);
|
|
} else {
|
|
setDocumentSidebarVisible(false);
|
|
}
|
|
};
|
|
|
|
interface RegenerationRequest {
|
|
messageId: number;
|
|
parentMessage: Message;
|
|
forceSearch?: boolean;
|
|
}
|
|
|
|
function createRegenerator(regenerationRequest: RegenerationRequest) {
|
|
// Returns new function that only needs `modelOverRide` to be specified when called
|
|
return async function (modelOverride: LlmDescriptor) {
|
|
return await onSubmit({
|
|
modelOverride,
|
|
messageIdToResend: regenerationRequest.parentMessage.messageId,
|
|
regenerationRequest,
|
|
forceSearch: regenerationRequest.forceSearch,
|
|
});
|
|
};
|
|
}
|
|
if (!user) {
|
|
redirect("/auth/login");
|
|
}
|
|
|
|
if (noAssistants)
|
|
return (
|
|
<>
|
|
<HealthCheckBanner />
|
|
<NoAssistantModal isAdmin={isAdmin} />
|
|
</>
|
|
);
|
|
|
|
const clearSelectedDocuments = () => {
|
|
setSelectedDocuments([]);
|
|
setSelectedDocumentTokens(0);
|
|
clearSelectedItems();
|
|
};
|
|
|
|
const toggleDocumentSelection = (document: OnyxDocument) => {
|
|
setSelectedDocuments((prev) =>
|
|
prev.some((d) => d.document_id === document.document_id)
|
|
? prev.filter((d) => d.document_id !== document.document_id)
|
|
: [...prev, document]
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<HealthCheckBanner />
|
|
|
|
{showApiKeyModal && !shouldShowWelcomeModal && (
|
|
<ApiKeyModal
|
|
hide={() => setShowApiKeyModal(false)}
|
|
setPopup={setPopup}
|
|
/>
|
|
)}
|
|
|
|
{/* ChatPopup is a custom popup that displays a admin-specified message on initial user visit.
|
|
Only used in the EE version of the app. */}
|
|
{popup}
|
|
|
|
<ChatPopup />
|
|
|
|
{currentFeedback && (
|
|
<FeedbackModal
|
|
feedbackType={currentFeedback[0]}
|
|
onClose={() => setCurrentFeedback(null)}
|
|
onSubmit={({ message, predefinedFeedback }) => {
|
|
onFeedback(
|
|
currentFeedback[1],
|
|
currentFeedback[0],
|
|
message,
|
|
predefinedFeedback
|
|
);
|
|
setCurrentFeedback(null);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{(settingsToggled || userSettingsToggled) && (
|
|
<UserSettingsModal
|
|
setPopup={setPopup}
|
|
setCurrentLlm={(newLlm) => llmManager.updateCurrentLlm(newLlm)}
|
|
defaultModel={user?.preferences.default_model!}
|
|
llmProviders={llmProviders}
|
|
onClose={() => {
|
|
setUserSettingsToggled(false);
|
|
setSettingsToggled(false);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{toggleDocSelection && (
|
|
<FilePickerModal
|
|
setPresentingDocument={setPresentingDocument}
|
|
buttonContent="Set as Context"
|
|
isOpen={true}
|
|
onClose={() => setToggleDocSelection(false)}
|
|
onSave={() => {
|
|
setToggleDocSelection(false);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<ChatSearchModal
|
|
open={isChatSearchModalOpen}
|
|
onCloseModal={() => setIsChatSearchModalOpen(false)}
|
|
/>
|
|
|
|
{retrievalEnabled && documentSidebarVisible && settings?.isMobile && (
|
|
<div className="md:hidden">
|
|
<Modal
|
|
hideDividerForTitle
|
|
onOutsideClick={() => setDocumentSidebarVisible(false)}
|
|
title="Sources"
|
|
>
|
|
<DocumentResults
|
|
agenticMessage={
|
|
aiMessage?.sub_questions?.length! > 0 ||
|
|
messageHistory.find(
|
|
(m) => m.messageId === aiMessage?.parentMessageId
|
|
)?.sub_questions?.length! > 0
|
|
? true
|
|
: false
|
|
}
|
|
humanMessage={humanMessage ?? null}
|
|
setPresentingDocument={setPresentingDocument}
|
|
modal={true}
|
|
ref={innerSidebarElementRef}
|
|
closeSidebar={() => {
|
|
setDocumentSidebarVisible(false);
|
|
}}
|
|
selectedMessage={aiMessage ?? null}
|
|
selectedDocuments={selectedDocuments}
|
|
toggleDocumentSelection={toggleDocumentSelection}
|
|
clearSelectedDocuments={clearSelectedDocuments}
|
|
selectedDocumentTokens={selectedDocumentTokens}
|
|
maxTokens={maxTokens}
|
|
initialWidth={400}
|
|
isOpen={true}
|
|
removeHeader
|
|
/>
|
|
</Modal>
|
|
</div>
|
|
)}
|
|
|
|
{presentingDocument && (
|
|
<TextView
|
|
presentingDocument={presentingDocument}
|
|
onClose={() => setPresentingDocument(null)}
|
|
/>
|
|
)}
|
|
|
|
{stackTraceModalContent && (
|
|
<ExceptionTraceModal
|
|
onOutsideClick={() => setStackTraceModalContent(null)}
|
|
exceptionTrace={stackTraceModalContent}
|
|
/>
|
|
)}
|
|
|
|
{sharedChatSession && (
|
|
<ShareChatSessionModal
|
|
assistantId={liveAssistant?.id}
|
|
message={message}
|
|
modelOverride={llmManager.currentLlm}
|
|
chatSessionId={sharedChatSession.id}
|
|
existingSharedStatus={sharedChatSession.shared_status}
|
|
onClose={() => setSharedChatSession(null)}
|
|
onShare={(shared) =>
|
|
setChatSessionSharedStatus(
|
|
shared
|
|
? ChatSessionSharedStatus.Public
|
|
: ChatSessionSharedStatus.Private
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{sharingModalVisible && chatSessionIdRef.current !== null && (
|
|
<ShareChatSessionModal
|
|
message={message}
|
|
assistantId={liveAssistant?.id}
|
|
modelOverride={llmManager.currentLlm}
|
|
chatSessionId={chatSessionIdRef.current}
|
|
existingSharedStatus={chatSessionSharedStatus}
|
|
onClose={() => setSharingModalVisible(false)}
|
|
/>
|
|
)}
|
|
|
|
{showAssistantsModal && (
|
|
<AssistantModal hideModal={() => setShowAssistantsModal(false)} />
|
|
)}
|
|
|
|
<div className="fixed inset-0 flex flex-col text-text-dark">
|
|
<div className="h-[100dvh] overflow-y-hidden">
|
|
<div className="w-full">
|
|
<div
|
|
ref={sidebarElementRef}
|
|
className={`
|
|
flex-none
|
|
fixed
|
|
left-0
|
|
z-40
|
|
bg-neutral-200
|
|
h-screen
|
|
transition-all
|
|
bg-opacity-80
|
|
duration-300
|
|
ease-in-out
|
|
${
|
|
!untoggled && (showHistorySidebar || sidebarVisible)
|
|
? "opacity-100 w-[250px] translate-x-0"
|
|
: "opacity-0 w-[250px] pointer-events-none -translate-x-10"
|
|
}`}
|
|
>
|
|
<div className="w-full relative">
|
|
<HistorySidebar
|
|
toggleChatSessionSearchModal={() =>
|
|
setIsChatSearchModalOpen((open) => !open)
|
|
}
|
|
liveAssistant={liveAssistant}
|
|
setShowAssistantsModal={setShowAssistantsModal}
|
|
explicitlyUntoggle={explicitlyUntoggle}
|
|
reset={reset}
|
|
page="chat"
|
|
ref={innerSidebarElementRef}
|
|
toggleSidebar={toggleSidebar}
|
|
toggled={sidebarVisible}
|
|
existingChats={chatSessions}
|
|
currentChatSession={selectedChatSession}
|
|
folders={folders}
|
|
removeToggle={removeToggle}
|
|
showShareModal={showShareModal}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className={`
|
|
flex-none
|
|
fixed
|
|
left-0
|
|
z-40
|
|
bg-background-100
|
|
h-screen
|
|
transition-all
|
|
bg-opacity-80
|
|
duration-300
|
|
ease-in-out
|
|
${
|
|
documentSidebarVisible &&
|
|
!settings?.isMobile &&
|
|
"opacity-100 w-[350px]"
|
|
}`}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
style={{ transition: "width 0.30s ease-out" }}
|
|
className={`
|
|
flex-none
|
|
fixed
|
|
right-0
|
|
z-[1000]
|
|
h-screen
|
|
transition-all
|
|
duration-300
|
|
ease-in-out
|
|
bg-transparent
|
|
transition-all
|
|
duration-300
|
|
ease-in-out
|
|
h-full
|
|
${
|
|
documentSidebarVisible && !settings?.isMobile
|
|
? "w-[400px]"
|
|
: "w-[0px]"
|
|
}
|
|
`}
|
|
>
|
|
<DocumentResults
|
|
humanMessage={humanMessage ?? null}
|
|
agenticMessage={
|
|
aiMessage?.sub_questions?.length! > 0 ||
|
|
messageHistory.find(
|
|
(m) => m.messageId === aiMessage?.parentMessageId
|
|
)?.sub_questions?.length! > 0
|
|
? true
|
|
: false
|
|
}
|
|
setPresentingDocument={setPresentingDocument}
|
|
modal={false}
|
|
ref={innerSidebarElementRef}
|
|
closeSidebar={() =>
|
|
setTimeout(() => setDocumentSidebarVisible(false), 300)
|
|
}
|
|
selectedMessage={aiMessage ?? null}
|
|
selectedDocuments={selectedDocuments}
|
|
toggleDocumentSelection={toggleDocumentSelection}
|
|
clearSelectedDocuments={clearSelectedDocuments}
|
|
selectedDocumentTokens={selectedDocumentTokens}
|
|
maxTokens={maxTokens}
|
|
initialWidth={400}
|
|
isOpen={documentSidebarVisible && !settings?.isMobile}
|
|
/>
|
|
</div>
|
|
|
|
<BlurBackground
|
|
visible={!untoggled && (showHistorySidebar || sidebarVisible)}
|
|
onClick={() => toggleSidebar()}
|
|
/>
|
|
|
|
<div
|
|
ref={masterFlexboxRef}
|
|
className="flex h-full w-full overflow-x-hidden"
|
|
>
|
|
<div
|
|
id="scrollableContainer"
|
|
className="flex h-full relative px-2 flex-col w-full"
|
|
>
|
|
{liveAssistant && (
|
|
<FunctionalHeader
|
|
toggleUserSettings={() => setUserSettingsToggled(true)}
|
|
sidebarToggled={sidebarVisible}
|
|
reset={() => setMessage("")}
|
|
page="chat"
|
|
setSharingModalVisible={
|
|
chatSessionIdRef.current !== null
|
|
? setSharingModalVisible
|
|
: undefined
|
|
}
|
|
documentSidebarVisible={
|
|
documentSidebarVisible && !settings?.isMobile
|
|
}
|
|
toggleSidebar={toggleSidebar}
|
|
currentChatSession={selectedChatSession}
|
|
hideUserDropdown={user?.is_anonymous_user}
|
|
/>
|
|
)}
|
|
|
|
{documentSidebarInitialWidth !== undefined && isReady ? (
|
|
<Dropzone
|
|
key={currentSessionId()}
|
|
onDrop={(acceptedFiles) =>
|
|
handleMessageSpecificFileUpload(acceptedFiles)
|
|
}
|
|
noClick
|
|
>
|
|
{({ getRootProps }) => (
|
|
<div className="flex h-full w-full">
|
|
{!settings?.isMobile && (
|
|
<div
|
|
style={{ transition: "width 0.30s ease-out" }}
|
|
className={`
|
|
flex-none
|
|
overflow-y-hidden
|
|
bg-transparent
|
|
transition-all
|
|
bg-opacity-80
|
|
duration-300
|
|
ease-in-out
|
|
h-full
|
|
${sidebarVisible ? "w-[200px]" : "w-[0px]"}
|
|
`}
|
|
></div>
|
|
)}
|
|
|
|
<div
|
|
className={`h-full w-full relative flex-auto transition-margin duration-300 overflow-x-auto mobile:pb-12 desktop:pb-[100px]`}
|
|
{...getRootProps()}
|
|
>
|
|
<div
|
|
onScroll={handleScroll}
|
|
className={`w-full h-[calc(100vh-160px)] flex flex-col default-scrollbar overflow-y-auto overflow-x-hidden relative`}
|
|
ref={scrollableDivRef}
|
|
>
|
|
{liveAssistant && (
|
|
<div className="z-20 fixed top-0 pointer-events-none left-0 w-full flex justify-center overflow-visible">
|
|
{!settings?.isMobile && (
|
|
<div
|
|
style={{ transition: "width 0.30s ease-out" }}
|
|
className={`
|
|
flex-none
|
|
overflow-y-hidden
|
|
transition-all
|
|
pointer-events-none
|
|
duration-300
|
|
ease-in-out
|
|
h-full
|
|
${sidebarVisible ? "w-[200px]" : "w-[0px]"}
|
|
`}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* ChatBanner is a custom banner that displays a admin-specified message at
|
|
the top of the chat page. Oly used in the EE version of the app. */}
|
|
{messageHistory.length === 0 &&
|
|
!isFetchingChatMessages &&
|
|
currentSessionChatState == "input" &&
|
|
!loadingError &&
|
|
!submittedMessage && (
|
|
<div className="h-full w-[95%] mx-auto flex flex-col justify-center items-center">
|
|
<ChatIntro selectedPersona={liveAssistant} />
|
|
|
|
{currentPersona && (
|
|
<StarterMessages
|
|
currentPersona={currentPersona}
|
|
onSubmit={(messageOverride) =>
|
|
onSubmit({
|
|
messageOverride,
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div
|
|
style={{ overflowAnchor: "none" }}
|
|
key={currentSessionId()}
|
|
className={
|
|
(hasPerformedInitialScroll ? "" : " hidden ") +
|
|
"desktop:-ml-4 w-full mx-auto " +
|
|
"absolute mobile:top-0 desktop:top-0 left-0 " +
|
|
(settings?.enterpriseSettings
|
|
?.two_lines_for_chat_header
|
|
? "pt-20 "
|
|
: "pt-4 ")
|
|
}
|
|
// NOTE: temporarily removing this to fix the scroll bug
|
|
// (hasPerformedInitialScroll ? "" : "invisible")
|
|
>
|
|
{messageHistory.map((message, i) => {
|
|
const messageMap = currentMessageMap(
|
|
completeMessageDetail
|
|
);
|
|
|
|
if (
|
|
currentRegenerationState()?.finalMessageIndex &&
|
|
currentRegenerationState()?.finalMessageIndex! <
|
|
message.messageId
|
|
) {
|
|
return <></>;
|
|
}
|
|
|
|
const messageReactComponentKey = `${i}-${currentSessionId()}`;
|
|
const parentMessage = message.parentMessageId
|
|
? messageMap.get(message.parentMessageId)
|
|
: null;
|
|
if (message.type === "user") {
|
|
if (
|
|
(currentSessionChatState == "loading" &&
|
|
i == messageHistory.length - 1) ||
|
|
(currentSessionRegenerationState?.regenerating &&
|
|
message.messageId >=
|
|
currentSessionRegenerationState?.finalMessageIndex!)
|
|
) {
|
|
return <></>;
|
|
}
|
|
const nextMessage =
|
|
messageHistory.length > i + 1
|
|
? messageHistory[i + 1]
|
|
: null;
|
|
return (
|
|
<div
|
|
id={`message-${message.messageId}`}
|
|
key={messageReactComponentKey}
|
|
>
|
|
<HumanMessage
|
|
setPresentingDocument={
|
|
setPresentingDocument
|
|
}
|
|
disableSwitchingForStreaming={
|
|
(nextMessage &&
|
|
nextMessage.is_generating) ||
|
|
false
|
|
}
|
|
stopGenerating={stopGenerating}
|
|
content={message.message}
|
|
files={message.files}
|
|
messageId={message.messageId}
|
|
onEdit={(editedContent) => {
|
|
const parentMessageId =
|
|
message.parentMessageId!;
|
|
const parentMessage =
|
|
messageMap.get(parentMessageId)!;
|
|
upsertToCompleteMessageMap({
|
|
messages: [
|
|
{
|
|
...parentMessage,
|
|
latestChildMessageId: null,
|
|
},
|
|
],
|
|
});
|
|
onSubmit({
|
|
messageIdToResend:
|
|
message.messageId || undefined,
|
|
messageOverride: editedContent,
|
|
});
|
|
}}
|
|
otherMessagesCanSwitchTo={
|
|
parentMessage?.childrenMessageIds || []
|
|
}
|
|
onMessageSelection={(messageId) => {
|
|
const newCompleteMessageMap = new Map(
|
|
messageMap
|
|
);
|
|
newCompleteMessageMap.get(
|
|
message.parentMessageId!
|
|
)!.latestChildMessageId = messageId;
|
|
updateCompleteMessageDetail(
|
|
currentSessionId(),
|
|
newCompleteMessageMap
|
|
);
|
|
setSelectedMessageForDocDisplay(
|
|
messageId
|
|
);
|
|
// set message as latest so we can edit this message
|
|
// and so it sticks around on page reload
|
|
setMessageAsLatest(messageId);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
} else if (message.type === "assistant") {
|
|
const previousMessage =
|
|
i !== 0 ? messageHistory[i - 1] : null;
|
|
|
|
const currentAlternativeAssistant =
|
|
message.alternateAssistantID != null
|
|
? availableAssistants.find(
|
|
(persona) =>
|
|
persona.id ==
|
|
message.alternateAssistantID
|
|
)
|
|
: null;
|
|
|
|
if (
|
|
(currentSessionChatState == "loading" &&
|
|
i > messageHistory.length - 1) ||
|
|
(currentSessionRegenerationState?.regenerating &&
|
|
message.messageId >
|
|
currentSessionRegenerationState?.finalMessageIndex!)
|
|
) {
|
|
return <></>;
|
|
}
|
|
if (parentMessage?.type == "assistant") {
|
|
return <></>;
|
|
}
|
|
const secondLevelMessage =
|
|
messageHistory[i + 1]?.type === "assistant"
|
|
? messageHistory[i + 1]
|
|
: undefined;
|
|
|
|
const secondLevelAssistantMessage =
|
|
messageHistory[i + 1]?.type === "assistant"
|
|
? messageHistory[i + 1]?.message
|
|
: undefined;
|
|
|
|
const agenticDocs =
|
|
messageHistory[i + 1]?.type === "assistant"
|
|
? messageHistory[i + 1]?.documents
|
|
: undefined;
|
|
|
|
const nextMessage =
|
|
messageHistory[i + 1]?.type === "assistant"
|
|
? messageHistory[i + 1]
|
|
: undefined;
|
|
|
|
const attachedFileDescriptors =
|
|
previousMessage?.files.filter(
|
|
(file) =>
|
|
file.type == ChatFileType.USER_KNOWLEDGE
|
|
);
|
|
const userFiles = allUserFiles?.filter((file) =>
|
|
attachedFileDescriptors?.some(
|
|
(descriptor) =>
|
|
descriptor.id === file.file_id
|
|
)
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="text-text"
|
|
id={`message-${message.messageId}`}
|
|
key={messageReactComponentKey}
|
|
ref={
|
|
i == messageHistory.length - 1
|
|
? lastMessageRef
|
|
: null
|
|
}
|
|
>
|
|
{message.is_agentic ? (
|
|
<AgenticMessage
|
|
resubmit={handleResubmitLastMessage}
|
|
error={uncaughtError}
|
|
isStreamingQuestions={
|
|
message.isStreamingQuestions ?? false
|
|
}
|
|
isGenerating={
|
|
message.is_generating ?? false
|
|
}
|
|
docSidebarToggled={
|
|
documentSidebarVisible &&
|
|
(selectedMessageForDocDisplay ==
|
|
message.messageId ||
|
|
selectedMessageForDocDisplay ==
|
|
secondLevelMessage?.messageId)
|
|
}
|
|
secondLevelGenerating={
|
|
(message.second_level_generating &&
|
|
currentSessionChatState !==
|
|
"input") ||
|
|
false
|
|
}
|
|
secondLevelSubquestions={message.sub_questions?.filter(
|
|
(subQuestion) =>
|
|
subQuestion.level === 1
|
|
)}
|
|
secondLevelAssistantMessage={
|
|
(message.second_level_message &&
|
|
message.second_level_message.length >
|
|
0
|
|
? message.second_level_message
|
|
: secondLevelAssistantMessage) ||
|
|
undefined
|
|
}
|
|
subQuestions={
|
|
message.sub_questions?.filter(
|
|
(subQuestion) =>
|
|
subQuestion.level === 0
|
|
) || []
|
|
}
|
|
agenticDocs={
|
|
message.agentic_docs || agenticDocs
|
|
}
|
|
docs={
|
|
message?.documents &&
|
|
message?.documents.length > 0
|
|
? message?.documents
|
|
: parentMessage?.documents
|
|
}
|
|
setPresentingDocument={
|
|
setPresentingDocument
|
|
}
|
|
continueGenerating={
|
|
i == messageHistory.length - 1 &&
|
|
currentCanContinue()
|
|
? continueGenerating
|
|
: undefined
|
|
}
|
|
overriddenModel={
|
|
message.overridden_model
|
|
}
|
|
regenerate={createRegenerator({
|
|
messageId: message.messageId,
|
|
parentMessage: parentMessage!,
|
|
})}
|
|
otherMessagesCanSwitchTo={
|
|
parentMessage?.childrenMessageIds ||
|
|
[]
|
|
}
|
|
onMessageSelection={(messageId) => {
|
|
const newCompleteMessageMap = new Map(
|
|
messageMap
|
|
);
|
|
newCompleteMessageMap.get(
|
|
message.parentMessageId!
|
|
)!.latestChildMessageId = messageId;
|
|
|
|
updateCompleteMessageDetail(
|
|
currentSessionId(),
|
|
newCompleteMessageMap
|
|
);
|
|
|
|
setSelectedMessageForDocDisplay(
|
|
messageId
|
|
);
|
|
// set message as latest so we can edit this message
|
|
// and so it sticks around on page reload
|
|
setMessageAsLatest(messageId);
|
|
}}
|
|
isActive={
|
|
messageHistory.length - 1 == i ||
|
|
messageHistory.length - 2 == i
|
|
}
|
|
toggleDocumentSelection={(
|
|
second: boolean
|
|
) => {
|
|
if (
|
|
(!second &&
|
|
!documentSidebarVisible) ||
|
|
(documentSidebarVisible &&
|
|
selectedMessageForDocDisplay ===
|
|
message.messageId)
|
|
) {
|
|
toggleDocumentSidebar();
|
|
}
|
|
if (
|
|
(second &&
|
|
!documentSidebarVisible) ||
|
|
(documentSidebarVisible &&
|
|
selectedMessageForDocDisplay ===
|
|
secondLevelMessage?.messageId)
|
|
) {
|
|
toggleDocumentSidebar();
|
|
}
|
|
|
|
setSelectedMessageForDocDisplay(
|
|
second
|
|
? secondLevelMessage?.messageId ||
|
|
null
|
|
: message.messageId
|
|
);
|
|
}}
|
|
currentPersona={liveAssistant}
|
|
alternativeAssistant={
|
|
currentAlternativeAssistant
|
|
}
|
|
messageId={message.messageId}
|
|
content={message.message}
|
|
files={message.files}
|
|
query={
|
|
messageHistory[i]?.query || undefined
|
|
}
|
|
citedDocuments={getCitedDocumentsFromMessage(
|
|
message
|
|
)}
|
|
toolCall={message.toolCall}
|
|
isComplete={
|
|
i !== messageHistory.length - 1 ||
|
|
(currentSessionChatState !=
|
|
"streaming" &&
|
|
currentSessionChatState !=
|
|
"toolBuilding")
|
|
}
|
|
handleFeedback={
|
|
i === messageHistory.length - 1 &&
|
|
currentSessionChatState != "input"
|
|
? undefined
|
|
: (feedbackType: FeedbackType) =>
|
|
setCurrentFeedback([
|
|
feedbackType,
|
|
message.messageId as number,
|
|
])
|
|
}
|
|
/>
|
|
) : (
|
|
<AIMessage
|
|
userKnowledgeFiles={userFiles}
|
|
docs={
|
|
message?.documents &&
|
|
message?.documents.length > 0
|
|
? message?.documents
|
|
: parentMessage?.documents
|
|
}
|
|
setPresentingDocument={
|
|
setPresentingDocument
|
|
}
|
|
index={i}
|
|
continueGenerating={
|
|
i == messageHistory.length - 1 &&
|
|
currentCanContinue()
|
|
? continueGenerating
|
|
: undefined
|
|
}
|
|
overriddenModel={
|
|
message.overridden_model
|
|
}
|
|
regenerate={createRegenerator({
|
|
messageId: message.messageId,
|
|
parentMessage: parentMessage!,
|
|
})}
|
|
otherMessagesCanSwitchTo={
|
|
parentMessage?.childrenMessageIds ||
|
|
[]
|
|
}
|
|
onMessageSelection={(messageId) => {
|
|
const newCompleteMessageMap = new Map(
|
|
messageMap
|
|
);
|
|
newCompleteMessageMap.get(
|
|
message.parentMessageId!
|
|
)!.latestChildMessageId = messageId;
|
|
|
|
updateCompleteMessageDetail(
|
|
currentSessionId(),
|
|
newCompleteMessageMap
|
|
);
|
|
|
|
setSelectedMessageForDocDisplay(
|
|
messageId
|
|
);
|
|
// set message as latest so we can edit this message
|
|
// and so it sticks around on page reload
|
|
setMessageAsLatest(messageId);
|
|
}}
|
|
isActive={
|
|
messageHistory.length - 1 == i
|
|
}
|
|
selectedDocuments={selectedDocuments}
|
|
toggleDocumentSelection={() => {
|
|
if (
|
|
!documentSidebarVisible ||
|
|
(documentSidebarVisible &&
|
|
selectedMessageForDocDisplay ===
|
|
message.messageId)
|
|
) {
|
|
toggleDocumentSidebar();
|
|
}
|
|
|
|
setSelectedMessageForDocDisplay(
|
|
message.messageId
|
|
);
|
|
}}
|
|
currentPersona={liveAssistant}
|
|
alternativeAssistant={
|
|
currentAlternativeAssistant
|
|
}
|
|
messageId={message.messageId}
|
|
content={message.message}
|
|
files={message.files}
|
|
query={
|
|
messageHistory[i]?.query || undefined
|
|
}
|
|
citedDocuments={getCitedDocumentsFromMessage(
|
|
message
|
|
)}
|
|
toolCall={message.toolCall}
|
|
isComplete={
|
|
i !== messageHistory.length - 1 ||
|
|
(currentSessionChatState !=
|
|
"streaming" &&
|
|
currentSessionChatState !=
|
|
"toolBuilding")
|
|
}
|
|
hasDocs={
|
|
(message.documents &&
|
|
message.documents.length > 0) ===
|
|
true
|
|
}
|
|
handleFeedback={
|
|
i === messageHistory.length - 1 &&
|
|
currentSessionChatState != "input"
|
|
? undefined
|
|
: (feedbackType) =>
|
|
setCurrentFeedback([
|
|
feedbackType,
|
|
message.messageId as number,
|
|
])
|
|
}
|
|
handleSearchQueryEdit={
|
|
i === messageHistory.length - 1 &&
|
|
currentSessionChatState == "input"
|
|
? (newQuery) => {
|
|
if (!previousMessage) {
|
|
setPopup({
|
|
type: "error",
|
|
message:
|
|
"Cannot edit query of first message - please refresh the page and try again.",
|
|
});
|
|
return;
|
|
}
|
|
if (
|
|
previousMessage.messageId ===
|
|
null
|
|
) {
|
|
setPopup({
|
|
type: "error",
|
|
message:
|
|
"Cannot edit query of a pending message - please wait a few seconds and try again.",
|
|
});
|
|
return;
|
|
}
|
|
onSubmit({
|
|
messageIdToResend:
|
|
previousMessage.messageId,
|
|
queryOverride: newQuery,
|
|
alternativeAssistantOverride:
|
|
currentAlternativeAssistant,
|
|
});
|
|
}
|
|
: undefined
|
|
}
|
|
handleForceSearch={() => {
|
|
if (
|
|
previousMessage &&
|
|
previousMessage.messageId
|
|
) {
|
|
createRegenerator({
|
|
messageId: message.messageId,
|
|
parentMessage: parentMessage!,
|
|
forceSearch: true,
|
|
})(llmManager.currentLlm);
|
|
} else {
|
|
setPopup({
|
|
type: "error",
|
|
message:
|
|
"Failed to force search - please refresh the page and try again.",
|
|
});
|
|
}
|
|
}}
|
|
retrievalDisabled={
|
|
currentAlternativeAssistant
|
|
? !personaIncludesRetrieval(
|
|
currentAlternativeAssistant!
|
|
)
|
|
: !retrievalEnabled
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
} else {
|
|
return (
|
|
<div key={messageReactComponentKey}>
|
|
<AIMessage
|
|
setPresentingDocument={
|
|
setPresentingDocument
|
|
}
|
|
currentPersona={liveAssistant}
|
|
messageId={message.messageId}
|
|
content={
|
|
<ErrorBanner
|
|
resubmit={handleResubmitLastMessage}
|
|
error={message.message}
|
|
showStackTrace={
|
|
message.stackTrace
|
|
? () =>
|
|
setStackTraceModalContent(
|
|
message.stackTrace!
|
|
)
|
|
: undefined
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
})}
|
|
|
|
{(currentSessionChatState == "loading" ||
|
|
(loadingError &&
|
|
!currentSessionRegenerationState?.regenerating &&
|
|
messageHistory[messageHistory.length - 1]
|
|
?.type != "user")) && (
|
|
<HumanMessage
|
|
setPresentingDocument={setPresentingDocument}
|
|
key={-2}
|
|
messageId={-1}
|
|
content={submittedMessage}
|
|
/>
|
|
)}
|
|
|
|
{currentSessionChatState == "loading" && (
|
|
<div
|
|
key={`${messageHistory.length}-${chatSessionIdRef.current}`}
|
|
>
|
|
<AIMessage
|
|
setPresentingDocument={setPresentingDocument}
|
|
key={-3}
|
|
currentPersona={liveAssistant}
|
|
alternativeAssistant={
|
|
alternativeGeneratingAssistant ??
|
|
alternativeAssistant
|
|
}
|
|
messageId={null}
|
|
content={
|
|
<div
|
|
key={"Generating"}
|
|
className="mr-auto relative inline-block"
|
|
>
|
|
<span className="text-sm loading-text">
|
|
Thinking...
|
|
</span>
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{loadingError && (
|
|
<div key={-1}>
|
|
<AIMessage
|
|
setPresentingDocument={setPresentingDocument}
|
|
currentPersona={liveAssistant}
|
|
messageId={-1}
|
|
content={
|
|
<p className="text-red-700 text-sm my-auto">
|
|
{loadingError}
|
|
</p>
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{messageHistory.length > 0 && (
|
|
<div
|
|
style={{
|
|
height: !autoScrollEnabled
|
|
? getContainerHeight()
|
|
: undefined,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Some padding at the bottom so the search bar has space at the bottom to not cover the last message*/}
|
|
<div ref={endPaddingRef} className="h-[95px]" />
|
|
|
|
<div ref={endDivRef} />
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={inputRef}
|
|
className="absolute pointer-events-none bottom-0 z-10 w-full"
|
|
>
|
|
{aboveHorizon && (
|
|
<div className="mx-auto w-fit !pointer-events-none flex sticky justify-center">
|
|
<button
|
|
onClick={() => clientScrollToBottom()}
|
|
className="p-1 pointer-events-auto text-neutral-700 dark:text-neutral-800 rounded-2xl bg-neutral-200 border border-border mx-auto "
|
|
>
|
|
<FiArrowDown size={18} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="pointer-events-auto w-[95%] mx-auto relative mb-8">
|
|
<ChatInputBar
|
|
proSearchEnabled={proSearchEnabled}
|
|
setProSearchEnabled={() => toggleProSearch()}
|
|
toggleDocumentSidebar={toggleDocumentSidebar}
|
|
availableSources={sources}
|
|
availableDocumentSets={documentSets}
|
|
availableTags={tags}
|
|
filterManager={filterManager}
|
|
llmManager={llmManager}
|
|
removeDocs={() => {
|
|
clearSelectedDocuments();
|
|
}}
|
|
retrievalEnabled={retrievalEnabled}
|
|
toggleDocSelection={() =>
|
|
setToggleDocSelection(true)
|
|
}
|
|
showConfigureAPIKey={() =>
|
|
setShowApiKeyModal(true)
|
|
}
|
|
selectedDocuments={selectedDocuments}
|
|
message={message}
|
|
setMessage={setMessage}
|
|
stopGenerating={stopGenerating}
|
|
onSubmit={onSubmit}
|
|
chatState={currentSessionChatState}
|
|
alternativeAssistant={alternativeAssistant}
|
|
selectedAssistant={
|
|
selectedAssistant || liveAssistant
|
|
}
|
|
setAlternativeAssistant={setAlternativeAssistant}
|
|
setFiles={setCurrentMessageFiles}
|
|
handleFileUpload={handleMessageSpecificFileUpload}
|
|
textAreaRef={textAreaRef}
|
|
/>
|
|
{enterpriseSettings &&
|
|
enterpriseSettings.custom_lower_disclaimer_content && (
|
|
<div className="mobile:hidden mt-4 flex items-center justify-center relative w-[95%] mx-auto">
|
|
<div className="text-sm text-text-500 max-w-searchbar-max px-4 text-center">
|
|
<MinimalMarkdown
|
|
content={
|
|
enterpriseSettings.custom_lower_disclaimer_content
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{enterpriseSettings &&
|
|
enterpriseSettings.use_custom_logotype && (
|
|
<div className="hidden lg:block absolute right-0 bottom-0">
|
|
<img
|
|
src="/api/enterprise-settings/logotype"
|
|
alt="logotype"
|
|
style={{ objectFit: "contain" }}
|
|
className="w-fit h-8"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
style={{ transition: "width 0.30s ease-out" }}
|
|
className={`
|
|
flex-none
|
|
overflow-y-hidden
|
|
transition-all
|
|
bg-opacity-80
|
|
duration-300
|
|
ease-in-out
|
|
h-full
|
|
${
|
|
documentSidebarVisible && !settings?.isMobile
|
|
? "w-[350px]"
|
|
: "w-[0px]"
|
|
}
|
|
`}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Dropzone>
|
|
) : (
|
|
<div className="mx-auto h-full flex">
|
|
<div
|
|
style={{ transition: "width 0.30s ease-out" }}
|
|
className={`flex-none bg-transparent transition-all bg-opacity-80 duration-300 ease-in-out h-full
|
|
${
|
|
sidebarVisible && !settings?.isMobile
|
|
? "w-[250px] "
|
|
: "w-[0px]"
|
|
}`}
|
|
/>
|
|
<div className="my-auto">
|
|
<OnyxInitializingLoader />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<FixedLogo backgroundToggled={sidebarVisible || showHistorySidebar} />
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|