diff --git a/src/components/GenerateCourse/AICourseLesson.tsx b/src/components/GenerateCourse/AICourseLesson.tsx index c213aac01..244b066ff 100644 --- a/src/components/GenerateCourse/AICourseLesson.tsx +++ b/src/components/GenerateCourse/AICourseLesson.tsx @@ -27,6 +27,7 @@ import { queryClient } from '../../stores/query-client'; import { AICourseFollowUp } from './AICourseFollowUp'; import './AICourseFollowUp.css'; import { RegenerateLesson } from './RegenerateLesson'; +import { TestMyKnowledgeAction } from './TestMyKnowledgeAction'; type AICourseLessonProps = { courseSlug: string; @@ -203,7 +204,9 @@ export function AICourseLesson(props: AICourseLessonProps) { isLoading; const cantGoBack = - (activeModuleIndex === 0 && activeLessonIndex === 0) || isGenerating || isLoading; + (activeModuleIndex === 0 && activeLessonIndex === 0) || + isGenerating || + isLoading; return ( <div className="mx-auto max-w-4xl"> @@ -319,6 +322,14 @@ export function AICourseLesson(props: AICourseLessonProps) { </div> )} + {!isLoading && !isGenerating && ( + <TestMyKnowledgeAction + courseSlug={courseSlug} + activeModuleIndex={activeModuleIndex} + activeLessonIndex={activeLessonIndex} + /> + )} + <div className="mt-8 flex items-center justify-between"> <button onClick={onGoToPrevLesson} diff --git a/src/components/GenerateCourse/TestMyKnowledgeAction.tsx b/src/components/GenerateCourse/TestMyKnowledgeAction.tsx new file mode 100644 index 000000000..8695748bb --- /dev/null +++ b/src/components/GenerateCourse/TestMyKnowledgeAction.tsx @@ -0,0 +1,409 @@ +import { + ChevronLeftIcon, + ChevronRightIcon, + CircleCheckIcon, + CircleIcon, + CircleXIcon, + FlaskConicalIcon, + Loader2Icon, + RotateCcwIcon, +} from 'lucide-react'; +import { cn } from '../../lib/classname'; +import { + generateAiCourseLessonQuestions, + readStream, + type Question, +} from '../../lib/ai'; +import { useCallback, useMemo, useState } from 'react'; +import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; + +type TestMyKnowledgeActionProps = { + courseSlug: string; + activeModuleIndex: number; + activeLessonIndex: number; +}; + +export function TestMyKnowledgeAction(props: TestMyKnowledgeActionProps) { + const { courseSlug, activeModuleIndex, activeLessonIndex } = props; + + const [questions, setQuestions] = useState<Question[]>([]); + const [isKnowledgeTestOpen, setIsKnowledgeTestOpen] = useState(false); + + const [isLoading, setIsLoading] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); + const [error, setError] = useState(''); + + const abortController = useMemo( + () => new AbortController(), + [activeModuleIndex, activeLessonIndex], + ); + + const generateAiLessonQuestions = async () => { + setIsLoading(true); + setError(''); + + if (!isLoggedIn()) { + setIsLoading(false); + setError('Please login to generate course content'); + return; + } + + const response = await fetch( + `${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course-lesson-question/${courseSlug}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + signal: abortController.signal, + credentials: 'include', + body: JSON.stringify({ + moduleIndex: activeModuleIndex, + lessonIndex: activeLessonIndex, + }), + }, + ); + + if (!response.ok) { + const data = await response.json(); + + setError(data?.message || 'Something went wrong'); + setIsLoading(false); + + // Logout user if token is invalid + if (data.status === 401) { + removeAuthToken(); + window.location.reload(); + } + return; + } + + if (!response.body) { + setIsLoading(false); + setError('No response body received'); + return; + } + + try { + const reader = response.body.getReader(); + setIsLoading(false); + setIsGenerating(true); + await readStream(reader, { + onStream: async (result) => { + if (abortController.signal.aborted) { + return; + } + + const questions = generateAiCourseLessonQuestions(result); + setQuestions(questions); + }, + onStreamEnd: async (result) => { + if (abortController.signal.aborted) { + return; + } + + const questions = generateAiCourseLessonQuestions(result); + setQuestions(questions); + setIsGenerating(false); + }, + }); + } catch (e) { + setError(e instanceof Error ? e.message : 'Something went wrong'); + setIsLoading(false); + setIsGenerating(false); + } + }; + + return ( + <div className="mt-12 flex flex-col gap-4"> + <div className="flex items-center gap-2"> + <button + className="flex flex-shrink-0 items-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900" + onClick={() => { + if (isGenerating || isLoading || isKnowledgeTestOpen) { + return; + } + + setIsKnowledgeTestOpen(true); + generateAiLessonQuestions(); + }} + > + <FlaskConicalIcon className="size-5 shrink-0" /> + <span>Test My Knowledge</span> + </button> + </div> + + {isKnowledgeTestOpen && ( + <ListQuestions + isLoading={isLoading} + isGenerating={isGenerating} + questions={questions} + /> + )} + </div> + ); +} + +type ListQuestionsProps = { + isLoading: boolean; + isGenerating: boolean; + questions: Question[]; +}; + +export function ListQuestions(props: ListQuestionsProps) { + const { isLoading, isGenerating, questions } = props; + + const [selectedAnswers, setSelectedAnswers] = useState< + Record<string, string[]> + >({}); + const [submitted, setSubmitted] = useState(false); + const [activeQuestionIndex, setActiveQuestionIndex] = useState(0); + + const activeQuestion = questions[activeQuestionIndex]; + const handleOptionSelectChange = useCallback( + (questionId: string, optionId: string) => { + setSelectedAnswers((prev) => { + const newSelectedAnswers = { ...prev }; + const selectedOptionIds = newSelectedAnswers[questionId] ?? []; + newSelectedAnswers[questionId] = selectedOptionIds.includes(optionId) + ? selectedOptionIds.filter((id) => id !== optionId) + : [...selectedOptionIds, optionId]; + return newSelectedAnswers; + }); + }, + [], + ); + + const handleNext = useCallback(() => { + const isLastQuestion = activeQuestionIndex === questions.length - 1; + if (isLastQuestion) { + setSubmitted(true); + setActiveQuestionIndex(0); + return; + } + + setActiveQuestionIndex(activeQuestionIndex + 1); + }, [activeQuestionIndex, questions, submitted]); + + const handlePrevious = useCallback(() => { + setActiveQuestionIndex((prev) => Math.max(prev - 1, 0)); + }, [questions]); + + const handleTryAgain = useCallback(() => { + setSelectedAnswers({}); + setSubmitted(false); + setActiveQuestionIndex(0); + }, []); + + const correctAnswerCount = useMemo(() => { + if (!submitted) { + return 0; + } + + return questions.filter((question) => { + const selectedOptionIds = selectedAnswers[question.id]; + const correctAnswerIds = question.options + .filter((option) => option.isCorrect) + .map((option) => option.id); + + return ( + correctAnswerIds.length === selectedOptionIds?.length && + correctAnswerIds.every((correctAnswerId) => + selectedOptionIds?.includes(correctAnswerId), + ) + ); + }).length; + }, [questions, selectedAnswers, submitted]); + + if (isLoading || !questions.length) { + return ( + <div className="flex h-[306px] w-full items-center justify-center rounded-lg border p-5 text-black"> + <Loader2Icon className="size-8 animate-spin stroke-[2.5]" /> + </div> + ); + } + + return ( + <QuizItem + totalQuestions={questions.length} + correctAnswerCount={correctAnswerCount} + isLoading={isGenerating} + question={activeQuestion} + onOptionSelectChange={handleOptionSelectChange} + selectedOptionIds={selectedAnswers[activeQuestion.id]} + submitted={submitted} + onNext={handleNext} + onPrevious={handlePrevious} + onTryAgain={handleTryAgain} + /> + ); +} + +type QuizItemProps = { + totalQuestions: number; + correctAnswerCount: number; + + question: Question; + onOptionSelectChange?: (id: string, optionId: string) => void; + selectedOptionIds?: string[]; + submitted?: boolean; + + isLoading: boolean; + + onNext?: () => void; + onPrevious?: () => void; + onTryAgain?: () => void; +}; + +export function QuizItem(props: QuizItemProps) { + const { + totalQuestions, + correctAnswerCount, + + isLoading, + + question, + onOptionSelectChange, + selectedOptionIds, + submitted = false, + onNext, + onPrevious, + onTryAgain, + } = props; + const { id: questionId, title, options } = question; + + const correctAnswerIds = options + .filter((option) => option.isCorrect) + .map((option) => option.id); + + const isAllCorrectAnswer = + correctAnswerIds.length === selectedOptionIds?.length && + correctAnswerIds.every((correctAnswerId) => + selectedOptionIds?.includes(correctAnswerId), + ); + const hasWrongAnswer = submitted && !isAllCorrectAnswer; + const hasCorrectAnswer = submitted && isAllCorrectAnswer; + + return ( + <div + className={cn('relative w-full rounded-lg border p-5 text-black', { + 'border-red-400': hasWrongAnswer, + 'border-green-500': hasCorrectAnswer, + })} + > + <h3 className="mx-2 text-balance text-lg font-medium">{title}</h3> + + <div className="mt-4 flex flex-col gap-1"> + {options.map((option, index) => { + let status: QuizOptionStatus = 'default'; + if (submitted) { + if (option.isCorrect) { + status = 'correct'; + } else if (selectedOptionIds?.includes(option.id)) { + status = 'wrong'; + } + } else { + if (selectedOptionIds?.includes(option.id)) { + status = 'selected'; + } + } + + return ( + <QuizOption + key={index} + title={option.title} + status={status} + onSelect={() => onOptionSelectChange?.(questionId, option.id)} + submitted={submitted} + /> + ); + })} + </div> + + <div className="mt-4 flex w-full items-center justify-between px-2"> + <div className="text-gray-500"> + {submitted ? ( + <span> + You got {correctAnswerCount} out of {totalQuestions} correct + </span> + ) : ( + <span>Answer all questions to submit</span> + )} + </div> + + <div className="flex gap-2"> + <button + className="flex h-8 items-center justify-center gap-1 rounded-lg border border-gray-200 p-2 pr-4 text-sm text-black hover:bg-black hover:text-white focus:outline-none max-sm:pr-2" + onClick={onPrevious} + > + <ChevronLeftIcon className="size-5 shrink-0" /> + <span className="max-sm:hidden">Previous</span> + </button> + <button + className="flex h-8 items-center justify-center gap-1 rounded-lg border border-gray-200 p-2 pl-4 text-sm text-black hover:bg-black hover:text-white focus:outline-none max-sm:pl-2" + onClick={onNext} + > + <span className="max-sm:hidden">Next</span> + <ChevronRightIcon className="size-5 shrink-0" /> + </button> + </div> + </div> + + {submitted && ( + <button + className="absolute right-2 top-2 flex h-8 items-center justify-center gap-1 rounded-lg border border-gray-200 p-2 text-sm text-black hover:bg-black hover:text-white focus:outline-none" + onClick={onTryAgain} + > + <RotateCcwIcon className="size-5 shrink-0" /> + <span className="max-sm:hidden">Try Again</span> + </button> + )} + + {isLoading && ( + <div className="absolute right-2 top-2 flex h-8 items-center justify-center gap-1 rounded-lg border border-gray-200 p-2 text-sm text-black hover:bg-black hover:text-white focus:outline-none"> + <Loader2Icon className="size-5 animate-spin" /> + </div> + )} + </div> + ); +} + +type QuizOptionStatus = 'default' | 'selected' | 'wrong' | 'correct'; + +type QuizOptionProps = { + title: string; + status?: QuizOptionStatus; + onSelect: () => void; + submitted?: boolean; +}; + +export function QuizOption(props: QuizOptionProps) { + const { title, status = 'default', onSelect, submitted = false } = props; + + return ( + <button + onClick={onSelect} + className={cn( + 'flex items-start gap-2 rounded-xl p-2 text-base disabled:cursor-not-allowed', + status === 'selected' && 'bg-gray-600 text-white', + status === 'wrong' && submitted && 'bg-red-200 text-black', + status === 'correct' && submitted && 'bg-green-200 text-black', + status === 'default' && 'bg-white hover:bg-gray-100', + submitted && status !== 'correct' && 'opacity-40', + )} + disabled={submitted} + > + <span className="mt-[1px]"> + {status === 'wrong' && submitted && <CircleXIcon className="size-5" />} + {status === 'correct' && submitted && ( + <CircleCheckIcon className="size-5" /> + )} + + {(status === 'selected' || status === 'default') && ( + <CircleIcon className="size-5" /> + )} + </span> + <p className="text-left">{title}</p> + </button> + ); +} diff --git a/src/lib/ai.ts b/src/lib/ai.ts index ac9bf1d9e..6a70d5e9f 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -1,3 +1,5 @@ +import { nanoid } from 'nanoid'; + export const IS_KEY_ONLY_ROADMAP_GENERATION = false; type Lesson = string; @@ -52,6 +54,7 @@ export function generateAiCourseStructure( return { title, modules, + done: [], }; } @@ -207,3 +210,59 @@ export async function readStream( onStreamEnd?.(result); reader.releaseLock(); } + +export type Question = { + id: string; + title: string; + options: { + id: string; + title: string; + isCorrect: boolean; + }[]; +}; + +export function generateAiCourseLessonQuestions( + questionData: string, +): Question[] { + const questions: Question[] = []; + + const lines = questionData.split('\n'); + let currentQuestion: Question | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line.startsWith('#')) { + if (currentQuestion) { + questions.push(currentQuestion); + currentQuestion = null; + } + + const title = line.replace('#', '').trim(); + currentQuestion = { + id: nanoid(), + title, + options: [], + }; + } else if (line.startsWith('-')) { + if (!currentQuestion) { + continue; + } + + let title = line.replace('-', '').trim(); + const isCorrect = title.startsWith('*'); + title = title.replace('*', '').trim(); + + currentQuestion.options.push({ + id: nanoid(), + title, + isCorrect, + }); + } + } + + if (currentQuestion) { + questions.push(currentQuestion); + } + + return questions; +}