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}
/>