diff --git a/src/components/CourseAI/CourseAI.tsx b/src/components/CourseAI/CourseAI.tsx index d57bbf5c7..d919204ef 100644 --- a/src/components/CourseAI/CourseAI.tsx +++ b/src/components/CourseAI/CourseAI.tsx @@ -3,6 +3,13 @@ import type { ChapterFileType } from '../../lib/course'; import { useState } from 'react'; import { CourseAIPopover } from './CourseAIPopover'; +export type AllowedAIChatRole = 'user' | 'assistant'; +export type AIChatHistoryType = { + role: AllowedAIChatRole; + content: string; + isDefault?: boolean; +}; + type CourseAIProps = { courseId: string; currentChapterId: string; @@ -13,6 +20,15 @@ type CourseAIProps = { export function CourseAI(props: CourseAIProps) { const [isOpen, setIsOpen] = useState(false); + const [courseAIChatHistory, setCourseAIChatHistory] = useState< + AIChatHistoryType[] + >([ + { + role: 'assistant', + content: 'Hey, how can I help you today? 🤖', + isDefault: true, + }, + ]); return (
@@ -25,7 +41,12 @@ export function CourseAI(props: CourseAIProps) { {isOpen && ( - setIsOpen(false)} /> + setIsOpen(false)} + courseAIChatHistory={courseAIChatHistory} + setCourseAIChatHistory={setCourseAIChatHistory} + /> )}
); diff --git a/src/components/CourseAI/CourseAIPopover.tsx b/src/components/CourseAI/CourseAIPopover.tsx index cc9928c29..90c4f7fde 100644 --- a/src/components/CourseAI/CourseAIPopover.tsx +++ b/src/components/CourseAI/CourseAIPopover.tsx @@ -1,16 +1,17 @@ import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; -import type { ChapterFileType } from '../../lib/course'; +import { + readCourseAIContentStream, + type ChapterFileType, +} from '../../lib/course'; import { Bot, Send } from 'lucide-react'; import { useOutsideClick } from '../../hooks/use-outside-click'; import { cn } from '../../lib/classname'; import { markdownToHtml } from '../../lib/markdown'; import { sanitizeHtml } from '../../lib/sanitize-html'; -import { - roadmapAIChatHistory, - type AllowedAIChatType, -} from '../../stores/course'; -import { useStore } from '@nanostores/react'; import { flushSync } from 'react-dom'; +import type { AIChatHistoryType, AllowedAIChatRole } from './CourseAI'; +import { useToast } from '../../hooks/use-toast'; +import { removeAuthToken } from '../../lib/jwt'; type CourseAIPopoverProps = { courseId: string; @@ -19,6 +20,9 @@ type CourseAIPopoverProps = { chapters: ChapterFileType[]; + courseAIChatHistory: AIChatHistoryType[]; + setCourseAIChatHistory: (value: AIChatHistoryType[]) => void; + onOutsideClick?: () => void; }; @@ -29,34 +33,44 @@ export function CourseAIPopover(props: CourseAIPopoverProps) { currentChapterId, currentLessonId, onOutsideClick, + + courseAIChatHistory, + setCourseAIChatHistory, } = props; + const toast = useToast(); const containerRef = useRef(null); const scrollareaRef = useRef(null); - const [message, setMessage] = useState(''); - const $roadmapAIChatHistory = useStore(roadmapAIChatHistory); + const [isLoading, setIsLoading] = useState(false); + const [message, setMessage] = useState(''); + const [streamedMessage, setStreamedMessage] = useState(''); useOutsideClick(containerRef, onOutsideClick); const handleChatSubmit = (e: FormEvent) => { e.preventDefault(); - if (!message) { + + const trimmedMessage = message.trim(); + if (!trimmedMessage || isLoading) { return; } + const newMessages: AIChatHistoryType[] = [ + ...courseAIChatHistory, + { + role: 'user', + content: trimmedMessage, + }, + ]; + flushSync(() => { - roadmapAIChatHistory.set([ - ...$roadmapAIChatHistory, - { - type: 'user', - message, - }, - ]); + setCourseAIChatHistory(newMessages); setMessage(''); }); scrollToBottom(); + completeCourseAIChat(newMessages); }; const scrollToBottom = () => { @@ -66,6 +80,78 @@ export function CourseAIPopover(props: CourseAIPopoverProps) { }); }; + const completeCourseAIChat = async (messages: AIChatHistoryType[]) => { + setIsLoading(true); + + const response = await fetch( + `${import.meta.env.PUBLIC_API_URL}/v1-course-ai/${courseId}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + chapterId: currentChapterId, + lessonId: currentLessonId, + + messages, + }), + }, + ); + + if (!response.ok) { + const data = await response.json(); + + toast.error(data?.message || 'Something went wrong'); + setCourseAIChatHistory([...messages].slice(0, messages.length - 1)); + setIsLoading(false); + + // Logout user if token is invalid + if (data.status === 401) { + removeAuthToken(); + window.location.reload(); + } + } + + const reader = response.body?.getReader(); + + if (!reader) { + setIsLoading(false); + toast.error('Something went wrong'); + return; + } + + await readCourseAIContentStream(reader, { + onStream: async (content) => { + flushSync(() => { + setStreamedMessage(content); + }); + + scrollToBottom(); + }, + onStreamEnd: async (content) => { + const newMessages: AIChatHistoryType[] = [ + ...messages, + { + role: 'assistant', + content, + }, + ]; + + flushSync(() => { + setStreamedMessage(''); + setIsLoading(false); + setCourseAIChatHistory(newMessages); + }); + + scrollToBottom(); + }, + }); + + setIsLoading(false); + }; + useEffect(() => { scrollToBottom(); }, []); @@ -86,15 +172,23 @@ export function CourseAIPopover(props: CourseAIPopoverProps) {
- {$roadmapAIChatHistory.map((chat, index) => { + {courseAIChatHistory.map((chat, index) => { return ( ); })} + + {isLoading && !streamedMessage && ( + + )} + + {streamedMessage && ( + + )}
@@ -109,9 +203,11 @@ export function CourseAIPopover(props: CourseAIPopoverProps) { placeholder="Ask AI anything about the course..." value={message} onChange={(e) => setMessage(e.target.value)} + autoFocus={true} />