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' && ( - - )} @@ -336,36 +325,38 @@ export function AICourseContent(props: AICourseContentProps) { )} > - {viewMode !== 'outline' && ( +
- )} - - {viewMode === 'outline' && ( - )} +
)} @@ -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

- +

+ Limit reached for today + {isPaidUser ? '. Please wait until tomorrow.' : ''} +

+ {!isPaidUser && ( + + )}
)} 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 && ( - - ))} + {!isPaidUser && ( + + )} - {(!isPaidUser || isNearLimit) && ( + {!isPaidUser && ( + + + + )} + + + ); +} 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') && ( + + )} +
+ )} + +
+
+ ); +} 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 ( + + ); +} 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) { /> )} -
+
{isDropdownVisible && ( -
+
-

- )} - {openAPIKey && ( -

- You have added your own OpenAI API key.{' '} - -

- )} +

+ We have hit the limit for AI roadmap generation. Please try + again tomorrow or{' '} + +

)} {!isKeyOnly && isAuthenticatedUser && ( @@ -560,25 +549,13 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) { {' '} roadmaps generated today. - {!openAPIKey && ( - - )} - - {openAPIKey && ( - - )} +
)} {!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 ( -
- - -

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 - - . -

- -
{ - 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(); - }} - > -
- { - setError(''); - setOpenaiApiKey((e.target as HTMLInputElement).value); - }} - /> - - {openaiApiKey && ( - - )} -
-

- We do not store your API key on our servers. -

- - {error && ( -

- {error} -

- )} - - {!defaultOpenAIKey && ( - - )} - {defaultOpenAIKey && ( - - )} -
-
- ); -} 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 && (
- {!openAPIKey && ( - <> -

- We have hit the limit for AI roadmap generation. Please try - again later or{' '} - -

- - )} - {openAPIKey && ( -

- You have added your own OpenAI API key.{' '} - -

- )} +

+ We have hit the limit for AI roadmap generation. Please try again + again later or{' '} + +

{isAuthenticatedUser && (

- {!openAPIKey && ( - - )} - - {openAPIKey && ( - - )} +

)}
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 && ( - - )} - {openAIKey && ( - - )} +
)} 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) {