feat/ai-courses
Arik Chakma 3 months ago
parent f86e2ca741
commit 4123e6ee90
  1. 141
      src/components/GenerateCourse/AICourse.tsx
  2. 241
      src/components/GenerateCourse/AICourseContent.tsx
  3. 129
      src/components/GenerateCourse/AICourseGenerateForm.tsx
  4. 22
      src/pages/ai-tutor/[courseSlug].astro

@ -1,8 +1,15 @@
import { Loader2, Search, Wand } from 'lucide-react'; import { Loader2, Search, Wand } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { cn } from '../../lib/classname';
import { AICourseGenerateForm } from './AICourseGenerateForm';
import { AICourseContent } from './AICourseContent';
// Define types for difficulty levels export const difficultyLevels = [
type Difficulty = 'beginner' | 'intermediate' | 'advanced'; 'beginner',
'intermediate',
'advanced',
] as const;
export type DifficultyLevel = (typeof difficultyLevels)[number];
type AICourseProps = { type AICourseProps = {
courseId?: string; courseId?: string;
@ -10,118 +17,34 @@ type AICourseProps = {
export function AICourse(props: AICourseProps) { export function AICourse(props: AICourseProps) {
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [difficulty, setDifficulty] = useState<Difficulty>('intermediate'); const [difficulty, setDifficulty] = useState<DifficultyLevel>('intermediate');
const [isLoading, setIsLoading] = useState(false);
const handleKeyDown = (e: React.KeyboardEvent) => { const [isGenerating, setIsGenerating] = useState(false);
if (e.key === 'Enter' && keyword.trim() && !isLoading) { const [isGeneratingError, setIsGeneratingError] = useState(false);
onSubmit();
}
};
function onSubmit() { function onSubmit() {
if (typeof window !== 'undefined') { setIsGenerating(true);
window.location.href = `/ai-tutor/search?term=${encodeURIComponent(keyword)}`; setIsGeneratingError(false);
}
} }
// Render the search UI
return ( return (
<section className="flex flex-grow flex-col bg-gray-100"> <>
<div className="container mx-auto flex max-w-3xl flex-col py-12"> {!isGenerating && (
<h1 className="mb-2 text-3xl font-bold">AI Course Generator</h1> <AICourseGenerateForm
<p className="mb-6 text-gray-600"> keyword={keyword}
Create personalized learning paths with AI setKeyword={setKeyword}
</p> difficulty={difficulty}
setDifficulty={(difficulty) =>
<div className="rounded-md border border-gray-200 bg-white p-6"> setDifficulty(difficulty as DifficultyLevel)
<p className="mb-6 text-gray-600"> }
Enter a keyword or topic, and our AI will create a personalized onSubmit={onSubmit}
learning course for you. isGenerating={isGenerating}
</p> />
)}
<form
className="flex flex-col gap-4" {isGenerating && (
onSubmit={(e) => { <AICourseContent term={keyword} difficulty={difficulty} />
e.preventDefault(); )}
onSubmit(); </>
}}
>
<div className="flex flex-col">
<label
htmlFor="keyword"
className="mb-2 text-sm font-medium text-gray-700"
>
Course Topic
</label>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<Search size={18} />
</div>
<input
id="keyword"
type="text"
value={keyword}
onChange={(e) => 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}
/>
<span className="absolute bottom-3 right-3 text-xs text-gray-400">
{keyword.length}/50
</span>
</div>
</div>
<div className="flex flex-col">
<label className="mb-2 text-sm font-medium text-gray-700">
Difficulty Level
</label>
<div className="flex gap-2">
{(['beginner', 'intermediate', 'advanced'] as Difficulty[]).map(
(level) => (
<button
key={level}
type="button"
onClick={() => setDifficulty(level)}
className={`rounded-md border px-4 py-2 capitalize ${
difficulty === level
? 'border-gray-800 bg-gray-800 text-white'
: 'border-gray-200 bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{level}
</button>
),
)}
</div>
</div>
<button
type="submit"
disabled={isLoading || !keyword.trim()}
className={`flex items-center justify-center ${
isLoading || !keyword.trim()
? 'cursor-not-allowed bg-gray-400'
: 'bg-black hover:bg-gray-800'
} mt-2 rounded-md px-4 py-2 font-medium text-white transition-colors`}
>
{isLoading ? (
<>
<Loader2 size={18} className="mr-2 animate-spin" />
Generating...
</>
) : (
<>
<Wand size={18} className="mr-2" />
Generate Course
</>
)}
</button>
</form>
</div>
</div>
</section>
); );
} }

@ -4,15 +4,13 @@ import {
ChevronDown, ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Layers,
Loader2, Loader2,
Menu, Menu,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { readAICourseStream } from '../../helper/read-stream'; import { readAICourseStream } from '../../helper/read-stream';
import { markdownToHtml } from '../../lib/markdown'; import { cn } from '../../lib/classname';
import { getUrlParams } from '../../lib/browser';
// Define types for our course structure // Define types for our course structure
type Lesson = string; type Lesson = string;
@ -28,30 +26,33 @@ type Course = {
difficulty: string; difficulty: string;
}; };
type AICourseContentProps = {}; type AICourseContentProps =
| {
slug: string;
term?: string;
difficulty?: string;
}
| {
slug?: string;
term: string;
difficulty: string;
};
export function AICourseContent(props: AICourseContentProps) { export function AICourseContent(props: AICourseContentProps) {
const [term, setTerm] = useState(''); const {
const [difficulty, setDifficulty] = useState('beginner'); term: defaultTerm,
difficulty: defaultDifficulty,
slug: defaultSlug,
} = props;
const [term, setTerm] = useState(defaultTerm || '');
const [difficulty, setDifficulty] = useState(defaultDifficulty || 'beginner');
const [courseSlug, setCourseSlug] = useState(defaultSlug || '');
const [courseId, setCourseId] = useState(''); const [courseId, setCourseId] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [courseContent, setCourseContent] = useState(''); const [courseContent, setCourseContent] = useState('');
useEffect(() => {
const urlParams = getUrlParams();
const termFromUrl = urlParams.term as string | '';
const difficultyFromUrl = urlParams.difficulty || 'beginner';
if (!termFromUrl) {
window.location.href = '/ai-tutor';
return;
}
setTerm(termFromUrl);
setDifficulty(difficultyFromUrl);
}, []);
const [streamedCourse, setStreamedCourse] = useState<{ const [streamedCourse, setStreamedCourse] = useState<{
title: string; title: string;
modules: Module[]; modules: Module[];
@ -90,18 +91,30 @@ export function AICourseContent(props: AICourseContentProps) {
}; };
useEffect(() => { useEffect(() => {
if (!term && !courseId) { if (!term || !difficulty) {
return; return;
} }
if (courseId) { generateCourse({ term, difficulty });
// fetchCourse(); }, [term, difficulty]);
} else {
generateCourse(term, difficulty); useEffect(() => {
if (!defaultSlug) {
return;
} }
}, [courseId, term, difficulty]);
const generateCourse = async (term: string, difficulty: string) => { generateCourse({ slug: defaultSlug });
}, [defaultSlug]);
const generateCourse = async ({
term,
difficulty,
slug: slugToBeUsed,
}: {
term?: string;
difficulty?: string;
slug?: string;
}) => {
setIsLoading(true); setIsLoading(true);
setStreamedCourse({ title: '', modules: [] }); setStreamedCourse({ title: '', modules: [] });
setExpandedModules({}); setExpandedModules({});
@ -116,8 +129,12 @@ export function AICourseContent(props: AICourseContentProps) {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
keyword: term, ...(slugToBeUsed
difficulty, ? { slug: slugToBeUsed }
: {
keyword: term,
difficulty,
}),
}), }),
credentials: 'include', credentials: 'include',
}, },
@ -141,23 +158,40 @@ export function AICourseContent(props: AICourseContentProps) {
return; return;
} }
// Define regex patterns to extract course ID
const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@'); const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@');
const COURSE_SLUG_REGEX = new RegExp(/@COURSESLUG:([\w-]+)@/);
await readAICourseStream(reader, { await readAICourseStream(reader, {
onStream: (result) => { onStream: (result) => {
// Check if the result contains a course ID // Check if the result contains a course ID
if (result.includes('@COURSEID')) { if (result.includes('@COURSEID') || result.includes('@COURSESLUG')) {
console.log('result', result);
const courseIdMatch = result.match(COURSE_ID_REGEX); const courseIdMatch = result.match(COURSE_ID_REGEX);
const courseSlugMatch = result.match(COURSE_SLUG_REGEX);
const extractedCourseId = courseIdMatch?.[1] || ''; const extractedCourseId = courseIdMatch?.[1] || '';
const extractedCourseSlug = courseSlugMatch?.[1] || '';
console.log('extractedCourseId', extractedCourseId);
console.log('extractedCourseSlug', extractedCourseSlug);
if (extractedCourseSlug && !defaultSlug) {
window.history.pushState(
{
courseId,
courseSlug: extractedCourseSlug,
},
'',
`${origin}/ai-tutor/${extractedCourseSlug}`,
);
}
if (extractedCourseId) { result = result
console.log('extractedCourseId', extractedCourseId); .replace(COURSE_ID_REGEX, '')
// setCourseId(extractedCourseId); .replace(COURSE_SLUG_REGEX, '');
// Remove the course ID token from the result setCourseId(extractedCourseId);
result = result.replace(COURSE_ID_REGEX, ''); setCourseSlug(extractedCourseSlug);
}
} }
// Store the raw content and log it // Store the raw content and log it
@ -206,48 +240,10 @@ export function AICourseContent(props: AICourseContentProps) {
} }
}, },
onStreamEnd: (result) => { onStreamEnd: (result) => {
// Clean up any tokens from the final result result = result
result = result.replace(COURSE_ID_REGEX, ''); .replace(COURSE_ID_REGEX, '')
.replace(COURSE_SLUG_REGEX, '');
setCourseContent(result); 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); setIsLoading(false);
}, },
}); });
@ -278,26 +274,6 @@ export function AICourseContent(props: AICourseContentProps) {
} }
}; };
const goToPrevModule = () => {
if (activeModuleIndex > 0) {
const prevModuleIndex = activeModuleIndex - 1;
setActiveModuleIndex(prevModuleIndex);
setActiveLessonIndex(0);
// Expand the previous module in the sidebar
setExpandedModules((prev) => {
const newState: Record<number, boolean> = {};
// Set all modules to collapsed
streamedCourse.modules.forEach((_, idx) => {
newState[idx] = false;
});
// Expand only the previous module
newState[prevModuleIndex] = true;
return newState;
});
}
};
const goToNextLesson = () => { const goToNextLesson = () => {
const currentModule = streamedCourse.modules[activeModuleIndex]; const currentModule = streamedCourse.modules[activeModuleIndex];
if (currentModule && activeLessonIndex < currentModule.lessons.length - 1) { if (currentModule && activeLessonIndex < currentModule.lessons.length - 1) {
@ -332,6 +308,29 @@ export function AICourseContent(props: AICourseContentProps) {
} }
}; };
useEffect(() => {
const handlePopState = (e: PopStateEvent) => {
const { courseId, courseSlug } = e.state || {};
if (!courseId || !courseSlug) {
window.location.reload();
return;
}
setCourseId(courseId);
setCourseSlug(courseSlug);
setIsLoading(true);
generateCourse({ slug: courseSlug }).finally(() => {
setIsLoading(false);
});
};
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, []);
const currentModule = streamedCourse.modules[activeModuleIndex]; const currentModule = streamedCourse.modules[activeModuleIndex];
const currentLesson = currentModule?.lessons[activeLessonIndex]; const currentLesson = currentModule?.lessons[activeLessonIndex];
const totalModules = streamedCourse.modules.length; const totalModules = streamedCourse.modules.length;
@ -384,9 +383,10 @@ export function AICourseContent(props: AICourseContentProps) {
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Sidebar */} {/* Sidebar */}
<aside <aside
className={`${ className={cn(
sidebarOpen ? 'translate-x-0' : '-translate-x-full' 'fixed inset-y-0 left-0 z-20 mt-16 w-80 transform overflow-y-auto border-r border-gray-200 bg-white pt-4 transition-transform duration-200 ease-in-out md:relative md:mt-0 md:translate-x-0',
} fixed inset-y-0 left-0 z-20 mt-16 w-80 transform overflow-y-auto border-r border-gray-200 bg-white pt-4 transition-transform duration-200 ease-in-out md:relative md:mt-0 md:translate-x-0`} sidebarOpen ? 'translate-x-0' : '-translate-x-full',
)}
> >
{/* Course title */} {/* Course title */}
<div className="mb-4 px-4"> <div className="mb-4 px-4">
@ -423,11 +423,12 @@ export function AICourseContent(props: AICourseContentProps) {
<div key={moduleIdx} className="rounded-md"> <div key={moduleIdx} className="rounded-md">
<button <button
onClick={() => toggleModule(moduleIdx)} onClick={() => toggleModule(moduleIdx)}
className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium ${ className={cn(
'flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium',
activeModuleIndex === moduleIdx activeModuleIndex === moduleIdx
? 'bg-gray-100 text-gray-900' ? 'bg-gray-100 text-gray-900'
: 'text-gray-700 hover:bg-gray-50' : 'text-gray-700 hover:bg-gray-50',
}`} )}
> >
<div className="flex min-w-0 items-start pr-2"> <div className="flex min-w-0 items-start pr-2">
<span className="mr-2 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-gray-200 text-xs font-semibold"> <span className="mr-2 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-gray-200 text-xs font-semibold">
@ -468,12 +469,13 @@ export function AICourseContent(props: AICourseContentProps) {
setSidebarOpen(true); setSidebarOpen(true);
setViewMode('module'); setViewMode('module');
}} }}
className={`flex w-full items-start rounded-md px-3 py-2 text-left text-sm ${ className={cn(
'flex w-full items-start rounded-md px-3 py-2 text-left text-sm',
activeModuleIndex === moduleIdx && activeModuleIndex === moduleIdx &&
activeLessonIndex === lessonIdx activeLessonIndex === lessonIdx
? 'bg-gray-800 text-white' ? 'bg-gray-800 text-white'
: 'text-gray-600 hover:bg-gray-50' : 'text-gray-600 hover:bg-gray-50',
}`} )}
> >
<span className="relative top-[2px] mr-2 flex-shrink-0 text-xs"> <span className="relative top-[2px] mr-2 flex-shrink-0 text-xs">
{lessonIdx + 1}. {lessonIdx + 1}.
@ -492,9 +494,10 @@ export function AICourseContent(props: AICourseContentProps) {
{/* Main content */} {/* Main content */}
<main <main
className={`flex-1 overflow-y-auto p-6 transition-all duration-200 ease-in-out ${ className={cn(
sidebarOpen ? 'md:ml-0' : '' 'flex-1 overflow-y-auto p-6 transition-all duration-200 ease-in-out',
}`} sidebarOpen ? 'md:ml-0' : '',
)}
> >
{viewMode === 'module' ? ( {viewMode === 'module' ? (
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
@ -538,11 +541,12 @@ export function AICourseContent(props: AICourseContentProps) {
disabled={ disabled={
activeModuleIndex === 0 && activeLessonIndex === 0 activeModuleIndex === 0 && activeLessonIndex === 0
} }
className={`flex items-center rounded-md px-4 py-2 ${ className={cn(
'flex items-center rounded-md px-4 py-2',
activeModuleIndex === 0 && activeLessonIndex === 0 activeModuleIndex === 0 && activeLessonIndex === 0
? 'cursor-not-allowed text-gray-400' ? 'cursor-not-allowed text-gray-400'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' : 'bg-gray-100 text-gray-700 hover:bg-gray-200',
}`} )}
> >
<ChevronLeft size={16} className="mr-2" /> <ChevronLeft size={16} className="mr-2" />
Previous Lesson Previous Lesson
@ -554,12 +558,13 @@ export function AICourseContent(props: AICourseContentProps) {
activeModuleIndex === totalModules - 1 && activeModuleIndex === totalModules - 1 &&
activeLessonIndex === totalLessons - 1 activeLessonIndex === totalLessons - 1
} }
className={`flex items-center rounded-md px-4 py-2 ${ className={cn(
'flex items-center rounded-md px-4 py-2',
activeModuleIndex === totalModules - 1 && activeModuleIndex === totalModules - 1 &&
activeLessonIndex === totalLessons - 1 activeLessonIndex === totalLessons - 1
? 'cursor-not-allowed text-gray-400' ? 'cursor-not-allowed text-gray-400'
: 'bg-gray-800 text-white hover:bg-gray-700' : 'bg-gray-800 text-white hover:bg-gray-700',
}`} )}
> >
Next Lesson Next Lesson
<ChevronRight size={16} className="ml-2" /> <ChevronRight size={16} className="ml-2" />

@ -0,0 +1,129 @@
import { Loader2Icon, SearchIcon, WandIcon } from 'lucide-react';
import { difficultyLevels } from './AICourse';
import { cn } from '../../lib/classname';
type AICourseGenerateFormProps = {
keyword: string;
setKeyword: (keyword: string) => void;
difficulty: string;
setDifficulty: (difficulty: string) => void;
onSubmit: () => void;
isGenerating: boolean;
};
export function AICourseGenerateForm(props: AICourseGenerateFormProps) {
const {
keyword,
setKeyword,
difficulty,
setDifficulty,
onSubmit,
isGenerating,
} = props;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && keyword.trim()) {
onSubmit();
}
};
return (
<section className="flex flex-grow flex-col bg-gray-100">
<div className="container mx-auto flex max-w-3xl flex-col py-12">
<h1 className="mb-2 text-3xl font-bold">AI Course Generator</h1>
<p className="mb-6 text-gray-600">
Create personalized learning paths with AI
</p>
<div className="rounded-md border border-gray-200 bg-white p-6">
<p className="mb-6 text-gray-600">
Enter a keyword or topic, and our AI will create a personalized
learning course for you.
</p>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
<div className="flex flex-col">
<label
htmlFor="keyword"
className="mb-2 text-sm font-medium text-gray-700"
>
Course Topic
</label>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<SearchIcon size={18} />
</div>
<input
id="keyword"
type="text"
value={keyword}
onChange={(e) => 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}
/>
<span className="absolute bottom-3 right-3 text-xs text-gray-400">
{keyword.length}/50
</span>
</div>
</div>
<div className="flex flex-col">
<label className="mb-2 text-sm font-medium text-gray-700">
Difficulty Level
</label>
<div className="flex gap-2">
{difficultyLevels.map((level) => (
<button
key={level}
type="button"
onClick={() => setDifficulty(level)}
className={cn(
'rounded-md border px-4 py-2 capitalize',
difficulty === level
? 'border-gray-800 bg-gray-800 text-white'
: 'border-gray-200 bg-gray-100 text-gray-700 hover:bg-gray-200',
)}
>
{level}
</button>
))}
</div>
</div>
<button
type="submit"
disabled={!keyword.trim()}
className={cn(
'mt-2 flex items-center justify-center rounded-md px-4 py-2 font-medium text-white transition-colors',
!keyword.trim()
? 'cursor-not-allowed bg-gray-400'
: 'bg-black hover:bg-gray-800',
)}
>
{isGenerating ? (
<>
<Loader2Icon size={18} className="mr-2 animate-spin" />
Generating...
</>
) : (
<>
<WandIcon size={18} className="mr-2" />
Generate Course
</>
)}
</button>
</form>
</div>
</div>
</section>
);
}

@ -0,0 +1,22 @@
---
import { AICourseContent } from '../../components/GenerateCourse/AICourseContent';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
export const prerender = false;
interface Params extends Record<string, string | undefined> {
courseSlug: string;
}
const { courseSlug } = Astro.params as Params;
---
<SkeletonLayout
title='AI Tutor'
briefTitle='AI Tutor'
description='AI Tutor'
keywords={['ai', 'tutor', 'education', 'learning']}
canonicalUrl={`/ai-tutor/${courseSlug}`}
>
<AICourseContent client:load slug={courseSlug} />
</SkeletonLayout>
Loading…
Cancel
Save