feat/ai-courses
Arik Chakma 2 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 { useState } from 'react';
import { cn } from '../../lib/classname';
import { AICourseGenerateForm } from './AICourseGenerateForm';
import { AICourseContent } from './AICourseContent';
// Define types for difficulty levels
type Difficulty = 'beginner' | 'intermediate' | 'advanced';
export const difficultyLevels = [
'beginner',
'intermediate',
'advanced',
] as const;
export type DifficultyLevel = (typeof difficultyLevels)[number];
type AICourseProps = {
courseId?: string;
@ -10,118 +17,34 @@ type AICourseProps = {
export function AICourse(props: AICourseProps) {
const [keyword, setKeyword] = useState('');
const [difficulty, setDifficulty] = useState<Difficulty>('intermediate');
const [isLoading, setIsLoading] = useState(false);
const [difficulty, setDifficulty] = useState<DifficultyLevel>('intermediate');
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && keyword.trim() && !isLoading) {
onSubmit();
}
};
const [isGenerating, setIsGenerating] = useState(false);
const [isGeneratingError, setIsGeneratingError] = useState(false);
function onSubmit() {
if (typeof window !== 'undefined') {
window.location.href = `/ai-tutor/search?term=${encodeURIComponent(keyword)}`;
}
setIsGenerating(true);
setIsGeneratingError(false);
}
// Render the search UI
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">
<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>
<>
{!isGenerating && (
<AICourseGenerateForm
keyword={keyword}
setKeyword={setKeyword}
difficulty={difficulty}
setDifficulty={(difficulty) =>
setDifficulty(difficulty as DifficultyLevel)
}
onSubmit={onSubmit}
isGenerating={isGenerating}
/>
)}
{isGenerating && (
<AICourseContent term={keyword} difficulty={difficulty} />
)}
</>
);
}

@ -4,15 +4,13 @@ import {
ChevronDown,
ChevronLeft,
ChevronRight,
Layers,
Loader2,
Menu,
X,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { readAICourseStream } from '../../helper/read-stream';
import { markdownToHtml } from '../../lib/markdown';
import { getUrlParams } from '../../lib/browser';
import { cn } from '../../lib/classname';
// Define types for our course structure
type Lesson = string;
@ -28,30 +26,33 @@ type Course = {
difficulty: string;
};
type AICourseContentProps = {};
type AICourseContentProps =
| {
slug: string;
term?: string;
difficulty?: string;
}
| {
slug?: string;
term: string;
difficulty: string;
};
export function AICourseContent(props: AICourseContentProps) {
const [term, setTerm] = useState('');
const [difficulty, setDifficulty] = useState('beginner');
const {
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 [isLoading, setIsLoading] = useState(false);
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<{
title: string;
modules: Module[];
@ -90,18 +91,30 @@ export function AICourseContent(props: AICourseContentProps) {
};
useEffect(() => {
if (!term && !courseId) {
if (!term || !difficulty) {
return;
}
if (courseId) {
// fetchCourse();
} else {
generateCourse(term, difficulty);
generateCourse({ term, difficulty });
}, [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);
setStreamedCourse({ title: '', modules: [] });
setExpandedModules({});
@ -116,8 +129,12 @@ export function AICourseContent(props: AICourseContentProps) {
'Content-Type': 'application/json',
},
body: JSON.stringify({
keyword: term,
difficulty,
...(slugToBeUsed
? { slug: slugToBeUsed }
: {
keyword: term,
difficulty,
}),
}),
credentials: 'include',
},
@ -141,23 +158,40 @@ export function AICourseContent(props: AICourseContentProps) {
return;
}
// Define regex patterns to extract course ID
const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@');
const COURSE_SLUG_REGEX = new RegExp(/@COURSESLUG:([\w-]+)@/);
await readAICourseStream(reader, {
onStream: (result) => {
// 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 courseSlugMatch = result.match(COURSE_SLUG_REGEX);
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) {
console.log('extractedCourseId', extractedCourseId);
// setCourseId(extractedCourseId);
result = result
.replace(COURSE_ID_REGEX, '')
.replace(COURSE_SLUG_REGEX, '');
// Remove the course ID token from the result
result = result.replace(COURSE_ID_REGEX, '');
}
setCourseId(extractedCourseId);
setCourseSlug(extractedCourseSlug);
}
// Store the raw content and log it
@ -206,48 +240,10 @@ export function AICourseContent(props: AICourseContentProps) {
}
},
onStreamEnd: (result) => {
// Clean up any tokens from the final result
result = result.replace(COURSE_ID_REGEX, '');
result = result
.replace(COURSE_ID_REGEX, '')
.replace(COURSE_SLUG_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);
},
});
@ -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 currentModule = streamedCourse.modules[activeModuleIndex];
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 currentLesson = currentModule?.lessons[activeLessonIndex];
const totalModules = streamedCourse.modules.length;
@ -384,9 +383,10 @@ export function AICourseContent(props: AICourseContentProps) {
<div className="flex flex-1 overflow-hidden">
{/* Sidebar */}
<aside
className={`${
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`}
className={cn(
'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 */}
<div className="mb-4 px-4">
@ -423,11 +423,12 @@ export function AICourseContent(props: AICourseContentProps) {
<div key={moduleIdx} className="rounded-md">
<button
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
? '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">
<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);
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 &&
activeLessonIndex === lessonIdx
activeLessonIndex === lessonIdx
? '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">
{lessonIdx + 1}.
@ -492,9 +494,10 @@ export function AICourseContent(props: AICourseContentProps) {
{/* Main content */}
<main
className={`flex-1 overflow-y-auto p-6 transition-all duration-200 ease-in-out ${
sidebarOpen ? 'md:ml-0' : ''
}`}
className={cn(
'flex-1 overflow-y-auto p-6 transition-all duration-200 ease-in-out',
sidebarOpen ? 'md:ml-0' : '',
)}
>
{viewMode === 'module' ? (
<div className="mx-auto max-w-4xl">
@ -538,11 +541,12 @@ export function AICourseContent(props: AICourseContentProps) {
disabled={
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
? '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" />
Previous Lesson
@ -554,12 +558,13 @@ export function AICourseContent(props: AICourseContentProps) {
activeModuleIndex === totalModules - 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 &&
activeLessonIndex === totalLessons - 1
activeLessonIndex === totalLessons - 1
? '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
<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