feat: course ai roadmap

feat/ai-roadmap
Arik Chakma 4 weeks ago
parent f0d208f050
commit 1b0f219641
  1. 137
      src/components/GenerateCourse/AICourseContent.tsx
  2. 127
      src/components/GenerateCourse/AICourseOutlineView.tsx
  3. 202
      src/components/GenerateCourse/AICourseRoadmapView.tsx
  4. 5
      src/components/GenerateCourse/AICourseSidebarModuleList.tsx
  5. 39
      src/components/GenerateCourse/AIRoadmapViewSwitch.tsx
  6. 11
      src/components/GenerateRoadmap/GenerateRoadmap.tsx
  7. 75
      src/lib/ai.ts

@ -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<AICourseViewMode>('outline');
const { isPaidUser } = useIsPaidUser();
@ -399,9 +400,10 @@ export function AICourseContent(props: AICourseContentProps) {
<main
className={cn(
'flex-1 overflow-y-auto p-6 transition-all duration-200 ease-in-out max-lg:p-3',
'flex-1 overflow-y-scroll p-6 transition-all duration-200 ease-in-out max-lg:p-3',
sidebarOpen ? 'lg:ml-0' : '',
)}
key={`${courseSlug}-${viewMode}`}
>
{viewMode === 'module' && (
<AICourseLesson
@ -420,103 +422,38 @@ export function AICourseContent(props: AICourseContentProps) {
/>
)}
{(viewMode === 'outline' || viewMode === 'roadmap') && (
<AIRoadmapViewSwitch
viewMode={viewMode}
setViewMode={setViewMode}
isLoading={isLoading}
/>
)}
{viewMode === 'outline' && (
<div className="mx-auto rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-3xl">
<div
className={cn(
'relative mb-1 flex items-start justify-between border-b border-gray-100 p-6 max-lg:hidden',
isLoading && 'striped-loader',
)}
>
<div>
<h2 className="mb-1 text-balance text-2xl font-bold max-lg:text-lg max-lg:leading-tight">
{course.title || 'Loading course ..'}
</h2>
<p className="text-sm capitalize text-gray-500">
{course.title ? course.difficulty : 'Please wait ..'}
</p>
</div>
{!isLoading && (
<RegenerateOutline
onRegenerateOutline={onRegenerateOutline}
/>
)}
</div>
{course.title ? (
<div className="flex flex-col p-6 max-lg:mt-0.5 max-lg:p-4">
{course.modules.map((courseModule, moduleIdx) => {
return (
<div
key={moduleIdx}
className="mb-5 pb-4 last:border-0 last:pb-0 max-lg:mb-2"
>
<h2 className="mb-4 text-xl font-bold text-gray-800 max-lg:mb-2 max-lg:text-lg max-lg:leading-tight">
{courseModule.title}
</h2>
<div className="divide-y divide-gray-100">
{courseModule.lessons.map((lesson, lessonIdx) => {
const key = `${slugify(String(moduleIdx))}-${slugify(String(lessonIdx))}`;
const isCompleted = aiCourseProgress.includes(key);
return (
<div
key={key}
className="flex cursor-pointer items-center gap-2 px-2 py-2.5 transition-colors hover:bg-gray-100 max-lg:px-0 max-lg:py-1.5"
onClick={() => {
setActiveModuleIndex(moduleIdx);
setActiveLessonIndex(lessonIdx);
setExpandedModules((prev) => {
const newState: Record<number, boolean> =
{};
course.modules.forEach((_, idx) => {
newState[idx] = false;
});
newState[moduleIdx] = true;
return newState;
});
setSidebarOpen(false);
setViewMode('module');
}}
>
{!isCompleted && (
<span
className={cn(
'flex size-6 flex-shrink-0 items-center justify-center rounded-full bg-gray-200 text-sm font-medium text-gray-800 max-lg:size-5 max-lg:text-xs',
)}
>
{lessonIdx + 1}
</span>
)}
{isCompleted && (
<CheckIcon additionalClasses="size-6 flex-shrink-0 text-green-500" />
)}
<p className="flex-1 truncate text-base text-gray-800 max-lg:text-sm">
{lesson.replace(/^Lesson\s*?\d+[\.:]\s*/, '')}
</p>
<span className="text-sm font-medium text-gray-700 max-lg:hidden">
{isCompleted ? 'View' : 'Start'}
</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
) : (
<div className="flex h-64 items-center justify-center">
<Loader2 size={36} className="animate-spin text-gray-300" />
</div>
)}
</div>
<AICourseOutlineView
course={course}
isLoading={isLoading}
onRegenerateOutline={onRegenerateOutline}
setActiveModuleIndex={setActiveModuleIndex}
setActiveLessonIndex={setActiveLessonIndex}
setSidebarOpen={setSidebarOpen}
setViewMode={setViewMode}
setExpandedModules={setExpandedModules}
/>
)}
{viewMode === 'roadmap' && !isLoading && (
<AICourseRoadmapView
courseSlug={courseSlug!}
setActiveModuleIndex={setActiveModuleIndex}
setActiveLessonIndex={setActiveLessonIndex}
setViewMode={setViewMode}
setExpandedModules={setExpandedModules}
/>
)}
<div className="mb-10 mt-5 mx-auto text-center text-sm text-gray-400">
<div className="mx-auto mb-10 mt-5 text-center text-sm text-gray-400">
AI can make mistakes, check imporant info.
</div>
</main>

@ -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<SetStateAction<Record<number, boolean>>>;
};
export function AICourseOutlineView(props: AICourseOutlineViewProps) {
const {
course,
isLoading,
onRegenerateOutline,
setActiveModuleIndex,
setActiveLessonIndex,
setSidebarOpen,
setViewMode,
setExpandedModules,
} = props;
const aiCourseProgress = course.done || [];
return (
<div className="mx-auto rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-3xl">
<div
className={cn(
'relative mb-1 flex items-start justify-between border-b border-gray-100 p-6 max-lg:hidden',
isLoading && 'striped-loader',
)}
>
<div>
<h2 className="mb-1 text-balance text-2xl font-bold max-lg:text-lg max-lg:leading-tight">
{course.title || 'Loading course ..'}
</h2>
<p className="text-sm capitalize text-gray-500">
{course.title ? course.difficulty : 'Please wait ..'}
</p>
</div>
{!isLoading && (
<RegenerateOutline onRegenerateOutline={onRegenerateOutline} />
)}
</div>
{course.title ? (
<div className="flex flex-col p-6 max-lg:mt-0.5 max-lg:p-4">
{course.modules.map((courseModule, moduleIdx) => {
return (
<div
key={moduleIdx}
className="mb-5 pb-4 last:border-0 last:pb-0 max-lg:mb-2"
>
<h2 className="mb-4 text-xl font-bold text-gray-800 max-lg:mb-2 max-lg:text-lg max-lg:leading-tight">
{courseModule.title}
</h2>
<div className="divide-y divide-gray-100">
{courseModule.lessons.map((lesson, lessonIdx) => {
const key = `${slugify(String(moduleIdx))}-${slugify(String(lessonIdx))}`;
const isCompleted = aiCourseProgress.includes(key);
return (
<div
key={key}
className="flex cursor-pointer items-center gap-2 px-2 py-2.5 transition-colors hover:bg-gray-100 max-lg:px-0 max-lg:py-1.5"
onClick={() => {
setActiveModuleIndex(moduleIdx);
setActiveLessonIndex(lessonIdx);
setExpandedModules((prev) => {
const newState: Record<number, boolean> = {};
course.modules.forEach((_, idx) => {
newState[idx] = false;
});
newState[moduleIdx] = true;
return newState;
});
setSidebarOpen(false);
setViewMode('module');
}}
>
{!isCompleted && (
<span
className={cn(
'flex size-6 flex-shrink-0 items-center justify-center rounded-full bg-gray-200 text-sm font-medium text-gray-800 max-lg:size-5 max-lg:text-xs',
)}
>
{lessonIdx + 1}
</span>
)}
{isCompleted && (
<CheckIcon additionalClasses="size-6 flex-shrink-0 text-green-500" />
)}
<p className="flex-1 truncate text-base text-gray-800 max-lg:text-sm">
{lesson.replace(/^Lesson\s*?\d+[\.:]\s*/, '')}
</p>
<span className="text-sm font-medium text-gray-700 max-lg:hidden">
{isCompleted ? 'View' : 'Start'}
</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
) : (
<div className="flex h-64 items-center justify-center">
<Loader2Icon size={36} className="animate-spin text-gray-300" />
</div>
)}
</div>
);
}

@ -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<SetStateAction<Record<number, boolean>>>;
};
export function AICourseRoadmapView(props: AICourseRoadmapViewProps) {
const {
courseSlug,
setActiveModuleIndex,
setActiveLessonIndex,
setViewMode,
setExpandedModules,
} = props;
const containerEl = useRef<HTMLDivElement>(null);
const [roadmapStructure, setRoadmapStructure] = useState<ResultItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLDivElement, unknown>) => {
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<number, boolean> = {};
roadmapStructure.forEach((_, idx) => {
newState[idx] = false;
});
newState[moduleIndex] = true;
return newState;
});
setActiveModuleIndex(moduleIndex);
setActiveLessonIndex(topicIndex);
setViewMode('module');
},
[
isLoading,
roadmapStructure,
setExpandedModules,
setActiveModuleIndex,
setActiveLessonIndex,
setViewMode,
],
);
return (
<div className="relative mx-auto min-h-[200px] rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-3xl">
{isLoading && (
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
<Loader2Icon className="h-10 w-10 animate-spin stroke-[3px]" />
</div>
)}
{error && !isLoading && !isGenerating && (
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
<ErrorIcon additionalClasses="h-10 w-10" />
<p className="text-sm text-gray-500">
{error || 'Something went wrong'}
</p>
</div>
)}
<div
id={'resource-svg-wrap'}
ref={containerEl}
onClick={handleNodeClick}
className="px-4 pb-2"
></div>
</div>
);
}

@ -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<number, boolean>;
setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>;

@ -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 (
<div className="sticky top-0 z-10 mx-auto mb-5 flex justify-center">
<div className="grid min-w-[200px] grid-cols-2 gap-0.5 rounded-xl border border-gray-200 bg-white p-0.5 shadow-sm">
<button
className={cn(
'rounded-lg px-2 py-1 text-sm font-medium disabled:cursor-not-allowed',
viewMode === 'outline' && 'bg-gray-100 text-gray-800',
)}
onClick={() => setViewMode('outline')}
disabled={isLoading}
>
Outline
</button>
<button
className={cn(
'rounded-lg px-2 py-1 text-sm font-medium disabled:cursor-not-allowed',
viewMode === 'roadmap' && 'bg-gray-100 text-gray-800',
)}
onClick={() => setViewMode('roadmap')}
disabled={isLoading}
>
Roadmap
</button>
</div>
</div>
);
}

@ -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) {
</div>
<div
className={cn({
'relative mb-20 max-h-[800px] min-h-[800px] overflow-hidden sm:max-h-[1000px] md:min-h-[1000px] lg:max-h-[1200px] lg:min-h-[1200px]':
'relative mb-20 max-h-[800px] min-h-[800px] overflow-hidden sm:max-h-[1000px] md:min-h-[1000px] lg:max-h-[1200px] lg:min-h-[1200px]':
!isAuthenticatedUser,
})}
>

@ -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;
}

Loading…
Cancel
Save