From dfd54b35b09a93b3bade818a3954e5cd73ce7fe7 Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Tue, 1 Apr 2025 17:09:14 +0600 Subject: [PATCH] feat: ai course chat (#8426) * feat: ai course chat * wip: remove old code * wip * feat: responsiveness of ai chat * fix: key warning * feat: make chat resizeable * wip * wip: default questions * wip * fix: fixed position * fix: hide button * Fix scroll issue * Improve questions UI * Refactor UI * Add close icon * Update UI for course chat * Close AI chat question --------- Co-authored-by: Kamran Ahmed --- package.json | 1 + pnpm-lock.yaml | 14 + .../GenerateCourse/AICourseContent.tsx | 35 +- .../GenerateCourse/AICourseFollowUp.tsx | 74 --- .../GenerateCourse/AICourseFooter.tsx | 19 + .../GenerateCourse/AICourseLesson.tsx | 448 +++++++++++------- ...rseFollowUp.css => AICourseLessonChat.css} | 0 ...owUpPopover.tsx => AICourseLessonChat.tsx} | 282 +++++++---- src/components/GenerateCourse/Resizeable.tsx | 42 ++ 9 files changed, 561 insertions(+), 354 deletions(-) delete mode 100644 src/components/GenerateCourse/AICourseFollowUp.tsx create mode 100644 src/components/GenerateCourse/AICourseFooter.tsx rename src/components/GenerateCourse/{AICourseFollowUp.css => AICourseLessonChat.css} (100%) rename src/components/GenerateCourse/{AICourseFollowUpPopover.tsx => AICourseLessonChat.tsx} (50%) create mode 100644 src/components/GenerateCourse/Resizeable.tsx diff --git a/package.json b/package.json index aeb3d2be3..aaa3f40f0 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "react-calendar-heatmap": "^1.9.0", "react-confetti": "^6.1.0", "react-dom": "^18.3.1", + "react-resizable-panels": "^2.1.7", "react-textarea-autosize": "^8.5.7", "react-tooltip": "^5.28.0", "reactflow": "^11.11.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a8d60a16..8c4b70579 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,6 +113,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-resizable-panels: + specifier: ^2.1.7 + version: 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-textarea-autosize: specifier: ^8.5.7 version: 8.5.7(@types/react@18.3.18)(react@18.3.1) @@ -2988,6 +2991,12 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-resizable-panels@2.1.7: + resolution: {integrity: sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-textarea-autosize@8.5.7: resolution: {integrity: sha512-2MqJ3p0Jh69yt9ktFIaZmORHXw4c4bxSIhCeWiFwmJ9EYKgLmuNII3e9c9b2UO+ijl4StnpZdqpxNIhTdHvqtQ==} engines: {node: '>=10'} @@ -6503,6 +6512,11 @@ snapshots: react-refresh@0.14.2: {} + react-resizable-panels@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-textarea-autosize@8.5.7(@types/react@18.3.18)(react@18.3.1): dependencies: '@babel/runtime': 7.26.9 diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index 556307ba9..8912f6242 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -5,9 +5,10 @@ import { CircleOff, Menu, X, - Map, + Map, MessageCircleOffIcon, + MessageCircleIcon } from 'lucide-react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { type AiCourse } from '../../lib/ai'; import { cn } from '../../lib/classname'; import { useIsPaidUser } from '../../queries/billing'; @@ -19,6 +20,7 @@ import { AICourseSidebarModuleList } from './AICourseSidebarModuleList'; import { AILimitsPopup } from './AILimitsPopup'; import { AICourseOutlineView } from './AICourseOutlineView'; import { AICourseRoadmapView } from './AICourseRoadmapView'; +import { AICourseFooter } from './AICourseFooter'; type AICourseContentProps = { courseSlug?: string; @@ -35,6 +37,7 @@ export function AICourseContent(props: AICourseContentProps) { const [showUpgradeModal, setShowUpgradeModal] = useState(false); const [showAILimitsPopup, setShowAILimitsPopup] = useState(false); + const [isAIChatsOpen, setIsAIChatsOpen] = useState(true); const [activeModuleIndex, setActiveModuleIndex] = useState(0); const [activeLessonIndex, setActiveLessonIndex] = useState(0); @@ -139,6 +142,12 @@ export function AICourseContent(props: AICourseContentProps) { ); + useEffect(() => { + if (window && window?.innerWidth < 1024 && isAIChatsOpen) { + setIsAIChatsOpen(false); + } + }, []); + if (error && !isLoading) { const isLimitReached = error.includes('limit'); const isNotFound = error.includes('not exist'); @@ -234,6 +243,19 @@ export function AICourseContent(props: AICourseContentProps) { /> + {viewMode === 'module' && ( + + )} + - - {showUpgradeModal && ( - setShowUpgradeModal(false)} /> - )} - - {isOpen && ( - { - setIsOpen(false); - setShowUpgradeModal(true); - }} - onOutsideClick={() => { - if (!isOpen) { - return; - } - - setIsOpen(false); - }} - /> - )} - - {isOpen && ( -
- )} -
- ); -} diff --git a/src/components/GenerateCourse/AICourseFooter.tsx b/src/components/GenerateCourse/AICourseFooter.tsx new file mode 100644 index 000000000..26a5b9d47 --- /dev/null +++ b/src/components/GenerateCourse/AICourseFooter.tsx @@ -0,0 +1,19 @@ +import { cn } from '../../lib/classname'; + +type AICourseFooterProps = { + className?: string; +}; +export function AICourseFooter(props: AICourseFooterProps) { + const { className } = props; + + return ( +
+ AI can make mistakes, check important info. +
+ ); +} diff --git a/src/components/GenerateCourse/AICourseLesson.tsx b/src/components/GenerateCourse/AICourseLesson.tsx index 2b0312c6f..12cab027a 100644 --- a/src/components/GenerateCourse/AICourseLesson.tsx +++ b/src/components/GenerateCourse/AICourseLesson.tsx @@ -5,6 +5,8 @@ import { ChevronRight, Loader2Icon, LockIcon, + MessageCircleIcon, + MessageCircleOffIcon, XIcon, } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; @@ -24,10 +26,31 @@ import { } from '../../queries/ai-course'; import { useIsPaidUser } from '../../queries/billing'; import { queryClient } from '../../stores/query-client'; -import { AICourseFollowUp } from './AICourseFollowUp'; -import './AICourseFollowUp.css'; +import './AICourseLessonChat.css'; import { RegenerateLesson } from './RegenerateLesson'; import { TestMyKnowledgeAction } from './TestMyKnowledgeAction'; +import { + AICourseLessonChat, + type AIChatHistoryType, +} from './AICourseLessonChat'; +import { AICourseFooter } from './AICourseFooter'; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from './Resizeable'; + +function getQuestionsFromResult(result: string) { + const matchedQuestions = result.match( + /=START_QUESTIONS=(.*?)=END_QUESTIONS=/, + ); + + if (matchedQuestions) { + return matchedQuestions[1].split('@@'); + } + + return []; +} type AICourseLessonProps = { courseSlug: string; @@ -44,6 +67,9 @@ type AICourseLessonProps = { onGoToNextLesson: () => void; onUpgrade: () => void; + + isAIChatsOpen: boolean; + setIsAIChatsOpen: (isOpen: boolean) => void; }; export function AICourseLesson(props: AICourseLessonProps) { @@ -62,17 +88,32 @@ export function AICourseLesson(props: AICourseLessonProps) { onGoToNextLesson, onUpgrade, + + isAIChatsOpen, + setIsAIChatsOpen, } = props; const [isLoading, setIsLoading] = useState(true); const [isGenerating, setIsGenerating] = useState(false); const [error, setError] = useState(''); + const [defaultQuestions, setDefaultQuestions] = useState([]); const [lessonHtml, setLessonHtml] = useState(''); const lessonId = `${slugify(String(activeModuleIndex))}-${slugify(String(activeLessonIndex))}`; const isLessonDone = progress?.includes(lessonId); + const [courseAIChatHistory, setCourseAIChatHistory] = useState< + AIChatHistoryType[] + >([ + { + role: 'assistant', + content: + 'Hey, I am your AI instructor. How can I help you today? 🤖', + isDefault: true, + }, + ]); + const { isPaidUser } = useIsPaidUser(); const abortController = useMemo( @@ -148,14 +189,29 @@ export function AICourseLesson(props: AICourseLessonProps) { return; } - setLessonHtml(markdownToHtml(result, false)); + const questions = getQuestionsFromResult(result); + setDefaultQuestions(questions); + const newResult = result.replace( + /=START_QUESTIONS=.*?=END_QUESTIONS=/, + '', + ); + + setLessonHtml(markdownToHtml(newResult, false)); }, onStreamEnd: async (result) => { if (abortController.signal.aborted) { return; } - setLessonHtml(await markdownToHtmlWithHighlighting(result)); + const questions = getQuestionsFromResult(result); + setDefaultQuestions(questions); + + const newResult = result.replace( + /=START_QUESTIONS=.*?=END_QUESTIONS=/, + '', + ); + + setLessonHtml(await markdownToHtmlWithHighlighting(newResult)); queryClient.invalidateQueries(getAiCourseLimitOptions()); setIsGenerating(false); }, @@ -209,190 +265,232 @@ export function AICourseLesson(props: AICourseLessonProps) { isLoading; return ( -
-
- {(isGenerating || isLoading) && ( -
- -
- )} - -
-
- Lesson {activeLessonIndex + 1} of {totalLessons} -
+
+ + +
+
+ {(isGenerating || isLoading) && ( +
+ +
+ )} - {!isGenerating && !isLoading && ( -
- { - generateAiCourseContent(true, prompt); - }} - /> - + + { + generateAiCourseContent(true, prompt); + }} /> - Please wait ... - - ) : ( - <> - {isLessonDone ? ( - <> - - Mark as Undone - - ) : ( - <> - - Mark as Done - - )} - + +
)} - -
- )} -
- -

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

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

+ {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 +

+
+ )} - {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 && ( + {!isLoading && !isGenerating && !error && ( + + )} + +
+ + +
- )} +
- ) : ( -

{error}

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

- Please login to generate course content -

+ +
+ + {isAIChatsOpen && ( + <> + + setIsAIChatsOpen(false)} + isAIChatsOpen={isAIChatsOpen} + setIsAIChatsOpen={setIsAIChatsOpen} + /> + )} - - {!isLoading && !isGenerating && !error && ( - - )} - -
- - -
- -
-
-
- - {!isGenerating && !isLoading && ( - - )} +
); } diff --git a/src/components/GenerateCourse/AICourseFollowUp.css b/src/components/GenerateCourse/AICourseLessonChat.css similarity index 100% rename from src/components/GenerateCourse/AICourseFollowUp.css rename to src/components/GenerateCourse/AICourseLessonChat.css diff --git a/src/components/GenerateCourse/AICourseFollowUpPopover.tsx b/src/components/GenerateCourse/AICourseLessonChat.tsx similarity index 50% rename from src/components/GenerateCourse/AICourseFollowUpPopover.tsx rename to src/components/GenerateCourse/AICourseLessonChat.tsx index 35b49051f..fc7cb3984 100644 --- a/src/components/GenerateCourse/AICourseFollowUpPopover.tsx +++ b/src/components/GenerateCourse/AICourseLessonChat.tsx @@ -6,11 +6,21 @@ import { HelpCircle, LockIcon, Send, + User2, + X, + XIcon, } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; +import { + Fragment, + 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'; @@ -22,6 +32,8 @@ import { import { getAiCourseLimitOptions } from '../../queries/ai-course'; import { queryClient } from '../../stores/query-client'; import { billingDetailsOptions } from '../../queries/billing'; +import { ResizablePanel } from './Resizeable'; +import { Spinner } from '../ReactIcons/Spinner'; export type AllowedAIChatRole = 'user' | 'assistant'; export type AIChatHistoryType = { @@ -31,40 +43,53 @@ export type AIChatHistoryType = { html?: string; }; -type AICourseFollowUpPopoverProps = { +type AICourseLessonChatProps = { courseSlug: string; moduleTitle: string; lessonTitle: string; + onUpgradeClick: () => void; + isDisabled?: boolean; + isGeneratingLesson?: boolean; + + defaultQuestions?: string[]; courseAIChatHistory: AIChatHistoryType[]; - setCourseAIChatHistory: (value: AIChatHistoryType[]) => void; + setCourseAIChatHistory: (history: AIChatHistoryType[]) => void; - onOutsideClick?: () => void; - onUpgradeClick: () => void; + onClose: () => void; + + isAIChatsOpen: boolean; + setIsAIChatsOpen: (isOpen: boolean) => void; }; -export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) { +export function AICourseLessonChat(props: AICourseLessonChatProps) { const { courseSlug, moduleTitle, lessonTitle, - onOutsideClick, onUpgradeClick, + isDisabled, + defaultQuestions = [], courseAIChatHistory, setCourseAIChatHistory, + + onClose, + + isAIChatsOpen, + setIsAIChatsOpen, + + isGeneratingLesson, } = props; const toast = useToast(); - const containerRef = useRef(null); const scrollareaRef = useRef(null); + const textareaRef = useRef(null); const [isStreamingMessage, setIsStreamingMessage] = useState(false); const [message, setMessage] = useState(''); const [streamedMessage, setStreamedMessage] = useState(''); - useOutsideClick(containerRef, onOutsideClick); - const { data: tokenUsage, isLoading } = useQuery( getAiCourseLimitOptions(), queryClient, @@ -107,12 +132,12 @@ export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) { completeCourseAIChat(newMessages); }; - const scrollToBottom = () => { + const scrollToBottom = useCallback(() => { scrollareaRef.current?.scrollTo({ top: scrollareaRef.current.scrollHeight, behavior: 'smooth', }); - }; + }, [scrollareaRef]); const completeCourseAIChat = async (messages: AIChatHistoryType[]) => { setIsStreamingMessage(true); @@ -191,103 +216,156 @@ export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) { }, []); return ( -
-
-

Course AI

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

Course AI

+ +
- {streamedMessage && ( - +
+
+
+ {isGeneratingLesson && ( +
+
+ +

+ Generating lesson... +

+
+
)} +
+ {courseAIChatHistory.map((chat, index) => { + return ( + + + + {chat.isDefault && defaultQuestions?.length > 1 && ( +
+

+ Some questions you might have about this lesson. +

+
+ {defaultQuestions.map((question, 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); - } - }} - /> - - -
+ {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); + } + }} + ref={textareaRef} + /> + + +
+ ); } @@ -324,7 +402,11 @@ function AIChatCard(props: AIChatCardProps) { : 'bg-yellow-400 text-black', )} > - + {role === 'user' ? ( + + ) : ( + + )}
) => ( + +); + +const ResizablePanel = ResizablePrimitive.Panel; + +const ResizableHandle = ({ + withHandle, + className, + ...props +}: React.ComponentProps & { + withHandle?: boolean; +}) => ( + div]:rotate-90', + className, + )} + {...props} + > + {withHandle && ( +
+ +
+ )} +
+); + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle };