diff --git a/.astro/settings.json b/.astro/settings.json index 50ff25a6a..8744206c6 100644 --- a/.astro/settings.json +++ b/.astro/settings.json @@ -3,6 +3,6 @@ "enabled": false }, "_variables": { - "lastUpdateCheck": 1739229597159 + "lastUpdateCheck": 1740595115510 } } \ No newline at end of file diff --git a/src/components/GenerateCourse/AICourse.tsx b/src/components/GenerateCourse/AICourse.tsx new file mode 100644 index 000000000..0f9ee01d6 --- /dev/null +++ b/src/components/GenerateCourse/AICourse.tsx @@ -0,0 +1,797 @@ +import { + ChevronDown, + ChevronRight, + Loader2, + Search, + Wand, + BookOpen, + ArrowLeft, + Menu, + ChevronLeft, + ChevronUp, + X, + BookOpenCheck, + Layers, + List, +} from 'lucide-react'; +import { useState, useEffect } from 'react'; +import { readAICourseStream } from '../../helper/read-stream'; + +// Define types for our course structure +type Lesson = string; + +type Module = { + title: string; + lessons: Lesson[]; +}; + +type Course = { + title: string; + modules: Module[]; + difficulty: string; +}; + +type Difficulty = 'beginner' | 'intermediate' | 'advanced'; + +type AICourseProps = { + courseId?: string; +}; + +export function AICourse(props: AICourseProps) { + const { courseId: courseIdFromProps } = props; + + const [courseId, setCourseId] = useState(courseIdFromProps); + const [keyword, setKeyword] = useState(''); + const [difficulty, setDifficulty] = useState('intermediate'); + const [isLoading, setIsLoading] = useState(false); + const [courseContent, setCourseContent] = useState(''); + const [streamedCourse, setStreamedCourse] = useState<{ + title: string; + modules: Module[]; + }>({ + title: '', + modules: [], + }); + const [expandedModules, setExpandedModules] = useState< + Record + >({}); + const [isStreamingMode, setIsStreamingMode] = useState(false); + const [activeModuleIndex, setActiveModuleIndex] = useState(0); + const [activeLessonIndex, setActiveLessonIndex] = useState(0); + const [sidebarOpen, setSidebarOpen] = useState(true); + const [viewMode, setViewMode] = useState<'module' | 'full'>('module'); + + const toggleModule = (index: number) => { + setExpandedModules((prev) => ({ + ...prev, + [index]: !prev[index], + })); + }; + + useEffect(() => { + if (courseIdFromProps) { + fetchCourse(); + } + }, [courseIdFromProps]); + + const fetchCourse = async () => { + if (!courseId) { + return; + } + + setIsLoading(true); + setStreamedCourse({ title: '', modules: [] }); + setExpandedModules({}); + setIsStreamingMode(true); + + try { + const response = await fetch( + `${import.meta.env.PUBLIC_API_URL || ''}/v1-get-ai-course/${courseId}`, + { + method: 'GET', + credentials: 'include', + }, + ); + + if (!response.ok) { + const data = await response.json(); + console.error( + 'Error fetching course:', + data?.message || 'Something went wrong', + ); + setIsLoading(false); + return; + } + + const reader = response.body?.getReader(); + + if (!reader) { + console.error('Failed to get reader from response'); + setIsLoading(false); + return; + } + + // Define regex patterns to extract course ID + const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@'); + + await readAICourseStream(reader, { + onStream: (result) => { + // Check if the result contains a course ID + if (result.includes('@COURSEID')) { + const courseIdMatch = result.match(COURSE_ID_REGEX); + const extractedCourseId = courseIdMatch?.[1] || ''; + + if (extractedCourseId) { + setCourseId(extractedCourseId); + + // Remove the course ID token from the result + result = result.replace(COURSE_ID_REGEX, ''); + } + } + + // Store the raw content and log it + setCourseContent(result); + + // Parse the streamed content to update the sidebar in real-time + try { + const lines = result.split('\n'); + let title = ''; + const modules: Module[] = []; + let currentModule: Module | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + if (i === 0 && line.startsWith('#')) { + // First line is the title + title = line.replace('#', '').trim(); + } else if (line.startsWith('## ')) { + // New module + if (currentModule) { + modules.push(currentModule); + } + currentModule = { + title: line.replace('## ', ''), + lessons: [], + }; + // Auto-expand the newest module + setExpandedModules((prev) => ({ + ...prev, + [modules.length]: true, + })); + } else if (line.startsWith('- ') && currentModule) { + // Lesson within current module + currentModule.lessons.push(line.replace('- ', '')); + } + } + + // Add the last module if it exists + if (currentModule) { + modules.push(currentModule); + } + + setStreamedCourse({ + title, + modules, + }); + } catch (e) { + console.error('Error parsing streamed course content:', e); + } + }, + onStreamEnd: (result) => { + // Clean up any tokens from the final result + result = result.replace(COURSE_ID_REGEX, ''); + setCourseContent(result); + + try { + const lines = result.split('\n'); + const title = lines[0].replace('#', '').trim(); + const modules: Module[] = []; + + let currentModule: Module | null = null; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + + if (line.startsWith('## ')) { + // New module + if (currentModule) { + modules.push(currentModule); + } + currentModule = { + title: line.replace('## ', ''), + lessons: [], + }; + } else if (line.startsWith('- ') && currentModule) { + // Lesson within current module + currentModule.lessons.push(line.replace('- ', '')); + } + } + + // Add the last module if it exists + if (currentModule) { + modules.push(currentModule); + } + + setStreamedCourse({ + title, + modules, + }); + } catch (e) { + console.error('Error parsing course content:', e); + } + + setIsLoading(false); + }, + }); + } catch (error) { + console.error('Error in course fetching:', error); + setIsLoading(false); + } + }; + + const generateCourse = async () => { + setIsLoading(true); + setStreamedCourse({ title: '', modules: [] }); + setExpandedModules({}); + setIsStreamingMode(true); + + try { + const response = await fetch( + `${import.meta.env.PUBLIC_API_URL || ''}/v1-generate-ai-course`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + keyword, + difficulty, + }), + }, + ); + + if (!response.ok) { + const data = await response.json(); + console.error( + 'Error generating course:', + data?.message || 'Something went wrong', + ); + setIsLoading(false); + setIsStreamingMode(false); + return; + } + + const reader = response.body?.getReader(); + + if (!reader) { + console.error('Failed to get reader from response'); + setIsLoading(false); + setIsStreamingMode(false); + return; + } + + // Define regex patterns to extract course ID + const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@'); + + await readAICourseStream(reader, { + onStream: (result) => { + // Check if the result contains a course ID + if (result.includes('@COURSEID')) { + const courseIdMatch = result.match(COURSE_ID_REGEX); + const extractedCourseId = courseIdMatch?.[1] || ''; + + if (extractedCourseId) { + setCourseId(extractedCourseId); + + // Remove the course ID token from the result + result = result.replace(COURSE_ID_REGEX, ''); + } + } + + // Store the raw content and log it + setCourseContent(result); + + // Parse the streamed content to update the sidebar in real-time + try { + const lines = result.split('\n'); + let title = ''; + const modules: Module[] = []; + let currentModule: Module | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + if (i === 0 && line.startsWith('#')) { + // First line is the title + title = line.replace('#', '').trim(); + } else if (line.startsWith('## ')) { + // New module + if (currentModule) { + modules.push(currentModule); + } + currentModule = { + title: line.replace('## ', ''), + lessons: [], + }; + // Auto-expand the newest module + setExpandedModules((prev) => ({ + ...prev, + [modules.length]: true, + })); + } else if (line.startsWith('- ') && currentModule) { + // Lesson within current module + currentModule.lessons.push(line.replace('- ', '')); + } + } + + // Add the last module if it exists + if (currentModule) { + modules.push(currentModule); + } + + setStreamedCourse({ + title, + modules, + }); + } catch (e) { + console.error('Error parsing streamed course content:', e); + } + }, + onStreamEnd: (result) => { + // Clean up any tokens from the final result + result = result.replace(COURSE_ID_REGEX, ''); + setCourseContent(result); + + try { + const lines = result.split('\n'); + const title = lines[0].replace('#', '').trim(); + const modules: Module[] = []; + + let currentModule: Module | null = null; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + + if (line.startsWith('## ')) { + // New module + if (currentModule) { + modules.push(currentModule); + } + currentModule = { + title: line.replace('## ', ''), + lessons: [], + }; + } else if (line.startsWith('- ') && currentModule) { + // Lesson within current module + currentModule.lessons.push(line.replace('- ', '')); + } + } + + // Add the last module if it exists + if (currentModule) { + modules.push(currentModule); + } + + setStreamedCourse({ + title, + modules, + }); + } catch (e) { + console.error('Error parsing course content:', e); + } + + setIsLoading(false); + }, + }); + } catch (error) { + console.error('Error in course generation:', error); + setIsLoading(false); + setIsStreamingMode(false); + } + }; + + function onSubmit() { + generateCourse(); + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && keyword.trim() && !isLoading) { + onSubmit(); + } + }; + + // Reset streaming mode + const handleReset = () => { + setIsStreamingMode(false); + setCourseContent(''); + setStreamedCourse({ title: '', modules: [] }); + }; + + // Navigation helpers + const goToNextModule = () => { + if (activeModuleIndex < streamedCourse.modules.length - 1) { + setActiveModuleIndex(activeModuleIndex + 1); + setActiveLessonIndex(0); + } + }; + + const goToPrevModule = () => { + if (activeModuleIndex > 0) { + setActiveModuleIndex(activeModuleIndex - 1); + setActiveLessonIndex(0); + } + }; + + const goToNextLesson = () => { + const currentModule = streamedCourse.modules[activeModuleIndex]; + if (currentModule && activeLessonIndex < currentModule.lessons.length - 1) { + setActiveLessonIndex(activeLessonIndex + 1); + } else { + goToNextModule(); + } + }; + + const goToPrevLesson = () => { + if (activeLessonIndex > 0) { + setActiveLessonIndex(activeLessonIndex - 1); + } else { + const prevModule = streamedCourse.modules[activeModuleIndex - 1]; + if (prevModule) { + setActiveModuleIndex(activeModuleIndex - 1); + setActiveLessonIndex(prevModule.lessons.length - 1); + } + } + }; + + // Render the course UI when in streaming mode + if (isStreamingMode) { + const currentModule = streamedCourse.modules[activeModuleIndex]; + const currentLesson = currentModule?.lessons[activeLessonIndex]; + const totalModules = streamedCourse.modules.length; + const totalLessons = currentModule?.lessons.length || 0; + + return ( +
+ {/* Top navigation bar */} +
+
+ +

+ {streamedCourse.title || 'Loading Course...'} +

+
+
+ + +
+
+ + {/* Main content with sidebar */} +
+ {/* Sidebar */} + + + {/* Main content */} +
+ {viewMode === 'module' ? ( +
+ {/* Module and lesson navigation */} +
+
+
+ Module {activeModuleIndex + 1} of {totalModules} +
+

+ {currentModule?.title?.replace( + /^Module\s*?\d+[\.:]\s*/, + '', + ) || 'Loading...'} +

+
+
+ + {/* Current lesson */} +
+
+
+ Lesson {activeLessonIndex + 1} of {totalLessons} +
+
+ +

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

+ +
+

+ This lesson is part of the "{currentModule?.title}" + module. +

+
+ + {/* Navigation buttons */} +
+ + + +
+
+
+ ) : ( + /* Full course content view */ +
+

Full Course Content

+ {streamedCourse.title ? ( +
+
'), + }} + /> +
+ ) : ( +
+ +
+ )} +
+ )} +
+
+ + {/* Overlay for mobile sidebar */} + {sidebarOpen && ( +
setSidebarOpen(false)} + >
+ )} +
+ ); + } + + // Render the original UI when not in streaming mode + return ( +
+
+

AI Course Generator

+

+ Create personalized learning paths with AI +

+ +
+

+ Enter a keyword or topic, and our AI will create a personalized + learning course for you. +

+ +
{ + e.preventDefault(); + onSubmit(); + }} + > +
+ +
+
+ +
+ setKeyword(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="e.g., Machine Learning, JavaScript, Photography" + className="w-full rounded-md border border-gray-300 bg-white p-3 pl-10 text-gray-900 focus:outline-none focus:ring-1 focus:ring-gray-500" + maxLength={50} + /> + + {keyword.length}/50 + +
+
+ +
+ +
+ {(['beginner', 'intermediate', 'advanced'] as Difficulty[]).map( + (level) => ( + + ), + )} +
+
+ + +
+
+
+
+ ); +} diff --git a/src/helper/read-stream.ts b/src/helper/read-stream.ts index 2df821443..b2a347765 100644 --- a/src/helper/read-stream.ts +++ b/src/helper/read-stream.ts @@ -71,3 +71,43 @@ export async function readAIRoadmapContentStream( onStreamEnd?.(result); reader.releaseLock(); } + +export async function readAICourseStream( + reader: ReadableStreamDefaultReader, + { + onStream, + onStreamEnd, + }: { + onStream?: (course: string) => void; + onStreamEnd?: (course: string) => void; + }, +) { + const decoder = new TextDecoder('utf-8'); + let result = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + + // Process the stream data as it comes in + if (value) { + let start = 0; + for (let i = 0; i < value.length; i++) { + if (value[i] === NEW_LINE) { + result += decoder.decode(value.slice(start, i + 1)); + onStream?.(result); + start = i + 1; + } + } + if (start < value.length) { + result += decoder.decode(value.slice(start)); + } + } + } + + onStream?.(result); + onStreamEnd?.(result); + reader.releaseLock(); +} diff --git a/src/pages/ai-tutor/[courseId].astro b/src/pages/ai-tutor/[courseId].astro new file mode 100644 index 000000000..8994c773d --- /dev/null +++ b/src/pages/ai-tutor/[courseId].astro @@ -0,0 +1,16 @@ +--- +import { AICourse } from '../../components/GenerateCourse/AICourse'; +import BaseLayout from '../../layouts/BaseLayout.astro'; + +export const prerender = false; + +interface Params extends Record { + courseId: string; +} + +const { courseId } = Astro.params as Params; +--- + + + + diff --git a/src/pages/ai-tutor/index.astro b/src/pages/ai-tutor/index.astro new file mode 100644 index 000000000..9db85eab5 --- /dev/null +++ b/src/pages/ai-tutor/index.astro @@ -0,0 +1,8 @@ +--- +import { AICourse } from '../../components/GenerateCourse/AICourse'; +import BaseLayout from '../../layouts/BaseLayout.astro'; +--- + + + +