From 02e7373bcde3f06a1a8d172924fd92d3ff158efd Mon Sep 17 00:00:00 2001 From: Kamran Ahmed Date: Tue, 10 Jun 2025 19:43:06 +0100 Subject: [PATCH] feat: add floating chat on roadmap pages (#8765) * Add floating chat * Refactor roadmap ai chat to hook * Chat inside floating chat * Fix bulk update not working * Add floating chat widget * Add chat header buttons * Show a default set of questions * Populate chat questions at bottom * Handle chat submission * Add personalize popup * Fix body scroll locking issue * Add scroll to bottom functionality * Fix focus issue on persona form * Fix responsiveness of the floating chat * Final implementation * Height fixes * Fix floating ui * Upgrade flow in floating chat * Upgrade responsive UI * Authetnicated checks * Responsive bottom bar --- .astro/settings.json | 2 +- .astro/types.d.ts | 1 - .../Billing/UpgradeAccountModal.tsx | 1 + .../EditorRoadmap/EditorRoadmap.tsx | 4 +- .../FrameRenderer/FrameRenderer.css | 2 +- .../FrameRenderer/RoadmapFloatingChat.tsx | 589 ++++++++++++++++++ src/components/Modal.tsx | 13 +- .../RoadmapAIChat/RoadmapAIChat.tsx | 331 ++-------- .../RoadmapAIChat/RoadmapTopicList.tsx | 2 +- .../RoadmapAIChat/UserProgressActionList.tsx | 6 +- src/components/TopicDetail/TopicDetail.tsx | 13 +- .../UserPersona/UpdatePersonaModal.tsx | 26 +- src/hooks/use-roadmap-ai-chat.tsx | 280 +++++++++ src/lib/dom.ts | 4 +- src/pages/[roadmapId].json.ts | 19 +- src/queries/roadmap-questions.ts | 16 + 16 files changed, 1017 insertions(+), 292 deletions(-) create mode 100644 src/components/FrameRenderer/RoadmapFloatingChat.tsx create mode 100644 src/hooks/use-roadmap-ai-chat.tsx create mode 100644 src/queries/roadmap-questions.ts diff --git a/.astro/settings.json b/.astro/settings.json index 4d5033b8e..501b47e44 100644 --- a/.astro/settings.json +++ b/.astro/settings.json @@ -3,6 +3,6 @@ "enabled": false }, "_variables": { - "lastUpdateCheck": 1748277554631 + "lastUpdateCheck": 1749494681580 } } \ No newline at end of file diff --git a/.astro/types.d.ts b/.astro/types.d.ts index 03d7cc43f..f964fe0cf 100644 --- a/.astro/types.d.ts +++ b/.astro/types.d.ts @@ -1,2 +1 @@ /// -/// \ No newline at end of file diff --git a/src/components/Billing/UpgradeAccountModal.tsx b/src/components/Billing/UpgradeAccountModal.tsx index 4192bf63f..cb8e08843 100644 --- a/src/components/Billing/UpgradeAccountModal.tsx +++ b/src/components/Billing/UpgradeAccountModal.tsx @@ -185,6 +185,7 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) { bodyClassName="p-4 sm:p-6 bg-white" wrapperClassName="h-auto rounded-xl max-w-3xl w-full min-h-[540px] mx-2 sm:mx-4" overlayClassName="items-start md:items-center" + hasCloseButton={true} >
e.stopPropagation()}> {errorContent} diff --git a/src/components/EditorRoadmap/EditorRoadmap.tsx b/src/components/EditorRoadmap/EditorRoadmap.tsx index 1a0df65a9..4f91be514 100644 --- a/src/components/EditorRoadmap/EditorRoadmap.tsx +++ b/src/components/EditorRoadmap/EditorRoadmap.tsx @@ -9,8 +9,8 @@ import { type ResourceType, } from '../../lib/resource-progress'; import { httpGet } from '../../lib/http'; -import { ProgressNudge } from '../FrameRenderer/ProgressNudge'; import { getUrlParams } from '../../lib/browser.ts'; +import { RoadmapFloatingChat } from '../FrameRenderer/RoadmapFloatingChat.tsx'; type EditorRoadmapProps = { resourceId: string; @@ -99,7 +99,7 @@ export function EditorRoadmap(props: EditorRoadmapProps) { dimensions={dimensions} resourceId={resourceId} /> - +
); } diff --git a/src/components/FrameRenderer/FrameRenderer.css b/src/components/FrameRenderer/FrameRenderer.css index a3b9514ce..fa076d228 100644 --- a/src/components/FrameRenderer/FrameRenderer.css +++ b/src/components/FrameRenderer/FrameRenderer.css @@ -4,7 +4,7 @@ svg text tspan { text-rendering: optimizeSpeed; } -code { +code:not(pre code) { background: #1e1e3f; color: #9efeff; padding: 3px 5px; diff --git a/src/components/FrameRenderer/RoadmapFloatingChat.tsx b/src/components/FrameRenderer/RoadmapFloatingChat.tsx new file mode 100644 index 000000000..25a77e64b --- /dev/null +++ b/src/components/FrameRenderer/RoadmapFloatingChat.tsx @@ -0,0 +1,589 @@ +import { useQuery } from '@tanstack/react-query'; +import type { JSONContent } from '@tiptap/core'; +import { + BookOpen, + ChevronDown, + MessageCirclePlus, + PauseCircleIcon, + PersonStanding, + SendIcon, + SquareArrowOutUpRight, + Trash2, + Wand2, + X, +} from 'lucide-react'; +import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; +import { flushSync } from 'react-dom'; +import { useKeydown } from '../../hooks/use-keydown'; +import { + useRoadmapAIChat, + type RoadmapAIChatHistoryType, +} from '../../hooks/use-roadmap-ai-chat'; +import { cn } from '../../lib/classname'; +import { lockBodyScroll } from '../../lib/dom'; +import { slugify } from '../../lib/slugger'; +import { getAiCourseLimitOptions } from '../../queries/ai-course'; +import { billingDetailsOptions } from '../../queries/billing'; +import { roadmapJSONOptions } from '../../queries/roadmap'; +import { roadmapQuestionsOptions } from '../../queries/roadmap-questions'; +import { queryClient } from '../../stores/query-client'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; +import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard'; +import { CLOSE_TOPIC_DETAIL_EVENT } from '../TopicDetail/TopicDetail'; +import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal'; +import { isLoggedIn } from '../../lib/jwt'; +import { showLoginPopup } from '../../lib/popup'; + +type ChatHeaderButtonProps = { + onClick?: () => void; + href?: string; + icon: React.ReactNode; + children?: React.ReactNode; + className?: string; + target?: string; +}; + +function ChatHeaderButton(props: ChatHeaderButtonProps) { + const { onClick, href, icon, children, className, target } = props; + + const classNames = cn( + 'flex items-center gap-1.5 text-xs text-gray-600 transition-colors hover:text-gray-900', + className, + ); + + if (!onClick && !href) { + return ( + + {icon} + {children && {children}} + + ); + } + + if (href) { + return ( + + {icon} + {children && {children}} + + ); + } + + return ( + + ); +} + +type UpgradeMessageProps = { + onUpgradeClick?: () => void; +}; + +function UpgradeMessage(props: UpgradeMessageProps) { + const { onUpgradeClick } = props; + + return ( +
+
+ +
+

+ You've reached your AI usage limit +

+

+ Upgrade to Pro for relaxed limits and advanced features +

+
+ +
+
+ ); +} + +type UsageButtonProps = { + percentageUsed: number; + onUpgradeClick?: () => void; +}; + +function UsageButton(props: UsageButtonProps) { + const { percentageUsed, onUpgradeClick } = props; + + return ( + + ); +} + +type RoadmapChatProps = { + roadmapId: string; +}; + +export function RoadmapFloatingChat(props: RoadmapChatProps) { + const { roadmapId } = props; + const [isOpen, setIsOpen] = useState(false); + const scrollareaRef = useRef(null); + const [inputValue, setInputValue] = useState(''); + const inputRef = useRef(null); + const [isPersonalizeOpen, setIsPersonalizeOpen] = useState(false); + const [showUpgradeModal, setShowUpgradeModal] = useState(false); + + // Fetch questions from API + const { data: questionsData } = useQuery( + roadmapQuestionsOptions(roadmapId), + queryClient, + ); + + // Randomly select 4 questions to display + const defaultQuestions = useMemo(() => { + if (!questionsData?.questions || questionsData.questions.length === 0) { + return []; + } + const shuffled = [...questionsData.questions].sort( + () => 0.5 - Math.random(), + ); + return shuffled.slice(0, 4); + }, [questionsData]); + + const { data: roadmapDetail, isLoading: isRoadmapDetailLoading } = useQuery( + roadmapJSONOptions(roadmapId), + queryClient, + ); + + const isAuthenticatedUser = isLoggedIn(); + + const { data: tokenUsage, isLoading: isTokenUsageLoading } = useQuery( + getAiCourseLimitOptions(), + queryClient, + ); + const isLimitExceeded = + isAuthenticatedUser && (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0); + const percentageUsed = Math.round( + ((tokenUsage?.used || 0) / (tokenUsage?.limit || 0)) * 100, + ); + + const { data: userBillingDetails, isLoading: isBillingDetailsLoading } = + useQuery(billingDetailsOptions(), queryClient); + const isPaidUser = userBillingDetails?.status === 'active'; + + const totalTopicCount = useMemo(() => { + const allowedTypes = ['topic', 'subtopic', 'todo']; + return ( + roadmapDetail?.json?.nodes.filter((node) => + allowedTypes.includes(node.type || ''), + ).length ?? 0 + ); + }, [roadmapDetail]); + + const onSelectTopic = (topicId: string, topicTitle: string) => { + // For now just scroll to bottom and close overlay + const topicSlug = slugify(topicTitle) + '@' + topicId; + window.dispatchEvent( + new CustomEvent('roadmap.node.click', { + detail: { + resourceType: 'roadmap', + resourceId: roadmapId, + topicId: topicSlug, + isCustomResource: false, + }, + }), + ); + // ensure chat visible + flushSync(() => { + setIsOpen(true); + }); + }; + + const { + aiChatHistory, + isStreamingMessage, + streamedMessage, + showScrollToBottom, + setShowScrollToBottom, + handleChatSubmit, + handleAbort, + scrollToBottom, + clearChat, + } = useRoadmapAIChat({ + roadmapId, + totalTopicCount, + scrollareaRef, + onSelectTopic, + }); + + useEffect(() => { + lockBodyScroll(isOpen); + }, [isOpen]); + + useKeydown('Escape', () => { + setIsOpen(false); + }); + + useEffect(() => { + // it means user came back to the AI chat from the topic detail + const handleCloseTopicDetail = () => { + lockBodyScroll(isOpen); + }; + + window.addEventListener(CLOSE_TOPIC_DETAIL_EVENT, handleCloseTopicDetail); + return () => { + window.removeEventListener( + CLOSE_TOPIC_DETAIL_EVENT, + handleCloseTopicDetail, + ); + }; + }, [isOpen, isPersonalizeOpen]); + + function textToJSON(text: string): JSONContent { + return { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text }] }], + }; + } + + const submitInput = () => { + if (!isLoggedIn()) { + setIsOpen(false); + showLoginPopup(); + return; + } + + const trimmed = inputValue.trim(); + if (!trimmed) { + return; + } + + const json: JSONContent = textToJSON(trimmed); + + setInputValue(''); + handleChatSubmit(json, isRoadmapDetailLoading); + }; + + const hasMessages = aiChatHistory.length > 0; + + return ( + <> + {isOpen && ( +
{ + setIsOpen(false); + }} + className="fixed inset-0 z-50 bg-black opacity-50" + >
+ )} + + {showUpgradeModal && ( + { + setShowUpgradeModal(false); + }} + /> + )} + + {isPersonalizeOpen && ( + { + setIsPersonalizeOpen(false); + }} + /> + )} + +
+ {isOpen && ( + <> +
+ {/* Messages area */} +
+
+ } + className="text-sm" + > + AI Tutor + +
+ +
+ } + className="hidden rounded-md py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300 sm:flex" + > + Open in new tab + + + setIsOpen(false)} + icon={} + className="rounded-md bg-red-100 px-1 py-1 text-red-500 hover:bg-red-200" + /> +
+
+
+
+ + Hey, I am your AI tutor. How can I help you today? 👋 + + } + isIntro + /> + + {/* Show default questions only when there's no chat history */} + {aiChatHistory.length === 0 && + defaultQuestions.length > 0 && ( +
+

+ Some questions you might have about this roadmap: +

+
+ {defaultQuestions.map((question, index) => ( + + ))} +
+
+ )} + + {aiChatHistory.map( + (chat: RoadmapAIChatHistoryType, index: number) => ( + + + + ), + )} + + {isStreamingMessage && !streamedMessage && ( + + )} + + {streamedMessage && ( + + )} +
+ + {/* Scroll to bottom button */} + {showScrollToBottom && ( + + )} +
+ + {/* Input area */} + {isLimitExceeded && ( + { + setShowUpgradeModal(true); + setIsOpen(false); + }} + /> + )} + {!isLimitExceeded && ( + <> +
+
+ { + if (!isLoggedIn()) { + setIsOpen(false); + showLoginPopup(); + return; + } + + setIsPersonalizeOpen(true); + }} + icon={} + className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300" + > + Personalize + + {!isPaidUser && isAuthenticatedUser && ( + { + setShowUpgradeModal(true); + setIsOpen(false); + }} + /> + )} +
+ {hasMessages && ( + { + setInputValue(''); + clearChat(); + }} + icon={} + className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300" + > + Clear + + )} +
+
+ setInputValue(e.target.value)} + autoFocus + disabled={isLimitExceeded} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (isStreamingMessage) { + return; + } + submitInput(); + } + }} + placeholder={ + isLimitExceeded + ? 'You have reached the usage limit for today..' + : 'Ask me anything about this roadmap...' + } + className={cn( + 'w-full resize-none px-3 py-4 outline-none', + isLimitExceeded && 'bg-gray-100 text-gray-400', + )} + /> + + +
+ + )} +
+ + )} + + {!isOpen && ( + + )} +
+ + ); +} diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index d8c05d0a0..e22b4cc45 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -2,6 +2,7 @@ import { type ReactNode, useRef } from 'react'; import { useOutsideClick } from '../hooks/use-outside-click'; import { useKeydown } from '../hooks/use-keydown'; import { cn } from '../lib/classname'; +import { X } from 'lucide-react'; type ModalProps = { onClose: () => void; @@ -9,6 +10,7 @@ type ModalProps = { overlayClassName?: string; bodyClassName?: string; wrapperClassName?: string; + hasCloseButton?: boolean; }; export function Modal(props: ModalProps) { @@ -18,6 +20,7 @@ export function Modal(props: ModalProps) { bodyClassName, wrapperClassName, overlayClassName, + hasCloseButton = true, } = props; const popupBodyEl = useRef(null); @@ -33,7 +36,7 @@ export function Modal(props: ModalProps) { return (
@@ -50,6 +53,14 @@ export function Modal(props: ModalProps) { bodyClassName, )} > + {hasCloseButton && ( + + )} {children}
diff --git a/src/components/RoadmapAIChat/RoadmapAIChat.tsx b/src/components/RoadmapAIChat/RoadmapAIChat.tsx index 6a6b2d80a..0f026ed20 100644 --- a/src/components/RoadmapAIChat/RoadmapAIChat.tsx +++ b/src/components/RoadmapAIChat/RoadmapAIChat.tsx @@ -22,25 +22,14 @@ import { } from 'lucide-react'; import { ChatEditor } from '../ChatEditor/ChatEditor'; import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree'; -import { type AllowedAIChatRole } from '../GenerateCourse/AICourseLessonChat'; -import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; +import { isLoggedIn } from '../../lib/jwt'; import type { JSONContent, Editor } from '@tiptap/core'; import { flushSync } from 'react-dom'; import { getAiCourseLimitOptions } from '../../queries/ai-course'; -import { readStream } from '../../lib/ai'; import { useToast } from '../../hooks/use-toast'; import { userResourceProgressOptions } from '../../queries/resource-progress'; import { ChatRoadmapRenderer } from './ChatRoadmapRenderer'; -import { - renderMessage, - type MessagePartRenderer, -} from '../../lib/render-chat-message'; import { RoadmapAIChatCard } from './RoadmapAIChatCard'; -import { UserProgressList } from './UserProgressList'; -import { UserProgressActionList } from './UserProgressActionList'; -import { RoadmapTopicList } from './RoadmapTopicList'; -import { ShareResourceLink } from './ShareResourceLink'; -import { RoadmapRecommendations } from './RoadmapRecommendations'; import { RoadmapAIChatHeader } from './RoadmapAIChatHeader'; import { showLoginPopup } from '../../lib/popup'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; @@ -58,21 +47,10 @@ import { userRoadmapPersonaOptions } from '../../queries/user-persona'; import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal'; import { lockBodyScroll } from '../../lib/dom'; import { TutorIntroMessage } from './TutorIntroMessage'; - -export type RoadmapAIChatHistoryType = { - role: AllowedAIChatRole; - isDefault?: boolean; - - // these two will be used only into the backend - // for transforming the raw message into the final message - content?: string; - json?: JSONContent; - - // these two will be used only into the frontend - // for rendering the message - html?: string; - jsx?: React.ReactNode; -}; +import { + useRoadmapAIChat, + type RoadmapAIChatHistoryType, +} from '../../hooks/use-roadmap-ai-chat'; export type RoadmapAIChatTab = 'chat' | 'topic'; @@ -102,12 +80,6 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { ); const [activeTab, setActiveTab] = useState('chat'); - const [aiChatHistory, setAiChatHistory] = useState< - RoadmapAIChatHistoryType[] - >([]); - const [isStreamingMessage, setIsStreamingMessage] = useState(false); - const [streamedMessage, setStreamedMessage] = - useState(null); const [showUpdatePersonaModal, setShowUpdatePersonaModal] = useState(false); const { data: roadmapDetail, error: roadmapDetailError } = useQuery( @@ -146,6 +118,15 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { const roadmapContainerRef = useRef(null); + const totalTopicCount = useMemo(() => { + const allowedTypes = ['topic', 'subtopic', 'todo']; + return ( + roadmapDetail?.json?.nodes.filter((node) => + allowedTypes.includes(node.type || ''), + ).length ?? 0 + ); + }, [roadmapDetail]); + useEffect(() => { if (!roadmapDetail || !roadmapContainerRef.current) { return; @@ -162,47 +143,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { setIsLoading(false); }, [roadmapTreeData, roadmapDetail, isUserPersonaLoading]); - const abortControllerRef = useRef(null); - const handleChatSubmit = (json: JSONContent) => { - if ( - !json || - isStreamingMessage || - !isLoggedIn() || - isLoading || - abortControllerRef.current - ) { - return; - } - - abortControllerRef.current = new AbortController(); - - const html = htmlFromTiptapJSON(json); - const newMessages: RoadmapAIChatHistoryType[] = [ - ...aiChatHistory, - { - role: 'user', - json, - html, - }, - ]; - - flushSync(() => { - setAiChatHistory(newMessages); - editorRef.current?.commands.setContent('

'); - }); - - scrollToBottom(); - completeAITutorChat(newMessages, abortControllerRef.current); - }; - - const scrollToBottom = useCallback(() => { - scrollareaRef.current?.scrollTo({ - top: scrollareaRef.current.scrollHeight, - behavior: 'smooth', - }); - }, [scrollareaRef]); - - const handleSelectTopic = useCallback( + const onSelectTopic = useCallback( (topicId: string, topicTitle: string) => { flushSync(() => { setSelectedTopicId(topicId); @@ -229,169 +170,21 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { [roadmapId, deviceType], ); - const totalTopicCount = useMemo(() => { - const allowedTypes = ['topic', 'subtopic', 'todo']; - return ( - roadmapDetail?.json?.nodes.filter((node) => - allowedTypes.includes(node.type || ''), - ).length ?? 0 - ); - }, [roadmapDetail]); - - const renderer: Record = useMemo(() => { - return { - 'user-progress': () => { - return ( - - ); - }, - 'update-progress': (options) => { - return ; - }, - 'roadmap-topics': (options) => { - return ( - { - const title = text.split(' > ').pop(); - if (!title) { - return; - } - - handleSelectTopic(topicId, title); - }} - {...options} - /> - ); - }, - 'resource-progress-link': () => { - return ; - }, - 'roadmap-recommendations': (options) => { - return ; - }, - }; - }, [roadmapId, handleSelectTopic, totalTopicCount]); - - const completeAITutorChat = async ( - messages: RoadmapAIChatHistoryType[], - abortController?: AbortController, - ) => { - try { - setIsStreamingMessage(true); - - const response = await fetch( - `${import.meta.env.PUBLIC_API_URL}/v1-chat-roadmap`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - signal: abortController?.signal, - body: JSON.stringify({ - roadmapId, - messages: messages.slice(-10), - }), - }, - ); - - if (!response.ok) { - const data = await response.json(); - - toast.error(data?.message || 'Something went wrong'); - setAiChatHistory([...messages].slice(0, messages.length - 1)); - setIsStreamingMessage(false); - - if (data.status === 401) { - removeAuthToken(); - window.location.reload(); - } - - queryClient.invalidateQueries(getAiCourseLimitOptions()); - return; - } - - const reader = response.body?.getReader(); - - if (!reader) { - setIsStreamingMessage(false); - toast.error('Something went wrong'); - return; - } - - await readStream(reader, { - onStream: async (content) => { - if (abortController?.signal.aborted) { - return; - } - - const jsx = await renderMessage(content, renderer, { - isLoading: true, - }); - - flushSync(() => { - setStreamedMessage(jsx); - }); - - scrollToBottom(); - }, - onStreamEnd: async (content) => { - if (abortController?.signal.aborted) { - return; - } - - const jsx = await renderMessage(content, renderer, { - isLoading: false, - }); - const newMessages: RoadmapAIChatHistoryType[] = [ - ...messages, - { - role: 'assistant', - content, - jsx, - }, - ]; - - flushSync(() => { - setStreamedMessage(null); - setIsStreamingMessage(false); - setAiChatHistory(newMessages); - }); - - queryClient.invalidateQueries(getAiCourseLimitOptions()); - scrollToBottom(); - }, - }); - - setIsStreamingMessage(false); - abortControllerRef.current = null; - } catch (error) { - setIsStreamingMessage(false); - setStreamedMessage(null); - abortControllerRef.current = null; - - if (abortController?.signal.aborted) { - return; - } - toast.error('Something went wrong'); - } - }; - - const handleAbort = () => { - abortControllerRef.current?.abort(); - abortControllerRef.current = null; - setIsStreamingMessage(false); - setStreamedMessage(null); - setAiChatHistory([...aiChatHistory].slice(0, aiChatHistory.length - 1)); - }; - - useEffect(() => { - scrollToBottom(); - }, []); + const { + aiChatHistory, + isStreamingMessage, + streamedMessage, + abortControllerRef, + handleChatSubmit, + handleAbort, + clearChat, + scrollToBottom, + } = useRoadmapAIChat({ + roadmapId, + totalTopicCount, + scrollareaRef, + onSelectTopic, + }); if (roadmapDetailError) { return ( @@ -442,7 +235,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { roadmapId={roadmapId} nodes={roadmapDetail?.json.nodes} edges={roadmapDetail?.json.edges} - onSelectTopic={handleSelectTopic} + onSelectTopic={onSelectTopic} /> {/* floating chat button */} @@ -498,13 +291,16 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { onTabChange={(tab) => { setActiveTab(tab); if (tab === 'topic' && selectedTopicId && selectedTopicTitle) { - handleSelectTopic(selectedTopicId, selectedTopicTitle); + scrollToBottom(); } }} onCloseTopic={() => { setSelectedTopicId(null); setSelectedTopicTitle(null); - setActiveTab('chat'); + flushSync(() => { + setActiveTab('chat'); + }); + scrollToBottom(); }} onCloseChat={() => { setIsChatMobileVisible(false); @@ -563,13 +359,15 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { isIntro /> - {aiChatHistory.map((chat, index) => { - return ( - - - - ); - })} + {aiChatHistory.map( + (chat: RoadmapAIChatHistoryType, index: number) => { + return ( + + + + ); + }, + )} {isStreamingMessage && !streamedMessage && ( { - setAiChatHistory([]); - }} + onClearChat={clearChat} /> )} @@ -624,7 +420,10 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { return; } - handleChatSubmit(content); + flushSync(() => { + editorRef.current?.commands.setContent('

'); + }); + handleChatSubmit(content, isDataLoading); }} /> @@ -670,7 +469,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { return; } - handleChatSubmit(json); + flushSync(() => { + editorRef.current?.commands.setContent('

'); + }); + + handleChatSubmit(json, isDataLoading); }} > {isStreamingMessage ? ( @@ -705,27 +508,3 @@ function isEmptyContent(content: JSONContent) { (!firstContent?.content || firstContent?.content?.length === 0) ); } - -export function htmlFromTiptapJSON(json: JSONContent) { - const content = json.content; - - let text = ''; - for (const child of content || []) { - switch (child.type) { - case 'text': - text += child.text; - break; - case 'paragraph': - text += `

${htmlFromTiptapJSON(child)}

`; - break; - case 'variable': - const label = child?.attrs?.label || ''; - text += `${label}`; - break; - default: - break; - } - } - - return text; -} diff --git a/src/components/RoadmapAIChat/RoadmapTopicList.tsx b/src/components/RoadmapAIChat/RoadmapTopicList.tsx index dd93851e8..d0efca6b2 100644 --- a/src/components/RoadmapAIChat/RoadmapTopicList.tsx +++ b/src/components/RoadmapAIChat/RoadmapTopicList.tsx @@ -67,7 +67,7 @@ export function RoadmapTopicList(props: RoadmapTopicListProps) { return (
{progressItemWithText.map((item) => { - const labelParts = item.text.split(' > '); + const labelParts = item.text.split(' > ').slice(-2); const labelPartCount = labelParts.length; return ( diff --git a/src/components/RoadmapAIChat/UserProgressActionList.tsx b/src/components/RoadmapAIChat/UserProgressActionList.tsx index 2ed8efd9e..7d5125635 100644 --- a/src/components/RoadmapAIChat/UserProgressActionList.tsx +++ b/src/components/RoadmapAIChat/UserProgressActionList.tsx @@ -92,6 +92,10 @@ export function UserProgressActionList(props: UserProgressActionListProps) { ); }, onSuccess: () => { + updateUserProgress.forEach((item) => { + renderTopicProgress(item.id, item.action); + }); + return queryClient.invalidateQueries( userResourceProgressOptions('roadmap', roadmapId), ); @@ -173,7 +177,7 @@ export function UserProgressActionList(props: UserProgressActionListProps) { + + {isLoadingUserPersona && ( +
+ +

Loading...

+
+ )} +

Tell us more about yourself

@@ -67,9 +83,15 @@ export function UpdatePersonaModal(props: UpdatePersonaModalProps) {

{ const trimmedGoal = data?.goal?.trim(); if (!trimmedGoal) { diff --git a/src/hooks/use-roadmap-ai-chat.tsx b/src/hooks/use-roadmap-ai-chat.tsx new file mode 100644 index 000000000..55ba716a5 --- /dev/null +++ b/src/hooks/use-roadmap-ai-chat.tsx @@ -0,0 +1,280 @@ +import { useCallback, useMemo, useRef, useState, useEffect } from 'react'; +import type { JSONContent } from '@tiptap/core'; +import { flushSync } from 'react-dom'; +import { removeAuthToken } from '../lib/jwt'; +import { readStream } from '../lib/ai'; +import { useToast } from './use-toast'; +import { getAiCourseLimitOptions } from '../queries/ai-course'; +import { queryClient } from '../stores/query-client'; +import { + renderMessage, + type MessagePartRenderer, +} from '../lib/render-chat-message'; +import { UserProgressList } from '../components/RoadmapAIChat/UserProgressList'; +import { UserProgressActionList } from '../components/RoadmapAIChat/UserProgressActionList'; +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'; + +export type RoadmapAIChatHistoryType = { + role: AllowedAIChatRole; + isDefault?: boolean; + content?: string; + json?: JSONContent; + html?: string; + jsx?: React.ReactNode; +}; + +type Options = { + roadmapId: string; + totalTopicCount: number; + scrollareaRef: React.RefObject; + onSelectTopic: (topicId: string, topicTitle: string) => void; +}; + +export function useRoadmapAIChat(options: Options) { + const { roadmapId, totalTopicCount, scrollareaRef, onSelectTopic } = options; + const toast = useToast(); + + const [aiChatHistory, setAiChatHistory] = useState< + RoadmapAIChatHistoryType[] + >([]); + const [isStreamingMessage, setIsStreamingMessage] = useState(false); + const [streamedMessage, setStreamedMessage] = + useState(null); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const abortControllerRef = useRef(null); + + const scrollToBottom = useCallback( + (behavior: 'smooth' | 'instant' = 'smooth') => { + scrollareaRef.current?.scrollTo({ + top: scrollareaRef.current.scrollHeight, + behavior, + }); + }, + [scrollareaRef], + ); + + // Check if user has scrolled away from bottom + const checkScrollPosition = useCallback(() => { + const scrollArea = scrollareaRef.current; + if (!scrollArea) { + return; + } + + const { scrollTop, scrollHeight, clientHeight } = scrollArea; + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50; // 50px threshold + setShowScrollToBottom(!isAtBottom && aiChatHistory.length > 0); + }, [aiChatHistory.length]); + + useEffect(() => { + const scrollArea = scrollareaRef.current; + if (!scrollArea) { + return; + } + + scrollArea.addEventListener('scroll', checkScrollPosition); + return () => scrollArea.removeEventListener('scroll', checkScrollPosition); + }, [checkScrollPosition]); + + // When user is already at the bottom and there is new message + // being streamed, we keep scrolling to bottom to show the new message + // unless user has scrolled up at which point we stop scrolling to bottom + useEffect(() => { + if (isStreamingMessage || streamedMessage) { + const scrollArea = scrollareaRef.current; + if (!scrollArea) { + return; + } + + const { scrollTop, scrollHeight, clientHeight } = scrollArea; + const isNearBottom = scrollTop + clientHeight >= scrollHeight - 100; + + if (isNearBottom) { + scrollToBottom('instant'); + setShowScrollToBottom(false); + } + } + }, [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) => , + }), + [roadmapId, onSelectTopic, totalTopicCount], + ); + + const completeAITutorChat = async ( + messages: RoadmapAIChatHistoryType[], + abortController?: AbortController, + ) => { + try { + const response = await fetch( + `${import.meta.env.PUBLIC_API_URL}/v1-chat-roadmap`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + signal: abortController?.signal, + body: JSON.stringify({ roadmapId, messages: messages.slice(-10) }), + }, + ); + + if (!response.ok) { + const data = await response.json(); + toast.error(data?.message || 'Something went wrong'); + setAiChatHistory(messages.slice(0, -1)); + setIsStreamingMessage(false); + if (data.status === 401) { + removeAuthToken(); + window.location.reload(); + } + queryClient.invalidateQueries(getAiCourseLimitOptions()); + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + setIsStreamingMessage(false); + toast.error('Something went wrong'); + return; + } + + await readStream(reader, { + onStream: async (content) => { + if (abortController?.signal.aborted) return; + const jsx = await renderMessage(content, renderer, { + isLoading: true, + }); + flushSync(() => setStreamedMessage(jsx)); + }, + onStreamEnd: async (content) => { + if (abortController?.signal.aborted) return; + const jsx = await renderMessage(content, renderer, { + isLoading: false, + }); + const newMessages = [ + ...messages, + { role: 'assistant' as AllowedAIChatRole, content, jsx }, + ]; + flushSync(() => { + setStreamedMessage(null); + setIsStreamingMessage(false); + setAiChatHistory(newMessages); + }); + queryClient.invalidateQueries(getAiCourseLimitOptions()); + }, + }); + + setIsStreamingMessage(false); + abortControllerRef.current = null; + } catch (error) { + setIsStreamingMessage(false); + setStreamedMessage(null); + abortControllerRef.current = null; + if (!abortController?.signal.aborted) { + toast.error('Something went wrong'); + } + } + }; + + const handleChatSubmit = useCallback( + (json: JSONContent, isLoading: boolean) => { + if ( + !json || + isStreamingMessage || + isLoading || + abortControllerRef.current + ) { + return; + } + + abortControllerRef.current = new AbortController(); + const html = htmlFromTiptapJSON(json); + const newMessages = [ + ...aiChatHistory, + { role: 'user' as AllowedAIChatRole, json, html }, + ]; + + setIsStreamingMessage(true); + flushSync(() => setAiChatHistory(newMessages)); + scrollToBottom('instant'); + completeAITutorChat(newMessages, abortControllerRef.current); + }, + [aiChatHistory, isStreamingMessage, scrollToBottom], + ); + + const handleAbort = useCallback(() => { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + setIsStreamingMessage(false); + setStreamedMessage(null); + setAiChatHistory(aiChatHistory.slice(0, -1)); + }, [aiChatHistory]); + + const clearChat = useCallback(() => { + setAiChatHistory([]); + setStreamedMessage(null); + setIsStreamingMessage(false); + scrollToBottom('instant'); + setShowScrollToBottom(false); + }, []); + + return { + aiChatHistory, + isStreamingMessage, + streamedMessage, + showScrollToBottom, + setShowScrollToBottom, + abortControllerRef, + handleChatSubmit, + handleAbort, + clearChat, + scrollToBottom, + }; +} + +function htmlFromTiptapJSON(json: JSONContent): string { + const content = json.content; + let text = ''; + for (const child of content || []) { + switch (child.type) { + case 'text': + text += child.text; + break; + case 'paragraph': + text += `

${htmlFromTiptapJSON(child)}

`; + break; + case 'variable': + const label = child?.attrs?.label || ''; + text += `${label}`; + break; + } + } + return text; +} diff --git a/src/lib/dom.ts b/src/lib/dom.ts index 6d9fc1679..8282157d8 100644 --- a/src/lib/dom.ts +++ b/src/lib/dom.ts @@ -9,7 +9,9 @@ export function replaceChildren(parentNode: Element, newChild: Element) { export function lockBodyScroll(shouldLock: boolean) { const isClient = document && 'body' in document; - if (!isClient) return; + if (!isClient) { + return; + } if (shouldLock) { document.body.classList.add('overflow-hidden'); diff --git a/src/pages/[roadmapId].json.ts b/src/pages/[roadmapId].json.ts index 423d37db3..c5720a5c4 100644 --- a/src/pages/[roadmapId].json.ts +++ b/src/pages/[roadmapId].json.ts @@ -13,7 +13,24 @@ const __dirname = path.dirname(__filename); // hack to make it work. TODO: Fix const projectRoot = path.resolve(__dirname, '../..').replace(/dist$/, ''); -export async function fetchRoadmapJson(roadmapId: string) { +type RoadmapJson = { + _id: string; + title: string; + description: string; + slug: string; + nodes: { + type: 'topic' | 'subtopic' | 'paragraph'; + data: { label: string }; + }[]; + edges: unknown[]; + draft: boolean; + createdAt: string; + updatedAt: string; +}; + +export async function fetchRoadmapJson( + roadmapId: string, +): Promise { const response = await fetch( `https://roadmap.sh/api/v1-official-roadmap/${roadmapId}`, ); diff --git a/src/queries/roadmap-questions.ts b/src/queries/roadmap-questions.ts new file mode 100644 index 000000000..e387fa33c --- /dev/null +++ b/src/queries/roadmap-questions.ts @@ -0,0 +1,16 @@ +import { queryOptions } from '@tanstack/react-query'; +import { httpGet } from '../lib/query-http'; + +export interface RoadmapQuestionsResponse { + questions: string[]; +} + +export function roadmapQuestionsOptions(roadmapId: string) { + return queryOptions({ + queryKey: ['roadmap-questions', roadmapId], + queryFn: () => { + return httpGet(`/v1-official-roadmap-questions/${roadmapId}`); + }, + refetchOnMount: false, + }); +} \ No newline at end of file