From 85202507e6b6a4c9c2d11a202fbd51af913f631e Mon Sep 17 00:00:00 2001
From: Arik Chakma
Date: Wed, 26 Mar 2025 04:21:39 +0600
Subject: [PATCH] feat: course ai roadmap (#8352)
* feat: course ai roadmap
* wip
* fix: error
* refactor: remove open ai key
* wip: view switch
* feat: add roadmap progress
* fix: simplify module
* wip
* Update outline generation
* Update course limits popup
* fix: module done
* Updates to AI usage
* UI and error handling
* Map and outline view to share header
* Outline switcher
* Responsive AI generation
* Update header for course
* Roadmap switch
---------
Co-authored-by: Kamran Ahmed
---
.../GenerateCourse/AICourseContent.tsx | 188 ++++---------
.../AICourseFollowUpPopover.tsx | 31 ++-
.../GenerateCourse/AICourseLesson.tsx | 2 +-
.../GenerateCourse/AICourseLimit.tsx | 22 +-
.../GenerateCourse/AICourseOutlineHeader.tsx | 80 ++++++
.../GenerateCourse/AICourseOutlineView.tsx | 117 ++++++++
.../GenerateCourse/AICourseRoadmapView.tsx | 252 ++++++++++++++++++
.../AICourseSidebarModuleList.tsx | 5 +-
.../GenerateCourse/AIRoadmapViewSwitch.tsx | 81 ++++++
.../GenerateCourse/RegenerateOutline.tsx | 13 +-
.../GenerateRoadmap/GenerateRoadmap.tsx | 81 ++----
.../GenerateRoadmap/IncreaseRoadmapLimit.tsx | 17 +-
.../GenerateRoadmap/OpenAISettings.tsx | 171 ------------
.../GenerateRoadmap/RoadmapSearch.tsx | 72 ++---
.../GenerateRoadmap/RoadmapTopicDetail.tsx | 30 +--
src/lib/ai.ts | 99 ++++++-
src/lib/jwt.ts | 21 --
17 files changed, 784 insertions(+), 498 deletions(-)
create mode 100644 src/components/GenerateCourse/AICourseOutlineHeader.tsx
create mode 100644 src/components/GenerateCourse/AICourseOutlineView.tsx
create mode 100644 src/components/GenerateCourse/AICourseRoadmapView.tsx
create mode 100644 src/components/GenerateCourse/AIRoadmapViewSwitch.tsx
delete mode 100644 src/components/GenerateRoadmap/OpenAISettings.tsx
diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx
index 565391ec0..bcdeb260d 100644
--- a/src/components/GenerateCourse/AICourseContent.tsx
+++ b/src/components/GenerateCourse/AICourseContent.tsx
@@ -3,24 +3,22 @@ import {
ChevronLeft,
CircleAlert,
CircleOff,
- Loader2,
Menu,
- Play,
X,
+ Map,
} from 'lucide-react';
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';
type AICourseContentProps = {
courseSlug?: string;
@@ -30,6 +28,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 +39,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();
@@ -257,6 +257,7 @@ export function AICourseContent(props: AICourseContentProps) {
{totalModules} modules
•
{totalCourseLessons} lessons
+
{viewMode === 'module' && (
•
@@ -271,6 +272,7 @@ export function AICourseContent(props: AICourseContentProps) {
)}
+
{finishedPercentage > 0 && (
<>
•
@@ -289,19 +291,6 @@ export function AICourseContent(props: AICourseContentProps) {
onShowLimits={() => setShowAILimitsPopup(true)}
/>
-
- {viewMode === 'module' && (
- {
- setExpandedModules({});
- setViewMode('outline');
- }}
- className="flex flex-shrink-0 items-center 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 max-lg:hidden"
- >
-
- View Course Outline
-
- )}
@@ -336,36 +325,38 @@ export function AICourseContent(props: AICourseContentProps) {
)}
>
- {viewMode !== 'outline' && (
+
{
setExpandedModules({});
setViewMode('outline');
}}
- className="flex items-center gap-1 rounded-md bg-gray-200 px-2.5 py-1.5 text-xs transition-colors hover:bg-gray-300"
+ className={cn(
+ 'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
+ viewMode === 'outline'
+ ? 'bg-gray-200 text-gray-900'
+ : 'text-gray-600 hover:bg-gray-50',
+ )}
>
- View Outline
+ Outline
- )}
-
- {viewMode === 'outline' && (
{
- setExpandedModules({
- ...expandedModules,
- 0: true,
- });
- setActiveModuleIndex(0);
- setActiveLessonIndex(0);
- setViewMode('module');
+ setExpandedModules({});
+ setViewMode('roadmap');
}}
- className="flex items-center gap-1 rounded-md bg-gray-200 px-2.5 py-1.5 text-xs transition-colors hover:bg-gray-300"
+ className={cn(
+ 'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
+ viewMode === 'roadmap'
+ ? 'bg-gray-200 text-gray-900'
+ : 'text-gray-600 hover:bg-gray-50',
+ )}
>
-
- Start Course
+
+ Map
- )}
+
)}
@@ -399,9 +390,10 @@ export function AICourseContent(props: AICourseContentProps) {
{viewMode === 'module' && (
-
-
-
- {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 && (
+ setShowUpgradeModal(true)}
+ viewMode={viewMode}
+ />
)}
-
- AI can make mistakes, check important info.
+
+ AI can make mistakes, check imporant info.
diff --git a/src/components/GenerateCourse/AICourseFollowUpPopover.tsx b/src/components/GenerateCourse/AICourseFollowUpPopover.tsx
index de116546b..35b49051f 100644
--- a/src/components/GenerateCourse/AICourseFollowUpPopover.tsx
+++ b/src/components/GenerateCourse/AICourseFollowUpPopover.tsx
@@ -2,8 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import {
BookOpen,
Bot,
- Code,
- Globe, Hammer,
+ Hammer,
HelpCircle,
LockIcon,
Send,
@@ -22,6 +21,7 @@ import {
} from '../../lib/markdown';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
+import { billingDetailsOptions } from '../../queries/billing';
export type AllowedAIChatRole = 'user' | 'assistant';
export type AIChatHistoryType = {
@@ -70,7 +70,11 @@ export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) {
queryClient,
);
+ const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
+ useQuery(billingDetailsOptions(), queryClient);
+
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
+ const isPaidUser = userBillingDetails?.status === 'active';
const handleChatSubmit = (e: FormEvent) => {
e.preventDefault();
@@ -247,15 +251,20 @@ export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) {
{isLimitExceeded && (
-
Limit reached for today
-
{
- onUpgradeClick();
- }}
- className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
- >
- Upgrade for more
-
+
+ Limit reached for today
+ {isPaidUser ? '. Please wait until tomorrow.' : ''}
+
+ {!isPaidUser && (
+
{
+ onUpgradeClick();
+ }}
+ className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
+ >
+ Upgrade for more
+
+ )}
)}
You have reached the AI usage limit for today.
{!isPaidUser && <>Please upgrade your account to continue.>}
- {isPaidUser && <>Please wait until tomorrow to continue.>}
+ {isPaidUser && <> Please wait until tomorrow to continue.>}
{!isPaidUser && (
diff --git a/src/components/GenerateCourse/AICourseLimit.tsx b/src/components/GenerateCourse/AICourseLimit.tsx
index efba35c2b..f2c80a80b 100644
--- a/src/components/GenerateCourse/AICourseLimit.tsx
+++ b/src/components/GenerateCourse/AICourseLimit.tsx
@@ -32,23 +32,21 @@ export function AICourseLimit(props: AICourseLimitProps) {
const totalPercentage = getPercentage(used, limit);
// has consumed 85% of the limit
- const isNearLimit = used >= limit * 0.85;
const isPaidUser = userBillingDetails.status === 'active';
return (
<>
- {!isPaidUser ||
- (isNearLimit && (
- onShowLimits()}
- >
-
- {totalPercentage}% limit used
-
- ))}
+ {!isPaidUser && (
+ onShowLimits()}
+ >
+
+ {totalPercentage}% limit used
+
+ )}
- {(!isPaidUser || isNearLimit) && (
+ {!isPaidUser && (
{
onShowLimits();
diff --git a/src/components/GenerateCourse/AICourseOutlineHeader.tsx b/src/components/GenerateCourse/AICourseOutlineHeader.tsx
new file mode 100644
index 000000000..a841c99bb
--- /dev/null
+++ b/src/components/GenerateCourse/AICourseOutlineHeader.tsx
@@ -0,0 +1,80 @@
+import { cn } from '../../lib/classname';
+import type { AiCourse } from '../../lib/ai';
+import { RegenerateOutline } from './RegenerateOutline';
+import type { AICourseViewMode } from './AICourseContent';
+import { BookOpenCheck, Signpost } from 'lucide-react';
+
+type AICourseOutlineHeaderProps = {
+ course: AiCourse;
+ isLoading: boolean;
+ onRegenerateOutline: (prompt?: string) => void;
+ viewMode: AICourseViewMode;
+ setViewMode: (mode: AICourseViewMode) => void;
+};
+
+export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) {
+ const { course, isLoading, onRegenerateOutline, viewMode, setViewMode } =
+ props;
+
+ return (
+
+
+
+ {course.title || 'Loading course ..'}
+
+
+ {course.title ? course.difficulty : 'Please wait ..'}
+
+
+
+
+ {!isLoading && (
+ <>
+
+
+ setViewMode('outline')}
+ className={cn(
+ 'flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors',
+ viewMode === 'outline'
+ ? 'bg-gray-200 text-gray-900'
+ : 'text-gray-500 hover:text-gray-900',
+ )}
+ >
+
+ Outline
+
+ setViewMode('roadmap')}
+ className={cn(
+ 'flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors',
+ viewMode === 'roadmap'
+ ? 'bg-gray-200 text-gray-900'
+ : 'text-gray-500 hover:text-gray-900',
+ )}
+ >
+
+ Map
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/components/GenerateCourse/AICourseOutlineView.tsx b/src/components/GenerateCourse/AICourseOutlineView.tsx
new file mode 100644
index 000000000..b58568ac6
--- /dev/null
+++ b/src/components/GenerateCourse/AICourseOutlineView.tsx
@@ -0,0 +1,117 @@
+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';
+import { AICourseOutlineHeader } from './AICourseOutlineHeader';
+
+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>>;
+ viewMode: AICourseViewMode;
+};
+
+export function AICourseOutlineView(props: AICourseOutlineViewProps) {
+ const {
+ course,
+ isLoading,
+ onRegenerateOutline,
+ setActiveModuleIndex,
+ setActiveLessonIndex,
+ setSidebarOpen,
+ setViewMode,
+ setExpandedModules,
+ viewMode,
+ } = props;
+
+ const aiCourseProgress = course.done || [];
+
+ return (
+
+
+ {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..d004d639f
--- /dev/null
+++ b/src/components/GenerateCourse/AICourseRoadmapView.tsx
@@ -0,0 +1,252 @@
+import '../GenerateRoadmap/GenerateRoadmap.css';
+import { renderFlowJSON } from '../../../editor/renderer/renderer';
+import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generator';
+import {
+ generateAICourseRoadmapStructure,
+ readAIRoadmapStream,
+ 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 { Frown, Loader2Icon } from 'lucide-react';
+import { renderTopicProgress } from '../../lib/resource-progress';
+import { queryClient } from '../../stores/query-client';
+import { useQuery } from '@tanstack/react-query';
+import { billingDetailsOptions } from '../../queries/billing';
+import { AICourseOutlineHeader } from './AICourseOutlineHeader';
+import type { AiCourse } from '../../lib/ai';
+
+export type AICourseRoadmapViewProps = {
+ done: string[];
+ courseSlug: string;
+ course: AiCourse;
+ isLoading: boolean;
+ onRegenerateOutline: (prompt?: string) => void;
+ setActiveModuleIndex: (index: number) => void;
+ setActiveLessonIndex: (index: number) => void;
+ setViewMode: (mode: AICourseViewMode) => void;
+ onUpgradeClick: () => void;
+ setExpandedModules: Dispatch>>;
+ viewMode: AICourseViewMode;
+};
+
+export function AICourseRoadmapView(props: AICourseRoadmapViewProps) {
+ const {
+ done = [],
+ courseSlug,
+ course,
+ isLoading,
+ onRegenerateOutline,
+ setActiveModuleIndex,
+ setActiveLessonIndex,
+ setViewMode,
+ setExpandedModules,
+ onUpgradeClick,
+ viewMode,
+ } = props;
+
+ const containerEl = useRef(null);
+ const [roadmapStructure, setRoadmapStructure] = useState([]);
+
+ const [isGenerating, setIsGenerating] = useState(false);
+ const [error, setError] = useState(null);
+
+ const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
+ useQuery(billingDetailsOptions(), queryClient);
+
+ const isPaidUser = userBillingDetails?.status === 'active';
+
+ 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');
+ setIsGenerating(false);
+ return;
+ }
+
+ const reader = response.body?.getReader();
+ if (!reader) {
+ console.error('Failed to get reader from response');
+ setError('Something went wrong');
+ setIsGenerating(false);
+ return;
+ }
+
+ setIsGenerating(true);
+ await readAIRoadmapStream(reader, {
+ onStream: async (result) => {
+ const roadmap = generateAICourseRoadmapStructure(result, true);
+ const { nodes, edges } = generateAIRoadmapFromText(roadmap);
+ const svg = await renderFlowJSON({ nodes, edges });
+ replaceChildren(containerEl.current!, svg);
+ },
+ onStreamEnd: async (result) => {
+ const roadmap = generateAICourseRoadmapStructure(result, true);
+ const { nodes, edges } = generateAIRoadmapFromText(roadmap);
+ const svg = await renderFlowJSON({ nodes, edges });
+ replaceChildren(containerEl.current!, svg);
+ setRoadmapStructure(roadmap);
+ setIsGenerating(false);
+
+ done.forEach((id) => {
+ renderTopicProgress(id, 'done');
+ });
+
+ const modules = roadmap.filter((item) => item.type === 'topic');
+ for (const module of modules) {
+ const moduleId = module.id;
+ const isAllLessonsDone =
+ module?.children?.every((child) => done.includes(child.id)) ??
+ false;
+ if (isAllLessonsDone) {
+ renderTopicProgress(moduleId, 'done');
+ }
+ }
+ },
+ });
+ } catch (error) {
+ console.error('Error generating course roadmap:', error);
+ setError('Something went wrong');
+ setIsGenerating(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!courseSlug) {
+ return;
+ }
+
+ generateAICourseRoadmap(courseSlug);
+ }, []);
+
+ const handleNodeClick = useCallback(
+ (e: MouseEvent) => {
+ if (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;
+ if (!nodeId || !nodeType) {
+ return null;
+ }
+
+ if (nodeType === 'topic') {
+ const topicIndex = roadmapStructure
+ .filter((item) => item.type === 'topic')
+ .findIndex((item) => item.id === nodeId);
+
+ setExpandedModules((prev) => {
+ const newState: Record = {};
+ roadmapStructure.forEach((_, idx) => {
+ newState[idx] = false;
+ });
+ newState[topicIndex] = true;
+ return newState;
+ });
+
+ setActiveModuleIndex(topicIndex);
+ setActiveLessonIndex(0);
+ setViewMode('module');
+ return;
+ }
+
+ if (nodeType !== 'subtopic') {
+ return null;
+ }
+
+ const [moduleIndex, topicIndex] = nodeId.split('-').map(Number);
+ setExpandedModules((prev) => {
+ const newState: Record = {};
+ roadmapStructure.forEach((_, idx) => {
+ newState[idx] = false;
+ });
+ newState[moduleIndex] = true;
+ return newState;
+ });
+ setActiveModuleIndex(moduleIndex);
+ setActiveLessonIndex(topicIndex);
+ setViewMode('module');
+ },
+ [
+ roadmapStructure,
+ setExpandedModules,
+ setActiveModuleIndex,
+ setActiveLessonIndex,
+ setViewMode,
+ ],
+ );
+
+ return (
+
+
{
+ setViewMode('outline');
+ onRegenerateOutline(prompt);
+ }}
+ viewMode={viewMode}
+ setViewMode={setViewMode}
+ />
+ {isLoading && (
+
+
+
+ )}
+
+ {error && !isGenerating && (
+
+
+
+ {error || 'Something went wrong'}
+
+
+ {!isPaidUser && (error || '')?.includes('limit') && (
+
+ Upgrade Account
+
+ )}
+
+ )}
+
+
+
+ );
+}
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..b3fee9bd4
--- /dev/null
+++ b/src/components/GenerateCourse/AIRoadmapViewSwitch.tsx
@@ -0,0 +1,81 @@
+import { BookOpenCheckIcon, SignpostIcon, type LucideIcon } from 'lucide-react';
+import { cn } from '../../lib/classname';
+import type { AICourseViewMode } from './AICourseContent';
+
+type AIRoadmapViewSwitchProps = {
+ viewMode: AICourseViewMode;
+ setViewMode: (mode: AICourseViewMode) => void;
+ isLoading: boolean;
+ variant?: 'icon' | 'text';
+};
+
+export function AIRoadmapViewSwitch(props: AIRoadmapViewSwitchProps) {
+ const { viewMode, setViewMode, isLoading, variant = 'icon' } = props;
+
+ return (
+
+ setViewMode('outline')}
+ isActive={viewMode === 'outline'}
+ disabled={isLoading}
+ variant={variant}
+ icon={BookOpenCheckIcon}
+ label="Outline"
+ />
+
+ setViewMode('roadmap')}
+ isActive={viewMode === 'roadmap'}
+ disabled={isLoading}
+ variant={variant}
+ icon={SignpostIcon}
+ label="Roadmap"
+ />
+
+ );
+}
+
+type SwitchButtonProps = {
+ onClick: () => void;
+ isActive: boolean;
+ disabled: boolean;
+ variant?: 'icon' | 'text';
+ icon: LucideIcon;
+ label: string;
+};
+
+export function SwitchButton(props: SwitchButtonProps) {
+ const {
+ onClick,
+ isActive,
+ disabled,
+ variant = 'icon',
+ icon: Icon,
+ label,
+ } = props;
+
+ return (
+
+
+ {variant === 'text' && label}
+
+ );
+}
diff --git a/src/components/GenerateCourse/RegenerateOutline.tsx b/src/components/GenerateCourse/RegenerateOutline.tsx
index aa2a3ef20..57634af2f 100644
--- a/src/components/GenerateCourse/RegenerateOutline.tsx
+++ b/src/components/GenerateCourse/RegenerateOutline.tsx
@@ -40,17 +40,20 @@ export function RegenerateOutline(props: RegenerateOutlineProps) {
/>
)}
-
+
setIsDropdownVisible(!isDropdownVisible)}
>
{isDropdownVisible && (
-
+
{
onRegenerateOutline();
diff --git a/src/components/GenerateRoadmap/GenerateRoadmap.tsx b/src/components/GenerateRoadmap/GenerateRoadmap.tsx
index 945fc2a35..6d0414441 100644
--- a/src/components/GenerateRoadmap/GenerateRoadmap.tsx
+++ b/src/components/GenerateRoadmap/GenerateRoadmap.tsx
@@ -12,7 +12,6 @@ import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generat
import { renderFlowJSON } from '../../../editor/renderer/renderer';
import { replaceChildren } from '../../lib/dom';
import {
- getOpenAIKey,
isLoggedIn,
removeAuthToken,
setAIReferralCode,
@@ -30,7 +29,11 @@ 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 {
+ generateAICourseRoadmapStructure,
+ 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 = [
@@ -124,13 +129,11 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
const [roadmapTopicLimitUsed, setRoadmapTopicLimitUsed] = useState(0);
const [isConfiguring, setIsConfiguring] = useState(false);
- const [openAPIKey, setOpenAPIKey] = useState(
- getOpenAIKey(),
- );
const isKeyOnly = IS_KEY_ONLY_ROADMAP_GENERATION;
const renderRoadmap = async (roadmap: string) => {
- const { nodes, edges } = generateAIRoadmapFromText(roadmap);
+ const result = generateAICourseRoadmapStructure(roadmap);
+ const { nodes, edges } = generateAIRoadmapFromText(result);
const svg = await renderFlowJSON({ nodes, edges });
if (roadmapContainerRef?.current) {
replaceChildren(roadmapContainerRef?.current, svg);
@@ -476,7 +479,6 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
{isConfiguring && (
{
- setOpenAPIKey(getOpenAIKey());
setIsConfiguring(false);
loadAIRoadmapLimit().finally(() => null);
}}
@@ -519,29 +521,16 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
{isKeyOnly && isAuthenticatedUser && (
- {!openAPIKey && (
-
- We have hit the limit for AI roadmap generation. Please
- try again tomorrow or{' '}
- setIsConfiguring(true)}
- className="font-semibold text-purple-600 underline underline-offset-2"
- >
- add your own OpenAI API key
-
-
- )}
- {openAPIKey && (
-
- You have added your own OpenAI API key.{' '}
- setIsConfiguring(true)}
- className="font-semibold text-purple-600 underline underline-offset-2"
- >
- Configure it here if you want.
-
-
- )}
+
+ We have hit the limit for AI roadmap generation. Please try
+ again tomorrow or{' '}
+ setIsConfiguring(true)}
+ className="font-semibold text-purple-600 underline underline-offset-2"
+ >
+ add more credits.
+
+
)}
{!isKeyOnly && isAuthenticatedUser && (
@@ -560,25 +549,13 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
{' '}
roadmaps generated today.
- {!openAPIKey && (
- setIsConfiguring(true)}
- className="rounded-xl border border-current px-2 py-0.5 text-left text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
- >
- Need to generate more?{' '}
- Click here.
-
- )}
-
- {openAPIKey && (
- setIsConfiguring(true)}
- className="flex flex-row items-center gap-1 rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
- >
-
- Configure OpenAI key
-
- )}
+ setIsConfiguring(true)}
+ className="rounded-xl border border-current px-2 py-0.5 text-left text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
+ >
+ Need to generate more?{' '}
+ Click here.
+
)}
{!isAuthenticatedUser && (
@@ -621,7 +598,7 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
!roadmapTerm ||
roadmapLimitUsed >= roadmapLimit ||
roadmapTerm === currentRoadmap?.term ||
- (isKeyOnly && !openAPIKey)))
+ isKeyOnly))
}
>
{isLoadingResults && (
@@ -719,7 +696,7 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
diff --git a/src/components/GenerateRoadmap/IncreaseRoadmapLimit.tsx b/src/components/GenerateRoadmap/IncreaseRoadmapLimit.tsx
index ca6873b74..9c41dc8c9 100644
--- a/src/components/GenerateRoadmap/IncreaseRoadmapLimit.tsx
+++ b/src/components/GenerateRoadmap/IncreaseRoadmapLimit.tsx
@@ -1,20 +1,16 @@
import { useState } from 'react';
import { cn } from '../../lib/classname';
-import { ChevronUp } from 'lucide-react';
import { Modal } from '../Modal';
import { ReferYourFriend } from './ReferYourFriend';
-import { OpenAISettings } from './OpenAISettings';
import { PayToBypass } from './PayToBypass';
import { PickLimitOption } from './PickLimitOption';
-import { getOpenAIKey } from '../../lib/jwt.ts';
-export type IncreaseTab = 'api-key' | 'refer-friends' | 'payment';
+export type IncreaseTab = 'refer-friends' | 'payment';
export const increaseLimitTabs: {
key: IncreaseTab;
title: string;
}[] = [
- { key: 'api-key', title: 'Add your own API Key' },
{ key: 'refer-friends', title: 'Refer your Friends' },
{ key: 'payment', title: 'Pay to Bypass the limit' },
];
@@ -25,9 +21,8 @@ type IncreaseRoadmapLimitProps = {
export function IncreaseRoadmapLimit(props: IncreaseRoadmapLimitProps) {
const { onClose } = props;
- const openAPIKey = getOpenAIKey();
const [activeTab, setActiveTab] = useState
(
- openAPIKey ? 'api-key' : null,
+ 'refer-friends',
);
return (
@@ -44,14 +39,6 @@ export function IncreaseRoadmapLimit(props: IncreaseRoadmapLimitProps) {
)}
- {activeTab === 'api-key' && (
- {
- onClose();
- }}
- onBack={() => setActiveTab(null)}
- />
- )}
{activeTab === 'refer-friends' && (
setActiveTab(null)} />
)}
diff --git a/src/components/GenerateRoadmap/OpenAISettings.tsx b/src/components/GenerateRoadmap/OpenAISettings.tsx
deleted file mode 100644
index 181a8347b..000000000
--- a/src/components/GenerateRoadmap/OpenAISettings.tsx
+++ /dev/null
@@ -1,171 +0,0 @@
-import { useEffect, useState } from 'react';
-import { deleteOpenAIKey, getOpenAIKey, saveOpenAIKey } from '../../lib/jwt.ts';
-import { cn } from '../../lib/classname.ts';
-import { CloseIcon } from '../ReactIcons/CloseIcon.tsx';
-import { useToast } from '../../hooks/use-toast.ts';
-import { httpPost } from '../../lib/http.ts';
-import { ChevronLeft } from 'lucide-react';
-
-type OpenAISettingsProps = {
- onClose: () => void;
- onBack: () => void;
-};
-
-export function OpenAISettings(props: OpenAISettingsProps) {
- const { onClose, onBack } = props;
-
- const [defaultOpenAIKey, setDefaultOpenAIKey] = useState('');
-
- const [error, setError] = useState('');
- const [openaiApiKey, setOpenaiApiKey] = useState('');
- const [isLoading, setIsLoading] = useState(false);
-
- const toast = useToast();
-
- useEffect(() => {
- const apiKey = getOpenAIKey();
- setOpenaiApiKey(apiKey || '');
- setDefaultOpenAIKey(apiKey || '');
- }, []);
-
- return (
-
-
-
- Back to options
-
-
-
OpenAI Settings
-
- Add your OpenAI API key below to bypass the roadmap generation limits.
- You can use your existing key or{' '}
-
- create a new one here
-
- .
-
-
-
-
- );
-}
diff --git a/src/components/GenerateRoadmap/RoadmapSearch.tsx b/src/components/GenerateRoadmap/RoadmapSearch.tsx
index e34dd45eb..cb26813c0 100644
--- a/src/components/GenerateRoadmap/RoadmapSearch.tsx
+++ b/src/components/GenerateRoadmap/RoadmapSearch.tsx
@@ -1,10 +1,9 @@
import { ArrowUpRight, Ban, Cog, Telescope, Wand } from 'lucide-react';
import type { FormEvent } from 'react';
import { useEffect, useState } from 'react';
-import { getOpenAIKey, isLoggedIn } from '../../lib/jwt';
+import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { cn } from '../../lib/classname.ts';
-import { OpenAISettings } from './OpenAISettings.tsx';
import { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx';
@@ -33,12 +32,10 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
const canGenerateMore = limitUsed < limit;
const [isConfiguring, setIsConfiguring] = useState(false);
- const [openAPIKey, setOpenAPIKey] = useState('');
const [isAuthenticatedUser, setIsAuthenticatedUser] = useState(false);
const [isLoadingResults, setIsLoadingResults] = useState(false);
useEffect(() => {
- setOpenAPIKey(getOpenAIKey() || '');
setIsAuthenticatedUser(isLoggedIn());
}, []);
@@ -49,7 +46,6 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
{isConfiguring && (
{
- setOpenAPIKey(getOpenAIKey()!);
setIsConfiguring(false);
loadAIRoadmapLimit();
}}
@@ -104,10 +100,7 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
disabled={
isLoadingResults ||
(isAuthenticatedUser &&
- (!limit ||
- !roadmapTerm ||
- limitUsed >= limit ||
- (isKeyOnly && !openAPIKey)))
+ (!limit || !roadmapTerm || limitUsed >= limit || isKeyOnly))
}
>
{isLoadingResults && (
@@ -202,31 +195,16 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
)}
{isKeyOnly && isAuthenticatedUser && (
diff --git a/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx b/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx
index 33938800a..178ac4cb7 100644
--- a/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx
+++ b/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx
@@ -3,10 +3,10 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useKeydown } from '../../hooks/use-keydown';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { markdownToHtml } from '../../lib/markdown';
-import { Ban, Cog, Contact, FileText, X } from 'lucide-react';
+import { Ban, Contact, FileText, X } from 'lucide-react';
import { Spinner } from '../ReactIcons/Spinner';
import type { RoadmapNodeDetails } from './GenerateRoadmap';
-import { getOpenAIKey, isLoggedIn, removeAuthToken } from '../../lib/jwt';
+import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
import { cn } from '../../lib/classname';
import { showLoginPopup } from '../../lib/popup';
import { readAIRoadmapContentStream } from '../../lib/ai';
@@ -121,7 +121,6 @@ export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) {
}, []);
const hasContent = topicHtml?.length > 0;
- const openAIKey = getOpenAIKey();
return (
@@ -146,24 +145,13 @@ export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) {
{' '}
topics generated
- {!openAIKey && (
-
- Need to generate more?{' '}
- Click here.
-
- )}
- {openAIKey && (
-
-
- Configure OpenAI Key
-
- )}
+
+ Need to generate more?{' '}
+ Click here.
+
)}
diff --git a/src/lib/ai.ts b/src/lib/ai.ts
index ac9bf1d9e..d401da377 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: [],
};
}
@@ -123,7 +126,7 @@ export async function readAIRoadmapStream(
for (let i = 0; i < value.length; i++) {
if (value[i] === NEW_LINE) {
result += decoder.decode(value.slice(start, i + 1));
- onStream?.(result);
+ await onStream?.(result);
start = i + 1;
}
}
@@ -133,8 +136,8 @@ export async function readAIRoadmapStream(
}
}
- onStream?.(result);
- onStreamEnd?.(result);
+ await onStream?.(result);
+ await onStreamEnd?.(result);
reader.releaseLock();
}
@@ -207,3 +210,93 @@ 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,
+ isCourseRoadmap: boolean = false,
+): 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 (line.startsWith('###')) {
+ if (currentTopic) {
+ result.push(currentTopic);
+ }
+
+ const label = line.replace('###', '').trim();
+ currentTopic = {
+ id: nanoid(),
+ type: 'topic',
+ label,
+ children: [],
+ };
+ } else if (line.startsWith('##')) {
+ result.push({
+ id: nanoid(),
+ type: 'label',
+ label: line.replace('##', '').trim(),
+ });
+ } else if (i === 0 && line.startsWith('#')) {
+ const title = line.replace('#', '').trim();
+ result.push({
+ id: nanoid(),
+ type: 'title',
+ label: title,
+ });
+ } else if (line.startsWith('-')) {
+ if (currentTopic) {
+ const label = line.replace('-', '').trim();
+
+ let id = nanoid();
+ if (isCourseRoadmap) {
+ const currentTopicIndex = result.length - 1;
+ const subTopicIndex = currentTopic.children?.length || 0;
+ id = `${currentTopicIndex}-${subTopicIndex}`;
+ }
+
+ currentTopic.children?.push({
+ id,
+ type: 'subtopic',
+ label,
+ });
+ }
+ }
+ }
+
+ if (currentTopic) {
+ result.push(currentTopic);
+ }
+
+ return result;
+}
diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts
index 6207be59b..e8fe92d49 100644
--- a/src/lib/jwt.ts
+++ b/src/lib/jwt.ts
@@ -70,27 +70,6 @@ export function visitAIRoadmap(roadmapId: string) {
});
}
-export function deleteOpenAIKey() {
- Cookies.remove('oak', {
- path: '/',
- domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
- });
-}
-
-export function saveOpenAIKey(apiKey: string) {
- Cookies.set('oak', apiKey, {
- path: '/',
- expires: 365,
- sameSite: 'lax',
- secure: true,
- domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
- });
-}
-
-export function getOpenAIKey() {
- return Cookies.get('oak');
-}
-
const AI_REFERRAL_COOKIE_NAME = 'referral_code';
export function setAIReferralCode(code: string) {