From b897b97406e026f6f72cb98cd659a4ffff92182b Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Fri, 28 Mar 2025 00:34:58 +0600 Subject: [PATCH] feat: ai course chat --- .../GenerateCourse/AICourseContent.tsx | 10 +- .../GenerateCourse/AICourseLesson.tsx | 323 +++++++------- .../GenerateCourse/AICourseLessonChat.tsx | 407 ++++++++++++++++++ 3 files changed, 583 insertions(+), 157 deletions(-) create mode 100644 src/components/GenerateCourse/AICourseLessonChat.tsx diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index 556307ba9..a5254ef2f 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -392,6 +392,9 @@ export function AICourseContent(props: AICourseContentProps) { className={cn( 'flex-1 overflow-y-scroll p-6 transition-all duration-200 ease-in-out max-lg:p-3', sidebarOpen ? 'lg:ml-0' : '', + viewMode === 'module' + ? 'flex flex-col overflow-hidden p-0 max-lg:p-0' + : '', )} key={`${courseSlug}-${viewMode}`} > @@ -442,7 +445,12 @@ export function AICourseContent(props: AICourseContentProps) { /> )} -
+
AI can make mistakes, check important info.
diff --git a/src/components/GenerateCourse/AICourseLesson.tsx b/src/components/GenerateCourse/AICourseLesson.tsx index 2b0312c6f..6310c04b3 100644 --- a/src/components/GenerateCourse/AICourseLesson.tsx +++ b/src/components/GenerateCourse/AICourseLesson.tsx @@ -28,6 +28,7 @@ import { AICourseFollowUp } from './AICourseFollowUp'; import './AICourseFollowUp.css'; import { RegenerateLesson } from './RegenerateLesson'; import { TestMyKnowledgeAction } from './TestMyKnowledgeAction'; +import { AICourseLessonChat } from './AICourseLessonChat'; type AICourseLessonProps = { courseSlug: string; @@ -209,39 +210,167 @@ export function AICourseLesson(props: AICourseLessonProps) { isLoading; return ( -
-
- {(isGenerating || isLoading) && ( -
- -
- )} +
+
+
+ {(isGenerating || isLoading) && ( +
+ +
+ )} + +
+
+ Lesson {activeLessonIndex + 1} of {totalLessons} +
-
-
- Lesson {activeLessonIndex + 1} of {totalLessons} + {!isGenerating && !isLoading && ( +
+ { + generateAiCourseContent(true, prompt); + }} + /> + +
+ )}
- {!isGenerating && !isLoading && ( -
- { - generateAiCourseContent(true, prompt); - }} - /> +

+ {currentLessonTitle?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')} +

+ + {!error && isLoggedIn() && ( +
+ )} + + {error && isLoggedIn() && ( +
+ {error.includes('reached the limit') ? ( +
+

+ Limit reached +

+

+ You have reached the AI usage limit for today. + {!isPaidUser && ( + <>Please upgrade your account to continue. + )} + {isPaidUser && ( + <> Please wait until tomorrow to continue. + )} +

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

{error}

+ )} +
+ )} + + {!isLoggedIn() && ( +
+ +

+ Please login to generate course content +

+
+ )} + + {!isLoading && !isGenerating && !error && ( + + )} + +
+ + +
- )} -
- -

- {currentLessonTitle?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')} -

- - {!error && isLoggedIn() && ( -
- )} - - {error && isLoggedIn() && ( -
- {error.includes('reached the limit') ? ( -
-

- Limit reached -

-

- You have reached the AI usage limit for today. - {!isPaidUser && <>Please upgrade your account to continue.} - {isPaidUser && <> Please wait until tomorrow to continue.} -

- - {!isPaidUser && ( - - )} -
- ) : ( -

{error}

- )}
- )} - - {!isLoggedIn() && ( -
- -

- Please login to generate course content -

-
- )} - - {!isLoading && !isGenerating && !error && ( - - )} - -
- -
- +
+ AI can make mistakes, check important info.
- {!isGenerating && !isLoading && ( - - )} +
); } diff --git a/src/components/GenerateCourse/AICourseLessonChat.tsx b/src/components/GenerateCourse/AICourseLessonChat.tsx new file mode 100644 index 000000000..3b5be326a --- /dev/null +++ b/src/components/GenerateCourse/AICourseLessonChat.tsx @@ -0,0 +1,407 @@ +import { useQuery } from '@tanstack/react-query'; +import { + BookOpen, + Bot, + Hammer, + HelpCircle, + LockIcon, + Send, +} from 'lucide-react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type FormEvent, +} from 'react'; +import { flushSync } from 'react-dom'; +import TextareaAutosize from 'react-textarea-autosize'; +import { useOutsideClick } from '../../hooks/use-outside-click'; +import { useToast } from '../../hooks/use-toast'; +import { readStream } from '../../lib/ai'; +import { cn } from '../../lib/classname'; +import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; +import { + markdownToHtml, + markdownToHtmlWithHighlighting, +} from '../../lib/markdown'; +import { getAiCourseLimitOptions } from '../../queries/ai-course'; +import { queryClient } from '../../stores/query-client'; +import { billingDetailsOptions } from '../../queries/billing'; + +export type AllowedAIChatRole = 'user' | 'assistant'; +export type AIChatHistoryType = { + role: AllowedAIChatRole; + content: string; + isDefault?: boolean; + html?: string; +}; + +type AICourseLessonChatProps = { + courseSlug: string; + moduleTitle: string; + lessonTitle: string; + onUpgradeClick: () => void; + isDisabled?: boolean; +}; + +export function AICourseLessonChat(props: AICourseLessonChatProps) { + const { courseSlug, moduleTitle, lessonTitle, onUpgradeClick, isDisabled } = + props; + + const toast = useToast(); + const scrollareaRef = useRef(null); + + const [courseAIChatHistory, setCourseAIChatHistory] = useState< + AIChatHistoryType[] + >([ + { + role: 'assistant', + content: + 'Hey, I am your AI instructor. Here are some examples of what you can ask me about 🤖', + isDefault: true, + }, + ]); + + const [isStreamingMessage, setIsStreamingMessage] = useState(false); + const [message, setMessage] = useState(''); + const [streamedMessage, setStreamedMessage] = useState(''); + + const { data: tokenUsage, isLoading } = 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 handleChatSubmit = (e: FormEvent) => { + e.preventDefault(); + + const trimmedMessage = message.trim(); + if ( + !trimmedMessage || + isStreamingMessage || + !isLoggedIn() || + isLimitExceeded || + isLoading + ) { + return; + } + + const newMessages: AIChatHistoryType[] = [ + ...courseAIChatHistory, + { + role: 'user', + content: trimmedMessage, + }, + ]; + + flushSync(() => { + setCourseAIChatHistory(newMessages); + setMessage(''); + }); + + scrollToBottom(); + completeCourseAIChat(newMessages); + }; + + const scrollToBottom = useCallback(() => { + scrollareaRef.current?.scrollTo({ + top: scrollareaRef.current.scrollHeight, + behavior: 'smooth', + }); + }, [scrollareaRef]); + + const completeCourseAIChat = async (messages: AIChatHistoryType[]) => { + 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), + }), + }, + ); + + if (!response.ok) { + const data = await response.json(); + + toast.error(data?.message || 'Something went wrong'); + setCourseAIChatHistory([...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), + }, + ]; + + flushSync(() => { + setStreamedMessage(''); + setIsStreamingMessage(false); + setCourseAIChatHistory(newMessages); + }); + + queryClient.invalidateQueries(getAiCourseLimitOptions()); + scrollToBottom(); + }, + }); + + setIsStreamingMessage(false); + }; + + useEffect(() => { + scrollToBottom(); + }, []); + + return ( +
+
+
+

Course AI

+
+ +
+
+
+
+ {courseAIChatHistory.map((chat, index) => { + return ( + <> + + + {chat.isDefault && ( +
+
+ {capabilities.map((capability, index) => ( + + ))} +
+
+ )} + + ); + })} + + {isStreamingMessage && !streamedMessage && ( + + )} + + {streamedMessage && ( + + )} +
+
+
+
+ +
+ {isLimitExceeded && ( +
+ +

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

+ {!isPaidUser && ( + + )} +
+ )} + setMessage(e.target.value)} + autoFocus={true} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + handleChatSubmit(e as unknown as FormEvent); + } + }} + /> + + +
+
+ ); +} + +type AIChatCardProps = { + role: AllowedAIChatRole; + content: string; + html?: string; +}; + +function AIChatCard(props: AIChatCardProps) { + const { role, content, html: defaultHtml } = props; + + const html = useMemo(() => { + if (defaultHtml) { + return defaultHtml; + } + + return markdownToHtml(content, false); + }, [content, defaultHtml]); + + return ( +
+
+
+ +
+
+
+
+ ); +} + +type CapabilityCardProps = { + icon: React.ReactNode; + title: string; + description: string; + className?: string; +}; + +function CapabilityCard({ + icon, + title, + description, + className, +}: CapabilityCardProps) { + return ( +
+
+ {icon} + + {title} + +
+

{description}

+
+ ); +} + +const capabilities = [ + { + icon: ( + + ), + title: 'Clarify Concepts', + description: "If you don't understand a concept, ask me to clarify it", + }, + { + icon: ( + + ), + title: 'More Details', + description: 'Get deeper insights about topics covered in the lesson', + }, + { + icon: ( + + ), + title: 'Real-world Examples', + description: 'Ask for real-world examples to understand better', + }, + { + icon: , + title: 'Best Practices', + description: 'Learn about best practices and common pitfalls', + }, +] as const;