diff --git a/src/components/RoadmapAIChat/AIChatActionButtons.tsx b/src/components/RoadmapAIChat/AIChatActionButtons.tsx index 3f197d013..e6211fbc0 100644 --- a/src/components/RoadmapAIChat/AIChatActionButtons.tsx +++ b/src/components/RoadmapAIChat/AIChatActionButtons.tsx @@ -24,10 +24,12 @@ type AIChatActionButtonsProps = { onTellUsAboutYourSelf: () => void; onClearChat: () => void; messageCount: number; + showClearChat: boolean; }; export function AIChatActionButtons(props: AIChatActionButtonsProps) { - const { onTellUsAboutYourSelf, onClearChat, messageCount } = props; + const { onTellUsAboutYourSelf, onClearChat, messageCount, showClearChat } = + props; return (
@@ -36,7 +38,7 @@ export function AIChatActionButtons(props: AIChatActionButtonsProps) { label="Tell us about your self" onClick={onTellUsAboutYourSelf} /> - {messageCount > 0 && ( + {showClearChat && messageCount > 0 && ( ('chat'); + const [activeChatHistoryId, setActiveChatHistoryId] = useState< + string | undefined + >(); const [showUpdatePersonaModal, setShowUpdatePersonaModal] = useState(false); @@ -136,10 +142,19 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { }, [roadmapDetail]); useEffect(() => { + const params = getUrlParams(); + const queryChatId = params.chatId; + if (!roadmapTreeData || !roadmapDetail || isUserPersonaLoading) { return; } + if (queryChatId) { + setIsChatHistoryLoading(true); + setActiveChatHistoryId(queryChatId); + deleteUrlParam('chatId'); + } + setIsLoading(false); }, [roadmapTreeData, roadmapDetail, isUserPersonaLoading]); @@ -170,6 +185,19 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { [roadmapId, deviceType], ); + const [isChatHistoryLoading, setIsChatHistoryLoading] = useState(true); + const { data: chatHistory } = useQuery( + chatHistoryOptions( + activeChatHistoryId, + roadmapAIChatRenderer({ + roadmapId, + totalTopicCount, + onSelectTopic, + }), + ), + queryClient, + ); + const { aiChatHistory, isStreamingMessage, @@ -179,13 +207,39 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { handleAbort, clearChat, scrollToBottom, + setAiChatHistory, } = useRoadmapAIChat({ + activeChatHistoryId, roadmapId, totalTopicCount, scrollareaRef, onSelectTopic, + onChatHistoryIdChange: (chatHistoryId) => { + setActiveChatHistoryId(chatHistoryId); + }, }); + useEffect(() => { + if (!chatHistory) { + return; + } + + setAiChatHistory(chatHistory?.messages ?? []); + setIsChatHistoryLoading(false); + setTimeout(() => { + scrollToBottom('instant'); + }, 0); + }, [chatHistory]); + + useEffect(() => { + if (activeChatHistoryId) { + return; + } + + setAiChatHistory([]); + setIsChatHistoryLoading(false); + }, [activeChatHistoryId, setAiChatHistory, setIsChatHistoryLoading]); + if (roadmapDetailError) { return (
@@ -308,6 +362,20 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { }} selectedTopicId={selectedTopicId} roadmapId={roadmapId} + activeChatHistoryId={activeChatHistoryId} + onChatHistoryClick={(chatHistoryId) => { + setIsChatHistoryLoading(true); + setActiveChatHistoryId(chatHistoryId); + }} + onNewChat={() => { + document.title = 'Roadmap AI Chat'; + setActiveChatHistoryId(undefined); + }} + onDeleteChatHistory={(chatHistoryId) => { + if (activeChatHistoryId === chatHistoryId) { + setActiveChatHistoryId(undefined); + } + }} /> {activeTab === 'topic' && selectedTopicId && ( @@ -335,61 +403,59 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { {activeTab === 'chat' && ( <>
- {isLoading && ( -
-
- - Loading Roadmap -
-
+ {isLoading && } + {isChatHistoryLoading && ( + )} - {shouldShowChatPersona && !isLoading && ( + {shouldShowChatPersona && !isLoading && !isChatHistoryLoading && ( )} - {!isLoading && !shouldShowChatPersona && ( -
-
-
- - } - isIntro - /> - - {aiChatHistory.map( - (chat: RoadmapAIChatHistoryType, index: number) => { - return ( - - - - ); - }, - )} - - {isStreamingMessage && !streamedMessage && ( + {!isLoading && + !isChatHistoryLoading && + !shouldShowChatPersona && ( +
+
+
+ } + isIntro /> - )} - {streamedMessage && ( - - )} + {aiChatHistory.map( + (chat: RoadmapAIChatHistoryType, index: number) => { + return ( + + + + ); + }, + )} + + {isStreamingMessage && !streamedMessage && ( + + )} + + {streamedMessage && ( + + )} +
-
- )} + )}
- {!isLoading && !shouldShowChatPersona && ( + {!isLoading && !isChatHistoryLoading && !shouldShowChatPersona && (
{!isLimitExceeded && ( )} @@ -509,3 +576,20 @@ function isEmptyContent(content: JSONContent) { (!firstContent?.content || firstContent?.content?.length === 0) ); } + +type LoaderProps = { + message?: string; +}; + +function Loader(props: LoaderProps) { + const { message } = props; + + return ( +
+
+ + {message ?? 'Loading Roadmap'} +
+
+ ); +} diff --git a/src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx b/src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx index 726fd6eb8..43a38a510 100644 --- a/src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx +++ b/src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx @@ -12,22 +12,6 @@ import { cn } from '../../lib/classname'; import { useKeydown } from '../../hooks/use-keydown'; import { RoadmapAIChatHistory } from '../RoadmapAIChatHistory/RoadmapAIChatHistory'; -type RoadmapAIChatHeaderProps = { - isLoading: boolean; - - onLogin: () => void; - onUpgrade: () => void; - - onCloseChat: () => void; - - activeTab: RoadmapAIChatTab; - onTabChange: (tab: RoadmapAIChatTab) => void; - onCloseTopic: () => void; - selectedTopicId: string | null; - - roadmapId: string; -}; - type TabButtonProps = { icon: React.ReactNode; label: string; @@ -68,6 +52,26 @@ function TabButton(props: TabButtonProps) { ); } +type RoadmapAIChatHeaderProps = { + isLoading: boolean; + + onLogin: () => void; + onUpgrade: () => void; + + onCloseChat: () => void; + + activeTab: RoadmapAIChatTab; + onTabChange: (tab: RoadmapAIChatTab) => void; + onCloseTopic: () => void; + selectedTopicId: string | null; + + roadmapId: string; + activeChatHistoryId?: string; + onChatHistoryClick: (chatHistoryId: string) => void; + onNewChat: () => void; + onDeleteChatHistory: (chatHistoryId: string) => void; +}; + export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) { const { onLogin, @@ -80,6 +84,10 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) { onCloseTopic, selectedTopicId, roadmapId, + activeChatHistoryId, + onChatHistoryClick, + onNewChat, + onDeleteChatHistory, } = props; const [showAILimitsPopup, setShowAILimitsPopup] = useState(false); @@ -175,7 +183,13 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) { )} - +
)}
diff --git a/src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx b/src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx index 8a36604db..0af0408e5 100644 --- a/src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx +++ b/src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx @@ -1,6 +1,6 @@ import { HistoryIcon, Loader2Icon, PlusIcon } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from '../Popover'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useInfiniteQuery } from '@tanstack/react-query'; import { listChatHistoryOptions } from '../../queries/chat-history'; import { isLoggedIn } from '../../lib/jwt'; @@ -14,13 +14,20 @@ type RoadmapAIChatHistoryProps = { activeChatHistoryId?: string; onChatHistoryClick: (id: string) => void; onDelete?: (id: string) => void; + onNewChat?: () => void; }; export function RoadmapAIChatHistory(props: RoadmapAIChatHistoryProps) { - const { roadmapId, activeChatHistoryId, onChatHistoryClick, onDelete } = - props; + const { + roadmapId, + activeChatHistoryId, + onChatHistoryClick, + onDelete, + onNewChat, + } = props; - const [isOpen, setIsOpen] = useState(true); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [query, setQuery] = useState(''); const { @@ -40,6 +47,14 @@ export function RoadmapAIChatHistory(props: RoadmapAIChatHistoryProps) { queryClient, ); + useEffect(() => { + if (!chatHistory) { + return; + } + + setIsLoading(false); + }, [chatHistory]); + const groupedChatHistory = useMemo(() => { const allHistories = chatHistory?.pages?.flatMap((page) => page.data); return groupChatHistory(allHistories ?? []); @@ -54,72 +69,89 @@ export function RoadmapAIChatHistory(props: RoadmapAIChatHistoryProps) { - + {isLoading && ( +
+ +
+ )} + {!isLoading && ( + <> + -
- {isEmptyHistory && ( -
-

No chat history

+
+ {isEmptyHistory && ( +
+

No chat history

+
+ )} + + {Object.entries(groupedChatHistory ?? {}).map(([key, value]) => { + if (value.histories.length === 0) { + return null; + } + + return ( + { + setIsOpen(false); + onChatHistoryClick(id); + }} + onDelete={(id) => { + setIsOpen(false); + onDelete?.(id); + }} + /> + ); + })} + + {hasNextPage && ( +
+ +
+ )}
- )} - {Object.entries(groupedChatHistory ?? {}).map(([key, value]) => { - if (value.histories.length === 0) { - return null; - } - - return ( - { - onChatHistoryClick(id); - }} - onDelete={(id) => { - onDelete?.(id); - }} - /> - ); - })} - - {hasNextPage && ( -
+
- )} -
- -
- -
+ + )} ); diff --git a/src/hooks/use-roadmap-ai-chat.tsx b/src/hooks/use-roadmap-ai-chat.tsx index cdc440265..dda428bcd 100644 --- a/src/hooks/use-roadmap-ai-chat.tsx +++ b/src/hooks/use-roadmap-ai-chat.tsx @@ -16,6 +16,7 @@ import { RoadmapTopicList } from '../components/RoadmapAIChat/RoadmapTopicList'; import { ShareResourceLink } from '../components/RoadmapAIChat/ShareResourceLink'; import { RoadmapRecommendations } from '../components/RoadmapAIChat/RoadmapRecommendations'; import type { AllowedAIChatRole } from '../components/GenerateCourse/AICourseLessonChat'; +import { readChatStream } from '../lib/chat'; type RoadmapAIChatRendererOptions = { totalTopicCount: number; @@ -67,26 +68,28 @@ export type RoadmapAIChatHistoryType = { }; type Options = { + activeChatHistoryId?: string; roadmapId: string; totalTopicCount: number; scrollareaRef: React.RefObject; onSelectTopic: (topicId: string, topicTitle: string) => void; - defaultMessages?: RoadmapAIChatHistoryType[]; + onChatHistoryIdChange?: (chatHistoryId: string) => void; }; export function useRoadmapAIChat(options: Options) { const { + activeChatHistoryId, roadmapId, totalTopicCount, scrollareaRef, onSelectTopic, - defaultMessages, + onChatHistoryIdChange, } = options; const toast = useToast(); const [aiChatHistory, setAiChatHistory] = useState< RoadmapAIChatHistoryType[] - >(defaultMessages ?? []); + >([]); const [isStreamingMessage, setIsStreamingMessage] = useState(false); const [streamedMessage, setStreamedMessage] = useState(null); @@ -146,33 +149,7 @@ export function useRoadmapAIChat(options: Options) { }, [isStreamingMessage, streamedMessage, scrollToBottom]); const renderer: Record = useMemo( - () => ({ - 'user-progress': () => ( - - ), - 'update-progress': (opts) => ( - - ), - 'roadmap-topics': (opts) => ( - { - const title = text.split(' > ').pop(); - if (title) { - onSelectTopic(topicId, title); - } - }} - {...opts} - /> - ), - 'resource-progress-link': () => ( - - ), - 'roadmap-recommendations': (opts) => , - }), + () => roadmapAIChatRenderer({ roadmapId, totalTopicCount, onSelectTopic }), [roadmapId, onSelectTopic, totalTopicCount], ); @@ -188,7 +165,13 @@ export function useRoadmapAIChat(options: Options) { headers: { 'Content-Type': 'application/json' }, credentials: 'include', signal: abortController?.signal, - body: JSON.stringify({ roadmapId, messages: messages.slice(-10) }), + body: JSON.stringify({ + roadmapId, + messages, + ...(activeChatHistoryId + ? { chatHistoryId: activeChatHistoryId } + : {}), + }), }, ); @@ -212,16 +195,24 @@ export function useRoadmapAIChat(options: Options) { return; } - await readStream(reader, { - onStream: async (content) => { - if (abortController?.signal.aborted) return; + await readChatStream(reader, { + onMessage: async (content) => { + if (abortController?.signal.aborted) { + return; + } + const jsx = await renderMessage(content, renderer, { isLoading: true, }); - flushSync(() => setStreamedMessage(jsx)); + flushSync(() => { + setStreamedMessage(jsx); + }); }, - onStreamEnd: async (content) => { - if (abortController?.signal.aborted) return; + onMessageEnd: async (content) => { + if (abortController?.signal.aborted) { + return; + } + const jsx = await renderMessage(content, renderer, { isLoading: false, }); @@ -235,6 +226,24 @@ export function useRoadmapAIChat(options: Options) { setAiChatHistory(newMessages); }); queryClient.invalidateQueries(getAiCourseLimitOptions()); + queryClient.invalidateQueries({ + predicate: (query) => { + return ( + query.queryKey[0] === 'list-chat-history' && + (query.queryKey[1] as { roadmapId: string })?.roadmapId === + roadmapId + ); + }, + }); + }, + onDetails: (details) => { + const detailsJson = JSON.parse(details); + const chatHistoryId = detailsJson?.chatHistoryId; + if (!chatHistoryId) { + return; + } + + onChatHistoryIdChange?.(chatHistoryId); }, }); @@ -303,10 +312,11 @@ export function useRoadmapAIChat(options: Options) { handleAbort, clearChat, scrollToBottom, + setAiChatHistory, }; } -function htmlFromTiptapJSON(json: JSONContent): string { +export function htmlFromTiptapJSON(json: JSONContent): string { const content = json.content; let text = ''; for (const child of content || []) { diff --git a/src/queries/chat-history.ts b/src/queries/chat-history.ts index e65834339..927f44ce4 100644 --- a/src/queries/chat-history.ts +++ b/src/queries/chat-history.ts @@ -7,12 +7,17 @@ import { type MessagePartRenderer, renderMessage, } from '../lib/render-chat-message'; -import type { RoadmapAIChatHistoryType } from '../hooks/use-roadmap-ai-chat'; +import { + htmlFromTiptapJSON, + type RoadmapAIChatHistoryType, +} from '../hooks/use-roadmap-ai-chat'; +import type { JSONContent } from '@tiptap/core'; export type ChatHistoryMessage = { _id: string; role: 'user' | 'assistant'; content: string; + json?: JSONContent; }; export interface ChatHistoryDocument { @@ -47,9 +52,14 @@ export function chatHistoryOptions( messages.push({ role: message.role, content: message.content, - ...(message.role === 'user' && { - html: markdownToHtml(message.content), - }), + ...(message.role === 'user' && + !message?.json && { + html: markdownToHtml(message.content), + }), + ...(message.role === 'user' && + message?.json && { + html: htmlFromTiptapJSON(message.json), + }), ...(message.role === 'assistant' && { jsx: await renderMessage(message.content, renderer ?? {}, { isLoading: false,