"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(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(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(null); const [presentingDocument, setPresentingDocument] = useState(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(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(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> >(new Map()); const updateCompleteMessageDetail = ( sessionId: string | null, messageMap: Map ) => { setCompleteMessageDetail((prevState) => { const newState = new Map(prevState); newState.set(sessionId, messageMap); return newState; }); }; const currentMessageMap = ( messageDetail: Map> ) => { return ( messageDetail.get(chatSessionIdRef.current) || new Map() ); }; 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 | null; chatSessionId?: string; replacementsMap?: Map | 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>( new Map([[chatSessionIdRef.current, firstMessage ? "loading" : "input"]]) ); const [regenerationState, setRegenerationState] = useState< Map >(new Map([[null, null]])); const [abortControllers, setAbortControllers] = useState< Map >(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>>, 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>( 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(null); const { aiMessage, humanMessage } = selectedMessageForDocDisplay ? getHumanAndAIMessageFromMessageNumber( messageHistory, selectedMessageForDocDisplay ) : { aiMessage: null, humanMessage: null }; const [chatSessionSharedStatus, setChatSessionSharedStatus] = useState(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(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(false); const [aboveHorizon, setAboveHorizon] = useState(false); const scrollableDivRef = useRef(null); const lastMessageRef = useRef(null); const inputRef = useRef(null); const endDivRef = useRef(null); const endPaddingRef = useRef(null); const previousHeight = useRef( inputRef.current?.getBoundingClientRect().height! ); const scrollDist = useRef(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(null); useEffect(() => { handleInputResize(); }, [message]); // used for resizing of the document sidebar const masterFlexboxRef = useRef(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(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>(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; } = 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(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(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(null); const [settingsToggled, setSettingsToggled] = useState(false); const [selectedDocuments, setSelectedDocuments] = useState( [] ); 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(); 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 ( <> ); 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 ( <> {showApiKeyModal && !shouldShowWelcomeModal && ( 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} {currentFeedback && ( setCurrentFeedback(null)} onSubmit={({ message, predefinedFeedback }) => { onFeedback( currentFeedback[1], currentFeedback[0], message, predefinedFeedback ); setCurrentFeedback(null); }} /> )} {(settingsToggled || userSettingsToggled) && ( llmManager.updateCurrentLlm(newLlm)} defaultModel={user?.preferences.default_model!} llmProviders={llmProviders} onClose={() => { setUserSettingsToggled(false); setSettingsToggled(false); }} /> )} {toggleDocSelection && ( setToggleDocSelection(false)} onSave={() => { setToggleDocSelection(false); }} /> )} setIsChatSearchModalOpen(false)} /> {retrievalEnabled && documentSidebarVisible && settings?.isMobile && (
setDocumentSidebarVisible(false)} title="Sources" > 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 />
)} {presentingDocument && ( setPresentingDocument(null)} /> )} {stackTraceModalContent && ( setStackTraceModalContent(null)} exceptionTrace={stackTraceModalContent} /> )} {sharedChatSession && ( setSharedChatSession(null)} onShare={(shared) => setChatSessionSharedStatus( shared ? ChatSessionSharedStatus.Public : ChatSessionSharedStatus.Private ) } /> )} {sharingModalVisible && chatSessionIdRef.current !== null && ( setSharingModalVisible(false)} /> )} {showAssistantsModal && ( setShowAssistantsModal(false)} /> )}
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} />
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} />
toggleSidebar()} />
{liveAssistant && ( 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 ? ( handleMessageSpecificFileUpload(acceptedFiles) } noClick > {({ getRootProps }) => (
{!settings?.isMobile && (
)}
{liveAssistant && (
{!settings?.isMobile && (
)}
)} {/* 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 && (
{currentPersona && ( onSubmit({ messageOverride, }) } /> )}
)} )} {loadingError && (
{loadingError}

} />
)} {messageHistory.length > 0 && (
)} {/* Some padding at the bottom so the search bar has space at the bottom to not cover the last message*/}
{aboveHorizon && (
)}
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 && (
)} {enterpriseSettings && enterpriseSettings.use_custom_logotype && (
logotype
)}
)} ) : (
)}
); }