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 <kamranahmed.se@gmail.com>
pull/8411/head
Arik Chakma 2 weeks ago committed by GitHub
parent 80a4ebbb3d
commit 85202507e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 188
      src/components/GenerateCourse/AICourseContent.tsx
  2. 31
      src/components/GenerateCourse/AICourseFollowUpPopover.tsx
  3. 2
      src/components/GenerateCourse/AICourseLesson.tsx
  4. 22
      src/components/GenerateCourse/AICourseLimit.tsx
  5. 80
      src/components/GenerateCourse/AICourseOutlineHeader.tsx
  6. 117
      src/components/GenerateCourse/AICourseOutlineView.tsx
  7. 252
      src/components/GenerateCourse/AICourseRoadmapView.tsx
  8. 5
      src/components/GenerateCourse/AICourseSidebarModuleList.tsx
  9. 81
      src/components/GenerateCourse/AIRoadmapViewSwitch.tsx
  10. 13
      src/components/GenerateCourse/RegenerateOutline.tsx
  11. 81
      src/components/GenerateRoadmap/GenerateRoadmap.tsx
  12. 17
      src/components/GenerateRoadmap/IncreaseRoadmapLimit.tsx
  13. 171
      src/components/GenerateRoadmap/OpenAISettings.tsx
  14. 72
      src/components/GenerateRoadmap/RoadmapSearch.tsx
  15. 30
      src/components/GenerateRoadmap/RoadmapTopicDetail.tsx
  16. 99
      src/lib/ai.ts
  17. 21
      src/lib/jwt.ts

@ -3,24 +3,22 @@ import {
ChevronLeft, ChevronLeft,
CircleAlert, CircleAlert,
CircleOff, CircleOff,
Loader2,
Menu, Menu,
Play,
X, X,
Map,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { type AiCourse } from '../../lib/ai'; import { type AiCourse } from '../../lib/ai';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { slugify } from '../../lib/slugger';
import { useIsPaidUser } from '../../queries/billing'; import { useIsPaidUser } from '../../queries/billing';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { CheckIcon } from '../ReactIcons/CheckIcon';
import { ErrorIcon } from '../ReactIcons/ErrorIcon'; import { ErrorIcon } from '../ReactIcons/ErrorIcon';
import { AICourseLesson } from './AICourseLesson'; import { AICourseLesson } from './AICourseLesson';
import { AICourseLimit } from './AICourseLimit'; import { AICourseLimit } from './AICourseLimit';
import { AICourseSidebarModuleList } from './AICourseSidebarModuleList'; import { AICourseSidebarModuleList } from './AICourseSidebarModuleList';
import { AILimitsPopup } from './AILimitsPopup'; import { AILimitsPopup } from './AILimitsPopup';
import { RegenerateOutline } from './RegenerateOutline'; import { AICourseOutlineView } from './AICourseOutlineView';
import { AICourseRoadmapView } from './AICourseRoadmapView';
type AICourseContentProps = { type AICourseContentProps = {
courseSlug?: string; courseSlug?: string;
@ -30,6 +28,8 @@ type AICourseContentProps = {
onRegenerateOutline: (prompt?: string) => void; onRegenerateOutline: (prompt?: string) => void;
}; };
export type AICourseViewMode = 'module' | 'outline' | 'roadmap';
export function AICourseContent(props: AICourseContentProps) { export function AICourseContent(props: AICourseContentProps) {
const { course, courseSlug, isLoading, error, onRegenerateOutline } = props; const { course, courseSlug, isLoading, error, onRegenerateOutline } = props;
@ -39,7 +39,7 @@ export function AICourseContent(props: AICourseContentProps) {
const [activeModuleIndex, setActiveModuleIndex] = useState(0); const [activeModuleIndex, setActiveModuleIndex] = useState(0);
const [activeLessonIndex, setActiveLessonIndex] = useState(0); const [activeLessonIndex, setActiveLessonIndex] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [viewMode, setViewMode] = useState<'module' | 'outline'>('outline'); const [viewMode, setViewMode] = useState<AICourseViewMode>('outline');
const { isPaidUser } = useIsPaidUser(); const { isPaidUser } = useIsPaidUser();
@ -257,6 +257,7 @@ export function AICourseContent(props: AICourseContentProps) {
<span className="font-medium">{totalModules} modules</span> <span className="font-medium">{totalModules} modules</span>
<span className="text-gray-400"></span> <span className="text-gray-400"></span>
<span className="font-medium">{totalCourseLessons} lessons</span> <span className="font-medium">{totalCourseLessons} lessons</span>
{viewMode === 'module' && ( {viewMode === 'module' && (
<span className="flex flex-row items-center gap-1 lg:hidden"> <span className="flex flex-row items-center gap-1 lg:hidden">
<span className="text-gray-400"></span> <span className="text-gray-400"></span>
@ -271,6 +272,7 @@ export function AICourseContent(props: AICourseContentProps) {
</button> </button>
</span> </span>
)} )}
{finishedPercentage > 0 && ( {finishedPercentage > 0 && (
<> <>
<span className="text-gray-400"></span> <span className="text-gray-400"></span>
@ -289,19 +291,6 @@ export function AICourseContent(props: AICourseContentProps) {
onShowLimits={() => setShowAILimitsPopup(true)} onShowLimits={() => setShowAILimitsPopup(true)}
/> />
</div> </div>
{viewMode === 'module' && (
<button
onClick={() => {
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"
>
<BookOpenCheck size={18} className="mr-2" />
View Course Outline
</button>
)}
</div> </div>
</header> </header>
@ -336,36 +325,38 @@ export function AICourseContent(props: AICourseContentProps) {
)} )}
></span> ></span>
{viewMode !== 'outline' && ( <div className="flex gap-0 rounded-md bg-white p-0.5">
<button <button
onClick={() => { onClick={() => {
setExpandedModules({}); setExpandedModules({});
setViewMode('outline'); 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',
)}
> >
<BookOpenCheck size={14} /> <BookOpenCheck size={14} />
View Outline Outline
</button> </button>
)}
{viewMode === 'outline' && (
<button <button
onClick={() => { onClick={() => {
setExpandedModules({ setExpandedModules({});
...expandedModules, setViewMode('roadmap');
0: true,
});
setActiveModuleIndex(0);
setActiveLessonIndex(0);
setViewMode('module');
}} }}
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',
)}
> >
<Play size={14} /> <Map size={14} />
Start Course Map
</button> </button>
)} </div>
</div> </div>
)} )}
@ -399,9 +390,10 @@ export function AICourseContent(props: AICourseContentProps) {
<main <main
className={cn( 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' : '', sidebarOpen ? 'lg:ml-0' : '',
)} )}
key={`${courseSlug}-${viewMode}`}
> >
{viewMode === 'module' && ( {viewMode === 'module' && (
<AICourseLesson <AICourseLesson
@ -421,103 +413,37 @@ export function AICourseContent(props: AICourseContentProps) {
)} )}
{viewMode === 'outline' && ( {viewMode === 'outline' && (
<div className="mx-auto rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-3xl"> <AICourseOutlineView
<div course={course}
className={cn( isLoading={isLoading}
'relative mb-1 flex items-start justify-between border-b border-gray-100 p-6 max-lg:hidden', onRegenerateOutline={onRegenerateOutline}
isLoading && 'striped-loader', setActiveModuleIndex={setActiveModuleIndex}
)} setActiveLessonIndex={setActiveLessonIndex}
> setSidebarOpen={setSidebarOpen}
<div> setViewMode={setViewMode}
<h2 className="mb-1 text-balance text-2xl font-bold max-lg:text-lg max-lg:leading-tight"> setExpandedModules={setExpandedModules}
{course.title || 'Loading course ..'} viewMode={viewMode}
</h2> />
<p className="text-sm capitalize text-gray-500"> )}
{course.title ? course.difficulty : 'Please wait ..'}
</p>
</div>
{!isLoading && ( {viewMode === 'roadmap' && !isLoading && (
<RegenerateOutline <AICourseRoadmapView
onRegenerateOutline={onRegenerateOutline} done={course.done}
/> courseSlug={courseSlug!}
)} course={course}
</div> isLoading={isLoading}
{course.title ? ( onRegenerateOutline={onRegenerateOutline}
<div className="flex flex-col p-6 max-lg:mt-0.5 max-lg:p-4"> setActiveModuleIndex={setActiveModuleIndex}
{course.modules.map((courseModule, moduleIdx) => { setActiveLessonIndex={setActiveLessonIndex}
return ( setViewMode={setViewMode}
<div setExpandedModules={setExpandedModules}
key={moduleIdx} onUpgradeClick={() => setShowUpgradeModal(true)}
className="mb-5 pb-4 last:border-0 last:pb-0 max-lg:mb-2" viewMode={viewMode}
> />
<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>
)} )}
<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 important info. AI can make mistakes, check imporant info.
</div> </div>
</main> </main>
</div> </div>

@ -2,8 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { import {
BookOpen, BookOpen,
Bot, Bot,
Code, Hammer,
Globe, Hammer,
HelpCircle, HelpCircle,
LockIcon, LockIcon,
Send, Send,
@ -22,6 +21,7 @@ import {
} from '../../lib/markdown'; } from '../../lib/markdown';
import { getAiCourseLimitOptions } from '../../queries/ai-course'; import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client'; import { queryClient } from '../../stores/query-client';
import { billingDetailsOptions } from '../../queries/billing';
export type AllowedAIChatRole = 'user' | 'assistant'; export type AllowedAIChatRole = 'user' | 'assistant';
export type AIChatHistoryType = { export type AIChatHistoryType = {
@ -70,7 +70,11 @@ export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) {
queryClient, queryClient,
); );
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0); const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
const handleChatSubmit = (e: FormEvent<HTMLFormElement>) => { const handleChatSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@ -247,15 +251,20 @@ export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) {
{isLimitExceeded && ( {isLimitExceeded && (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black text-white"> <div className="absolute inset-0 flex items-center justify-center gap-2 bg-black text-white">
<LockIcon className="size-4 cursor-not-allowed" strokeWidth={2.5} /> <LockIcon className="size-4 cursor-not-allowed" strokeWidth={2.5} />
<p className="cursor-not-allowed">Limit reached for today</p> <p className="cursor-not-allowed">
<button Limit reached for today
onClick={() => { {isPaidUser ? '. Please wait until tomorrow.' : ''}
onUpgradeClick(); </p>
}} {!isPaidUser && (
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300" <button
> onClick={() => {
Upgrade for more onUpgradeClick();
</button> }}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
>
Upgrade for more
</button>
)}
</div> </div>
)} )}
<TextareaAutosize <TextareaAutosize

@ -290,7 +290,7 @@ export function AICourseLesson(props: AICourseLessonProps) {
<p className="my-3 text-red-600"> <p className="my-3 text-red-600">
You have reached the AI usage limit for today. You have reached the AI usage limit for today.
{!isPaidUser && <>Please upgrade your account to continue.</>} {!isPaidUser && <>Please upgrade your account to continue.</>}
{isPaidUser && <>Please wait until tomorrow to continue.</>} {isPaidUser && <>&nbsp;Please wait until tomorrow to continue.</>}
</p> </p>
{!isPaidUser && ( {!isPaidUser && (

@ -32,23 +32,21 @@ export function AICourseLimit(props: AICourseLimitProps) {
const totalPercentage = getPercentage(used, limit); const totalPercentage = getPercentage(used, limit);
// has consumed 85% of the limit // has consumed 85% of the limit
const isNearLimit = used >= limit * 0.85;
const isPaidUser = userBillingDetails.status === 'active'; const isPaidUser = userBillingDetails.status === 'active';
return ( return (
<> <>
{!isPaidUser || {!isPaidUser && (
(isNearLimit && ( <button
<button className="mr-1 flex items-center gap-1 text-sm font-medium underline underline-offset-2 lg:hidden"
className="mr-1 flex items-center gap-1 text-sm font-medium underline underline-offset-2 lg:hidden" onClick={() => onShowLimits()}
onClick={() => onShowLimits()} >
> <Info className="size-4" />
<Info className="size-4" /> {totalPercentage}% limit used
{totalPercentage}% limit used </button>
</button> )}
))}
{(!isPaidUser || isNearLimit) && ( {!isPaidUser && (
<button <button
onClick={() => { onClick={() => {
onShowLimits(); onShowLimits();

@ -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 (
<div
className={cn(
'relative mb-1 flex items-start justify-between border-b border-gray-100 p-6 max-lg:p-3',
isLoading && 'striped-loader',
)}
>
<div className="max-lg:hidden">
<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>
<div className="absolute right-3 top-3 flex gap-2 max-lg:relative max-lg:right-0 max-lg:top-0 max-lg:w-full max-lg:flex-row-reverse max-lg:justify-between">
{!isLoading && (
<>
<RegenerateOutline onRegenerateOutline={onRegenerateOutline} />
<div className="mr-1 flex rounded-lg border border-gray-200 bg-white p-0.5">
<button
onClick={() => 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',
)}
>
<BookOpenCheck
className={cn(
'size-4',
viewMode === 'outline' && 'text-gray-900',
)}
/>
<span>Outline</span>
</button>
<button
onClick={() => 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',
)}
>
<Signpost
className={cn(
'size-4',
viewMode === 'roadmap' && 'text-gray-900',
)}
/>
<span>Map</span>
</button>
</div>
</>
)}
</div>
</div>
);
}

@ -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<SetStateAction<Record<number, boolean>>>;
viewMode: AICourseViewMode;
};
export function AICourseOutlineView(props: AICourseOutlineViewProps) {
const {
course,
isLoading,
onRegenerateOutline,
setActiveModuleIndex,
setActiveLessonIndex,
setSidebarOpen,
setViewMode,
setExpandedModules,
viewMode,
} = props;
const aiCourseProgress = course.done || [];
return (
<div className="mx-auto rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-5xl">
<AICourseOutlineHeader
course={course}
isLoading={isLoading}
onRegenerateOutline={onRegenerateOutline}
viewMode={viewMode}
setViewMode={setViewMode}
/>
{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,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<SetStateAction<Record<number, boolean>>>;
viewMode: AICourseViewMode;
};
export function AICourseRoadmapView(props: AICourseRoadmapViewProps) {
const {
done = [],
courseSlug,
course,
isLoading,
onRegenerateOutline,
setActiveModuleIndex,
setActiveLessonIndex,
setViewMode,
setExpandedModules,
onUpgradeClick,
viewMode,
} = props;
const containerEl = useRef<HTMLDivElement>(null);
const [roadmapStructure, setRoadmapStructure] = useState<ResultItem[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLDivElement, unknown>) => {
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<number, boolean> = {};
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<number, boolean> = {};
roadmapStructure.forEach((_, idx) => {
newState[idx] = false;
});
newState[moduleIndex] = true;
return newState;
});
setActiveModuleIndex(moduleIndex);
setActiveLessonIndex(topicIndex);
setViewMode('module');
},
[
roadmapStructure,
setExpandedModules,
setActiveModuleIndex,
setActiveLessonIndex,
setViewMode,
],
);
return (
<div className="relative mx-auto min-h-[500px] rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-5xl">
<AICourseOutlineHeader
course={course}
isLoading={isLoading}
onRegenerateOutline={(prompt) => {
setViewMode('outline');
onRegenerateOutline(prompt);
}}
viewMode={viewMode}
setViewMode={setViewMode}
/>
{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 && !isGenerating && (
<div className="absolute inset-0 flex h-full w-full flex-col items-center justify-center">
<Frown className="size-20 text-red-500" />
<p className="mx-auto mt-5 max-w-[250px] text-balance text-center text-base text-red-500">
{error || 'Something went wrong'}
</p>
{!isPaidUser && (error || '')?.includes('limit') && (
<button
onClick={onUpgradeClick}
className="mt-5 rounded-full bg-red-600 px-4 py-1 text-white hover:bg-red-700"
>
Upgrade Account
</button>
)}
</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 { slugify } from '../../lib/slugger';
import { CheckIcon } from '../ReactIcons/CheckIcon'; import { CheckIcon } from '../ReactIcons/CheckIcon';
import { CircularProgress } from './CircularProgress'; import { CircularProgress } from './CircularProgress';
import type { AICourseViewMode } from './AICourseContent';
type AICourseModuleListProps = { type AICourseModuleListProps = {
course: AiCourse; course: AiCourse;
@ -16,8 +17,8 @@ type AICourseModuleListProps = {
setSidebarOpen: (open: boolean) => void; setSidebarOpen: (open: boolean) => void;
viewMode: 'module' | 'outline'; viewMode: AICourseViewMode;
setViewMode: (mode: 'module' | 'outline') => void; setViewMode: (mode: AICourseViewMode) => void;
expandedModules: Record<number, boolean>; expandedModules: Record<number, boolean>;
setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>; setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>;

@ -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 (
<div
className={cn(
'grid shrink-0 grid-cols-2 gap-0.5 rounded-md border border-gray-300 bg-white p-0.5 shadow-sm',
)}
>
<SwitchButton
onClick={() => setViewMode('outline')}
isActive={viewMode === 'outline'}
disabled={isLoading}
variant={variant}
icon={BookOpenCheckIcon}
label="Outline"
/>
<SwitchButton
onClick={() => setViewMode('roadmap')}
isActive={viewMode === 'roadmap'}
disabled={isLoading}
variant={variant}
icon={SignpostIcon}
label="Roadmap"
/>
</div>
);
}
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 (
<button
className={cn(
'flex items-center justify-center gap-1.5 rounded text-sm hover:bg-gray-100 disabled:cursor-not-allowed',
isActive && 'bg-gray-100 text-gray-800',
variant === 'text' ? 'px-2 py-1.5' : 'p-[5px]',
)}
onClick={onClick}
disabled={disabled}
>
<Icon
className={cn(
'size-4',
variant === 'icon' && 'h-3 w-3',
isActive && 'text-gray-800',
)}
/>
{variant === 'text' && label}
</button>
);
}

@ -40,17 +40,20 @@ export function RegenerateOutline(props: RegenerateOutlineProps) {
/> />
)} )}
<div className="absolute right-3 top-3" ref={ref}> <div ref={ref} className="flex relative items-stretch">
<button <button
className={cn('text-gray-400 hover:text-black', { className={cn(
'text-black': isDropdownVisible, 'rounded-md px-2.5 text-gray-400 hover:text-black',
})} {
'text-black': isDropdownVisible,
},
)}
onClick={() => setIsDropdownVisible(!isDropdownVisible)} onClick={() => setIsDropdownVisible(!isDropdownVisible)}
> >
<PenSquare className="text-current" size={16} strokeWidth={2.5} /> <PenSquare className="text-current" size={16} strokeWidth={2.5} />
</button> </button>
{isDropdownVisible && ( {isDropdownVisible && (
<div className="absolute right-0 top-full min-w-[170px] overflow-hidden rounded-md border border-gray-200 bg-white"> <div className="absolute right-0 top-full translate-y-1 min-w-[170px] overflow-hidden rounded-md border border-gray-200 bg-white shadow-md">
<button <button
onClick={() => { onClick={() => {
onRegenerateOutline(); onRegenerateOutline();

@ -12,7 +12,6 @@ import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generat
import { renderFlowJSON } from '../../../editor/renderer/renderer'; import { renderFlowJSON } from '../../../editor/renderer/renderer';
import { replaceChildren } from '../../lib/dom'; import { replaceChildren } from '../../lib/dom';
import { import {
getOpenAIKey,
isLoggedIn, isLoggedIn,
removeAuthToken, removeAuthToken,
setAIReferralCode, setAIReferralCode,
@ -30,7 +29,11 @@ import { showLoginPopup } from '../../lib/popup.ts';
import { cn } from '../../lib/classname.ts'; import { cn } from '../../lib/classname.ts';
import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx'; import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx';
import { AIRoadmapAlert } from './AIRoadmapAlert.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 { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx'; import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx';
import { AuthenticationForm } from '../AuthenticationFlow/AuthenticationForm.tsx'; import { AuthenticationForm } from '../AuthenticationFlow/AuthenticationForm.tsx';
@ -51,6 +54,7 @@ export type RoadmapNodeDetails = {
targetGroup?: SVGElement; targetGroup?: SVGElement;
nodeTitle?: string; nodeTitle?: string;
parentTitle?: string; parentTitle?: string;
parentId?: string;
}; };
export function getNodeDetails( export function getNodeDetails(
@ -62,9 +66,10 @@ export function getNodeDetails(
const nodeType = targetGroup?.dataset?.type; const nodeType = targetGroup?.dataset?.type;
const nodeTitle = targetGroup?.dataset?.title; const nodeTitle = targetGroup?.dataset?.title;
const parentTitle = targetGroup?.dataset?.parentTitle; const parentTitle = targetGroup?.dataset?.parentTitle;
const parentId = targetGroup?.dataset?.parentId;
if (!nodeId || !nodeType) return null; if (!nodeId || !nodeType) return null;
return { nodeId, nodeType, targetGroup, nodeTitle, parentTitle }; return { nodeId, nodeType, targetGroup, nodeTitle, parentTitle, parentId };
} }
export const allowedClickableNodeTypes = [ export const allowedClickableNodeTypes = [
@ -124,13 +129,11 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
const [roadmapTopicLimitUsed, setRoadmapTopicLimitUsed] = useState(0); const [roadmapTopicLimitUsed, setRoadmapTopicLimitUsed] = useState(0);
const [isConfiguring, setIsConfiguring] = useState(false); const [isConfiguring, setIsConfiguring] = useState(false);
const [openAPIKey, setOpenAPIKey] = useState<string | undefined>(
getOpenAIKey(),
);
const isKeyOnly = IS_KEY_ONLY_ROADMAP_GENERATION; const isKeyOnly = IS_KEY_ONLY_ROADMAP_GENERATION;
const renderRoadmap = async (roadmap: string) => { 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 }); const svg = await renderFlowJSON({ nodes, edges });
if (roadmapContainerRef?.current) { if (roadmapContainerRef?.current) {
replaceChildren(roadmapContainerRef?.current, svg); replaceChildren(roadmapContainerRef?.current, svg);
@ -476,7 +479,6 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
{isConfiguring && ( {isConfiguring && (
<IncreaseRoadmapLimit <IncreaseRoadmapLimit
onClose={() => { onClose={() => {
setOpenAPIKey(getOpenAIKey());
setIsConfiguring(false); setIsConfiguring(false);
loadAIRoadmapLimit().finally(() => null); loadAIRoadmapLimit().finally(() => null);
}} }}
@ -519,29 +521,16 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
<AIRoadmapAlert /> <AIRoadmapAlert />
{isKeyOnly && isAuthenticatedUser && ( {isKeyOnly && isAuthenticatedUser && (
<div className="flex flex-row gap-4"> <div className="flex flex-row gap-4">
{!openAPIKey && ( <p className={'text-left text-red-500'}>
<p className={'text-left text-red-500'}> We have hit the limit for AI roadmap generation. Please try
We have hit the limit for AI roadmap generation. Please again tomorrow or{' '}
try again tomorrow or{' '} <button
<button onClick={() => setIsConfiguring(true)}
onClick={() => setIsConfiguring(true)} className="font-semibold text-purple-600 underline underline-offset-2"
className="font-semibold text-purple-600 underline underline-offset-2" >
> add more credits.
add your own OpenAI API key </button>
</button> </p>
</p>
)}
{openAPIKey && (
<p className={'text-left text-gray-500'}>
You have added your own OpenAI API key.{' '}
<button
onClick={() => setIsConfiguring(true)}
className="font-semibold text-purple-600 underline underline-offset-2"
>
Configure it here if you want.
</button>
</p>
)}
</div> </div>
)} )}
{!isKeyOnly && isAuthenticatedUser && ( {!isKeyOnly && isAuthenticatedUser && (
@ -560,25 +549,13 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
</span>{' '} </span>{' '}
roadmaps generated today. roadmaps generated today.
</span> </span>
{!openAPIKey && ( <button
<button onClick={() => setIsConfiguring(true)}
onClick={() => 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"
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?{' '}
Need to generate more?{' '} <span className="font-semibold">Click here.</span>
<span className="font-semibold">Click here.</span> </button>
</button>
)}
{openAPIKey && (
<button
onClick={() => 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"
>
<Cog size={15} />
Configure OpenAI key
</button>
)}
</div> </div>
)} )}
{!isAuthenticatedUser && ( {!isAuthenticatedUser && (
@ -621,7 +598,7 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
!roadmapTerm || !roadmapTerm ||
roadmapLimitUsed >= roadmapLimit || roadmapLimitUsed >= roadmapLimit ||
roadmapTerm === currentRoadmap?.term || roadmapTerm === currentRoadmap?.term ||
(isKeyOnly && !openAPIKey))) isKeyOnly))
} }
> >
{isLoadingResults && ( {isLoadingResults && (
@ -719,7 +696,7 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
</div> </div>
<div <div
className={cn({ 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, !isAuthenticatedUser,
})} })}
> >

@ -1,20 +1,16 @@
import { useState } from 'react'; import { useState } from 'react';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { ChevronUp } from 'lucide-react';
import { Modal } from '../Modal'; import { Modal } from '../Modal';
import { ReferYourFriend } from './ReferYourFriend'; import { ReferYourFriend } from './ReferYourFriend';
import { OpenAISettings } from './OpenAISettings';
import { PayToBypass } from './PayToBypass'; import { PayToBypass } from './PayToBypass';
import { PickLimitOption } from './PickLimitOption'; 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: { export const increaseLimitTabs: {
key: IncreaseTab; key: IncreaseTab;
title: string; title: string;
}[] = [ }[] = [
{ key: 'api-key', title: 'Add your own API Key' },
{ key: 'refer-friends', title: 'Refer your Friends' }, { key: 'refer-friends', title: 'Refer your Friends' },
{ key: 'payment', title: 'Pay to Bypass the limit' }, { key: 'payment', title: 'Pay to Bypass the limit' },
]; ];
@ -25,9 +21,8 @@ type IncreaseRoadmapLimitProps = {
export function IncreaseRoadmapLimit(props: IncreaseRoadmapLimitProps) { export function IncreaseRoadmapLimit(props: IncreaseRoadmapLimitProps) {
const { onClose } = props; const { onClose } = props;
const openAPIKey = getOpenAIKey();
const [activeTab, setActiveTab] = useState<IncreaseTab | null>( const [activeTab, setActiveTab] = useState<IncreaseTab | null>(
openAPIKey ? 'api-key' : null, 'refer-friends',
); );
return ( return (
@ -44,14 +39,6 @@ export function IncreaseRoadmapLimit(props: IncreaseRoadmapLimitProps) {
<PickLimitOption activeTab={activeTab} setActiveTab={setActiveTab} /> <PickLimitOption activeTab={activeTab} setActiveTab={setActiveTab} />
)} )}
{activeTab === 'api-key' && (
<OpenAISettings
onClose={() => {
onClose();
}}
onBack={() => setActiveTab(null)}
/>
)}
{activeTab === 'refer-friends' && ( {activeTab === 'refer-friends' && (
<ReferYourFriend onBack={() => setActiveTab(null)} /> <ReferYourFriend onBack={() => setActiveTab(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 (
<div className="p-4">
<button
onClick={onBack}
className="mb-5 flex items-center gap-1.5 text-sm leading-none opacity-40 transition-opacity hover:opacity-100 focus:outline-none"
>
<ChevronLeft size={16} />
Back to options
</button>
<h2 className="text-xl font-semibold text-gray-800">OpenAI Settings</h2>
<p className="mt-2 text-sm leading-normal text-gray-500">
Add your OpenAI API key below to bypass the roadmap generation limits.
You can use your existing key or{' '}
<a
className="underline underline-offset-2 hover:text-gray-900"
href={'https://platform.openai.com/signup'}
target="_blank"
>
create a new one here
</a>
.
</p>
<form
className="mt-4"
onSubmit={async (e) => {
e.preventDefault();
setError('');
const normalizedKey = openaiApiKey.trim();
if (!normalizedKey) {
deleteOpenAIKey();
toast.success('OpenAI API key removed');
onClose();
return;
}
if (!normalizedKey.startsWith('sk-')) {
setError("Invalid OpenAI API key. It should start with 'sk-'");
return;
}
setIsLoading(true);
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-validate-openai-key`,
{
key: normalizedKey,
},
);
if (error) {
setError(error.message);
setIsLoading(false);
return;
}
// Save the API key to cookies
saveOpenAIKey(normalizedKey);
toast.success('OpenAI API key saved');
onClose();
}}
>
<div className="relative">
<input
type="text"
name="openai-api-key"
id="openai-api-key"
className={cn(
'block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-800 transition-colors focus:border-black focus:outline-none',
{
'border-red-500 bg-red-100 focus:border-red-500': error,
},
)}
placeholder="Enter your OpenAI API key"
value={openaiApiKey}
onChange={(e) => {
setError('');
setOpenaiApiKey((e.target as HTMLInputElement).value);
}}
/>
{openaiApiKey && (
<button
type={'button'}
onClick={() => {
setOpenaiApiKey('');
}}
className="absolute right-2 top-1/2 flex h-[20px] w-[20px] -translate-y-1/2 items-center justify-center rounded-full bg-gray-400 text-white hover:bg-gray-600"
>
<CloseIcon className="h-[13px] w-[13px] stroke-[3.5]" />
</button>
)}
</div>
<p className={'mb-2 mt-1 text-xs text-gray-500'}>
We do not store your API key on our servers.
</p>
{error && (
<p className="mt-2 text-sm text-red-500">
{error}
</p>
)}
<button
disabled={isLoading}
type="submit"
className={
'mt-2 w-full rounded-md bg-gray-700 px-4 py-2 text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-50'
}
>
{!isLoading && 'Save'}
{isLoading && 'Validating ..'}
</button>
{!defaultOpenAIKey && (
<button
type="button"
onClick={() => {
onClose();
}}
className="mt-1 w-full rounded-md border border-red-500 px-4 py-2 text-sm text-red-600 transition-colors hover:bg-red-700 hover:text-white"
>
Cancel
</button>
)}
{defaultOpenAIKey && (
<button
type="button"
onClick={() => {
deleteOpenAIKey();
onClose();
toast.success('OpenAI API key removed');
}}
className="mt-1 w-full rounded-md border border-red-500 px-4 py-2 text-sm text-red-600 transition-colors hover:bg-red-700 hover:text-white"
>
Remove API Key
</button>
)}
</form>
</div>
);
}

@ -1,10 +1,9 @@
import { ArrowUpRight, Ban, Cog, Telescope, Wand } from 'lucide-react'; import { ArrowUpRight, Ban, Cog, Telescope, Wand } from 'lucide-react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getOpenAIKey, isLoggedIn } from '../../lib/jwt'; import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup'; import { showLoginPopup } from '../../lib/popup';
import { cn } from '../../lib/classname.ts'; import { cn } from '../../lib/classname.ts';
import { OpenAISettings } from './OpenAISettings.tsx';
import { AITermSuggestionInput } from './AITermSuggestionInput.tsx'; import { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx'; import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx';
@ -33,12 +32,10 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
const canGenerateMore = limitUsed < limit; const canGenerateMore = limitUsed < limit;
const [isConfiguring, setIsConfiguring] = useState(false); const [isConfiguring, setIsConfiguring] = useState(false);
const [openAPIKey, setOpenAPIKey] = useState('');
const [isAuthenticatedUser, setIsAuthenticatedUser] = useState(false); const [isAuthenticatedUser, setIsAuthenticatedUser] = useState(false);
const [isLoadingResults, setIsLoadingResults] = useState(false); const [isLoadingResults, setIsLoadingResults] = useState(false);
useEffect(() => { useEffect(() => {
setOpenAPIKey(getOpenAIKey() || '');
setIsAuthenticatedUser(isLoggedIn()); setIsAuthenticatedUser(isLoggedIn());
}, []); }, []);
@ -49,7 +46,6 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
{isConfiguring && ( {isConfiguring && (
<IncreaseRoadmapLimit <IncreaseRoadmapLimit
onClose={() => { onClose={() => {
setOpenAPIKey(getOpenAIKey()!);
setIsConfiguring(false); setIsConfiguring(false);
loadAIRoadmapLimit(); loadAIRoadmapLimit();
}} }}
@ -104,10 +100,7 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
disabled={ disabled={
isLoadingResults || isLoadingResults ||
(isAuthenticatedUser && (isAuthenticatedUser &&
(!limit || (!limit || !roadmapTerm || limitUsed >= limit || isKeyOnly))
!roadmapTerm ||
limitUsed >= limit ||
(isKeyOnly && !openAPIKey)))
} }
> >
{isLoadingResults && ( {isLoadingResults && (
@ -202,31 +195,16 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
)} )}
{isKeyOnly && isAuthenticatedUser && ( {isKeyOnly && isAuthenticatedUser && (
<div className="mx-auto mt-12 flex max-w-[450px] flex-col items-center gap-4"> <div className="mx-auto mt-12 flex max-w-[450px] flex-col items-center gap-4">
{!openAPIKey && ( <p className={'text-center text-red-500'}>
<> We have hit the limit for AI roadmap generation. Please try again
<p className={'text-center text-red-500'}> again later or{' '}
We have hit the limit for AI roadmap generation. Please try <button
again later or{' '} onClick={() => setIsConfiguring(true)}
<button className="font-semibold text-purple-600 underline underline-offset-2"
onClick={() => setIsConfiguring(true)} >
className="font-semibold text-purple-600 underline underline-offset-2" get more credits.
> </button>
add your own OpenAI API key. </p>
</button>
</p>
</>
)}
{openAPIKey && (
<p className={'text-center text-gray-500'}>
You have added your own OpenAI API key.{' '}
<button
onClick={() => setIsConfiguring(true)}
className="font-semibold text-purple-600 underline underline-offset-2"
>
Configure it here if you want.
</button>
</p>
)}
<p className="flex flex-col gap-2 text-center text-gray-500 sm:flex-row"> <p className="flex flex-col gap-2 text-center text-gray-500 sm:flex-row">
<a <a
@ -259,25 +237,13 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
</p> </p>
{isAuthenticatedUser && ( {isAuthenticatedUser && (
<p className="flex items-center text-sm"> <p className="flex items-center text-sm">
{!openAPIKey && ( <button
<button onClick={() => setIsConfiguring(true)}
onClick={() => setIsConfiguring(true)} className="rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
className="rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white" >
> Need to generate more?{' '}
Need to generate more?{' '} <span className="font-semibold">Click here.</span>
<span className="font-semibold">Click here.</span> </button>
</button>
)}
{openAPIKey && (
<button
onClick={() => 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"
>
<Cog size={15} />
Configure OpenAI key
</button>
)}
</p> </p>
)} )}
</div> </div>

@ -3,10 +3,10 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useKeydown } from '../../hooks/use-keydown'; import { useKeydown } from '../../hooks/use-keydown';
import { useOutsideClick } from '../../hooks/use-outside-click'; import { useOutsideClick } from '../../hooks/use-outside-click';
import { markdownToHtml } from '../../lib/markdown'; 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 { Spinner } from '../ReactIcons/Spinner';
import type { RoadmapNodeDetails } from './GenerateRoadmap'; import type { RoadmapNodeDetails } from './GenerateRoadmap';
import { getOpenAIKey, isLoggedIn, removeAuthToken } from '../../lib/jwt'; import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { showLoginPopup } from '../../lib/popup'; import { showLoginPopup } from '../../lib/popup';
import { readAIRoadmapContentStream } from '../../lib/ai'; import { readAIRoadmapContentStream } from '../../lib/ai';
@ -121,7 +121,6 @@ export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) {
}, []); }, []);
const hasContent = topicHtml?.length > 0; const hasContent = topicHtml?.length > 0;
const openAIKey = getOpenAIKey();
return ( return (
<div className={'relative z-[92]'}> <div className={'relative z-[92]'}>
@ -146,24 +145,13 @@ export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) {
</span>{' '} </span>{' '}
topics generated topics generated
</span> </span>
{!openAIKey && ( <button
<button className="rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
className="rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center" onClick={onConfigureOpenAI}
onClick={onConfigureOpenAI} >
> Need to generate more?{' '}
Need to generate more?{' '} <span className="font-semibold">Click here.</span>
<span className="font-semibold">Click here.</span> </button>
</button>
)}
{openAIKey && (
<button
className="flex items-center gap-1 rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
onClick={onConfigureOpenAI}
>
<Cog className="-mt-0.5 inline-block h-4 w-4" />
Configure OpenAI Key
</button>
)}
</div> </div>
)} )}

@ -1,3 +1,5 @@
import { nanoid } from 'nanoid';
export const IS_KEY_ONLY_ROADMAP_GENERATION = false; export const IS_KEY_ONLY_ROADMAP_GENERATION = false;
type Lesson = string; type Lesson = string;
@ -52,6 +54,7 @@ export function generateAiCourseStructure(
return { return {
title, title,
modules, modules,
done: [],
}; };
} }
@ -123,7 +126,7 @@ export async function readAIRoadmapStream(
for (let i = 0; i < value.length; i++) { for (let i = 0; i < value.length; i++) {
if (value[i] === NEW_LINE) { if (value[i] === NEW_LINE) {
result += decoder.decode(value.slice(start, i + 1)); result += decoder.decode(value.slice(start, i + 1));
onStream?.(result); await onStream?.(result);
start = i + 1; start = i + 1;
} }
} }
@ -133,8 +136,8 @@ export async function readAIRoadmapStream(
} }
} }
onStream?.(result); await onStream?.(result);
onStreamEnd?.(result); await onStreamEnd?.(result);
reader.releaseLock(); reader.releaseLock();
} }
@ -207,3 +210,93 @@ export async function readStream(
onStreamEnd?.(result); onStreamEnd?.(result);
reader.releaseLock(); 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;
}

@ -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'; const AI_REFERRAL_COOKIE_NAME = 'referral_code';
export function setAIReferralCode(code: string) { export function setAIReferralCode(code: string) {

Loading…
Cancel
Save