- {guideSlug && (
+ {guideSlug && !aiGuideError && (
)}
- {!guideSlug &&
}
+ {!guideSlug && !aiGuideError && (
+
+ )}
{aiGuide && !isRegenerating && (
diff --git a/src/components/GenerateGuide/AIGuideChat.tsx b/src/components/GenerateGuide/AIGuideChat.tsx
index 407ddc00d..a3c1b3cc5 100644
--- a/src/components/GenerateGuide/AIGuideChat.tsx
+++ b/src/components/GenerateGuide/AIGuideChat.tsx
@@ -60,11 +60,8 @@ export function AIGuideChat(props: AIGuideChatProps) {
refetch: refetchTokenUsage,
} = useQuery(getAiCourseLimitOptions(), queryClient);
- const {
- data: userBillingDetails,
- isLoading: isBillingDetailsLoading,
- refetch: refetchBillingDetails,
- } = useQuery(billingDetailsOptions(), queryClient);
+ const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
+ useQuery(billingDetailsOptions(), queryClient);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
@@ -127,6 +124,10 @@ export function AIGuideChat(props: AIGuideChatProps) {
});
sendMessages(newMessages);
setInputValue('');
+
+ setTimeout(() => {
+ scrollToBottom('smooth');
+ }, 0);
},
[inputValue, isStreamingMessage, messages, sendMessages, setMessages],
);
@@ -332,6 +333,24 @@ export function AIGuideChat(props: AIGuideChatProps) {
)}
+ {!isLoggedIn() && (
+
+
+
Please login to continue
+
{
+ showLoginPopup();
+ }}
+ className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
+ >
+ Login / Register
+
+
+ )}
+
void;
isLoading?: boolean;
+ guideSlug?: string;
};
export function AIGuideContent(props: AIGuideContentProps) {
- const { html, onRegenerate, isLoading } = props;
+ const { html, onRegenerate, isLoading, guideSlug } = props;
return (
)}
- {onRegenerate && !isLoading && (
+ {onRegenerate && !isLoading && guideSlug && (
)}
diff --git a/src/components/GenerateGuide/AIGuideRegenerate.tsx b/src/components/GenerateGuide/AIGuideRegenerate.tsx
index 3be0df9c0..ae9c11947 100644
--- a/src/components/GenerateGuide/AIGuideRegenerate.tsx
+++ b/src/components/GenerateGuide/AIGuideRegenerate.tsx
@@ -1,25 +1,74 @@
-import { PenSquare, RefreshCcw } from 'lucide-react';
+import { PenSquare, RefreshCcw, SettingsIcon } from 'lucide-react';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { cn } from '../../lib/classname';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { ModifyCoursePrompt } from '../GenerateCourse/ModifyCoursePrompt';
+import { UpdatePreferences } from './UpdatePreferences';
+import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { getAiGuideOptions } from '../../queries/ai-guide';
+import { queryClient } from '../../stores/query-client';
+import { httpPost } from '../../lib/query-http';
+import { useAuth } from '../../hooks/use-auth';
+import { showLoginPopup } from '../../lib/popup';
+import { isLoggedIn } from '../../lib/jwt';
type AIGuideRegenerateProps = {
onRegenerate: (prompt?: string) => void;
+ guideSlug: string;
};
export function AIGuideRegenerate(props: AIGuideRegenerateProps) {
- const { onRegenerate } = props;
+ const { onRegenerate, guideSlug } = props;
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [showPromptModal, setShowPromptModal] = useState(false);
+ const [showUpdatePreferencesModal, setShowUpdatePreferencesModal] =
+ useState(false);
+ const currentUser = useAuth();
const ref = useRef
(null);
useOutsideClick(ref, () => setIsDropdownVisible(false));
+ const { data: aiGuide } = useQuery(getAiGuideOptions(guideSlug), queryClient);
+ const { mutate: updatePreferences, isPending: isUpdating } = useMutation(
+ {
+ mutationFn: (questionAndAnswers: QuestionAnswerChatMessage[]) => {
+ return httpPost(`/v1-update-guide-preferences/${guideSlug}`, {
+ questionAndAnswers,
+ });
+ },
+ onSuccess: (_, vars) => {
+ queryClient.setQueryData(
+ getAiGuideOptions(guideSlug).queryKey,
+ (old) => {
+ if (!old) {
+ return old;
+ }
+
+ return {
+ ...old,
+ questionAndAnswers: vars,
+ };
+ },
+ );
+
+ setShowUpdatePreferencesModal(false);
+ setIsDropdownVisible(false);
+ onRegenerate();
+ },
+ },
+ queryClient,
+ );
+
+ const showUpdatePreferences =
+ aiGuide?.questionAndAnswers &&
+ aiGuide.questionAndAnswers.length > 0 &&
+ currentUser?.id === aiGuide.userId;
+
return (
<>
{showUpgradeModal && (
@@ -41,6 +90,19 @@ export function AIGuideRegenerate(props: AIGuideRegenerateProps) {
/>
)}
+ {showUpdatePreferencesModal && (
+ setShowUpdatePreferencesModal(false)}
+ questionAndAnswers={aiGuide?.questionAndAnswers}
+ term={aiGuide?.keyword || ''}
+ format="guide"
+ onUpdatePreferences={(questionAndAnswers) => {
+ updatePreferences(questionAndAnswers);
+ }}
+ isUpdating={isUpdating}
+ />
+ )}
+
{isDropdownVisible && (
-
+
+ {showUpdatePreferences && (
+
{
+ setIsDropdownVisible(false);
+ setShowUpdatePreferencesModal(true);
+ }}
+ className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
+ >
+
+ Update Preferences
+
+ )}
+
{
+ if (!isLoggedIn()) {
+ showLoginPopup();
+ return;
+ }
+
setIsDropdownVisible(false);
onRegenerate();
}}
@@ -68,6 +152,11 @@ export function AIGuideRegenerate(props: AIGuideRegenerateProps) {
{
+ if (!isLoggedIn()) {
+ showLoginPopup();
+ return;
+ }
+
setIsDropdownVisible(false);
setShowPromptModal(true);
}}
diff --git a/src/components/GenerateGuide/GenerateAIGuide.tsx b/src/components/GenerateGuide/GenerateAIGuide.tsx
index 64ed875a0..c1d512ec3 100644
--- a/src/components/GenerateGuide/GenerateAIGuide.tsx
+++ b/src/components/GenerateGuide/GenerateAIGuide.tsx
@@ -7,6 +7,8 @@ import { AIGuideContent } from './AIGuideContent';
import { queryClient } from '../../stores/query-client';
import { getAiGuideOptions } from '../../queries/ai-guide';
import { LoadingChip } from '../LoadingChip';
+import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
+import { getQuestionAnswerChatMessages } from '../../lib/ai-questions';
type GenerateAIGuideProps = {
onGuideSlugChange?: (guideSlug: string) => void;
@@ -26,48 +28,32 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
useEffect(() => {
const params = getUrlParams();
const paramsTerm = params?.term;
- const paramsDepth = params?.depth;
const paramsSrc = params?.src || 'search';
- if (!paramsTerm || !paramsDepth) {
+ if (!paramsTerm) {
return;
}
- let paramsGoal = '';
- let paramsAbout = '';
- let paramsCustomInstructions = '';
-
+ let questionAndAnswers: QuestionAnswerChatMessage[] = [];
const sessionId = params?.id;
if (sessionId) {
- const fineTuneData = getCourseFineTuneData(sessionId);
- if (fineTuneData) {
- paramsGoal = fineTuneData.goal;
- paramsAbout = fineTuneData.about;
- paramsCustomInstructions = fineTuneData.customInstructions;
- }
+ questionAndAnswers = getQuestionAnswerChatMessages(sessionId);
}
handleGenerateDocument({
term: paramsTerm,
- depth: paramsDepth,
- instructions: paramsCustomInstructions,
- goal: paramsGoal,
- about: paramsAbout,
src: paramsSrc,
+ questionAndAnswers,
});
}, []);
const handleGenerateDocument = async (options: {
term: string;
- depth: string;
- instructions?: string;
- goal?: string;
- about?: string;
isForce?: boolean;
prompt?: string;
src?: string;
+ questionAndAnswers?: QuestionAnswerChatMessage[];
}) => {
- const { term, depth, isForce, prompt, instructions, goal, about, src } =
- options;
+ const { term, isForce, prompt, src, questionAndAnswers } = options;
if (!isLoggedIn()) {
window.location.href = '/ai';
@@ -76,7 +62,6 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
await generateGuide({
term,
- depth,
onDetailsChange: (details) => {
const { guideId, guideSlug, creatorId, title } = details;
@@ -86,7 +71,6 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
title,
html: htmlRef.current,
keyword: term,
- depth,
content,
tokens: {
prompt: 0,
@@ -96,6 +80,7 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
relatedTopics: [],
deepDiveTopics: [],
questions: [],
+ questionAndAnswers,
viewCount: 0,
lastVisitedAt: new Date(),
createdAt: new Date(),
@@ -112,12 +97,10 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
},
onLoadingChange: setIsLoading,
onError: setError,
- instructions,
- goal,
- about,
isForce,
prompt,
src,
+ questionAndAnswers,
onHtmlChange: (html) => {
htmlRef.current = html;
setHtml(html);
diff --git a/src/components/GenerateGuide/UpdatePreferences.tsx b/src/components/GenerateGuide/UpdatePreferences.tsx
new file mode 100644
index 000000000..8ec1626af
--- /dev/null
+++ b/src/components/GenerateGuide/UpdatePreferences.tsx
@@ -0,0 +1,98 @@
+import { useMemo, useState } from 'react';
+import type { AllowedFormat } from '../ContentGenerator/ContentGenerator';
+import {
+ QuestionAnswerChat,
+ type QuestionAnswerChatMessage,
+} from '../ContentGenerator/QuestionAnswerChat';
+import { Modal } from '../Modal';
+
+type UpdatePreferencesProps = {
+ onClose: () => void;
+ term: string;
+ format: AllowedFormat;
+ questionAndAnswers?: QuestionAnswerChatMessage[];
+ onUpdatePreferences: (
+ questionAndAnswers: QuestionAnswerChatMessage[],
+ ) => void;
+ isUpdating: boolean;
+};
+
+export function UpdatePreferences(props: UpdatePreferencesProps) {
+ const {
+ onClose,
+ questionAndAnswers: defaultQuestionAndAnswers,
+ term,
+ format,
+ onUpdatePreferences,
+ isUpdating,
+ } = props;
+
+ const [questionAnswerChatMessages, setQuestionAnswerChatMessages] = useState<
+ QuestionAnswerChatMessage[]
+ >(defaultQuestionAndAnswers || []);
+
+ const defaultQuestions = defaultQuestionAndAnswers
+ ?.filter((message) => message.role === 'assistant')
+ .map((message) => ({
+ question: message.question,
+ possibleAnswers: message.possibleAnswers,
+ }));
+
+ const hasChangedQuestionAndAnswers = useMemo(() => {
+ return (
+ JSON.stringify(questionAnswerChatMessages) !==
+ JSON.stringify(defaultQuestionAndAnswers)
+ );
+ }, [questionAnswerChatMessages, defaultQuestionAndAnswers]);
+
+ console.log(questionAnswerChatMessages);
+ console.log(defaultQuestionAndAnswers);
+
+ const userAnswers = questionAnswerChatMessages.filter(
+ (message) => message.role === 'user',
+ );
+
+ const hasAnsweredAllQuestions =
+ userAnswers.length === defaultQuestions?.length;
+
+ return (
+
+
+
Update Preferences
+
+ Update your preferences for better content
+
+
+
+ {
+ onUpdatePreferences(questionAnswerChatMessages);
+ }}
+ className="-mx-2 h-[400px] border-none p-0"
+ type="update"
+ />
+
+ {hasChangedQuestionAndAnswers && hasAnsweredAllQuestions && (
+ {
+ onUpdatePreferences(questionAnswerChatMessages);
+ }}
+ >
+ {isUpdating ? 'Updating...' : 'Apply preferences'}
+
+ )}
+
+ );
+}
diff --git a/src/components/GenerateRoadmap/IncreaseRoadmapLimit.tsx b/src/components/GenerateRoadmap/IncreaseRoadmapLimit.tsx
deleted file mode 100644
index 1bd6c3565..000000000
--- a/src/components/GenerateRoadmap/IncreaseRoadmapLimit.tsx
+++ /dev/null
@@ -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(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.
-
-
-
-
-
-
- {isCopied ? (
- <>
-
- Copied to Clipboard
- >
- ) : (
- <>
-
- Copy URL
- >
- )}
-
-
-
-
- );
-}
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 (
-
-
-
- Back to options
-
-
-
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.
-
-
-
-
-
-
- {isCopied ? (
- <>
-
- Copied to Clipboard
- >
- ) : (
- <>
-
- Copy URL
- >
- )}
-
-
-
- );
-}
diff --git a/src/components/Library/LibraryTab.tsx b/src/components/Library/LibraryTab.tsx
index 18d3c445f..ceb0aa06b 100644
--- a/src/components/Library/LibraryTab.tsx
+++ b/src/components/Library/LibraryTab.tsx
@@ -1,8 +1,8 @@
-import { BookOpen, FileTextIcon, type LucideIcon } from 'lucide-react';
+import { BookOpen, FileTextIcon, MapIcon, type LucideIcon } from 'lucide-react';
import { cn } from '../../lib/classname';
type LibraryTabsProps = {
- activeTab: 'guides' | 'courses';
+ activeTab: 'guides' | 'courses' | 'roadmaps';
};
export function LibraryTabs(props: LibraryTabsProps) {
@@ -22,6 +22,12 @@ export function LibraryTabs(props: LibraryTabsProps) {
label="Guides"
href="/ai/guides"
/>
+
);
}
diff --git a/src/components/Navigation/Navigation.astro b/src/components/Navigation/Navigation.astro
index 9f7298902..8241f0313 100644
--- a/src/components/Navigation/Navigation.astro
+++ b/src/components/Navigation/Navigation.astro
@@ -1,11 +1,11 @@
---
+import { Fragment } from 'react';
import { AccountStreak } from '../AccountStreak/AccountStreak';
import Icon from '../AstroIcon.astro';
import { NavigationDropdown } from '../NavigationDropdown';
-import { RoadmapDropdownMenu } from '../RoadmapDropdownMenu/RoadmapDropdownMenu';
+import { RoadmapDropdownMenu } from '../TopNavDropdowns/RoadmapDropdownMenu';
+import { AIDropdownMenu } from '../TopNavDropdowns/AIDropdownMenu';
import { AccountDropdown } from './AccountDropdown';
-import { CourseAnnouncement } from '../SQLCourse/CourseAnnouncement';
-import { Fragment } from 'react';
---
@@ -33,21 +33,7 @@ import { Fragment } from 'react';
Start Here
-
- AI Tutor
-
-
-
-
-
-
-
+
diff --git a/src/components/NavigationDropdownMenu.tsx b/src/components/NavigationDropdownMenu.tsx
new file mode 100644
index 000000000..2cfd9e552
--- /dev/null
+++ b/src/components/NavigationDropdownMenu.tsx
@@ -0,0 +1,101 @@
+import { useRef } from 'react';
+import type { LucideIcon } from 'lucide-react';
+import { cn } from '../lib/classname.ts';
+import { useOutsideClick } from '../hooks/use-outside-click.ts';
+
+export interface DropdownLink {
+ link: string;
+ label: string;
+ description: string;
+ Icon: LucideIcon;
+ isExternal?: boolean;
+ isHighlighted?: boolean;
+ isNew?: boolean;
+}
+
+interface NavigationDropdownMenuProps {
+ links: DropdownLink[];
+ trigger: React.ReactNode;
+ isOpen: boolean;
+ onOpen: () => void;
+ onClose: () => void;
+}
+
+export function NavigationDropdownMenu(props: NavigationDropdownMenuProps) {
+ const { links, trigger, isOpen, onOpen, onClose } = props;
+
+ const dropdownRef = useRef(null);
+
+ useOutsideClick(dropdownRef, onClose);
+
+ return (
+
+ );
+}
diff --git a/src/components/RoadmapDropdownMenu/RoadmapDropdownMenu.tsx b/src/components/RoadmapDropdownMenu/RoadmapDropdownMenu.tsx
deleted file mode 100644
index d693dc7a9..000000000
--- a/src/components/RoadmapDropdownMenu/RoadmapDropdownMenu.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import { ChevronDown, Globe, Sparkles, Map } from 'lucide-react';
-import { useEffect, useRef } from 'react';
-import { useOutsideClick } from '../../hooks/use-outside-click';
-import { cn } from '../../lib/classname';
-import {
- navigationDropdownOpen,
- roadmapsDropdownOpen,
-} from '../../stores/page.ts';
-import { useStore } from '@nanostores/react';
-
-const links = [
- {
- link: '/roadmaps',
- label: 'Official Roadmaps',
- description: 'Made by subject matter experts',
- Icon: Map,
- isHighlighted: true,
- },
- {
- link: '/ai-roadmaps',
- label: 'AI Roadmaps',
- description: 'Generate roadmaps with AI',
- Icon: Sparkles,
- isHighlighted: false,
- },
- {
- link: '/community',
- label: 'Community Roadmaps',
- description: 'Made by community members',
- Icon: Globe,
- isHighlighted: false,
- },
-];
-
-export function RoadmapDropdownMenu() {
- const dropdownRef = useRef(null);
-
- const $roadmapsDropdownOpen = useStore(roadmapsDropdownOpen);
- const $navigationDropdownOpen = useStore(navigationDropdownOpen);
-
- useOutsideClick(dropdownRef, () => {
- roadmapsDropdownOpen.set(false);
- });
-
- useEffect(() => {
- if ($navigationDropdownOpen) {
- roadmapsDropdownOpen.set(false);
- }
- }, [$navigationDropdownOpen]);
-
- return (
-
-
roadmapsDropdownOpen.set(true)}
- onMouseOver={() => roadmapsDropdownOpen.set(true)}
- aria-label="Open Navigation Dropdown"
- aria-expanded={$roadmapsDropdownOpen}
- >
- Roadmaps{' '}
-
-
-
-
- );
-}
diff --git a/src/components/TopNavDropdowns/AIDropdownMenu.tsx b/src/components/TopNavDropdowns/AIDropdownMenu.tsx
new file mode 100644
index 000000000..f42bb8233
--- /dev/null
+++ b/src/components/TopNavDropdowns/AIDropdownMenu.tsx
@@ -0,0 +1,60 @@
+import { ChevronDown, MessageCircle, Plus } from 'lucide-react';
+import { useEffect } from 'react';
+import { NavigationDropdownMenu } from '../NavigationDropdownMenu.tsx';
+import {
+ navigationDropdownOpen,
+ aiDropdownOpen,
+ roadmapsDropdownOpen,
+} from '../../stores/page.ts';
+import { useStore } from '@nanostores/react';
+
+const links = [
+ {
+ link: '/ai',
+ label: 'Create with AI',
+ description: 'Learn something new with AI',
+ Icon: Plus,
+ },
+ {
+ link: '/ai/chat',
+ label: 'Ask AI Tutor',
+ description: 'Career, resume guidance, and more',
+ Icon: MessageCircle,
+ },
+];
+
+export function AIDropdownMenu() {
+ const isOpen = useStore(aiDropdownOpen);
+ const isNavOpen = useStore(navigationDropdownOpen);
+
+ useEffect(() => {
+ if (isNavOpen) {
+ aiDropdownOpen.set(false);
+ }
+ }, [isNavOpen]);
+
+ return (
+
+ AI Tutor
+
+
+
+
+
+
+
+
+ }
+ isOpen={isOpen}
+ onOpen={() => {
+ navigationDropdownOpen.set(false);
+ roadmapsDropdownOpen.set(false);
+ aiDropdownOpen.set(true);
+ }}
+ onClose={() => aiDropdownOpen.set(false)}
+ />
+ );
+}
diff --git a/src/components/TopNavDropdowns/RoadmapDropdownMenu.tsx b/src/components/TopNavDropdowns/RoadmapDropdownMenu.tsx
new file mode 100644
index 000000000..94494e301
--- /dev/null
+++ b/src/components/TopNavDropdowns/RoadmapDropdownMenu.tsx
@@ -0,0 +1,63 @@
+import { ChevronDown, Globe, Map, Sparkles } from 'lucide-react';
+import { useEffect } from 'react';
+import { NavigationDropdownMenu } from '../NavigationDropdownMenu.tsx';
+import {
+ aiDropdownOpen,
+ navigationDropdownOpen,
+ roadmapsDropdownOpen,
+} from '../../stores/page.ts';
+import { useStore } from '@nanostores/react';
+
+const links = [
+ {
+ link: '/roadmaps',
+ label: 'Official Roadmaps',
+ description: 'Made by subject matter experts',
+ Icon: Map,
+ isHighlighted: true,
+ },
+ {
+ link: '/ai?format=roadmap',
+ label: 'AI Roadmaps',
+ description: 'Generate roadmaps with AI',
+ Icon: Sparkles,
+ isHighlighted: false,
+ },
+ {
+ link: '/community',
+ label: 'Community Roadmaps',
+ description: 'Made by community members',
+ Icon: Globe,
+ isHighlighted: false,
+ },
+];
+
+export function RoadmapDropdownMenu() {
+ const isOpen = useStore(roadmapsDropdownOpen);
+ const isNavOpen = useStore(navigationDropdownOpen);
+
+ useEffect(() => {
+ if (isNavOpen) {
+ roadmapsDropdownOpen.set(false);
+ }
+ }, [isNavOpen]);
+
+ return (
+
+ Roadmaps{' '}
+
+
+ }
+ isOpen={isOpen}
+ onOpen={() => {
+ navigationDropdownOpen.set(false);
+ aiDropdownOpen.set(false);
+ roadmapsDropdownOpen.set(true);
+ }}
+ onClose={() => roadmapsDropdownOpen.set(false)}
+ />
+ );
+}
diff --git a/src/helper/generate-ai-course.ts b/src/helper/generate-ai-course.ts
index 209451b48..3aef15adc 100644
--- a/src/helper/generate-ai-course.ts
+++ b/src/helper/generate-ai-course.ts
@@ -5,16 +5,13 @@ import {
} from '../lib/ai';
import { queryClient } from '../stores/query-client';
import { getAiCourseLimitOptions } from '../queries/ai-course';
+import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
type GenerateCourseOptions = {
term: string;
- difficulty: string;
slug?: string;
isForce?: boolean;
prompt?: string;
- instructions?: string;
- goal?: string;
- about?: string;
onCourseIdChange?: (courseId: string) => void;
onCourseSlugChange?: (courseSlug: string) => void;
onCourseChange?: (course: AiCourse, rawData: string) => void;
@@ -22,13 +19,13 @@ type GenerateCourseOptions = {
onCreatorIdChange?: (creatorId: string) => void;
onError?: (error: string) => void;
src?: string;
+ questionAndAnswers?: QuestionAnswerChatMessage[];
};
export async function generateCourse(options: GenerateCourseOptions) {
const {
term,
slug,
- difficulty,
onCourseIdChange,
onCourseSlugChange,
onCourseChange,
@@ -37,10 +34,8 @@ export async function generateCourse(options: GenerateCourseOptions) {
onCreatorIdChange,
isForce = false,
prompt,
- instructions,
- goal,
- about,
src = 'search',
+ questionAndAnswers,
} = options;
onLoadingChange?.(true);
@@ -48,7 +43,6 @@ export async function generateCourse(options: GenerateCourseOptions) {
{
title: '',
modules: [],
- difficulty: '',
done: [],
},
'',
@@ -83,12 +77,9 @@ export async function generateCourse(options: GenerateCourseOptions) {
},
body: JSON.stringify({
keyword: term,
- difficulty,
isForce,
customPrompt: prompt,
- instructions,
- goal,
- about,
+ questionAndAnswers,
src,
}),
credentials: 'include',
@@ -136,7 +127,6 @@ export async function generateCourse(options: GenerateCourseOptions) {
courseId: extractedCourseId,
courseSlug: extractedCourseSlug,
term,
- difficulty,
},
'',
`${origin}/ai/${extractedCourseSlug}`,
@@ -155,13 +145,7 @@ export async function generateCourse(options: GenerateCourseOptions) {
try {
const aiCourse = generateAiCourseStructure(result);
- onCourseChange?.(
- {
- ...aiCourse,
- difficulty: difficulty || '',
- },
- result,
- );
+ onCourseChange?.(aiCourse, result);
} catch (e) {
console.error('Error parsing streamed course content:', e);
}
diff --git a/src/helper/generate-ai-guide.ts b/src/helper/generate-ai-guide.ts
index fb33f9564..350017424 100644
--- a/src/helper/generate-ai-guide.ts
+++ b/src/helper/generate-ai-guide.ts
@@ -2,6 +2,7 @@ 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';
type GuideDetails = {
guideId: string;
@@ -12,13 +13,9 @@ type GuideDetails = {
type GenerateGuideOptions = {
term: string;
- depth: string;
slug?: string;
isForce?: boolean;
prompt?: string;
- instructions?: string;
- goal?: string;
- about?: string;
onGuideSlugChange?: (guideSlug: string) => void;
onGuideChange?: (guide: string) => void;
onLoadingChange?: (isLoading: boolean) => void;
@@ -28,26 +25,24 @@ type GenerateGuideOptions = {
onStreamingChange?: (isStreaming: boolean) => void;
onDetailsChange?: (details: GuideDetails) => void;
onFinish?: () => void;
+ questionAndAnswers?: QuestionAnswerChatMessage[];
};
export async function generateGuide(options: GenerateGuideOptions) {
const {
term,
slug,
- depth,
onGuideChange,
onLoadingChange,
onError,
isForce = false,
prompt,
- instructions,
- goal,
- about,
src = 'search',
onHtmlChange,
onStreamingChange,
onDetailsChange,
onFinish,
+ questionAndAnswers,
} = options;
onLoadingChange?.(true);
@@ -80,12 +75,9 @@ export async function generateGuide(options: GenerateGuideOptions) {
},
body: JSON.stringify({
keyword: term,
- depth,
isForce,
customPrompt: prompt,
- instructions,
- goal,
- about,
+ questionAndAnswers,
src,
}),
credentials: 'include',
diff --git a/src/lib/ai-questions.ts b/src/lib/ai-questions.ts
new file mode 100644
index 000000000..846ad3400
--- /dev/null
+++ b/src/lib/ai-questions.ts
@@ -0,0 +1,29 @@
+import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
+
+export function storeQuestionAnswerChatMessages(
+ messages: QuestionAnswerChatMessage[],
+) {
+ const sessionId = Date.now().toString();
+
+ localStorage.setItem(sessionId, JSON.stringify(messages));
+ localStorage.setItem('lastMessagesSessionId', sessionId);
+
+ return sessionId;
+}
+
+export function getQuestionAnswerChatMessages(sessionId: string) {
+ const messages = localStorage.getItem(sessionId);
+ if (!messages) {
+ return [];
+ }
+
+ return JSON.parse(messages);
+}
+
+export function getLastMessagesSessionId() {
+ return localStorage.getItem('lastMessagesSessionId');
+}
+
+export function clearQuestionAnswerChatMessages() {
+ localStorage.removeItem('lastMessagesSessionId');
+}
diff --git a/src/lib/ai.ts b/src/lib/ai.ts
index 19f07ac4b..0afb4c05f 100644
--- a/src/lib/ai.ts
+++ b/src/lib/ai.ts
@@ -12,13 +12,10 @@ type Module = {
export type AiCourse = {
title: string;
modules: Module[];
- difficulty: string;
done: string[];
};
-export function generateAiCourseStructure(
- data: string,
-): Omit {
+export function generateAiCourseStructure(data: string): AiCourse {
const lines = data.split('\n');
let title = '';
const modules: Module[] = [];
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/pages/ai/roadmaps.astro b/src/pages/ai/roadmaps.astro
new file mode 100644
index 000000000..0c17ac982
--- /dev/null
+++ b/src/pages/ai/roadmaps.astro
@@ -0,0 +1,17 @@
+---
+import { UserRoadmapsList } from '../../components/AIRoadmap/UserRoadmapsList';
+import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
+import { AILibraryLayout } from '../../components/AIGuide/AILibraryLayout';
+const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
+---
+
+
+
+
+
+
diff --git a/src/queries/ai-course.ts b/src/queries/ai-course.ts
index 04ffa942c..92383df68 100644
--- a/src/queries/ai-course.ts
+++ b/src/queries/ai-course.ts
@@ -1,6 +1,7 @@
import { httpGet } from '../lib/query-http';
import { isLoggedIn } from '../lib/jwt';
import { queryOptions } from '@tanstack/react-query';
+import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
export interface AICourseProgressDocument {
_id: string;
@@ -30,6 +31,7 @@ export interface AICourseDocument {
difficulty: string;
modules: AICourseModule[];
viewCount: number;
+ questionAndAnswers?: QuestionAnswerChatMessage[];
createdAt: Date;
updatedAt: Date;
}
@@ -45,6 +47,7 @@ export function getAiCourseOptions(params: GetAICourseParams) {
);
},
enabled: !!params.aiCourseSlug,
+ refetchOnMount: false,
};
}
diff --git a/src/queries/ai-guide.ts b/src/queries/ai-guide.ts
index dc5822674..3bddb7fbc 100644
--- a/src/queries/ai-guide.ts
+++ b/src/queries/ai-guide.ts
@@ -5,6 +5,7 @@ import {
markdownToHtml,
markdownToHtmlWithHighlighting,
} from '../lib/markdown';
+import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
export interface AIGuideDocument {
_id: string;
@@ -12,7 +13,6 @@ export interface AIGuideDocument {
title: string;
slug?: string;
keyword: string;
- depth: string;
content: string;
tokens: {
prompt: number;
@@ -24,6 +24,8 @@ export interface AIGuideDocument {
deepDiveTopics: string[];
questions: string[];
+ questionAndAnswers?: QuestionAnswerChatMessage[];
+
viewCount: number;
lastVisitedAt: Date;
createdAt: Date;
@@ -64,6 +66,7 @@ export function aiGuideSuggestionsOptions(guideSlug?: string) {
);
},
enabled: !!guideSlug && !!isLoggedIn(),
+ refetchOnMount: false,
});
}
diff --git a/src/queries/ai-roadmap.ts b/src/queries/ai-roadmap.ts
new file mode 100644
index 000000000..200d1e759
--- /dev/null
+++ b/src/queries/ai-roadmap.ts
@@ -0,0 +1,213 @@
+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';
+
+ questionAndAnswers?: QuestionAnswerChatMessage[];
+
+ 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';
+import type { AIGuideDocument } from './ai-guide';
+import { isLoggedIn } from '../lib/jwt';
+
+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);
+ }
+}
+
+export type ListUserAiRoadmapsQuery = {
+ perPage?: string;
+ currPage?: string;
+ query?: string;
+};
+
+export type ListUserAiRoadmapsResponse = {
+ data: Omit[];
+ totalCount: number;
+ totalPages: number;
+ currPage: number;
+ perPage: number;
+};
+
+export function listUserAiRoadmapsOptions(
+ params: ListUserAiRoadmapsQuery = {
+ perPage: '21',
+ currPage: '1',
+ query: '',
+ },
+) {
+ return queryOptions({
+ queryKey: ['user-ai-roadmaps', params],
+ queryFn: () => {
+ return httpGet(
+ `/v1-list-user-ai-roadmaps`,
+ params,
+ );
+ },
+ enabled: !!isLoggedIn(),
+ });
+}
diff --git a/src/queries/user-ai-session.ts b/src/queries/user-ai-session.ts
new file mode 100644
index 000000000..0164d16fc
--- /dev/null
+++ b/src/queries/user-ai-session.ts
@@ -0,0 +1,37 @@
+import { queryOptions } from '@tanstack/react-query';
+import { httpGet } from '../lib/query-http';
+
+type AIQuestionSuggestionsQuery = {
+ term: string;
+ format: string;
+};
+
+export type AIQuestionSuggestionsResponse = {
+ questions: {
+ question: string;
+ possibleAnswers: string[];
+ }[];
+};
+
+export function aiQuestionSuggestionsOptions(
+ query: AIQuestionSuggestionsQuery,
+ defaultQuestions?: AIQuestionSuggestionsResponse['questions'],
+) {
+ return queryOptions({
+ queryKey: ['ai-question-suggestions', query],
+ queryFn: () => {
+ if (defaultQuestions) {
+ return {
+ questions: defaultQuestions,
+ };
+ }
+
+ return httpGet(
+ `/v1-ai-question-suggestions`,
+ query,
+ );
+ },
+ enabled: !!query.term && !!query.format,
+ refetchOnMount: false,
+ });
+}
diff --git a/src/stores/page.ts b/src/stores/page.ts
index f4041e842..a269b1a93 100644
--- a/src/stores/page.ts
+++ b/src/stores/page.ts
@@ -6,4 +6,6 @@ export const sponsorHidden = atom(false);
export const roadmapsDropdownOpen = atom(false);
export const navigationDropdownOpen = atom(false);
-export const isOnboardingStripHidden = atom(false);
\ No newline at end of file
+export const isOnboardingStripHidden = atom(false);
+
+export const aiDropdownOpen = atom(false);
\ No newline at end of file
diff --git a/src/stores/query-client.ts b/src/stores/query-client.ts
index 210d1d733..044e74f91 100644
--- a/src/stores/query-client.ts
+++ b/src/stores/query-client.ts
@@ -5,6 +5,7 @@ export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
+ refetchOnMount: false,
enabled: !import.meta.env.SSR,
},
},