diff --git a/src/components/TopicDetail/TopicDetail.tsx b/src/components/TopicDetail/TopicDetail.tsx index eac458dd3..d87f15d62 100644 --- a/src/components/TopicDetail/TopicDetail.tsx +++ b/src/components/TopicDetail/TopicDetail.tsx @@ -49,6 +49,7 @@ import { import { TopicDetailAI } from './TopicDetailAI.tsx'; import { cn } from '../../lib/classname.ts'; import type { AIChatHistoryType } from '../GenerateCourse/AICourseLessonChat.tsx'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal.tsx'; type TopicDetailProps = { resourceId?: string; @@ -121,6 +122,8 @@ export function TopicDetail(props: TopicDetailProps) { useState('content'); const [aiChatHistory, setAiChatHistory] = useState(defaultChatHistory); + const [showUpgradeModal, setShowUpgradeModal] = useState(false); + const [isCustomResource, setIsCustomResource] = useState(false); const toast = useToast(); @@ -139,6 +142,7 @@ export function TopicDetail(props: TopicDetailProps) { const handleClose = () => { setIsActive(false); + setShowUpgradeModal(false); setAiChatHistory(defaultChatHistory); setActiveTab('content'); }; @@ -209,6 +213,7 @@ export function TopicDetail(props: TopicDetailProps) { setTopicId(topicId); setResourceType(resourceType); setResourceId(resourceId); + setIsCustomResource(isCustomResource); const topicPartial = topicId.replaceAll(':', '/'); let topicUrl = @@ -369,6 +374,8 @@ export function TopicDetail(props: TopicDetailProps) { (resource) => resource?.url?.toLowerCase().indexOf('scrimba') !== -1, ); + const shouldShowAiTab = !isCustomResource && resourceType === 'roadmap'; + return (
+ {showUpgradeModal && ( + setShowUpgradeModal(false)} /> + )} + {isLoading && (
-
+
{!isEmbed && (
- + {shouldShowAiTab && ( + + )} - {activeTab === 'ai' && ( + {activeTab === 'ai' && shouldShowAiTab && ( setShowUpgradeModal(true)} /> )} diff --git a/src/components/TopicDetail/TopicDetailAI.tsx b/src/components/TopicDetail/TopicDetailAI.tsx index 9aa27de37..a853c7ac9 100644 --- a/src/components/TopicDetail/TopicDetailAI.tsx +++ b/src/components/TopicDetail/TopicDetailAI.tsx @@ -11,7 +11,7 @@ import { billingDetailsOptions } from '../../queries/billing'; import { getAiCourseLimitOptions } from '../../queries/ai-course'; import { queryClient } from '../../stores/query-client'; import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; -import { BotIcon, LockIcon, SendIcon } from 'lucide-react'; +import { BotIcon, Loader2Icon, LockIcon, SendIcon } from 'lucide-react'; import { showLoginPopup } from '../../lib/popup'; import { cn } from '../../lib/classname'; import TextareaAutosize from 'react-textarea-autosize'; @@ -23,15 +23,31 @@ import { import { useToast } from '../../hooks/use-toast'; import { readStream } from '../../lib/ai'; import { markdownToHtmlWithHighlighting } from '../../lib/markdown'; +import type { ResourceType } from '../../lib/resource-progress'; +import { getPercentage } from '../../lib/number'; type TopicDetailAIProps = { + resourceId: string; + resourceType: ResourceType; + topicId: string; + aiChatHistory: AIChatHistoryType[]; setAiChatHistory: (history: AIChatHistoryType[]) => void; + + onUpgrade: () => void; }; export function TopicDetailAI(props: TopicDetailAIProps) { - const { aiChatHistory, setAiChatHistory } = props; + const { + aiChatHistory, + setAiChatHistory, + resourceId, + resourceType, + topicId, + onUpgrade, + } = props; + const textareaRef = useRef(null); const scrollareaRef = useRef(null); const toast = useToast(); @@ -78,7 +94,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) { }); scrollToBottom(); - // completeCourseAIChat(newMessages); + completeAITutorChat(newMessages); }; const scrollToBottom = useCallback(() => { @@ -88,86 +104,103 @@ export function TopicDetailAI(props: TopicDetailAIProps) { }); }, [scrollareaRef]); - const completeCourseAIChat = async (messages: AIChatHistoryType[]) => { - setIsStreamingMessage(true); + const completeAITutorChat = async (messages: AIChatHistoryType[]) => { + try { + setIsStreamingMessage(true); - // const response = await fetch( - // `${import.meta.env.PUBLIC_API_URL}/v1-follow-up-ai-course/${courseSlug}`, - // { - // method: 'POST', - // headers: { - // 'Content-Type': 'application/json', - // }, - // credentials: 'include', - // body: JSON.stringify({ - // moduleTitle, - // lessonTitle, - // messages: messages.slice(-10), - // }), - // }, - // ); + const sanitizedTopicId = topicId?.includes('@') + ? topicId?.split('@')?.[1] + : topicId; - const response = new Response(); - - 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(); - } - } - - const reader = response.body?.getReader(); - - if (!reader) { - setIsStreamingMessage(false); - toast.error('Something went wrong'); - return; - } - - await readStream(reader, { - onStream: async (content) => { - flushSync(() => { - setStreamedMessage(content); - }); - - scrollToBottom(); - }, - onStreamEnd: async (content) => { - const newMessages: AIChatHistoryType[] = [ - ...messages, - { - role: 'assistant', - content, - html: await markdownToHtmlWithHighlighting(content), + const response = await fetch( + `${import.meta.env.PUBLIC_API_URL}/v1-topic-detail-chat`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', }, - ]; + credentials: 'include', + body: JSON.stringify({ + resourceId, + resourceType, + topicId: sanitizedTopicId, + messages: messages.slice(-10), + }), + }, + ); - flushSync(() => { - setStreamedMessage(''); - setIsStreamingMessage(false); - setAiChatHistory(newMessages); - }); + 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()); - scrollToBottom(); - }, - }); + return; + } - setIsStreamingMessage(false); + const reader = response.body?.getReader(); + + if (!reader) { + setIsStreamingMessage(false); + toast.error('Something went wrong'); + return; + } + + await readStream(reader, { + onStream: async (content) => { + flushSync(() => { + setStreamedMessage(content); + }); + + scrollToBottom(); + }, + onStreamEnd: async (content) => { + const newMessages: AIChatHistoryType[] = [ + ...messages, + { + role: 'assistant', + content, + html: await markdownToHtmlWithHighlighting(content), + }, + ]; + + flushSync(() => { + setStreamedMessage(''); + setIsStreamingMessage(false); + setAiChatHistory(newMessages); + }); + + queryClient.invalidateQueries(getAiCourseLimitOptions()); + scrollToBottom(); + }, + }); + + setIsStreamingMessage(false); + } catch (error) { + toast.error('Something went wrong'); + setIsStreamingMessage(false); + } }; useEffect(() => { scrollToBottom(); }, []); + const isDataLoading = isLoading || isBillingDetailsLoading; + const usagePercentage = getPercentage( + tokenUsage?.used || 0, + tokenUsage?.limit || 0, + ); + return ( -
+

AI Tutor

+ + {!isDataLoading && !isPaidUser && ( +

+ {usagePercentage}% used +

+ )}
- - {/* {chat.isDefault && defaultQuestions?.length > 1 && ( -
-

- Some questions you might have about this lesson. -

-
- {defaultQuestions.map((question, index) => ( - - ))} -
-
- )} */} ); })} @@ -247,9 +261,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {

{!isPaidUser && (
)} + + {isDataLoading && ( +
+ +

Loading...

+
+ )} + setMessage(e.target.value)} autoFocus={true} onKeyDown={(e) => { - // if (e.key === 'Enter' && !e.shiftKey) { - // handleChatSubmit(e as unknown as FormEvent); - // } + if (e.key === 'Enter' && !e.shiftKey) { + handleChatSubmit(e as unknown as FormEvent); + } }} - // ref={textareaRef} + ref={textareaRef} />