mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-02 22:02:39 +02:00
Merge branch 'feat/questions-chat' of github.com:kamranahmedse/developer-roadmap into feat/questions-chat
This commit is contained in:
@@ -11,30 +11,15 @@ type AIGuideCardProps = {
|
||||
export function AIGuideCard(props: AIGuideCardProps) {
|
||||
const { guide, showActions = true } = props;
|
||||
|
||||
const guideDepthColor =
|
||||
{
|
||||
essentials: 'text-green-700',
|
||||
detailed: 'text-blue-700',
|
||||
complete: 'text-purple-700',
|
||||
}[guide.depth] || 'text-gray-700';
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-grow flex-col">
|
||||
<a
|
||||
href={`/ai/guide/${guide.slug}`}
|
||||
className="group relative flex h-full min-h-[120px] w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white p-3 text-left hover:border-gray-300 hover:bg-gray-50 sm:p-4"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between sm:mb-3">
|
||||
<span
|
||||
className={`rounded-full text-xs font-medium capitalize opacity-80 ${guideDepthColor}`}
|
||||
>
|
||||
{guide.depth}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative max-h-[180px] min-h-[140px] overflow-y-hidden sm:max-h-[200px] sm:min-h-[160px]">
|
||||
<div
|
||||
className="prose prose-sm prose-pre:bg-gray-100 [&_h1]:hidden [&_h1:first-child]:block [&_h1:first-child]:text-base [&_h1:first-child]:font-bold [&_h1:first-child]:leading-[1.35] [&_h1:first-child]:text-pretty sm:[&_h1:first-child]:text-lg [&_h2]:hidden [&_h3]:hidden [&_h4]:hidden [&_h5]:hidden [&_h6]:hidden"
|
||||
className="prose prose-sm prose-pre:bg-gray-100 [&_h1]:hidden [&_h1:first-child]:block [&_h1:first-child]:text-base [&_h1:first-child]:leading-[1.35] [&_h1:first-child]:font-bold [&_h1:first-child]:text-pretty sm:[&_h1:first-child]:text-lg [&_h2]:hidden [&_h3]:hidden [&_h4]:hidden [&_h5]:hidden [&_h6]:hidden"
|
||||
dangerouslySetInnerHTML={{ __html: guide.html }}
|
||||
/>
|
||||
|
||||
|
58
src/components/AIRoadmap/AIRoadmap.css
Normal file
58
src/components/AIRoadmap/AIRoadmap.css
Normal 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;
|
||||
}
|
80
src/components/AIRoadmap/AIRoadmap.tsx
Normal file
80
src/components/AIRoadmap/AIRoadmap.tsx
Normal file
@@ -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 (
|
||||
<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
|
||||
svgHtml={aiRoadmap?.svgHtml || ''}
|
||||
isLoading={isLoadingBySlug || isRegenerating}
|
||||
/>
|
||||
)}
|
||||
{!roadmapSlug && (
|
||||
<GenerateAIRoadmap onRoadmapSlugChange={setRoadmapSlug} />
|
||||
)}
|
||||
</div>
|
||||
<AIRoadmapChat
|
||||
roadmapSlug={roadmapSlug}
|
||||
isRoadmapLoading={!aiRoadmap}
|
||||
onUpgrade={() => setShowUpgradeModal(true)}
|
||||
/>
|
||||
</AITutorLayout>
|
||||
);
|
||||
}
|
338
src/components/AIRoadmap/AIRoadmapChat.tsx
Normal file
338
src/components/AIRoadmap/AIRoadmapChat.tsx
Normal file
@@ -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<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-center p-2">
|
||||
<button
|
||||
className="flex items-center justify-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow"
|
||||
onClick={() => {
|
||||
setIsChatOpen(true);
|
||||
}}
|
||||
>
|
||||
<MessageCircleIcon className="h-4 w-4" />
|
||||
<span className="text-sm">Open Chat</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex h-full w-full max-w-full flex-col overflow-hidden border-l border-gray-200 bg-white md:relative md:max-w-[40%]">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-gray-200 bg-white p-2">
|
||||
<h2 className="flex items-center gap-2 text-sm font-medium">
|
||||
<BotIcon className="h-4 w-4" />
|
||||
AI Roadmap
|
||||
</h2>
|
||||
|
||||
<button
|
||||
className="mr-2 flex size-5 items-center justify-center rounded-md text-gray-500 hover:bg-gray-300 md:hidden"
|
||||
onClick={() => {
|
||||
setIsChatOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
|
||||
<LoadingChip message="Loading..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<>
|
||||
<div className="relative grow overflow-y-auto" ref={scrollareaRef}>
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<div className="relative flex grow flex-col justify-end">
|
||||
<div className="flex flex-col justify-end gap-2 px-3 py-2">
|
||||
<RoadmapAIChatCard
|
||||
role="assistant"
|
||||
html="Hello, how can I help you today?"
|
||||
isIntro
|
||||
/>
|
||||
|
||||
{messages.map((chat, index) => {
|
||||
return (
|
||||
<RoadmapAIChatCard key={`chat-${index}`} {...chat} />
|
||||
);
|
||||
})}
|
||||
|
||||
{status === 'streaming' && !streamedMessageHtml && (
|
||||
<RoadmapAIChatCard role="assistant" html="Thinking..." />
|
||||
)}
|
||||
|
||||
{status === 'streaming' && streamedMessageHtml && (
|
||||
<RoadmapAIChatCard
|
||||
role="assistant"
|
||||
html={streamedMessageHtml}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(hasMessages || showScrollToBottom) && (
|
||||
<div className="flex flex-row justify-between gap-2 border-t border-gray-200 px-3 py-2">
|
||||
<ChatHeaderButton
|
||||
icon={<Trash2Icon className="h-3.5 w-3.5" />}
|
||||
className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300"
|
||||
onClick={() => {
|
||||
setMessages([]);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</ChatHeaderButton>
|
||||
{showScrollToBottom && (
|
||||
<ChatHeaderButton
|
||||
icon={<ArrowDownIcon className="h-3.5 w-3.5" />}
|
||||
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
|
||||
</ChatHeaderButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex items-center border-t border-gray-200 text-sm">
|
||||
{isLimitExceeded && isLoggedIn() && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
|
||||
<LockIcon
|
||||
className="size-4 cursor-not-allowed"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<p className="cursor-not-allowed">
|
||||
Limit reached for today
|
||||
{isPaidUser ? '. Please wait until tomorrow.' : ''}
|
||||
</p>
|
||||
{!isPaidUser && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onUpgrade?.();
|
||||
}}
|
||||
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
|
||||
>
|
||||
Upgrade for more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
|
||||
<button
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-zinc-500 hover:text-black disabled:opacity-50"
|
||||
onClick={() => {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status !== 'idle') {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
handleSubmitInput();
|
||||
}}
|
||||
>
|
||||
{isStreamingMessage ? (
|
||||
<PauseCircleIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<SendIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
32
src/components/AIRoadmap/AIRoadmapContent.tsx
Normal file
32
src/components/AIRoadmap/AIRoadmapContent.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'relative mx-auto w-full max-w-4xl',
|
||||
isLoading && 'min-h-full',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
id="roadmap-container"
|
||||
className="relative min-h-[400px] [&>svg]:mx-auto"
|
||||
dangerouslySetInnerHTML={{ __html: svgHtml }}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<LoadingChip message="Please wait..." />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
115
src/components/AIRoadmap/GenerateAIRoadmap.tsx
Normal file
115
src/components/AIRoadmap/GenerateAIRoadmap.tsx
Normal file
@@ -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<string | null>(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 <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 <AIRoadmapContent isLoading={isLoading} svgHtml={svgHtml} />;
|
||||
}
|
@@ -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 (
|
||||
<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">
|
||||
@@ -142,7 +151,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;
|
||||
|
||||
|
@@ -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}
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
|
15
src/pages/ai/roadmap/index.astro
Normal file
15
src/pages/ai/roadmap/index.astro
Normal 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>
|
175
src/queries/ai-roadmap.ts
Normal file
175
src/queries/ai-roadmap.ts
Normal file
@@ -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<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 });
|
||||
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);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user