import { type FormEvent, type MouseEvent, useCallback, useEffect, useRef, useState, } from 'react'; import './GenerateRoadmap.css'; import { useToast } from '../../hooks/use-toast'; import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generator'; import { renderFlowJSON } from '../../../editor/renderer/renderer'; import { replaceChildren } from '../../lib/dom'; import { readAIRoadmapStream } from '../../helper/read-stream'; import { getOpenAIKey, isLoggedIn, removeAuthToken, setAIReferralCode, visitAIRoadmap, } from '../../lib/jwt'; import { RoadmapSearch } from './RoadmapSearch.tsx'; import { Spinner } from '../ReactIcons/Spinner.tsx'; import { Ban, Cog, Download, PenSquare, Save, Wand } from 'lucide-react'; import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx'; import { httpGet, httpPost } from '../../lib/http.ts'; import { pageProgressMessage } from '../../stores/page.ts'; import { deleteUrlParam, getUrlParams, setUrlParams, } from '../../lib/browser.ts'; import { downloadGeneratedRoadmapImage } from '../../helper/download-image.ts'; import { showLoginPopup } from '../../lib/popup.ts'; import { cn } from '../../lib/classname.ts'; import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx'; import { AIRoadmapAlert } from './AIRoadmapAlert.tsx'; import { OpenAISettings } from './OpenAISettings.tsx'; import { IS_KEY_ONLY_ROADMAP_GENERATION } from '../../lib/ai.ts'; import { AITermSuggestionInput } from './AITermSuggestionInput.tsx'; import { useParams } from '../../hooks/use-params.ts'; import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx'; import { AuthenticationForm } from '../AuthenticationFlow/AuthenticationForm.tsx'; export type GetAIRoadmapLimitResponse = { used: number; limit: number; topicUsed: number; topicLimit: number; }; const ROADMAP_ID_REGEX = new RegExp('@ROADMAPID:(\\w+)@'); export type RoadmapNodeDetails = { nodeId: string; nodeType: string; targetGroup?: SVGElement; nodeTitle?: string; parentTitle?: string; }; export function getNodeDetails( svgElement: SVGElement, ): RoadmapNodeDetails | null { const targetGroup = (svgElement?.closest('g') as SVGElement) || {}; const nodeId = targetGroup?.dataset?.nodeId; const nodeType = targetGroup?.dataset?.type; const nodeTitle = targetGroup?.dataset?.title; const parentTitle = targetGroup?.dataset?.parentTitle; if (!nodeId || !nodeType) return null; return { nodeId, nodeType, targetGroup, nodeTitle, parentTitle }; } export const allowedClickableNodeTypes = [ 'topic', 'subtopic', 'button', 'link-item', ]; type GetAIRoadmapResponse = { id: string; term: string; title: string; data: string; }; export function GenerateRoadmap() { const roadmapContainerRef = useRef(null); const { id: roadmapId, rc: referralCode } = getUrlParams() as { id: string; rc?: string; }; const toast = useToast(); const [hasSubmitted, setHasSubmitted] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isLoadingResults, setIsLoadingResults] = useState(false); const [roadmapTerm, setRoadmapTerm] = useState(''); const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState(''); const [currentRoadmap, setCurrentRoadmap] = useState(null); const [selectedNode, setSelectedNode] = useState( null, ); const [roadmapLimit, setRoadmapLimit] = useState(0); const [roadmapLimitUsed, setRoadmapLimitUsed] = useState(0); const [roadmapTopicLimit, setRoadmapTopicLimit] = useState(0); const [roadmapTopicLimitUsed, setRoadmapTopicLimitUsed] = useState(0); const [isConfiguring, setIsConfiguring] = useState(false); const [openAPIKey, setOpenAPIKey] = useState( getOpenAIKey(), ); const isKeyOnly = IS_KEY_ONLY_ROADMAP_GENERATION; const isAuthenticatedUser = isLoggedIn(); const renderRoadmap = async (roadmap: string) => { const { nodes, edges } = generateAIRoadmapFromText(roadmap); const svg = await renderFlowJSON({ nodes, edges }); if (roadmapContainerRef?.current) { replaceChildren(roadmapContainerRef?.current, svg); } }; const loadTermRoadmap = async (term: string) => { setIsLoading(true); setHasSubmitted(true); deleteUrlParam('id'); setCurrentRoadmap(null); const response = await fetch( `${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-roadmap`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ term }), }, ); if (!response.ok) { const data = await response.json(); toast.error(data?.message || 'Something went wrong'); setIsLoading(false); // Logout user if token is invalid if (data.status === 401) { removeAuthToken(); window.location.reload(); } } const reader = response.body?.getReader(); if (!reader) { setIsLoading(false); toast.error('Something went wrong'); return; } await readAIRoadmapStream(reader, { onStream: async (result) => { if (result.includes('@ROADMAPID')) { // @ROADMAPID: is a special token that we use to identify the roadmap // @ROADMAPID:1234@ is the format, we will remove the token and the id // and replace it with a empty string const roadmapId = result.match(ROADMAP_ID_REGEX)?.[1] || ''; setUrlParams({ id: roadmapId }); result = result.replace(ROADMAP_ID_REGEX, ''); const roadmapTitle = result.trim().split('\n')[0]?.replace('#', '')?.trim() || term; setRoadmapTerm(roadmapTitle); setCurrentRoadmap({ id: roadmapId, term: roadmapTerm, title: roadmapTitle, data: result, }); } await renderRoadmap(result); }, onStreamEnd: async (result) => { result = result.replace(ROADMAP_ID_REGEX, ''); setGeneratedRoadmapContent(result); loadAIRoadmapLimit().finally(() => {}); }, }); setIsLoading(false); }; const handleSubmit = async (e: FormEvent) => { e.preventDefault(); if (!roadmapTerm || isLoadingResults) { return; } if (roadmapTerm === currentRoadmap?.term) { return; } loadTermRoadmap(roadmapTerm).finally(() => null); }; const saveAIRoadmap = async () => { if (!isLoggedIn()) { showLoginPopup(); return; } pageProgressMessage.set('Redirecting to Editor'); const { nodes, edges } = generateAIRoadmapFromText(generatedRoadmapContent); const { response, error } = await httpPost<{ roadmapId: string; }>( `${import.meta.env.PUBLIC_API_URL}/v1-save-ai-roadmap/${currentRoadmap?.id}`, { title: roadmapTerm, nodes: nodes.map((node) => ({ ...node, // To reset the width and height of the node // so that it can be calculated based on the content in the editor width: undefined, height: undefined, style: { ...node.style, width: undefined, height: undefined, }, })), edges, }, ); if (error || !response) { toast.error(error?.message || 'Something went wrong'); pageProgressMessage.set(''); setIsLoading(false); return; } setIsLoading(false); pageProgressMessage.set(''); return response.roadmapId; }; const downloadGeneratedRoadmapContent = async () => { if (!isLoggedIn()) { showLoginPopup(); return; } pageProgressMessage.set('Downloading Roadmap'); const node = document.getElementById('roadmap-container'); if (!node) { toast.error('Something went wrong'); return; } try { await downloadGeneratedRoadmapImage(roadmapTerm, node); pageProgressMessage.set(''); } catch (error) { console.error(error); toast.error('Something went wrong'); } }; const loadAIRoadmapLimit = async () => { const { response, error } = await httpGet( `${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap-limit`, ); if (error || !response) { toast.error(error?.message || 'Something went wrong'); return; } const { limit, used, topicLimit, topicUsed } = response; setRoadmapLimit(limit); setRoadmapLimitUsed(used); setRoadmapTopicLimit(topicLimit); setRoadmapTopicLimitUsed(topicUsed); }; const loadAIRoadmap = async (roadmapId: string) => { pageProgressMessage.set('Loading Roadmap'); const { response, error } = await httpGet<{ term: string; title: string; data: string; }>(`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap/${roadmapId}`); if (error || !response) { toast.error(error?.message || 'Something went wrong'); setIsLoading(false); return; } const { term, title, data } = response; await renderRoadmap(data); setCurrentRoadmap({ id: roadmapId, title: title, term: term, data, }); setRoadmapTerm(term); setGeneratedRoadmapContent(data); visitAIRoadmap(roadmapId); }; const handleNodeClick = useCallback( (e: MouseEvent) => { if (isLoading) { return; } const target = e.target as SVGElement; const { nodeId, nodeType, targetGroup, nodeTitle, parentTitle } = getNodeDetails(target) || {}; if ( !nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType) || !nodeTitle ) return; if (nodeType === 'button' || nodeType === 'link-item') { const link = targetGroup?.dataset?.link || ''; const isExternalLink = link.startsWith('http'); if (isExternalLink) { window.open(link, '_blank'); } else { window.location.href = link; } return; } setSelectedNode({ nodeId, nodeType, nodeTitle, ...(nodeType === 'subtopic' && { parentTitle }), }); }, [isLoading], ); useEffect(() => { loadAIRoadmapLimit().finally(() => {}); }, []); useEffect(() => { if (!referralCode || isLoggedIn()) { deleteUrlParam('rc'); return; } setAIReferralCode(referralCode); deleteUrlParam('rc'); showLoginPopup(); }, []); useEffect(() => { if (!roadmapId || roadmapId === currentRoadmap?.id) { return; } setHasSubmitted(true); loadAIRoadmap(roadmapId).finally(() => { pageProgressMessage.set(''); }); }, [roadmapId, currentRoadmap]); if (!hasSubmitted) { return ( { setRoadmapTerm(term); loadTermRoadmap(term).finally(() => {}); }} /> ); } const pageUrl = `https://roadmap.sh/ai?id=${roadmapId}`; const canGenerateMore = roadmapLimitUsed < roadmapLimit; return ( <> {isConfiguring && ( { setOpenAPIKey(getOpenAIKey()); setIsConfiguring(false); loadAIRoadmapLimit().finally(() => null); }} /> )} {selectedNode && currentRoadmap && !isLoading && ( { setSelectedNode(null); setIsConfiguring(true); }} onClose={() => { setSelectedNode(null); loadAIRoadmapLimit().finally(() => {}); }} roadmapId={currentRoadmap?.id || ''} topicLimit={roadmapTopicLimit} topicLimitUsed={roadmapTopicLimitUsed} onTopicContentGenerateComplete={async () => { await loadAIRoadmapLimit(); }} /> )}
{isLoading && ( Generating roadmap .. )} {!isLoading && (
{isKeyOnly && isAuthenticatedUser && (
{!openAPIKey && (

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

)} {openAPIKey && (

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

)}
)} {!isKeyOnly && isAuthenticatedUser && (
{roadmapLimitUsed} of {roadmapLimit} {' '} roadmaps generated today. {!openAPIKey && ( )} {openAPIKey && ( )}
)} {!isAuthenticatedUser && ( )}
setRoadmapTerm(value)} placeholder="e.g. Try searching for Ansible or DevOps" wrapperClassName="grow" onSelect={(id, title) => { loadTermRoadmap(title).finally(() => null); }} />
{roadmapId && ( )}
)}
{!isAuthenticatedUser && (

Sign up to View the full roadmap

You must be logged in to view the complete roadmap

Already have an account?{' '} Login
)}
); }