From 766bd5df6af94fc21620d3f2ab7f636ec47790ab Mon Sep 17 00:00:00 2001 From: Flavien David Date: Fri, 21 Jun 2024 12:43:54 +0200 Subject: [PATCH] Unified Conversation Page Refactor (#5766) * tmp * Tmp * Fix broken scroll * Add Assistant browser container * Partially working * Improve new conversation * It works * Id * Not needed * Polish * :scissors: * Fix conversation title top bar * :sparkles: * :scissors: * :sparkles: :sparkles: * :see_no_evil: * :sparkles: * Fix new conversation input bar animation * :scissors: * :back: * Fix assistant details action * Fix Try assistant drawer * Fix layout + conversation title glitch * Address comments from review * Remove `.slice()`. * Reduce navigation bar duration * Delight --- .vscode/settings.json | 4 +- .../components/assistant/AssistantBrowser.tsx | 5 +- front/components/assistant/TryAssistant.tsx | 25 +- .../AssistantBrowserContainer.tsx | 78 ++++ .../conversation/ConversationContainer.tsx | 354 +++++++++++++++++ .../conversation/ConversationLayout.tsx | 27 +- .../conversation/ConversationViewer.tsx | 126 ++++--- .../conversation/HelpAndQuickGuideWrapper.tsx | 82 ++++ .../assistant/conversation/SidebarMenu.tsx | 11 +- .../conversation/input_bar/InputBar.tsx | 8 +- front/components/navigation/Navigation.tsx | 4 +- front/lib/swr.ts | 45 ++- front/pages/w/[wId]/assistant/[cId]/index.tsx | 202 +++------- front/pages/w/[wId]/assistant/new.tsx | 356 ------------------ 14 files changed, 731 insertions(+), 596 deletions(-) create mode 100644 front/components/assistant/conversation/AssistantBrowserContainer.tsx create mode 100644 front/components/assistant/conversation/ConversationContainer.tsx create mode 100644 front/components/assistant/conversation/HelpAndQuickGuideWrapper.tsx delete mode 100644 front/pages/w/[wId]/assistant/new.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 13771d5a0a04..c949f5df7805 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,8 +3,8 @@ "rust-analyzer.linkedProjects": ["./core/Cargo.toml"], "editor.codeActionsOnSave": { - "source.organizeImports": false, - "source.fixAll.eslint": true + "source.organizeImports": "never", + "source.fixAll.eslint": "explicit" }, "eslint.run": "onSave", "typescript.preferences.importModuleSpecifier": "non-relative" diff --git a/front/components/assistant/AssistantBrowser.tsx b/front/components/assistant/AssistantBrowser.tsx index e1cdc35728a5..48c5c0847f72 100644 --- a/front/components/assistant/AssistantBrowser.tsx +++ b/front/components/assistant/AssistantBrowser.tsx @@ -27,9 +27,7 @@ import type { GetAgentConfigurationsResponseBody } from "@app/pages/api/w/[wId]/ interface AssistantListProps { owner: WorkspaceType; agents: LightAgentConfigurationType[]; - // for speed purposes, there is a partially loaded state for which we - // can show a subset of the agents - loadingStatus: "loading" | "partial" | "finished"; + loadingStatus: "loading" | "finished"; handleAssistantClick: (agent: LightAgentConfigurationType) => void; mutateAgentConfigurations: KeyedMutator; } @@ -191,7 +189,6 @@ export function AssistantBrowser({ subtitle={agent.lastAuthors?.join(", ") ?? ""} description="" variant="minimal" - onClick={() => handleAssistantClick(agent)} onActionClick={() => setAssistantIdToShow(agent.sId)} /> diff --git a/front/components/assistant/TryAssistant.tsx b/front/components/assistant/TryAssistant.tsx index 957c8d2724cd..a7493a228c2f 100644 --- a/front/components/assistant/TryAssistant.tsx +++ b/front/components/assistant/TryAssistant.tsx @@ -72,10 +72,10 @@ export function TryAssistantModal({ >
- {conversation && ( + {conversation ? ( + ) : ( + // Empty div to prefill the data. +
)} -
- -
+
diff --git a/front/components/assistant/conversation/AssistantBrowserContainer.tsx b/front/components/assistant/conversation/AssistantBrowserContainer.tsx new file mode 100644 index 000000000000..9fc2590624df --- /dev/null +++ b/front/components/assistant/conversation/AssistantBrowserContainer.tsx @@ -0,0 +1,78 @@ +import { Page } from "@dust-tt/sparkle"; +import type { + LightAgentConfigurationType, + WorkspaceType, +} from "@dust-tt/types"; +import { useCallback } from "react"; + +import { AssistantBrowser } from "@app/components/assistant/AssistantBrowser"; +import { useProgressiveAgentConfigurations } from "@app/lib/swr"; +import { classNames } from "@app/lib/utils"; + +interface AssistantBrowserContainerProps { + onAgentConfigurationClick: (agent: LightAgentConfigurationType) => void; + owner: WorkspaceType; + setAssistantToMention: (agent: LightAgentConfigurationType) => void; +} + +export function AssistantBrowserContainer({ + onAgentConfigurationClick, + owner, + setAssistantToMention, +}: AssistantBrowserContainerProps) { + const { agentConfigurations, isLoading, mutateAgentConfigurations } = + useProgressiveAgentConfigurations({ + workspaceId: owner.sId, + }); + + const handleAssistantClick = useCallback( + // On click, scroll to the input bar and set the selected assistant. + async (agent: LightAgentConfigurationType) => { + const scrollContainerElement = document.getElementById( + "assistant-input-header" + ); + + if (!scrollContainerElement) { + console.log("Unexpected: scrollContainerElement not found"); + return; + } + const scrollDistance = scrollContainerElement.getBoundingClientRect().top; + + // If the input bar is already in view, set the mention directly. We leave + // a little margin, -2 instead of 0, since the autoscroll below can + // sometimes scroll a bit over 0, to -0.3 or -0.5, in which case if there + // is a clic on a visible assistant we still want this condition to + // trigger. + if (scrollDistance > -2) { + return onAgentConfigurationClick(agent); + } + + // Otherwise, scroll to the input bar and set the ref (mention will be set via intersection observer). + scrollContainerElement.scrollIntoView({ behavior: "smooth" }); + + setAssistantToMention(agent); + }, + [setAssistantToMention, onAgentConfigurationClick] + ); + + return ( +
+
+ +
+ +
+ ); +} diff --git a/front/components/assistant/conversation/ConversationContainer.tsx b/front/components/assistant/conversation/ConversationContainer.tsx new file mode 100644 index 000000000000..1af6201f16d1 --- /dev/null +++ b/front/components/assistant/conversation/ConversationContainer.tsx @@ -0,0 +1,354 @@ +import { Page } from "@dust-tt/sparkle"; +import type { + AgentMention, + AgentMessageWithRankType, + LightAgentConfigurationType, + MentionType, + SubscriptionType, + UserMessageWithRankType, + UserType, + WorkspaceType, +} from "@dust-tt/types"; +import { Transition } from "@headlessui/react"; +import { cloneDeep } from "lodash"; +import { useRouter } from "next/router"; +import { + Fragment, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; + +import { ReachedLimitPopup } from "@app/components/app/ReachedLimitPopup"; +import { AssistantBrowserContainer } from "@app/components/assistant/conversation/AssistantBrowserContainer"; +import ConversationViewer from "@app/components/assistant/conversation/ConversationViewer"; +import { HelpAndQuickGuideWrapper } from "@app/components/assistant/conversation/HelpAndQuickGuideWrapper"; +import { FixedAssistantInputBar } from "@app/components/assistant/conversation/input_bar/InputBar"; +import { InputBarContext } from "@app/components/assistant/conversation/input_bar/InputBarContext"; +import type { ContentFragmentInput } from "@app/components/assistant/conversation/lib"; +import { + createConversationWithMessage, + createPlaceholderUserMessage, + submitMessage, +} from "@app/components/assistant/conversation/lib"; +import { SendNotificationsContext } from "@app/components/sparkle/Notification"; +import type { FetchConversationMessagesResponse } from "@app/lib/api/assistant/messages"; +import { getRandomGreetingForName } from "@app/lib/client/greetings"; +import { useSubmitFunction } from "@app/lib/client/utils"; +import { useConversationMessages } from "@app/lib/swr"; + +interface ConversationContainerProps { + conversationId: string | null; + owner: WorkspaceType; + subscription: SubscriptionType; + user: UserType; +} + +export function ConversationContainer({ + conversationId, + owner, + subscription, + user, +}: ConversationContainerProps) { + const [activeConversationId, setActiveConversationId] = + useState(conversationId); + const [planLimitReached, setPlanLimitReached] = useState(false); + const [stickyMentions, setStickyMentions] = useState([]); + + const { animate, setAnimate } = useContext(InputBarContext); + + const assistantToMention = useRef(null); + + const router = useRouter(); + + const sendNotification = useContext(SendNotificationsContext); + + const { mutateMessages } = useConversationMessages({ + conversationId: activeConversationId, + workspaceId: owner.sId, + limit: 50, + }); + + useEffect(() => { + if (animate) { + setTimeout(() => setAnimate(false), 500); + } + }); + + const handleSubmit = async ( + input: string, + mentions: MentionType[], + contentFragments: ContentFragmentInput[] + ) => { + if (!activeConversationId) { + return null; + } + + const messageData = { input, mentions, contentFragments }; + + try { + // Update the local state immediately and fire the + // request. Since the API will return the updated + // data, there is no need to start a new revalidation + // and we can directly populate the cache. + await mutateMessages( + async (currentMessagePages) => { + const result = await submitMessage({ + owner, + user, + conversationId: activeConversationId, + messageData, + }); + + // Replace placeholder message with API response. + if (result.isOk()) { + const { message } = result.value; + + return updateMessagePagesWithOptimisticData( + currentMessagePages, + message + ); + } + + if (result.error.type === "plan_limit_reached_error") { + setPlanLimitReached(true); + } else { + sendNotification({ + title: result.error.title, + description: result.error.message, + type: "error", + }); + } + + throw result.error; + }, + { + // Add optimistic data placeholder. + optimisticData: (currentMessagePages) => { + const lastMessageRank = + currentMessagePages?.at(0)?.messages.at(-1)?.rank ?? 0; + + const placeholderMessage = createPlaceholderUserMessage({ + input, + mentions, + user, + lastMessageRank, + }); + return updateMessagePagesWithOptimisticData( + currentMessagePages, + placeholderMessage + ); + }, + revalidate: false, + // Rollback optimistic update on errors. + rollbackOnError: true, + populateCache: true, + } + ); + } catch (err) { + // If the API errors, the original data will be + // rolled back by SWR automatically. + console.error("Failed to post message:", err); + } + }; + + const { submit: handleMessageSubmit } = useSubmitFunction( + useCallback( + async ( + input: string, + mentions: MentionType[], + contentFragments: ContentFragmentInput[] + ) => { + const conversationRes = await createConversationWithMessage({ + owner, + user, + messageData: { + input, + mentions, + contentFragments, + }, + }); + if (conversationRes.isErr()) { + if (conversationRes.error.type === "plan_limit_reached_error") { + setPlanLimitReached(true); + } else { + sendNotification({ + title: conversationRes.error.title, + description: conversationRes.error.message, + type: "error", + }); + } + } else { + // We start the push before creating the message to optimize for instantaneity as well. + setActiveConversationId(conversationRes.value.sId); + void router.push( + `/w/${owner.sId}/assistant/${conversationRes.value.sId}`, + undefined, + { shallow: true } + ); + } + }, + [owner, user, sendNotification, setActiveConversationId, router] + ) + ); + + const setInputbarMention = useCallback( + (agent: LightAgentConfigurationType) => { + setStickyMentions((prev) => { + const alreadyInStickyMention = prev.find( + (m) => m.configurationId === agent.sId + ); + + if (alreadyInStickyMention) { + return prev; + } + + return [...prev, { configurationId: agent.sId }]; + }); + setAnimate(true); + }, + [setStickyMentions, setAnimate] + ); + + useEffect(() => { + const scrollContainerElement = document.getElementById( + "assistant-input-header" + ); + + if (scrollContainerElement) { + const observer = new IntersectionObserver( + () => { + if (assistantToMention.current) { + setInputbarMention(assistantToMention.current); + assistantToMention.current = null; + } + }, + { threshold: 0.8 } + ); + observer.observe(scrollContainerElement); + } + }, [setAnimate, setInputbarMention]); + + const [greeting, setGreeting] = useState(""); + useEffect(() => { + setGreeting(getRandomGreetingForName(user.firstName)); + }, [user]); + + const onStickyMentionsChange = useCallback( + (mentions: AgentMention[]) => { + setStickyMentions(mentions); + }, + [setStickyMentions] + ); + + return ( + <> + + {activeConversationId ? ( + + ) : ( +
+ )} +
+ + +
+ + +
+
+ + + + + { + assistantToMention.current = assistant; + }} + owner={owner} + /> + + + {activeConversationId !== "new" && ( + + )} + + setPlanLimitReached(false)} + subscription={subscription} + owner={owner} + code="message_limit" + /> + + ); +} + +/** + * If no message pages exist, create a single page with the optimistic message. + * If message pages exist, add the optimistic message to the first page, since + * the message pages array is not yet reversed. + */ +export function updateMessagePagesWithOptimisticData( + currentMessagePages: FetchConversationMessagesResponse[] | undefined, + messageOrPlaceholder: AgentMessageWithRankType | UserMessageWithRankType +): FetchConversationMessagesResponse[] { + if (!currentMessagePages || currentMessagePages.length === 0) { + return [ + { + messages: [messageOrPlaceholder], + hasMore: false, + lastValue: null, + }, + ]; + } + + // We need to deep clone here, since SWR relies on the reference. + const updatedMessages = cloneDeep(currentMessagePages); + updatedMessages.at(0)?.messages.push(messageOrPlaceholder); + + return updatedMessages; +} diff --git a/front/components/assistant/conversation/ConversationLayout.tsx b/front/components/assistant/conversation/ConversationLayout.tsx index 70116aaa477d..985dce77a4f1 100644 --- a/front/components/assistant/conversation/ConversationLayout.tsx +++ b/front/components/assistant/conversation/ConversationLayout.tsx @@ -35,6 +35,9 @@ export default function ConversationLayout({ const sendNotification = useContext(SendNotificationsContext); const [detailViewContent, setDetailViewContent] = useState(""); + const [activeConversationId, setActiveConversationId] = useState( + conversationId !== "new" ? conversationId : null + ); const handleCloseModal = () => { const currentPathname = router.pathname; @@ -52,11 +55,25 @@ export default function ConversationLayout({ useEffect(() => { const handleRouteChange = () => { const assistantSId = router.query.assistantDetails ?? []; + // We use shallow browsing when creating a new conversation. + // Monitor router to update conversation info. + const conversationId = router.query.cId ?? ""; + if (assistantSId && typeof assistantSId === "string") { setDetailViewContent(assistantSId); } else { setDetailViewContent(""); } + + if ( + conversationId && + typeof conversationId === "string" && + conversationId !== activeConversationId + ) { + setActiveConversationId( + conversationId !== "new" ? conversationId : null + ); + } }; // Initial check in case the component mounts with the query already set. @@ -66,10 +83,15 @@ export default function ConversationLayout({ return () => { router.events.off("routeChangeComplete", handleRouteChange); }; - }, [router.query, router.events]); + }, [ + router.query, + router.events, + setActiveConversationId, + activeConversationId, + ]); const { conversation } = useConversation({ - conversationId, + conversationId: activeConversationId, workspaceId: owner.sId, }); @@ -79,6 +101,7 @@ export default function ConversationLayout({ void; owner: WorkspaceType; user: UserType; @@ -72,15 +71,21 @@ interface ConversationViewerProps { * @param isInModal is the conversation happening in a side modal, i.e. when testing an assistant? * @returns */ -export default function ConversationViewer({ - owner, - user, - conversationId, - onStickyMentionsChange, - isInModal = false, - hideReactions = false, - isFading = false, -}: ConversationViewerProps) { +const ConversationViewer = React.forwardRef< + HTMLDivElement, + ConversationViewerProps +>(function ConversationViewer( + { + owner, + user, + conversationId, + onStickyMentionsChange, + isInModal = false, + hideReactions = false, + isFading = false, + }, + ref +) { const { conversation, isConversationError, @@ -198,29 +203,34 @@ export default function ConversationViewer({ isValidating, ]); - // Handle sticky mentions changes. - useEffect(() => { - if (!onStickyMentionsChange) { - return; - } - - const lastUserMessage = latestPage?.messages.findLast( + const lastUserMessage = useMemo(() => { + return latestPage?.messages.findLast( (message) => isUserMessageType(message) && message.visibility !== "deleted" && message.user?.id === user.id ); + }, [latestPage, user.id]); + const agentMentions = useMemo(() => { if (!lastUserMessage || !isUserMessageType(lastUserMessage)) { + return []; + } + return lastUserMessage.mentions.filter(isAgentMention); + }, [lastUserMessage]); + + // Handle sticky mentions changes. + useEffect(() => { + if (!onStickyMentionsChange) { return; } - const { mentions } = lastUserMessage; - const agentMentions = mentions.filter(isAgentMention); - onStickyMentionsChange(agentMentions); - }, [latestPage, onStickyMentionsChange, user.id]); + if (agentMentions.length > 0) { + onStickyMentionsChange(agentMentions); + } + }, [agentMentions, onStickyMentionsChange]); - const { ref, inView: isTopOfListVisible } = useInView(); + const { ref: viewRef, inView: isTopOfListVisible } = useInView(); // On page load or when new data is loaded, check if the top of the list // is visible and there is more data to load. If so, set the current @@ -351,15 +361,6 @@ export default function ConversationViewer({ const groupedMessages = useMemo(() => groupMessages(messages), [messages]); - if (isConversationLoading) { - return
; - } else if (isConversationError) { - return
Error loading conversation
; - } - if (!conversation) { - return
No conversation here
; - } - return (
+ {isConversationError && ( +
+ Error loading conversation +
+ )} {/* Invisible span to detect when the user has scrolled to the top of the list. */} {hasMore && !isMessagesLoading && !prevFirstMessageId && ( - + )} {(isMessagesLoading || prevFirstMessageId) && (
@@ -378,32 +385,35 @@ export default function ConversationViewer({
)} - {groupedMessages.map((group) => { - return group.map((message) => { - return ( - - ); - }); - })} + {conversation && + groupedMessages.map((group) => { + return group.map((message) => { + return ( + + ); + }); + })}
); -} +}); + +export default ConversationViewer; // Grouping messages into arrays based on their type, associating content_fragments with the upcoming following user_message. // Example: diff --git a/front/components/assistant/conversation/HelpAndQuickGuideWrapper.tsx b/front/components/assistant/conversation/HelpAndQuickGuideWrapper.tsx new file mode 100644 index 000000000000..5c1bdba733a8 --- /dev/null +++ b/front/components/assistant/conversation/HelpAndQuickGuideWrapper.tsx @@ -0,0 +1,82 @@ +import { Button, HeartAltIcon } from "@dust-tt/sparkle"; +import type { UserType, WorkspaceType } from "@dust-tt/types"; +import { useEffect, useState } from "react"; + +import { HelpDrawer } from "@app/components/assistant/HelpDrawer"; +import { QuickStartGuide } from "@app/components/quick_start_guide"; +import { useSubmitFunction } from "@app/lib/client/utils"; +import { useUserMetadata } from "@app/lib/swr"; +import { setUserMetadataFromClient } from "@app/lib/user"; + +interface HelpAndQuickGuideWrapperProps { + owner: WorkspaceType; + user: UserType; +} + +export function HelpAndQuickGuideWrapper({ + owner, + user, +}: HelpAndQuickGuideWrapperProps) { + const [isHelpDrawerOpen, setIsHelpDrawerOpen] = useState(false); + const [showQuickGuide, setShowQuickGuide] = useState(false); + + const { + metadata: quickGuideSeen, + isMetadataError: isQuickGuideSeenError, + isMetadataLoading: isQuickGuideSeenLoading, + mutateMetadata: mutateQuickGuideSeen, + } = useUserMetadata("quick_guide_seen"); + + const { submit: handleCloseQuickGuide } = useSubmitFunction(async () => { + setUserMetadataFromClient({ key: "quick_guide_seen", value: "true" }) + .then(() => { + return mutateQuickGuideSeen(); + }) + .catch(console.error); + setShowQuickGuide(false); + }); + + useEffect(() => { + if (!quickGuideSeen && !isQuickGuideSeenError && !isQuickGuideSeenLoading) { + // Quick guide has never been shown, lets show it. + setShowQuickGuide(true); + } + }, [isQuickGuideSeenError, isQuickGuideSeenLoading, quickGuideSeen]); + + return ( + <> + setIsHelpDrawerOpen(false)} + /> + { + void handleCloseQuickGuide(); + }} + /> + + {/* Quick start guide CTA */} +
+
+ + ); +} diff --git a/front/components/assistant/conversation/SidebarMenu.tsx b/front/components/assistant/conversation/SidebarMenu.tsx index bfedf8155abe..7ee9dd133161 100644 --- a/front/components/assistant/conversation/SidebarMenu.tsx +++ b/front/components/assistant/conversation/SidebarMenu.tsx @@ -84,10 +84,13 @@ export function AssistantSidebarMenu({ owner }: { owner: WorkspaceType }) { href={`/w/${owner.sId}/assistant/new`} onClick={() => { setSidebarOpen(false); - if ( - router.pathname === "/w/[wId]/assistant/new" && - triggerInputAnimation - ) { + const { cId } = router.query; + const isNewConversation = + router.pathname === "/w/[wId]/assistant/[cId]" && + typeof cId === "string" && + cId === "new"; + + if (isNewConversation && triggerInputAnimation) { triggerInputAnimation(); } }} diff --git a/front/components/assistant/conversation/input_bar/InputBar.tsx b/front/components/assistant/conversation/input_bar/InputBar.tsx index 69076514a5f0..5357077a0ae8 100644 --- a/front/components/assistant/conversation/input_bar/InputBar.tsx +++ b/front/components/assistant/conversation/input_bar/InputBar.tsx @@ -26,6 +26,8 @@ import { useAgentConfigurations } from "@app/lib/swr"; import { ClientSideTracking } from "@app/lib/tracking/client"; import { classNames } from "@app/lib/utils"; +const DEFAULT_INPUT_BAR_ACTIONS = [...INPUT_BAR_ACTIONS]; + // AGENT MENTION function AgentMention({ @@ -57,7 +59,7 @@ export function AssistantInputBar({ conversationId, stickyMentions, additionalAgentConfiguration, - actions = INPUT_BAR_ACTIONS.slice(), + actions = DEFAULT_INPUT_BAR_ACTIONS, disableAutoFocus = false, isFloating = true, }: { @@ -361,7 +363,7 @@ export function FixedAssistantInputBar({ stickyMentions, conversationId, additionalAgentConfiguration, - actions = INPUT_BAR_ACTIONS.slice(), + actions = DEFAULT_INPUT_BAR_ACTIONS, disableAutoFocus = false, }: { owner: WorkspaceType; @@ -377,7 +379,7 @@ export function FixedAssistantInputBar({ disableAutoFocus?: boolean; }) { return ( -
+
diff --git a/front/lib/swr.ts b/front/lib/swr.ts index 2c6e04342854..07a74e2a96b6 100644 --- a/front/lib/swr.ts +++ b/front/lib/swr.ts @@ -640,7 +640,7 @@ export function useConversationMessages({ workspaceId, limit, }: { - conversationId: string; + conversationId: string | null; workspaceId: string; limit: number; }) { @@ -649,6 +649,10 @@ export function useConversationMessages({ const { data, error, mutate, size, setSize, isLoading, isValidating } = useSWRInfinite( (pageIndex: number, previousPageData) => { + if (!conversationId) { + return null; + } + // If we have reached the last page and there are no more // messages or the previous page has no messages, return null. if ( @@ -823,6 +827,45 @@ export function useAgentConfigurations({ }; } +export function useProgressiveAgentConfigurations({ + workspaceId, +}: { + workspaceId: string; +}) { + const { + agentConfigurations: initialAgentConfigurations, + isAgentConfigurationsLoading: isInitialAgentConfigurationsLoading, + } = useAgentConfigurations({ + workspaceId, + agentsGetView: "assistants-search", + limit: 24, + includes: ["usage"], + }); + + const { + agentConfigurations: agentConfigurationsWithAuthors, + isAgentConfigurationsLoading: isAgentConfigurationsWithAuthorsLoading, + mutateAgentConfigurations, + } = useAgentConfigurations({ + workspaceId, + agentsGetView: "assistants-search", + includes: ["authors", "usage"], + }); + + const isLoading = + isInitialAgentConfigurationsLoading || + isAgentConfigurationsWithAuthorsLoading; + const agentConfigurations = isAgentConfigurationsWithAuthorsLoading + ? initialAgentConfigurations + : agentConfigurationsWithAuthors; + + return { + agentConfigurations, + isLoading, + mutateAgentConfigurations, + }; +} + /* * Agent configurations for poke. Currently only supports archived assistant. * A null agentsGetView means no fetching diff --git a/front/pages/w/[wId]/assistant/[cId]/index.tsx b/front/pages/w/[wId]/assistant/[cId]/index.tsx index 2e0b3e1d2794..dfb194eb78a7 100644 --- a/front/pages/w/[wId]/assistant/[cId]/index.tsx +++ b/front/pages/w/[wId]/assistant/[cId]/index.tsx @@ -1,36 +1,21 @@ -import type { - AgentMessageWithRankType, - UserMessageWithRankType, - UserType, -} from "@dust-tt/types"; -import type { AgentMention, MentionType } from "@dust-tt/types"; -import { cloneDeep } from "lodash"; +import type { UserType } from "@dust-tt/types"; import type { InferGetServerSidePropsType } from "next"; import { useRouter } from "next/router"; import type { ReactElement } from "react"; -import { useContext, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; +import React from "react"; -import { ReachedLimitPopup } from "@app/components/app/ReachedLimitPopup"; +import { ConversationContainer } from "@app/components/assistant/conversation/ConversationContainer"; import type { ConversationLayoutProps } from "@app/components/assistant/conversation/ConversationLayout"; import ConversationLayout from "@app/components/assistant/conversation/ConversationLayout"; -import ConversationViewer from "@app/components/assistant/conversation/ConversationViewer"; -import { FixedAssistantInputBar } from "@app/components/assistant/conversation/input_bar/InputBar"; -import type { ContentFragmentInput } from "@app/components/assistant/conversation/lib"; -import { - createPlaceholderUserMessage, - submitMessage, -} from "@app/components/assistant/conversation/lib"; -import { SendNotificationsContext } from "@app/components/sparkle/Notification"; -import type { FetchConversationMessagesResponse } from "@app/lib/api/assistant/messages"; import { withDefaultUserAuthRequirements } from "@app/lib/iam/session"; -import { useConversationMessages } from "@app/lib/swr"; const { URL = "", GA_TRACKING_ID = "" } = process.env; export const getServerSideProps = withDefaultUserAuthRequirements< ConversationLayoutProps & { // Here, override conversationId. - conversationId: string; + conversationId: string | null; user: UserType; } >(async (context, auth) => { @@ -39,14 +24,27 @@ export const getServerSideProps = withDefaultUserAuthRequirements< const subscription = auth.subscription(); if (!owner || !user || !auth.isUser() || !subscription) { + const { cId } = context.query; + + if (typeof cId === "string") { + return { + redirect: { + destination: `/w/${context.query.wId}/join?cId=${cId}`, + permanent: false, + }, + }; + } + return { redirect: { - destination: `/w/${context.query.wId}/join?cId=${context.query.cId}`, + destination: "/", permanent: false, }, }; } + const { cId } = context.params; + return { props: { user, @@ -54,53 +52,36 @@ export const getServerSideProps = withDefaultUserAuthRequirements< subscription, baseUrl: URL, gaTrackingId: GA_TRACKING_ID, - conversationId: context.params?.cId as string, + conversationId: getValidConversationId(cId), }, }; }); -/** - * If no message pages exist, create a single page with the optimistic message. - * If message pages exist, add the optimistic message to the first page, since - * the message pages array is not yet reversed. - */ -export function updateMessagePagesWithOptimisticData( - currentMessagePages: FetchConversationMessagesResponse[] | undefined, - messageOrPlaceholder: AgentMessageWithRankType | UserMessageWithRankType -): FetchConversationMessagesResponse[] { - if (!currentMessagePages || currentMessagePages.length === 0) { - return [ - { - messages: [messageOrPlaceholder], - hasMore: false, - lastValue: null, - }, - ]; - } - - // We need to deep clone here, since SWR relies on the reference. - const updatedMessages = cloneDeep(currentMessagePages); - updatedMessages.at(0)?.messages.push(messageOrPlaceholder); - - return updatedMessages; -} - export default function AssistantConversation({ - conversationId, + conversationId: initialConversationId, owner, subscription, user, }: InferGetServerSidePropsType) { + const [conversationKey, setConversationKey] = useState(null); const router = useRouter(); - const [stickyMentions, setStickyMentions] = useState([]); - const [planLimitReached, setPlanLimitReached] = useState(false); - const sendNotification = useContext(SendNotificationsContext); - const { mutateMessages } = useConversationMessages({ - conversationId, - workspaceId: owner.sId, - limit: 50, - }); + // This useEffect handles whether to change the key of the ConversationContainer + // or not. Altering the key forces a re-render of the component. A random number + // is used in the key to maintain the component during the transition from new + // to the conversation view. The key is reset when navigating to a new conversation. + useEffect(() => { + const { cId } = router.query; + const conversationId = getValidConversationId(cId); + + if (conversationId && initialConversationId) { + // Set conversation id as key if it exists. + setConversationKey(conversationId); + } else if (!conversationId && !initialConversationId) { + // Force re-render by setting a new key with a random number. + setConversationKey(`new_${Math.random() * 1000}`); + } + }, [router.query, setConversationKey, initialConversationId]); useEffect(() => { function handleNewConvoShortcut(event: KeyboardEvent) { @@ -117,105 +98,22 @@ export default function AssistantConversation({ }; }, [owner.sId, router]); - const handleSubmit = async ( - input: string, - mentions: MentionType[], - contentFragments: ContentFragmentInput[] - ) => { - const messageData = { input, mentions, contentFragments }; - - try { - // Update the local state immediately and fire the - // request. Since the API will return the updated - // data, there is no need to start a new revalidation - // and we can directly populate the cache. - await mutateMessages( - async (currentMessagePages) => { - const result = await submitMessage({ - owner, - user, - conversationId, - messageData, - }); - - // Replace placeholder message with API response. - if (result.isOk()) { - const { message } = result.value; - - return updateMessagePagesWithOptimisticData( - currentMessagePages, - message - ); - } - - if (result.error.type === "plan_limit_reached_error") { - setPlanLimitReached(true); - } else { - sendNotification({ - title: result.error.title, - description: result.error.message, - type: "error", - }); - } - - throw result.error; - }, - { - // Add optimistic data placeholder. - optimisticData: (currentMessagePages) => { - const lastMessageRank = - currentMessagePages?.at(0)?.messages.at(-1)?.rank ?? 0; - - const placeholderMessage = createPlaceholderUserMessage({ - input, - mentions, - user, - lastMessageRank, - }); - return updateMessagePagesWithOptimisticData( - currentMessagePages, - placeholderMessage - ); - }, - revalidate: false, - // Rollback optimistic update on errors. - rollbackOnError: true, - populateCache: true, - } - ); - } catch (err) { - // If the API errors, the original data will be - // rolled back by SWR automatically. - console.error("Failed to post message:", err); - } - }; - return ( - <> - - - setPlanLimitReached(false)} - subscription={subscription} - owner={owner} - code="message_limit" - /> - + ); } AssistantConversation.getLayout = (page: ReactElement, pageProps: any) => { return {page}; }; + +function getValidConversationId(cId: unknown) { + return typeof cId === "string" && cId !== "new" ? cId : null; +} diff --git a/front/pages/w/[wId]/assistant/new.tsx b/front/pages/w/[wId]/assistant/new.tsx deleted file mode 100644 index d0f8f589c0e0..000000000000 --- a/front/pages/w/[wId]/assistant/new.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import { Button, HeartAltIcon, Page } from "@dust-tt/sparkle"; -import type { - LightAgentConfigurationType, - MentionType, - PlanType, - UserType, - WorkspaceType, -} from "@dust-tt/types"; -import type { InferGetServerSidePropsType } from "next"; -import { useRouter } from "next/router"; -import type { ReactElement } from "react"; -import { useCallback, useContext, useEffect, useRef, useState } from "react"; - -import { ReachedLimitPopup } from "@app/components/app/ReachedLimitPopup"; -import { AssistantBrowser } from "@app/components/assistant/AssistantBrowser"; -import type { ConversationLayoutProps } from "@app/components/assistant/conversation/ConversationLayout"; -import ConversationLayout from "@app/components/assistant/conversation/ConversationLayout"; -import { AssistantInputBar } from "@app/components/assistant/conversation/input_bar/InputBar"; -import { InputBarContext } from "@app/components/assistant/conversation/input_bar/InputBarContext"; -import type { ContentFragmentInput } from "@app/components/assistant/conversation/lib"; -import { createConversationWithMessage } from "@app/components/assistant/conversation/lib"; -import { HelpDrawer } from "@app/components/assistant/HelpDrawer"; -import { QuickStartGuide } from "@app/components/quick_start_guide"; -import { SendNotificationsContext } from "@app/components/sparkle/Notification"; -import { getAgentConfiguration } from "@app/lib/api/assistant/configuration"; -import config from "@app/lib/api/config"; -import { getRandomGreetingForName } from "@app/lib/client/greetings"; -import { useSubmitFunction } from "@app/lib/client/utils"; -import { withDefaultUserAuthRequirements } from "@app/lib/iam/session"; -import { useAgentConfigurations, useUserMetadata } from "@app/lib/swr"; -import { setUserMetadataFromClient } from "@app/lib/user"; -import { classNames } from "@app/lib/utils"; - -const { GA_TRACKING_ID = "" } = process.env; - -export const getServerSideProps = withDefaultUserAuthRequirements< - ConversationLayoutProps & { - user: UserType; - owner: WorkspaceType; - plan: PlanType | null; - isBuilder: boolean; - helper: LightAgentConfigurationType | null; - } ->(async (_context, auth) => { - const owner = auth.workspace(); - const user = auth.user(); - const subscription = auth.subscription(); - const plan = auth.plan(); - - if (!owner || !auth.isUser() || !subscription || !user) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } - - const helper = await getAgentConfiguration(auth, "helper"); - - return { - props: { - baseUrl: config.getAppUrl(), - conversationId: null, - gaTrackingId: GA_TRACKING_ID, - helper, - isBuilder: auth.isBuilder(), - owner, - subscription, - user, - plan, - }, - }; -}); - -export default function AssistantNew({ - owner, - subscription, - user, -}: InferGetServerSidePropsType) { - const router = useRouter(); - - const [planLimitReached, setPlanLimitReached] = useState(false); - const sendNotification = useContext(SendNotificationsContext); - - const [isHelpDrawerOpen, setIsHelpDrawerOpen] = useState(false); - const { animate, setAnimate, setSelectedAssistant } = - useContext(InputBarContext); - - const assistantToMention = useRef(null); - - // fast loading of a few company assistants so we can show them immediately - const { - agentConfigurations: initialAgentConfigurations, - isAgentConfigurationsLoading: isInitialAgentConfigurationsLoading, - } = useAgentConfigurations({ - workspaceId: owner.sId, - agentsGetView: "assistants-search", - limit: 24, - includes: ["usage"], - }); - - // we load all assistants with authors in the background - const { - agentConfigurations: agentConfigurationsWithAuthors, - isAgentConfigurationsLoading: isAgentConfigurationsWithAuthorsLoading, - mutateAgentConfigurations, - } = useAgentConfigurations({ - workspaceId: owner.sId, - agentsGetView: "assistants-search", - includes: ["authors", "usage"], - }); - - const displayedAgentConfigurations = isAgentConfigurationsWithAuthorsLoading - ? initialAgentConfigurations - : agentConfigurationsWithAuthors; - - const { submit: handleMessageSubmit } = useSubmitFunction( - useCallback( - async ( - input: string, - mentions: MentionType[], - contentFragments: ContentFragmentInput[] - ) => { - const conversationRes = await createConversationWithMessage({ - owner, - user, - messageData: { - input, - mentions, - contentFragments, - }, - }); - if (conversationRes.isErr()) { - if (conversationRes.error.type === "plan_limit_reached_error") { - setPlanLimitReached(true); - } else { - sendNotification({ - title: conversationRes.error.title, - description: conversationRes.error.message, - type: "error", - }); - } - } else { - // We start the push before creating the message to optimize for instantaneity as well. - void router.push( - `/w/${owner.sId}/assistant/${conversationRes.value.sId}` - ); - } - }, - [owner, user, router, sendNotification] - ) - ); - - const { - metadata: quickGuideSeen, - isMetadataError: isQuickGuideSeenError, - isMetadataLoading: isQuickGuideSeenLoading, - mutateMetadata: mutateQuickGuideSeen, - } = useUserMetadata("quick_guide_seen"); - - const [showQuickGuide, setShowQuickGuide] = useState(false); - const [greeting, setGreeting] = useState(""); - - const { submit: handleCloseQuickGuide } = useSubmitFunction(async () => { - setUserMetadataFromClient({ key: "quick_guide_seen", value: "true" }) - .then(() => { - return mutateQuickGuideSeen(); - }) - .catch(console.error); - setShowQuickGuide(false); - }); - - useEffect(() => { - if (!quickGuideSeen && !isQuickGuideSeenError && !isQuickGuideSeenLoading) { - // Quick guide has never been shown, lets show it. - setShowQuickGuide(true); - } - }, [isQuickGuideSeenError, isQuickGuideSeenLoading, quickGuideSeen]); - - useEffect(() => { - setGreeting(getRandomGreetingForName(user.firstName)); - }, [user]); - - const setInputbarMention = useCallback( - (agent: LightAgentConfigurationType) => { - setSelectedAssistant({ - configurationId: agent.sId, - }); - setAnimate(true); - }, - [setSelectedAssistant, setAnimate] - ); - - const handleAssistantClick = useCallback( - // on click, scroll to the input bar and set the selected assistant - async (agent: LightAgentConfigurationType) => { - const scrollContainerElement = document.getElementById( - "assistant-input-header" - ); - - if (!scrollContainerElement) { - console.log("Unexpected: scrollContainerElement not found"); - return; - } - const scrollDistance = scrollContainerElement.getBoundingClientRect().top; - - // If the input bar is already in view, set the mention directly. We leave - // a little margin, -2 instead of 0, since the autoscroll below can - // sometimes scroll a bit over 0, to -0.3 or -0.5, in which case if there - // is a clic on a visible assistant we still want this condition to - // trigger - if (scrollDistance > -2) { - setInputbarMention(agent); - return; - } - - // Otherwise, scroll to the input bar and set the ref (mention will be set via intersection observer) - scrollContainerElement.scrollIntoView({ behavior: "smooth" }); - - assistantToMention.current = agent; - }, - [setInputbarMention] - ); - - useEffect(() => { - if (animate) { - setTimeout(() => setAnimate(false), 500); - } - }); - - useEffect(() => { - const scrollContainerElement = document.getElementById( - "assistant-input-header" - ); - if (scrollContainerElement) { - const observer = new IntersectionObserver( - () => { - if (assistantToMention.current) { - setInputbarMention(assistantToMention.current); - assistantToMention.current = null; - } - }, - { threshold: 0.8 } - ); - observer.observe(scrollContainerElement); - } - }, [setAnimate, setInputbarMention]); - - return ( - <> - setIsHelpDrawerOpen(false)} - /> - { - void handleCloseQuickGuide(); - }} - /> -
- {/* Assistant input bar container*/} -
-
- - -
-
- -
-
- - {/* Assistants browse section */} -
-
- -
- -
-
- - {/* Quick start guide CTA */} -
-
- - setPlanLimitReached(false)} - subscription={subscription} - owner={owner} - code="message_limit" - /> - - ); -} - -AssistantNew.getLayout = ( - page: ReactElement, - pageProps: ConversationLayoutProps -) => { - return {page}; -};