1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-09 08:40:40 +02:00

wip: ai roadmap

This commit is contained in:
Arik Chakma
2025-06-24 21:05:56 +06:00
parent 1c73ab3c1d
commit ae790470fe
12 changed files with 519 additions and 352 deletions

View File

@@ -0,0 +1,58 @@
@font-face {
font-family: 'balsamiq';
src: url('/fonts/balsamiq.woff2');
}
svg text tspan {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeSpeed;
}
svg > g[data-type='topic'],
svg > g[data-type='subtopic'],
svg > g > g[data-type='link-item'],
svg > g[data-type='button'] {
cursor: pointer;
}
svg > g[data-type='topic']:hover > rect {
fill: #d6d700;
}
svg > g[data-type='subtopic']:hover > rect {
fill: #f3c950;
}
svg > g[data-type='button']:hover {
opacity: 0.8;
}
svg .done rect {
fill: #cbcbcb !important;
}
svg .done text,
svg .skipped text {
text-decoration: line-through;
}
svg > g[data-type='topic'].learning > rect + text,
svg > g[data-type='topic'].done > rect + text {
fill: black;
}
svg > g[data-type='subtipic'].done > rect + text,
svg > g[data-type='subtipic'].learning > rect + text {
fill: #cbcbcb;
}
svg .learning rect {
fill: #dad1fd !important;
}
svg .learning text {
text-decoration: underline;
}
svg .skipped rect {
fill: #496b69 !important;
}

View File

@@ -0,0 +1,83 @@
import './AIRoadmap.css';
import { useQuery } from '@tanstack/react-query';
import { useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { useToast } from '../../hooks/use-toast';
import { queryClient } from '../../stores/query-client';
import { AITutorLayout } from '../AITutor/AITutorLayout';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { aiRoadmapOptions } from '../../queries/ai-roadmap';
import { GenerateAIRoadmap } from './GenerateAIRoadmap';
import { AIRoadmapContent } from './AIRoadmapContent';
type AIRoadmapProps = {
roadmapSlug?: string;
};
export function AIRoadmap(props: AIRoadmapProps) {
const { roadmapSlug: defaultRoadmapSlug } = props;
const [roadmapSlug, setRoadmapSlug] = useState(defaultRoadmapSlug);
const toast = useToast();
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
// only fetch the guide if the guideSlug is provided
// otherwise we are still generating the guide
const { data: aiRoadmap, isLoading: isLoadingBySlug } = useQuery(
aiRoadmapOptions(roadmapSlug, containerRef),
queryClient,
);
const handleRegenerate = async (prompt?: string) => {
flushSync(() => {
setIsRegenerating(true);
});
queryClient.cancelQueries(aiRoadmapOptions(roadmapSlug));
queryClient.setQueryData(aiRoadmapOptions(roadmapSlug).queryKey, (old) => {
if (!old) {
return old;
}
return {
...old,
data: '',
svg: null,
};
});
};
return (
<AITutorLayout
wrapperClassName="flex-row p-0 lg:p-0 overflow-hidden bg-white"
containerClassName="h-[calc(100vh-49px)] overflow-hidden relative"
>
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
<div className="grow overflow-y-auto p-4 pt-0">
{roadmapSlug && (
<AIRoadmapContent
svg={aiRoadmap?.svg || null}
containerRef={containerRef}
isLoading={isLoadingBySlug || isRegenerating}
/>
)}
{!roadmapSlug && (
<GenerateAIRoadmap onRoadmapSlugChange={setRoadmapSlug} />
)}
</div>
{/* <AIGuideChat
guideSlug={guideSlug}
isGuideLoading={!aiGuide}
onUpgrade={() => setShowUpgradeModal(true)}
randomQuestions={randomQuestions}
isQuestionsLoading={isAiGuideSuggestionsLoading}
/> */}
</AITutorLayout>
);
}

View File

@@ -0,0 +1,35 @@
import { cn } from '../../lib/classname';
import { LoadingChip } from '../LoadingChip';
import { useEffect, type RefObject } from 'react';
import { replaceChildren } from '../../lib/dom';
type AIRoadmapContentProps = {
svg: SVGElement | null;
isLoading?: boolean;
containerRef: RefObject<HTMLDivElement | null>;
};
export function AIRoadmapContent(props: AIRoadmapContentProps) {
const { svg, isLoading, containerRef } = props;
return (
<div
className={cn(
'relative mx-auto w-full max-w-4xl',
isLoading && 'min-h-full',
)}
>
<div
ref={containerRef}
id="roadmap-container"
className="relative min-h-[400px] px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]"
/>
{isLoading && !svg && (
<div className="absolute inset-0 flex items-center justify-center">
<LoadingChip message="Please wait..." />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,122 @@
import { useEffect, useRef, useState } from 'react';
import { getUrlParams } from '../../lib/browser';
import { isLoggedIn } from '../../lib/jwt';
import { queryClient } from '../../stores/query-client';
import { LoadingChip } from '../LoadingChip';
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
import { getQuestionAnswerChatMessages } from '../../lib/ai-questions';
import { aiRoadmapOptions, generateAIRoadmap } from '../../queries/ai-roadmap';
import { replaceChildren } from '../../lib/dom';
type GenerateAIRoadmapProps = {
onRoadmapSlugChange?: (roadmapSlug: string) => void;
};
export function GenerateAIRoadmap(props: GenerateAIRoadmapProps) {
const { onRoadmapSlugChange } = props;
const [isLoading, setIsLoading] = useState(true);
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState('');
const [content, setContent] = useState('');
const svgRef = useRef<SVGElement | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const params = getUrlParams();
const paramsTerm = params?.term;
const paramsSrc = params?.src || 'search';
if (!paramsTerm) {
return;
}
let questionAndAnswers: QuestionAnswerChatMessage[] = [];
const sessionId = params?.id;
if (sessionId) {
questionAndAnswers = getQuestionAnswerChatMessages(sessionId);
}
handleGenerateDocument({
term: paramsTerm,
src: paramsSrc,
questionAndAnswers,
});
}, []);
const handleGenerateDocument = async (options: {
term: string;
isForce?: boolean;
prompt?: string;
src?: string;
questionAndAnswers?: QuestionAnswerChatMessage[];
}) => {
const { term, isForce, prompt, src, questionAndAnswers } = options;
if (!isLoggedIn()) {
window.location.href = '/ai';
return;
}
await generateAIRoadmap({
term,
isForce,
prompt,
questionAndAnswers,
onDetailsChange: (details) => {
const { roadmapId, roadmapSlug, title, userId } = details;
const aiRoadmapData = {
_id: roadmapId,
userId,
title,
term,
data: content,
questionAndAnswers,
viewCount: 0,
svg: svgRef.current,
lastVisitedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
queryClient.setQueryData(
aiRoadmapOptions(roadmapSlug).queryKey,
aiRoadmapData,
);
onRoadmapSlugChange?.(roadmapSlug);
window.history.replaceState(null, '', `/ai-roadmaps/${roadmapSlug}`);
},
onLoadingChange: setIsLoading,
onError: setError,
onStreamingChange: setIsStreaming,
onRoadmapSvgChange: (svg) => {
svgRef.current = svg;
if (containerRef.current) {
replaceChildren(containerRef.current, svg);
}
},
});
};
if (error) {
return <div className="text-red-500">{error}</div>;
}
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingChip message="Please wait..." />
</div>
);
}
return (
<div
ref={containerRef}
id="roadmap-container"
className="relative min-h-[400px] px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]"
/>
);
}

View File

@@ -1,6 +1,7 @@
import {
BookOpenIcon,
FileTextIcon,
MapIcon,
SparklesIcon,
type LucideIcon,
} from 'lucide-react';
@@ -55,6 +56,11 @@ export function ContentGenerator() {
icon: FileTextIcon,
value: 'guide',
},
{
label: 'Roadmap',
icon: MapIcon,
value: 'roadmap',
},
];
const handleSubmit = () => {
@@ -74,6 +80,8 @@ export function ContentGenerator() {
window.location.href = `/ai/course?term=${encodeURIComponent(trimmedTitle)}&id=${sessionId}&format=${selectedFormat}`;
} else if (selectedFormat === 'guide') {
window.location.href = `/ai/guide?term=${encodeURIComponent(trimmedTitle)}&id=${sessionId}&format=${selectedFormat}`;
} else if (selectedFormat === 'roadmap') {
window.location.href = `/ai/roadmap?term=${encodeURIComponent(trimmedTitle)}&id=${sessionId}&format=${selectedFormat}`;
}
};
@@ -87,6 +95,7 @@ export function ContentGenerator() {
const trimmedTitle = title.trim();
const canGenerate = trimmedTitle && trimmedTitle.length >= 3;
return (
<div className="mx-auto flex w-full max-w-2xl flex-grow flex-col pt-4 md:justify-center md:pt-10 lg:pt-4">
<div className="relative">
@@ -141,7 +150,7 @@ export function ContentGenerator() {
<label className="inline-block text-gray-500">
Choose the format
</label>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-3 gap-3">
{allowedFormats.map((format) => {
const isSelected = format.value === selectedFormat;

View File

@@ -8,7 +8,7 @@ type AIGuideContentProps = {
html: string;
onRegenerate?: (prompt?: string) => void;
isLoading?: boolean;
guideSlug: string;
guideSlug?: string;
};
export function AIGuideContent(props: AIGuideContentProps) {
@@ -32,7 +32,7 @@ export function AIGuideContent(props: AIGuideContentProps) {
</div>
)}
{onRegenerate && !isLoading && (
{onRegenerate && !isLoading && guideSlug && (
<div className="absolute top-4 right-4">
<AIGuideRegenerate
onRegenerate={onRegenerate}

View File

@@ -1,84 +0,0 @@
import { Check, Clipboard } from 'lucide-react';
import { useRef } from 'react';
import { useAuth } from '../../hooks/use-auth';
import { useCopyText } from '../../hooks/use-copy-text';
import { useToast } from '../../hooks/use-toast';
import { cn } from '../../lib/classname';
import { Modal } from '../Modal';
type IncreaseRoadmapLimitProps = {
onClose: () => void;
};
export function IncreaseRoadmapLimit(props: IncreaseRoadmapLimitProps) {
const { onClose } = props;
const user = useAuth();
const toast = useToast();
const inputRef = useRef<HTMLInputElement>(null);
const { copyText, isCopied } = useCopyText();
const referralLink = new URL(
`/ai?rc=${user?.id}`,
import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh',
).toString();
const handleCopy = () => {
inputRef.current?.select();
copyText(referralLink);
toast.success('Copied to clipboard');
};
return (
<Modal
onClose={onClose}
overlayClassName={cn('overscroll-contain')}
wrapperClassName="max-w-lg mx-auto"
bodyClassName={cn('h-auto pt-px')}
>
<div className="p-4">
<h2 className="text-xl font-semibold text-gray-800">
Refer your Friends
</h2>
<p className="mt-2 text-sm text-gray-500">
Share the URL below with your friends. When they sign up with your
link, you will get extra roadmap generation credits.
</p>
<label className="mt-4 flex flex-col gap-2">
<input
ref={inputRef}
className="w-full rounded-md border bg-gray-100 p-2 px-2.5 text-gray-700 focus:outline-hidden"
value={referralLink}
readOnly={true}
onClick={handleCopy}
/>
<button
className={cn(
'flex h-10 items-center justify-center gap-1.5 rounded-md p-2 px-2.5 text-sm',
{
'bg-green-500 text-black transition-colors': isCopied,
'rounded-md bg-black text-white': !isCopied,
},
)}
onClick={handleCopy}
disabled={isCopied}
>
{isCopied ? (
<>
<Check className="h-4 w-4" />
Copied to Clipboard
</>
) : (
<>
<Clipboard className="h-4 w-4" />
Copy URL
</>
)}
</button>
</label>
</div>
</Modal>
);
}

View File

@@ -1,164 +0,0 @@
import { ChevronLeft } from 'lucide-react';
import { useAuth } from '../../hooks/use-auth';
type PayToBypassProps = {
onBack: () => void;
onClose: () => void;
};
export function PayToBypass(props: PayToBypassProps) {
const { onBack, onClose } = props;
const user = useAuth();
const userId = 'entry.1665642993';
const nameId = 'entry.527005328';
const emailId = 'entry.982906376';
const amountId = 'entry.1826002937';
const roadmapCountId = 'entry.1161404075';
const usageId = 'entry.535914744';
const feedbackId = 'entry.1024388959';
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-hidden"
>
<ChevronLeft size={16} />
Back to options
</button>
<h2 className="text-xl font-semibold text-gray-800">Pay to Bypass</h2>
<p className="mt-2 text-sm leading-normal text-gray-500">
Tell us more about how you will be using this.
</p>
<form
className="mt-4 flex flex-col gap-3"
action="https://docs.google.com/forms/u/0/d/e/1FAIpQLSeec1oboTc9vCWHxmoKsC5NIbACpQEk7erp8wBKJMz-nzC7LQ/formResponse"
target="_blank"
>
<div className="sr-only" aria-hidden="true">
<label htmlFor={userId} className="sr-only">
User Id
</label>
<input
id={userId}
name={userId}
type="text"
className="block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
value={user?.id}
readOnly
/>
</div>
<div className="sr-only" aria-hidden="true">
<label htmlFor={nameId} className="sr-only">
Name
</label>
<input
id={nameId}
name={nameId}
type="text"
className="block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
value={user?.name}
readOnly
/>
</div>
<div className="sr-only" aria-hidden="true">
<label htmlFor={emailId} className="sr-only">
Email
</label>
<input
id={emailId}
name={emailId}
type="email"
className="block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
value={user?.email}
readOnly
/>
</div>
<div>
<label
htmlFor={amountId}
className="mb-2 block text-sm font-semibold"
>
How much are you willing to pay for this? *
</label>
<input
id={amountId}
name={amountId}
type="text"
required
className="block w-full rounded-lg border p-3 py-2 shadow-xs outline-hidden placeholder:text-sm placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="How much are you willing to pay for this?"
/>
</div>
<div>
<label
htmlFor={roadmapCountId}
className="mb-2 block text-sm font-semibold"
>
How many roadmaps you will be generating (daily, or monthly)? *
</label>
<textarea
id={roadmapCountId}
name={roadmapCountId}
required
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="How many roadmaps you will be generating (daily, or monthly)?"
/>
</div>
<div>
<label htmlFor={usageId} className="mb-2 block text-sm font-semibold">
How will you be using this feature? *
</label>
<textarea
id={usageId}
name={usageId}
required
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="How will you be using this"
/>
</div>
<div>
<label
htmlFor={feedbackId}
className="mb-2 block text-sm font-semibold"
>
Do you have any feedback for us to improve this feature?
</label>
<textarea
id={feedbackId}
name={feedbackId}
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="Do you have any feedback?"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
className="disbaled:opacity-60 w-full rounded-lg border border-gray-300 py-2 text-sm hover:bg-gray-100 disabled:cursor-not-allowed"
onClick={() => {
onClose();
}}
>
Cancel
</button>
<button
type="submit"
className="w-full rounded-lg bg-gray-900 py-2 text-sm text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
setTimeout(() => {
onClose();
}, 100);
}}
>
Submit
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,76 +0,0 @@
import { Check, Clipboard } from 'lucide-react';
import { useAuth } from '../../hooks/use-auth';
import { useCopyText } from '../../hooks/use-copy-text';
import { useToast } from '../../hooks/use-toast';
import { useRef } from 'react';
import { cn } from '../../lib/classname.ts';
type ReferYourFriendProps = {
onBack: () => void;
};
export function ReferYourFriend(props: ReferYourFriendProps) {
const { onBack } = props;
const user = useAuth();
const toast = useToast();
const inputRef = useRef<HTMLInputElement>(null);
const { copyText, isCopied } = useCopyText();
const referralLink = new URL(
`/ai?rc=${user?.id}`,
import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh',
).toString();
const handleCopy = () => {
inputRef.current?.select();
copyText(referralLink);
toast.success('Copied to clipboard');
};
return (
<div className="p-4">
<h2 className="text-xl font-semibold text-gray-800">
Refer your Friends
</h2>
<p className="mt-2 text-sm text-gray-500">
Share the URL below with your friends. When they sign up with your link,
you will get extra roadmap generation credits.
</p>
<label className="mt-4 flex flex-col gap-2">
<input
ref={inputRef}
className="w-full rounded-md border bg-gray-100 p-2 px-2.5 text-gray-700 focus:outline-hidden"
value={referralLink}
readOnly={true}
onClick={handleCopy}
/>
<button
className={cn(
'flex h-10 items-center justify-center gap-1.5 rounded-md p-2 px-2.5 text-sm',
{
'bg-green-500 text-black transition-colors': isCopied,
'bg-black text-white rounded-md': !isCopied,
},
)}
onClick={handleCopy}
disabled={isCopied}
>
{isCopied ? (
<>
<Check className="h-4 w-4" />
Copied to Clipboard
</>
) : (
<>
<Clipboard className="h-4 w-4" />
Copy URL
</>
)}
</button>
</label>
</div>
);
}

View File

@@ -1,8 +1,6 @@
---
import { aiRoadmapApi } from '../../api/ai-roadmap';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { GenerateRoadmap } from '../../components/GenerateRoadmap/GenerateRoadmap';
import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification';
import { AIRoadmap } from '../../components/AIRoadmap/AIRoadmap';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
export const prerender = false;
@@ -11,26 +9,14 @@ interface Params extends Record<string, string | undefined> {
}
const { aiRoadmapSlug } = Astro.params as Params;
if (!aiRoadmapSlug) {
return Astro.redirect('/404');
}
const aiRoadmapClient = aiRoadmapApi(Astro as any);
const { response: roadmap, error } =
await aiRoadmapClient.getAIRoadmapBySlug(aiRoadmapSlug);
let errorMessage = '';
if (error || !roadmap) {
errorMessage = error?.message || 'Error loading AI Roadmap';
}
const title = roadmap?.title || 'Roadmap AI';
---
<BaseLayout title={title} noIndex={true}>
<GenerateRoadmap
roadmapId={roadmap?.id}
isAuthenticatedUser={roadmap?.isAuthenticatedUser}
client:load
/>
<CheckSubscriptionVerification client:load />
</BaseLayout>
<SkeletonLayout
title='AI Tutor'
briefTitle='AI Tutor'
description='AI Tutor'
keywords={['ai', 'tutor', 'education', 'learning']}
canonicalUrl={`/ai-roadmaps/${aiRoadmapSlug}`}
>
<AIRoadmap client:load roadmapSlug={aiRoadmapSlug} />
</SkeletonLayout>

View File

@@ -0,0 +1,15 @@
---
import { AIRoadmap } from '../../../components/AIRoadmap/AIRoadmap';
import SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
---
<SkeletonLayout
title='AI Tutor'
briefTitle='AI Tutor'
description='AI Tutor'
keywords={['ai', 'tutor', 'education', 'learning']}
canonicalUrl='/ai/guide'
noIndex={true}
>
<AIRoadmap client:load />
</SkeletonLayout>

183
src/queries/ai-roadmap.ts Normal file
View File

@@ -0,0 +1,183 @@
import { queryOptions } from '@tanstack/react-query';
import { httpGet } from '../lib/query-http';
import { generateAICourseRoadmapStructure } from '../lib/ai';
import { generateAIRoadmapFromText, renderFlowJSON } from '@roadmapsh/editor';
export interface AIRoadmapDocument {
_id: string;
userId?: string;
userIp?: string;
title: string;
slug?: string;
term: string;
data: string;
viewCount: number;
lastVisitedAt: Date;
keyType?: 'system' | 'user';
createdAt: Date;
updatedAt: Date;
}
export type AIRoadmapResponse = AIRoadmapDocument & {
svg?: SVGElement | null;
};
export function aiRoadmapOptions(
roadmapSlug?: string,
containerRef?: RefObject<HTMLDivElement | null>,
) {
return queryOptions<AIRoadmapResponse>({
queryKey: ['ai-roadmap', roadmapSlug],
queryFn: async () => {
const res = await httpGet<AIRoadmapResponse>(
`/v1-get-ai-roadmap/${roadmapSlug}`,
);
const result = generateAICourseRoadmapStructure(res.data);
const { nodes, edges } = generateAIRoadmapFromText(result);
const svg = await renderFlowJSON({ nodes, edges });
if (containerRef?.current) {
replaceChildren(containerRef.current, svg);
}
return {
...res,
svg,
};
},
enabled: !!roadmapSlug,
});
}
import { queryClient } from '../stores/query-client';
import { getAiCourseLimitOptions } from '../queries/ai-course';
import { readChatStream } from '../lib/chat';
import { markdownToHtmlWithHighlighting } from '../lib/markdown';
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
import type { RefObject } from 'react';
import { replaceChildren } from '../lib/dom';
type RoadmapDetails = {
roadmapId: string;
roadmapSlug: string;
userId: string;
title: string;
};
type GenerateAIRoadmapOptions = {
term: string;
isForce?: boolean;
prompt?: string;
questionAndAnswers?: QuestionAnswerChatMessage[];
roadmapSlug?: string;
onRoadmapSvgChange?: (svg: SVGElement) => void;
onDetailsChange?: (details: RoadmapDetails) => void;
onLoadingChange?: (isLoading: boolean) => void;
onStreamingChange?: (isStreaming: boolean) => void;
onError?: (error: string) => void;
onFinish?: () => void;
};
export async function generateAIRoadmap(options: GenerateAIRoadmapOptions) {
const {
term,
roadmapSlug,
onLoadingChange,
onError,
isForce = false,
prompt,
onDetailsChange,
onFinish,
questionAndAnswers,
onRoadmapSvgChange,
onStreamingChange,
} = options;
onLoadingChange?.(true);
onStreamingChange?.(false);
try {
let response = null;
if (roadmapSlug && isForce) {
response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-regenerate-ai-roadmap/${roadmapSlug}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
prompt,
}),
},
);
} else {
response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-roadmap`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
term,
isForce,
customPrompt: prompt,
questionAndAnswers,
}),
credentials: 'include',
},
);
}
if (!response.ok) {
const data = await response.json();
console.error(
'Error generating course:',
data?.message || 'Something went wrong',
);
onLoadingChange?.(false);
onError?.(data?.message || 'Something went wrong');
return;
}
const stream = response.body;
if (!stream) {
console.error('Failed to get stream from response');
onError?.('Something went wrong');
onLoadingChange?.(false);
return;
}
onLoadingChange?.(false);
onStreamingChange?.(true);
await readChatStream(stream, {
onMessage: async (message) => {
const result = generateAICourseRoadmapStructure(message);
const { nodes, edges } = generateAIRoadmapFromText(result);
const svg = await renderFlowJSON({ nodes, edges });
onRoadmapSvgChange?.(svg);
},
onMessageEnd: async () => {
queryClient.invalidateQueries(getAiCourseLimitOptions());
onStreamingChange?.(false);
},
onDetails: async (details) => {
if (!details?.roadmapId || !details?.roadmapSlug) {
throw new Error('Invalid details');
}
onDetailsChange?.(details);
},
});
onFinish?.();
} catch (error: any) {
onError?.(error?.message || 'Something went wrong');
console.error('Error in course generation:', error);
onLoadingChange?.(false);
onStreamingChange?.(false);
}
}