From 1b0f2196419ab02574e1fdd09fb0b8f709221538 Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Thu, 20 Mar 2025 00:43:18 +0600 Subject: [PATCH] feat: course ai roadmap --- .../GenerateCourse/AICourseContent.tsx | 137 ++++-------- .../GenerateCourse/AICourseOutlineView.tsx | 127 +++++++++++ .../GenerateCourse/AICourseRoadmapView.tsx | 202 ++++++++++++++++++ .../AICourseSidebarModuleList.tsx | 5 +- .../GenerateCourse/AIRoadmapViewSwitch.tsx | 39 ++++ .../GenerateRoadmap/GenerateRoadmap.tsx | 11 +- src/lib/ai.ts | 75 +++++++ 7 files changed, 491 insertions(+), 105 deletions(-) create mode 100644 src/components/GenerateCourse/AICourseOutlineView.tsx create mode 100644 src/components/GenerateCourse/AICourseRoadmapView.tsx create mode 100644 src/components/GenerateCourse/AIRoadmapViewSwitch.tsx diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index a2ada7ad9..4f147c7ea 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -3,7 +3,6 @@ import { ChevronLeft, CircleAlert, CircleOff, - Loader2, Menu, Play, X, @@ -11,16 +10,16 @@ import { import { useState } from 'react'; import { type AiCourse } from '../../lib/ai'; import { cn } from '../../lib/classname'; -import { slugify } from '../../lib/slugger'; import { useIsPaidUser } from '../../queries/billing'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; -import { CheckIcon } from '../ReactIcons/CheckIcon'; import { ErrorIcon } from '../ReactIcons/ErrorIcon'; import { AICourseLesson } from './AICourseLesson'; import { AICourseLimit } from './AICourseLimit'; import { AICourseSidebarModuleList } from './AICourseSidebarModuleList'; import { AILimitsPopup } from './AILimitsPopup'; -import { RegenerateOutline } from './RegenerateOutline'; +import { AICourseOutlineView } from './AICourseOutlineView'; +import { AICourseRoadmapView } from './AICourseRoadmapView'; +import { AIRoadmapViewSwitch } from './AIRoadmapViewSwitch'; type AICourseContentProps = { courseSlug?: string; @@ -30,6 +29,8 @@ type AICourseContentProps = { onRegenerateOutline: (prompt?: string) => void; }; +export type AICourseViewMode = 'module' | 'outline' | 'roadmap'; + export function AICourseContent(props: AICourseContentProps) { const { course, courseSlug, isLoading, error, onRegenerateOutline } = props; @@ -39,7 +40,7 @@ export function AICourseContent(props: AICourseContentProps) { const [activeModuleIndex, setActiveModuleIndex] = useState(0); const [activeLessonIndex, setActiveLessonIndex] = useState(0); const [sidebarOpen, setSidebarOpen] = useState(false); - const [viewMode, setViewMode] = useState<'module' | 'outline'>('outline'); + const [viewMode, setViewMode] = useState('outline'); const { isPaidUser } = useIsPaidUser(); @@ -399,9 +400,10 @@ export function AICourseContent(props: AICourseContentProps) {
{viewMode === 'module' && ( )} + {(viewMode === 'outline' || viewMode === 'roadmap') && ( + + )} + {viewMode === 'outline' && ( -
-
-
-

- {course.title || 'Loading course ..'} -

-

- {course.title ? course.difficulty : 'Please wait ..'} -

-
- - {!isLoading && ( - - )} -
- {course.title ? ( -
- {course.modules.map((courseModule, moduleIdx) => { - return ( -
-

- {courseModule.title} -

-
- {courseModule.lessons.map((lesson, lessonIdx) => { - const key = `${slugify(String(moduleIdx))}-${slugify(String(lessonIdx))}`; - const isCompleted = aiCourseProgress.includes(key); - - return ( -
{ - setActiveModuleIndex(moduleIdx); - setActiveLessonIndex(lessonIdx); - setExpandedModules((prev) => { - const newState: Record = - {}; - course.modules.forEach((_, idx) => { - newState[idx] = false; - }); - newState[moduleIdx] = true; - return newState; - }); - - setSidebarOpen(false); - setViewMode('module'); - }} - > - {!isCompleted && ( - - {lessonIdx + 1} - - )} - - {isCompleted && ( - - )} - -

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

- - {isCompleted ? 'View' : 'Start'} → - -
- ); - })} -
-
- ); - })} -
- ) : ( -
- -
- )} -
+ + )} + + {viewMode === 'roadmap' && !isLoading && ( + )} -
+
AI can make mistakes, check imporant info.
diff --git a/src/components/GenerateCourse/AICourseOutlineView.tsx b/src/components/GenerateCourse/AICourseOutlineView.tsx new file mode 100644 index 000000000..ed28f8830 --- /dev/null +++ b/src/components/GenerateCourse/AICourseOutlineView.tsx @@ -0,0 +1,127 @@ +import { RegenerateOutline } from './RegenerateOutline'; +import { cn } from '../../lib/classname'; +import type { AiCourse } from '../../lib/ai'; +import { slugify } from '../../lib/slugger'; +import { CheckIcon } from '../ReactIcons/CheckIcon'; +import type { Dispatch, SetStateAction } from 'react'; +import { Loader2Icon } from 'lucide-react'; +import type { AICourseViewMode } from './AICourseContent'; + +type AICourseOutlineViewProps = { + course: AiCourse; + isLoading: boolean; + onRegenerateOutline: (prompt?: string) => void; + setActiveModuleIndex: (index: number) => void; + setActiveLessonIndex: (index: number) => void; + setSidebarOpen: (open: boolean) => void; + setViewMode: (mode: AICourseViewMode) => void; + setExpandedModules: Dispatch>>; +}; + +export function AICourseOutlineView(props: AICourseOutlineViewProps) { + const { + course, + isLoading, + onRegenerateOutline, + setActiveModuleIndex, + setActiveLessonIndex, + setSidebarOpen, + setViewMode, + setExpandedModules, + } = props; + + const aiCourseProgress = course.done || []; + + return ( +
+
+
+

+ {course.title || 'Loading course ..'} +

+

+ {course.title ? course.difficulty : 'Please wait ..'} +

+
+ + {!isLoading && ( + + )} +
+ {course.title ? ( +
+ {course.modules.map((courseModule, moduleIdx) => { + return ( +
+

+ {courseModule.title} +

+
+ {courseModule.lessons.map((lesson, lessonIdx) => { + const key = `${slugify(String(moduleIdx))}-${slugify(String(lessonIdx))}`; + const isCompleted = aiCourseProgress.includes(key); + + return ( +
{ + setActiveModuleIndex(moduleIdx); + setActiveLessonIndex(lessonIdx); + setExpandedModules((prev) => { + const newState: Record = {}; + course.modules.forEach((_, idx) => { + newState[idx] = false; + }); + newState[moduleIdx] = true; + return newState; + }); + + setSidebarOpen(false); + setViewMode('module'); + }} + > + {!isCompleted && ( + + {lessonIdx + 1} + + )} + + {isCompleted && ( + + )} + +

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

+ + {isCompleted ? 'View' : 'Start'} → + +
+ ); + })} +
+
+ ); + })} +
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/GenerateCourse/AICourseRoadmapView.tsx b/src/components/GenerateCourse/AICourseRoadmapView.tsx new file mode 100644 index 000000000..59355ae82 --- /dev/null +++ b/src/components/GenerateCourse/AICourseRoadmapView.tsx @@ -0,0 +1,202 @@ +import '../GenerateRoadmap/GenerateRoadmap.css'; +import { renderFlowJSON } from '../../../editor/renderer/renderer'; +import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generator'; +import { + generateAICourseRoadmapStructure, + readStream, + type ResultItem, +} from '../../lib/ai'; +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type SetStateAction, + type MouseEvent, +} from 'react'; +import type { AICourseViewMode } from './AICourseContent'; +import { replaceChildren } from '../../lib/dom'; +import { Loader2Icon } from 'lucide-react'; +import { ErrorIcon } from '../ReactIcons/ErrorIcon'; + +export type AICourseRoadmapViewProps = { + courseSlug: string; + setActiveModuleIndex: (index: number) => void; + setActiveLessonIndex: (index: number) => void; + setViewMode: (mode: AICourseViewMode) => void; + setExpandedModules: Dispatch>>; +}; + +export function AICourseRoadmapView(props: AICourseRoadmapViewProps) { + const { + courseSlug, + setActiveModuleIndex, + setActiveLessonIndex, + setViewMode, + setExpandedModules, + } = props; + + const containerEl = useRef(null); + const [roadmapStructure, setRoadmapStructure] = useState([]); + + const [isLoading, setIsLoading] = useState(true); + const [isGenerating, setIsGenerating] = useState(false); + const [error, setError] = useState(null); + + const generateAICourseRoadmap = async (courseSlug: string) => { + try { + const response = await fetch( + `${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course-roadmap/${courseSlug}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }, + ); + + if (!response.ok) { + const data = await response.json(); + console.error( + 'Error generating course roadmap:', + data?.message || 'Something went wrong', + ); + setError(data?.message || 'Something went wrong'); + setIsLoading(false); + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + console.error('Failed to get reader from response'); + setError('Something went wrong'); + setIsLoading(false); + return; + } + + setIsLoading(false); + setIsGenerating(true); + await readStream(reader, { + onStream: async (result) => { + const roadmap = generateAICourseRoadmapStructure(result); + const { nodes, edges } = generateAIRoadmapFromText(roadmap); + const svg = await renderFlowJSON({ nodes, edges }); + replaceChildren(containerEl.current!, svg); + }, + onStreamEnd: async (result) => { + const roadmap = generateAICourseRoadmapStructure(result); + const { nodes, edges } = generateAIRoadmapFromText(roadmap); + const svg = await renderFlowJSON({ nodes, edges }); + replaceChildren(containerEl.current!, svg); + setRoadmapStructure(roadmap); + setIsGenerating(false); + }, + }); + } catch (error) { + console.error('Error generating course roadmap:', error); + setError('Something went wrong'); + setIsLoading(false); + } + }; + + useEffect(() => { + if (!courseSlug) { + return; + } + + generateAICourseRoadmap(courseSlug); + }, []); + + const handleNodeClick = useCallback( + (e: MouseEvent) => { + if (isLoading || isGenerating) { + return; + } + + const target = e.target as SVGElement; + const targetGroup = (target?.closest('g') as SVGElement) || {}; + + const nodeId = targetGroup?.dataset?.nodeId; + const nodeType = targetGroup?.dataset?.type; + const nodeTitle = targetGroup?.dataset?.title; + const parentTitle = targetGroup?.dataset?.parentTitle; + if (!nodeId || !nodeType) { + return null; + } + + const filteredRoadmapStructure = roadmapStructure.filter( + (module) => module.type !== 'title', + ); + + const moduleIndex = filteredRoadmapStructure.findIndex( + (module) => module.label === parentTitle, + ); + + const module = filteredRoadmapStructure[moduleIndex]; + if (module?.type !== 'topic') { + return; + } + + const topicIndex = module.children?.findIndex( + (topic) => topic.label === nodeTitle, + ); + + if (topicIndex === undefined) { + return; + } + + const topic = module.children?.[topicIndex]; + if (topic?.type !== 'subtopic') { + return; + } + + setExpandedModules((prev) => { + const newState: Record = {}; + roadmapStructure.forEach((_, idx) => { + newState[idx] = false; + }); + newState[moduleIndex] = true; + return newState; + }); + setActiveModuleIndex(moduleIndex); + setActiveLessonIndex(topicIndex); + setViewMode('module'); + }, + [ + isLoading, + roadmapStructure, + setExpandedModules, + setActiveModuleIndex, + setActiveLessonIndex, + setViewMode, + ], + ); + + return ( +
+ {isLoading && ( +
+ +
+ )} + + {error && !isLoading && !isGenerating && ( +
+ +

+ {error || 'Something went wrong'} +

+
+ )} + +
+
+ ); +} diff --git a/src/components/GenerateCourse/AICourseSidebarModuleList.tsx b/src/components/GenerateCourse/AICourseSidebarModuleList.tsx index 5158f91b8..e149da580 100644 --- a/src/components/GenerateCourse/AICourseSidebarModuleList.tsx +++ b/src/components/GenerateCourse/AICourseSidebarModuleList.tsx @@ -5,6 +5,7 @@ import { cn } from '../../lib/classname'; import { slugify } from '../../lib/slugger'; import { CheckIcon } from '../ReactIcons/CheckIcon'; import { CircularProgress } from './CircularProgress'; +import type { AICourseViewMode } from './AICourseContent'; type AICourseModuleListProps = { course: AiCourse; @@ -16,8 +17,8 @@ type AICourseModuleListProps = { setSidebarOpen: (open: boolean) => void; - viewMode: 'module' | 'outline'; - setViewMode: (mode: 'module' | 'outline') => void; + viewMode: AICourseViewMode; + setViewMode: (mode: AICourseViewMode) => void; expandedModules: Record; setExpandedModules: Dispatch>>; diff --git a/src/components/GenerateCourse/AIRoadmapViewSwitch.tsx b/src/components/GenerateCourse/AIRoadmapViewSwitch.tsx new file mode 100644 index 000000000..ea4516dcc --- /dev/null +++ b/src/components/GenerateCourse/AIRoadmapViewSwitch.tsx @@ -0,0 +1,39 @@ +import { cn } from '../../lib/classname'; +import type { AICourseViewMode } from './AICourseContent'; + +type AIRoadmapViewSwitchProps = { + viewMode: AICourseViewMode; + setViewMode: (mode: AICourseViewMode) => void; + isLoading: boolean; +}; + +export function AIRoadmapViewSwitch(props: AIRoadmapViewSwitchProps) { + const { viewMode, setViewMode, isLoading } = props; + + return ( +
+
+ + +
+
+ ); +} diff --git a/src/components/GenerateRoadmap/GenerateRoadmap.tsx b/src/components/GenerateRoadmap/GenerateRoadmap.tsx index 945fc2a35..9ff1c0785 100644 --- a/src/components/GenerateRoadmap/GenerateRoadmap.tsx +++ b/src/components/GenerateRoadmap/GenerateRoadmap.tsx @@ -30,7 +30,10 @@ import { showLoginPopup } from '../../lib/popup.ts'; import { cn } from '../../lib/classname.ts'; import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx'; import { AIRoadmapAlert } from './AIRoadmapAlert.tsx'; -import { IS_KEY_ONLY_ROADMAP_GENERATION, readAIRoadmapStream } from '../../lib/ai.ts'; +import { + IS_KEY_ONLY_ROADMAP_GENERATION, + readAIRoadmapStream, +} from '../../lib/ai.ts'; import { AITermSuggestionInput } from './AITermSuggestionInput.tsx'; import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx'; import { AuthenticationForm } from '../AuthenticationFlow/AuthenticationForm.tsx'; @@ -51,6 +54,7 @@ export type RoadmapNodeDetails = { targetGroup?: SVGElement; nodeTitle?: string; parentTitle?: string; + parentId?: string; }; export function getNodeDetails( @@ -62,9 +66,10 @@ export function getNodeDetails( const nodeType = targetGroup?.dataset?.type; const nodeTitle = targetGroup?.dataset?.title; const parentTitle = targetGroup?.dataset?.parentTitle; + const parentId = targetGroup?.dataset?.parentId; if (!nodeId || !nodeType) return null; - return { nodeId, nodeType, targetGroup, nodeTitle, parentTitle }; + return { nodeId, nodeType, targetGroup, nodeTitle, parentTitle, parentId }; } export const allowedClickableNodeTypes = [ @@ -719,7 +724,7 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
diff --git a/src/lib/ai.ts b/src/lib/ai.ts index ac9bf1d9e..be6fbd1c2 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; @@ -207,3 +209,76 @@ export async function readStream( onStreamEnd?.(result); reader.releaseLock(); } + +export type SubTopic = { + id: string; + type: 'subtopic'; + label: string; +}; + +export type Topic = { + id: string; + type: 'topic'; + label: string; + children?: SubTopic[]; +}; + +export type Label = { + id: string; + type: 'label'; + label: string; +}; + +export type Title = { + id: string; + type: 'title'; + label: string; +}; + +export type ResultItem = Title | Topic | Label; + +export function generateAICourseRoadmapStructure(data: string): ResultItem[] { + const lines = data.split('\n'); + + const result: ResultItem[] = []; + let currentTopic: Topic | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (i === 0 && line.startsWith('#')) { + const title = line.replace('#', '').trim(); + result.push({ + id: nanoid(), + type: 'title', + label: title, + }); + } else if (line.startsWith('###')) { + if (currentTopic) { + result.push(currentTopic); + } + + const label = line.replace('###', '').trim(); + currentTopic = { + id: nanoid(), + type: 'topic', + label, + children: [], + }; + } else if (line.startsWith('- ')) { + if (currentTopic) { + const label = line.replace('-', '').trim(); + currentTopic.children?.push({ + id: nanoid(), + type: 'subtopic', + label, + }); + } + } + } + + if (currentTopic) { + result.push(currentTopic); + } + + return result; +}