import './RoadmapAIChat.css'; import { useQuery } from '@tanstack/react-query'; import { roadmapJSONOptions } from '../../queries/roadmap'; import { queryClient } from '../../stores/query-client'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { Frown, Loader2Icon, LockIcon, PauseCircleIcon, SendIcon, } 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 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'; import { billingDetailsOptions } from '../../queries/billing'; export type RoamdapAIChatHistoryType = { 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; }; type RoadmapAIChatProps = { roadmapId: string; }; export function RoadmapAIChat(props: RoadmapAIChatProps) { const { roadmapId } = props; const toast = useToast(); const editorRef = useRef(null); const scrollareaRef = useRef(null); const [isLoading, setIsLoading] = useState(true); const [showUpgradeModal, setShowUpgradeModal] = useState(false); const [aiChatHistory, setAiChatHistory] = useState< RoamdapAIChatHistoryType[] >([]); const [isStreamingMessage, setIsStreamingMessage] = useState(false); const [streamedMessage, setStreamedMessage] = useState(null); const { data: roadmapDetail, error: roadmapDetailError } = useQuery( roadmapJSONOptions(roadmapId), queryClient, ); const { data: roadmapTreeData, isLoading: roadmapTreeLoading } = useQuery( roadmapTreeMappingOptions(roadmapId), queryClient, ); const { data: userResourceProgressData, isLoading: userResourceProgressLoading, } = useQuery(userResourceProgressOptions('roadmap', roadmapId), queryClient); const { data: tokenUsage, isLoading: isTokenUsageLoading } = useQuery( getAiCourseLimitOptions(), queryClient, ); const { data: userBillingDetails, isLoading: isBillingDetailsLoading } = useQuery(billingDetailsOptions(), queryClient); const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0); const isPaidUser = userBillingDetails?.status === 'active'; const roadmapContainerRef = useRef(null); useEffect(() => { if (!roadmapDetail || !roadmapContainerRef.current) { return; } roadmapContainerRef.current.replaceChildren(roadmapDetail.svg); }, [roadmapDetail]); useEffect(() => { if (!roadmapTreeData || !roadmapDetail) { return; } setIsLoading(false); }, [roadmapTreeData, roadmapDetail]); 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: RoamdapAIChatHistoryType[] = [ ...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 renderer: Record = useMemo(() => { return { 'user-progress': () => { return ; }, 'update-progress': (options) => { return ; }, 'roadmap-topics': (options) => { return ; }, 'resource-progress-link': () => { return ; }, 'roadmap-recommendations': (options) => { return ; }, }; }, [roadmapId]); const completeAITutorChat = async ( messages: RoamdapAIChatHistoryType[], 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: RoamdapAIChatHistoryType[] = [ ...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(); }, []); if (roadmapDetailError) { return (

There was an error

{roadmapDetailError.message}

); } const isDataLoading = isLoading || roadmapTreeLoading || userResourceProgressLoading || isTokenUsageLoading || isBillingDetailsLoading; const hasChatHistory = aiChatHistory.length > 0; return (
{isLoading && (
)} {roadmapDetail?.json && !isLoading && (
)}
{showUpgradeModal && ( setShowUpgradeModal(false)} /> )} { showLoginPopup(); }} onUpgrade={() => { setShowUpgradeModal(true); }} />
{isLoading && (
Loading Roadmap
)} {!isLoading && (
{aiChatHistory.map((chat, index) => { return ( ); })} {isStreamingMessage && !streamedMessage && ( )} {streamedMessage && ( )}
)}
{!isLoading && (
{ if ( isStreamingMessage || abortControllerRef.current || !isLoggedIn() || isDataLoading || isEmptyContent(content) ) { return; } handleChatSubmit(content); }} /> {isLimitExceeded && isLoggedIn() && (

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

{!isPaidUser && ( )}
)} {!isLoggedIn() && (

Please login to continue

)}
)}
); } function isEmptyContent(content: JSONContent) { if (!content) { return true; } // because they wrap the content in type doc const firstContent = content.content?.[0]; if (!firstContent) { return true; } return ( firstContent.type === 'paragraph' && (!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; }