diff --git a/src/components/AIRoadmap/AIRoadmap.css b/src/components/AIRoadmap/AIRoadmap.css
new file mode 100644
index 000000000..d30ca465a
--- /dev/null
+++ b/src/components/AIRoadmap/AIRoadmap.css
@@ -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;
+}
diff --git a/src/components/AIRoadmap/AIRoadmap.tsx b/src/components/AIRoadmap/AIRoadmap.tsx
new file mode 100644
index 000000000..a252a8f29
--- /dev/null
+++ b/src/components/AIRoadmap/AIRoadmap.tsx
@@ -0,0 +1,80 @@
+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';
+import { AIRoadmapChat } from './AIRoadmapChat';
+
+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);
+
+ // only fetch the guide if the guideSlug is provided
+ // otherwise we are still generating the guide
+ const { data: aiRoadmap, isLoading: isLoadingBySlug } = useQuery(
+ aiRoadmapOptions(roadmapSlug),
+ 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 (
+
+ {showUpgradeModal && (
+ setShowUpgradeModal(false)} />
+ )}
+
+
+ {roadmapSlug && (
+
+ )}
+ {!roadmapSlug && (
+
+ )}
+
+ setShowUpgradeModal(true)}
+ />
+
+ );
+}
diff --git a/src/components/AIRoadmap/AIRoadmapChat.tsx b/src/components/AIRoadmap/AIRoadmapChat.tsx
new file mode 100644
index 000000000..557f89b7e
--- /dev/null
+++ b/src/components/AIRoadmap/AIRoadmapChat.tsx
@@ -0,0 +1,338 @@
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from 'react';
+import { useChat, type ChatMessage } from '../../hooks/use-chat';
+import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard';
+import {
+ ArrowDownIcon,
+ BotIcon,
+ LockIcon,
+ MessageCircleIcon,
+ PauseCircleIcon,
+ SendIcon,
+ Trash2Icon,
+ XIcon,
+} from 'lucide-react';
+import { ChatHeaderButton } from '../FrameRenderer/RoadmapFloatingChat';
+import { isLoggedIn } from '../../lib/jwt';
+import { showLoginPopup } from '../../lib/popup';
+import { flushSync } from 'react-dom';
+import { markdownToHtml } from '../../lib/markdown';
+import { getAiCourseLimitOptions } from '../../queries/ai-course';
+import { useQuery } from '@tanstack/react-query';
+import { queryClient } from '../../stores/query-client';
+import { billingDetailsOptions } from '../../queries/billing';
+import { LoadingChip } from '../LoadingChip';
+import { getTailwindScreenDimension } from '../../lib/is-mobile';
+import { useToast } from '../../hooks/use-toast';
+
+type AIRoadmapChatProps = {
+ roadmapSlug?: string;
+ isRoadmapLoading?: boolean;
+ onUpgrade?: () => void;
+};
+
+export function AIRoadmapChat(props: AIRoadmapChatProps) {
+ const { roadmapSlug, isRoadmapLoading, onUpgrade } = props;
+
+ const toast = useToast();
+ const scrollareaRef = useRef
(null);
+ const inputRef = useRef(null);
+
+ const [inputValue, setInputValue] = useState('');
+ const [showScrollToBottom, setShowScrollToBottom] = useState(false);
+ const [isChatOpen, setIsChatOpen] = useState(true);
+ const [isMobile, setIsMobile] = useState(false);
+
+ const {
+ data: tokenUsage,
+ isLoading: isTokenUsageLoading,
+ refetch: refetchTokenUsage,
+ } = useQuery(getAiCourseLimitOptions(), queryClient);
+
+ const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
+ useQuery(billingDetailsOptions(), queryClient);
+
+ const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
+ const isPaidUser = userBillingDetails?.status === 'active';
+
+ const {
+ messages,
+ status,
+ streamedMessageHtml,
+ sendMessages,
+ setMessages,
+ stop,
+ } = useChat({
+ endpoint: `${import.meta.env.PUBLIC_API_URL}/v1-ai-roadmap-chat`,
+ onError: (error) => {
+ console.error(error);
+ toast.error(error?.message || 'Something went wrong');
+ },
+ data: {
+ aiRoadmapSlug: roadmapSlug,
+ },
+ onFinish: () => {
+ refetchTokenUsage();
+ },
+ });
+
+ const scrollToBottom = useCallback(
+ (behavior: 'smooth' | 'instant' = 'smooth') => {
+ scrollareaRef.current?.scrollTo({
+ top: scrollareaRef.current.scrollHeight,
+ behavior,
+ });
+ },
+ [scrollareaRef],
+ );
+
+ const isStreamingMessage = status === 'streaming';
+ const hasMessages = messages.length > 0;
+
+ const handleSubmitInput = useCallback(
+ (defaultInputValue?: string) => {
+ const message = defaultInputValue || inputValue;
+ if (!isLoggedIn()) {
+ showLoginPopup();
+ return;
+ }
+
+ if (isStreamingMessage) {
+ return;
+ }
+
+ const newMessages: ChatMessage[] = [
+ ...messages,
+ {
+ role: 'user',
+ content: message,
+ html: markdownToHtml(message),
+ },
+ ];
+ flushSync(() => {
+ setMessages(newMessages);
+ });
+ sendMessages(newMessages);
+ setInputValue('');
+ },
+ [inputValue, isStreamingMessage, messages, sendMessages, setMessages],
+ );
+
+ const checkScrollPosition = useCallback(() => {
+ const scrollArea = scrollareaRef.current;
+ if (!scrollArea) {
+ return;
+ }
+
+ const { scrollTop, scrollHeight, clientHeight } = scrollArea;
+ const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50; // 50px threshold
+ setShowScrollToBottom(!isAtBottom && messages.length > 0);
+ }, [messages.length]);
+
+ useEffect(() => {
+ const scrollArea = scrollareaRef.current;
+ if (!scrollArea) {
+ return;
+ }
+
+ scrollArea.addEventListener('scroll', checkScrollPosition);
+ return () => scrollArea.removeEventListener('scroll', checkScrollPosition);
+ }, [checkScrollPosition]);
+
+ const isLoading =
+ isRoadmapLoading || isTokenUsageLoading || isBillingDetailsLoading;
+
+ useLayoutEffect(() => {
+ const deviceType = getTailwindScreenDimension();
+ const isMediumSize = ['sm', 'md'].includes(deviceType);
+
+ if (!isMediumSize) {
+ const storedState = localStorage.getItem('chat-history-sidebar-open');
+ setIsChatOpen(storedState === null ? true : storedState === 'true');
+ } else {
+ setIsChatOpen(!isMediumSize);
+ }
+
+ setIsMobile(isMediumSize);
+ }, []);
+
+ useEffect(() => {
+ if (!isMobile) {
+ localStorage.setItem('chat-history-sidebar-open', isChatOpen.toString());
+ }
+ }, [isChatOpen, isMobile]);
+
+ if (!isChatOpen) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ AI Roadmap
+
+
+
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ {!isLoading && (
+ <>
+
+
+
+
+
+
+ {messages.map((chat, index) => {
+ return (
+
+ );
+ })}
+
+ {status === 'streaming' && !streamedMessageHtml && (
+
+ )}
+
+ {status === 'streaming' && streamedMessageHtml && (
+
+ )}
+
+
+
+
+
+ {(hasMessages || showScrollToBottom) && (
+
+ }
+ className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300"
+ onClick={() => {
+ setMessages([]);
+ }}
+ >
+ Clear
+
+ {showScrollToBottom && (
+ }
+ className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300"
+ onClick={() => {
+ scrollToBottom('smooth');
+ }}
+ >
+ Scroll to bottom
+
+ )}
+
+ )}
+
+
+ {isLimitExceeded && isLoggedIn() && (
+
+
+
+ Limit reached for today
+ {isPaidUser ? '. Please wait until tomorrow.' : ''}
+
+ {!isPaidUser && (
+
+ )}
+
+ )}
+
+
setInputValue(e.target.value)}
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ if (isStreamingMessage) {
+ return;
+ }
+ handleSubmitInput();
+ }
+ }}
+ placeholder="Ask me anything about this roadmap..."
+ className="w-full resize-none px-3 py-4 outline-none"
+ />
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/components/AIRoadmap/AIRoadmapContent.tsx b/src/components/AIRoadmap/AIRoadmapContent.tsx
new file mode 100644
index 000000000..41f37b18e
--- /dev/null
+++ b/src/components/AIRoadmap/AIRoadmapContent.tsx
@@ -0,0 +1,32 @@
+import { cn } from '../../lib/classname';
+import { LoadingChip } from '../LoadingChip';
+
+type AIRoadmapContentProps = {
+ isLoading?: boolean;
+ svgHtml: string;
+};
+
+export function AIRoadmapContent(props: AIRoadmapContentProps) {
+ const { isLoading, svgHtml } = props;
+
+ return (
+
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/AIRoadmap/GenerateAIRoadmap.tsx b/src/components/AIRoadmap/GenerateAIRoadmap.tsx
new file mode 100644
index 000000000..fe6eb2957
--- /dev/null
+++ b/src/components/AIRoadmap/GenerateAIRoadmap.tsx
@@ -0,0 +1,115 @@
+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 { AIRoadmapContent } from './AIRoadmapContent';
+
+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 [svgHtml, setSvgHtml] = useState('');
+ const [content, setContent] = useState('');
+ const svgRef = useRef(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,
+ svgHtml: 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) => {
+ const svgHtml = svg.outerHTML;
+ svgRef.current = svgHtml;
+ setSvgHtml(svgHtml);
+ },
+ });
+ };
+
+ if (error) {
+ return {error}
;
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return ;
+}
diff --git a/src/components/ContentGenerator/ContentGenerator.tsx b/src/components/ContentGenerator/ContentGenerator.tsx
index 849149b3e..5737ef345 100644
--- a/src/components/ContentGenerator/ContentGenerator.tsx
+++ b/src/components/ContentGenerator/ContentGenerator.tsx
@@ -1,6 +1,7 @@
import {
BookOpenIcon,
FileTextIcon,
+ MapIcon,
SparklesIcon,
type LucideIcon,
} from 'lucide-react';
@@ -56,6 +57,11 @@ export function ContentGenerator() {
icon: FileTextIcon,
value: 'guide',
},
+ {
+ label: 'Roadmap',
+ icon: MapIcon,
+ value: 'roadmap',
+ },
];
const handleSubmit = () => {
@@ -75,6 +81,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}`;
}
};
@@ -88,6 +96,7 @@ export function ContentGenerator() {
const trimmedTitle = title.trim();
const canGenerate = trimmedTitle && trimmedTitle.length >= 3;
+
return (
@@ -142,7 +151,7 @@ export function ContentGenerator() {
-
+
{allowedFormats.map((format) => {
const isSelected = format.value === selectedFormat;
diff --git a/src/components/GenerateGuide/AIGuideContent.tsx b/src/components/GenerateGuide/AIGuideContent.tsx
index 470f30e12..d968f41c8 100644
--- a/src/components/GenerateGuide/AIGuideContent.tsx
+++ b/src/components/GenerateGuide/AIGuideContent.tsx
@@ -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) {
)}
- {onRegenerate && !isLoading && (
+ {onRegenerate && !isLoading && guideSlug && (
void;
-};
-
-export function IncreaseRoadmapLimit(props: IncreaseRoadmapLimitProps) {
- const { onClose } = props;
-
- const user = useAuth();
- const toast = useToast();
- const inputRef = useRef(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 (
-
-
-
- Refer your Friends
-
-
- Share the URL below with your friends. When they sign up with your
- link, you will get extra roadmap generation credits.
-
-
-
-
-
- );
-}
diff --git a/src/components/GenerateRoadmap/PayToBypass.tsx b/src/components/GenerateRoadmap/PayToBypass.tsx
deleted file mode 100644
index 7b40c9fff..000000000
--- a/src/components/GenerateRoadmap/PayToBypass.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
Pay to Bypass
-
- Tell us more about how you will be using this.
-
-
-
-
- );
-}
diff --git a/src/components/GenerateRoadmap/ReferYourFriend.tsx b/src/components/GenerateRoadmap/ReferYourFriend.tsx
deleted file mode 100644
index a92a6945f..000000000
--- a/src/components/GenerateRoadmap/ReferYourFriend.tsx
+++ /dev/null
@@ -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(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 (
-
-
- Refer your Friends
-
-
- Share the URL below with your friends. When they sign up with your link,
- you will get extra roadmap generation credits.
-
-
-
-
- );
-}
diff --git a/src/pages/ai-roadmaps/[aiRoadmapSlug].astro b/src/pages/ai-roadmaps/[aiRoadmapSlug].astro
index 79fbfbf97..dd5d00de4 100644
--- a/src/pages/ai-roadmaps/[aiRoadmapSlug].astro
+++ b/src/pages/ai-roadmaps/[aiRoadmapSlug].astro
@@ -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 {
}
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';
---
-
-
-
-
+
+
+
diff --git a/src/pages/ai/roadmap/index.astro b/src/pages/ai/roadmap/index.astro
new file mode 100644
index 000000000..d84b180d6
--- /dev/null
+++ b/src/pages/ai/roadmap/index.astro
@@ -0,0 +1,15 @@
+---
+import { AIRoadmap } from '../../../components/AIRoadmap/AIRoadmap';
+import SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
+---
+
+
+
+
diff --git a/src/queries/ai-roadmap.ts b/src/queries/ai-roadmap.ts
new file mode 100644
index 000000000..7ecac3f6b
--- /dev/null
+++ b/src/queries/ai-roadmap.ts
@@ -0,0 +1,175 @@
+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 & {
+ svgHtml?: string;
+};
+
+export function aiRoadmapOptions(roadmapSlug?: string) {
+ return queryOptions({
+ queryKey: ['ai-roadmap', roadmapSlug],
+ queryFn: async () => {
+ const res = await httpGet(
+ `/v1-get-ai-roadmap/${roadmapSlug}`,
+ );
+
+ const result = generateAICourseRoadmapStructure(res.data);
+ const { nodes, edges } = generateAIRoadmapFromText(result);
+ const svg = await renderFlowJSON({ nodes, edges });
+ const svgHtml = svg.outerHTML;
+
+ return {
+ ...res,
+ svgHtml,
+ };
+ },
+ enabled: !!roadmapSlug,
+ });
+}
+
+import { queryClient } from '../stores/query-client';
+import { getAiCourseLimitOptions } from '../queries/ai-course';
+import { readChatStream } from '../lib/chat';
+import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
+
+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);
+ }
+}