diff --git a/src/components/AIGuide/AIGuideCard.tsx b/src/components/AIGuide/AIGuideCard.tsx index c5f96c1d8..273da8e0a 100644 --- a/src/components/AIGuide/AIGuideCard.tsx +++ b/src/components/AIGuide/AIGuideCard.tsx @@ -11,30 +11,15 @@ type AIGuideCardProps = { export function AIGuideCard(props: AIGuideCardProps) { const { guide, showActions = true } = props; - const guideDepthColor = - { - essentials: 'text-green-700', - detailed: 'text-blue-700', - complete: 'text-purple-700', - }[guide.depth] || 'text-gray-700'; - return (
-
- - {guide.depth} - -
-
diff --git a/src/components/AIRoadmap/AIRoadmap.tsx b/src/components/AIRoadmap/AIRoadmap.tsx index 8aead8620..a252a8f29 100644 --- a/src/components/AIRoadmap/AIRoadmap.tsx +++ b/src/components/AIRoadmap/AIRoadmap.tsx @@ -10,6 +10,7 @@ import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; import { aiRoadmapOptions } from '../../queries/ai-roadmap'; import { GenerateAIRoadmap } from './GenerateAIRoadmap'; import { AIRoadmapContent } from './AIRoadmapContent'; +import { AIRoadmapChat } from './AIRoadmapChat'; type AIRoadmapProps = { roadmapSlug?: string; @@ -22,12 +23,11 @@ export function AIRoadmap(props: AIRoadmapProps) { const toast = useToast(); const [showUpgradeModal, setShowUpgradeModal] = useState(false); const [isRegenerating, setIsRegenerating] = useState(false); - const containerRef = useRef(null); // only fetch the guide if the guideSlug is provided // otherwise we are still generating the guide const { data: aiRoadmap, isLoading: isLoadingBySlug } = useQuery( - aiRoadmapOptions(roadmapSlug, containerRef), + aiRoadmapOptions(roadmapSlug), queryClient, ); @@ -62,8 +62,7 @@ export function AIRoadmap(props: AIRoadmapProps) {
{roadmapSlug && ( )} @@ -71,13 +70,11 @@ export function AIRoadmap(props: AIRoadmapProps) { )}
- {/* setShowUpgradeModal(true)} - randomQuestions={randomQuestions} - isQuestionsLoading={isAiGuideSuggestionsLoading} - /> */} + /> ); } diff --git a/src/components/AIRoadmap/AIRoadmapChat.tsx b/src/components/AIRoadmap/AIRoadmapChat.tsx new file mode 100644 index 000000000..4149b05cc --- /dev/null +++ b/src/components/AIRoadmap/AIRoadmapChat.tsx @@ -0,0 +1,338 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { useChat, type ChatMessage } from '../../hooks/use-chat'; +import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard'; +import { + ArrowDownIcon, + BotIcon, + LockIcon, + MessageCircleIcon, + PauseCircleIcon, + SendIcon, + Trash2Icon, + XIcon, +} from 'lucide-react'; +import { ChatHeaderButton } from '../FrameRenderer/RoadmapFloatingChat'; +import { isLoggedIn } from '../../lib/jwt'; +import { showLoginPopup } from '../../lib/popup'; +import { flushSync } from 'react-dom'; +import { markdownToHtml } from '../../lib/markdown'; +import { getAiCourseLimitOptions } from '../../queries/ai-course'; +import { useQuery } from '@tanstack/react-query'; +import { queryClient } from '../../stores/query-client'; +import { billingDetailsOptions } from '../../queries/billing'; +import { LoadingChip } from '../LoadingChip'; +import { getTailwindScreenDimension } from '../../lib/is-mobile'; + +type AIRoadmapChatProps = { + roadmapSlug?: string; + isRoadmapLoading?: boolean; + onUpgrade?: () => void; +}; + +export function AIRoadmapChat(props: AIRoadmapChatProps) { + const { roadmapSlug, isRoadmapLoading, onUpgrade } = props; + + const scrollareaRef = useRef(null); + const inputRef = useRef(null); + + const [inputValue, setInputValue] = useState(''); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [isChatOpen, setIsChatOpen] = useState(true); + const [isMobile, setIsMobile] = useState(false); + + const { + data: tokenUsage, + isLoading: isTokenUsageLoading, + refetch: refetchTokenUsage, + } = useQuery(getAiCourseLimitOptions(), queryClient); + + const { + data: userBillingDetails, + isLoading: isBillingDetailsLoading, + refetch: refetchBillingDetails, + } = useQuery(billingDetailsOptions(), queryClient); + + const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0); + const isPaidUser = userBillingDetails?.status === 'active'; + + const { + messages, + status, + streamedMessageHtml, + sendMessages, + setMessages, + stop, + } = useChat({ + endpoint: `${import.meta.env.PUBLIC_API_URL}/v1-ai-roadmap-chat`, + onError: (error) => { + console.error(error); + }, + data: { + roadmapSlug, + }, + onFinish: () => { + refetchTokenUsage(); + }, + }); + + const scrollToBottom = useCallback( + (behavior: 'smooth' | 'instant' = 'smooth') => { + scrollareaRef.current?.scrollTo({ + top: scrollareaRef.current.scrollHeight, + behavior, + }); + }, + [scrollareaRef], + ); + + const isStreamingMessage = status === 'streaming'; + const hasMessages = messages.length > 0; + + const handleSubmitInput = useCallback( + (defaultInputValue?: string) => { + const message = defaultInputValue || inputValue; + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + if (isStreamingMessage) { + return; + } + + const newMessages: ChatMessage[] = [ + ...messages, + { + role: 'user', + content: message, + html: markdownToHtml(message), + }, + ]; + flushSync(() => { + setMessages(newMessages); + }); + sendMessages(newMessages); + setInputValue(''); + }, + [inputValue, isStreamingMessage, messages, sendMessages, setMessages], + ); + + 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 && messages.length > 0); + }, [messages.length]); + + useEffect(() => { + const scrollArea = scrollareaRef.current; + if (!scrollArea) { + return; + } + + scrollArea.addEventListener('scroll', checkScrollPosition); + return () => scrollArea.removeEventListener('scroll', checkScrollPosition); + }, [checkScrollPosition]); + + const isLoading = + isRoadmapLoading || isTokenUsageLoading || isBillingDetailsLoading; + + useLayoutEffect(() => { + const deviceType = getTailwindScreenDimension(); + const isMediumSize = ['sm', 'md'].includes(deviceType); + + if (!isMediumSize) { + const storedState = localStorage.getItem('chat-history-sidebar-open'); + setIsChatOpen(storedState === null ? true : storedState === 'true'); + } else { + setIsChatOpen(!isMediumSize); + } + + setIsMobile(isMediumSize); + }, []); + + useEffect(() => { + if (!isMobile) { + localStorage.setItem('chat-history-sidebar-open', isChatOpen.toString()); + } + }, [isChatOpen, isMobile]); + + if (!isChatOpen) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ + AI Roadmap +

+ + +
+ + {isLoading && ( +
+ +
+ )} + + {!isLoading && ( + <> +
+
+
+
+ + + {messages.map((chat, index) => { + return ( + + ); + })} + + {status === 'streaming' && !streamedMessageHtml && ( + + )} + + {status === 'streaming' && streamedMessageHtml && ( + + )} +
+
+
+
+ + {(hasMessages || showScrollToBottom) && ( +
+ } + className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300" + onClick={() => { + setMessages([]); + }} + > + Clear + + {showScrollToBottom && ( + } + className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300" + onClick={() => { + scrollToBottom('smooth'); + }} + > + Scroll to bottom + + )} +
+ )} + +
+ {isLimitExceeded && isLoggedIn() && ( +
+ +

+ Limit reached for today + {isPaidUser ? '. Please wait until tomorrow.' : ''} +

+ {!isPaidUser && ( + + )} +
+ )} + + setInputValue(e.target.value)} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (isStreamingMessage) { + return; + } + handleSubmitInput(); + } + }} + placeholder="Ask me anything about this roadmap..." + className="w-full resize-none px-3 py-4 outline-none" + /> + + +
+ + )} +
+ ); +} diff --git a/src/components/AIRoadmap/AIRoadmapContent.tsx b/src/components/AIRoadmap/AIRoadmapContent.tsx index d0e8192b4..41f37b18e 100644 --- a/src/components/AIRoadmap/AIRoadmapContent.tsx +++ b/src/components/AIRoadmap/AIRoadmapContent.tsx @@ -1,16 +1,13 @@ import { cn } from '../../lib/classname'; import { LoadingChip } from '../LoadingChip'; -import { useEffect, type RefObject } from 'react'; -import { replaceChildren } from '../../lib/dom'; type AIRoadmapContentProps = { - svg: SVGElement | null; isLoading?: boolean; - containerRef: RefObject; + svgHtml: string; }; export function AIRoadmapContent(props: AIRoadmapContentProps) { - const { svg, isLoading, containerRef } = props; + const { isLoading, svgHtml } = props; return (
- {isLoading && !svg && ( + {isLoading && (
diff --git a/src/components/AIRoadmap/GenerateAIRoadmap.tsx b/src/components/AIRoadmap/GenerateAIRoadmap.tsx index f0ede7557..fe6eb2957 100644 --- a/src/components/AIRoadmap/GenerateAIRoadmap.tsx +++ b/src/components/AIRoadmap/GenerateAIRoadmap.tsx @@ -6,7 +6,7 @@ import { LoadingChip } from '../LoadingChip'; import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat'; import { getQuestionAnswerChatMessages } from '../../lib/ai-questions'; import { aiRoadmapOptions, generateAIRoadmap } from '../../queries/ai-roadmap'; -import { replaceChildren } from '../../lib/dom'; +import { AIRoadmapContent } from './AIRoadmapContent'; type GenerateAIRoadmapProps = { onRoadmapSlugChange?: (roadmapSlug: string) => void; @@ -19,9 +19,9 @@ export function GenerateAIRoadmap(props: GenerateAIRoadmapProps) { const [isStreaming, setIsStreaming] = useState(false); const [error, setError] = useState(''); + const [svgHtml, setSvgHtml] = useState(''); const [content, setContent] = useState(''); - const svgRef = useRef(null); - const containerRef = useRef(null); + const svgRef = useRef(null); useEffect(() => { const params = getUrlParams(); @@ -74,7 +74,7 @@ export function GenerateAIRoadmap(props: GenerateAIRoadmapProps) { data: content, questionAndAnswers, viewCount: 0, - svg: svgRef.current, + svgHtml: svgRef.current || '', lastVisitedAt: new Date(), createdAt: new Date(), updatedAt: new Date(), @@ -92,10 +92,9 @@ export function GenerateAIRoadmap(props: GenerateAIRoadmapProps) { onError: setError, onStreamingChange: setIsStreaming, onRoadmapSvgChange: (svg) => { - svgRef.current = svg; - if (containerRef.current) { - replaceChildren(containerRef.current, svg); - } + const svgHtml = svg.outerHTML; + svgRef.current = svgHtml; + setSvgHtml(svgHtml); }, }); }; @@ -112,11 +111,5 @@ export function GenerateAIRoadmap(props: GenerateAIRoadmapProps) { ); } - return ( -
- ); + return ; } diff --git a/src/queries/ai-roadmap.ts b/src/queries/ai-roadmap.ts index 4b943465b..7ecac3f6b 100644 --- a/src/queries/ai-roadmap.ts +++ b/src/queries/ai-roadmap.ts @@ -19,13 +19,10 @@ export interface AIRoadmapDocument { } export type AIRoadmapResponse = AIRoadmapDocument & { - svg?: SVGElement | null; + svgHtml?: string; }; -export function aiRoadmapOptions( - roadmapSlug?: string, - containerRef?: RefObject, -) { +export function aiRoadmapOptions(roadmapSlug?: string) { return queryOptions({ queryKey: ['ai-roadmap', roadmapSlug], queryFn: async () => { @@ -36,13 +33,11 @@ export function aiRoadmapOptions( const result = generateAICourseRoadmapStructure(res.data); const { nodes, edges } = generateAIRoadmapFromText(result); const svg = await renderFlowJSON({ nodes, edges }); - if (containerRef?.current) { - replaceChildren(containerRef.current, svg); - } + const svgHtml = svg.outerHTML; return { ...res, - svg, + svgHtml, }; }, enabled: !!roadmapSlug, @@ -52,10 +47,7 @@ export function aiRoadmapOptions( import { queryClient } from '../stores/query-client'; import { getAiCourseLimitOptions } from '../queries/ai-course'; import { readChatStream } from '../lib/chat'; -import { markdownToHtmlWithHighlighting } from '../lib/markdown'; import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat'; -import type { RefObject } from 'react'; -import { replaceChildren } from '../lib/dom'; type RoadmapDetails = { roadmapId: string;