Refactor AI roadmap generator (#5300)

* fix: roadmap refetching

* fix: remove current roadmap

* feat: explore ai roadmaps

* feat: generate roadmap content

* fix: roadmap topic details

* fix: make roadmap link

* feat: add visit cookie

* chore: update naming

* Update UI for roadmap search

* Update

* Update

* UI updates

* fix: expire visit cookie in 1 hour

* chore: limit roadmap topic content generation

* Add alert on generate roadmap

* UI for search

* Refactor nodesg

* Refactor

* Load roadmap on click

* Refactor UI for ai

* Allow overriding with own API key

* Allow overriding keys

* Add configuration for open ai key

* Add open ai saving

* Fix responsiveness issues

* Fix responsiveness issues

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
pull/5327/head
Arik Chakma 9 months ago committed by GitHub
parent 09cb1ea827
commit cd6232035f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 149
      src/components/ExploreAIRoadmap/ExploreAIRoadmap.tsx
  2. 53
      src/components/GenerateRoadmap/AIRoadmapAlert.tsx
  3. 347
      src/components/GenerateRoadmap/GenerateRoadmap.tsx
  4. 168
      src/components/GenerateRoadmap/OpenAISettings.tsx
  5. 133
      src/components/GenerateRoadmap/RoadmapSearch.tsx
  6. 241
      src/components/GenerateRoadmap/RoadmapTopicDetail.tsx
  7. 30
      src/helper/read-stream.ts
  8. 4
      src/lib/date.ts
  9. 36
      src/lib/jwt.ts
  10. 21
      src/lib/markdown.ts
  11. 10
      src/pages/ai/explore.astro

@ -0,0 +1,149 @@
import { useCallback, useEffect, useState } from 'react';
import { useToast } from '../../hooks/use-toast';
import { httpGet } from '../../lib/http';
import { getRelativeTimeString } from '../../lib/date';
import { Eye, Loader2, RefreshCcw } from 'lucide-react';
import { AIRoadmapAlert } from '../GenerateRoadmap/AIRoadmapAlert.tsx';
export interface AIRoadmapDocument {
_id?: string;
term: string;
title: string;
data: string;
viewCount: number;
createdAt: Date;
updatedAt: Date;
}
type ExploreRoadmapsResponse = {
data: AIRoadmapDocument[];
totalCount: number;
totalPages: number;
currPage: number;
perPage: number;
};
export function ExploreAIRoadmap() {
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [roadmaps, setRoadmaps] = useState<AIRoadmapDocument[]>([]);
const [currPage, setCurrPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const loadAIRoadmaps = useCallback(
async (currPage: number) => {
const { response, error } = await httpGet<ExploreRoadmapsResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-ai-roadmaps`,
{
currPage,
},
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
const newRoadmaps = [...roadmaps, ...response.data];
if (
JSON.stringify(roadmaps) === JSON.stringify(response.data) ||
JSON.stringify(roadmaps) === JSON.stringify(newRoadmaps)
) {
return;
}
setRoadmaps(newRoadmaps);
setCurrPage(response.currPage);
setTotalPages(response.totalPages);
},
[currPage, roadmaps],
);
useEffect(() => {
loadAIRoadmaps(currPage).finally(() => {
setIsLoading(false);
});
}, []);
const hasMorePages = currPage < totalPages;
return (
<section className="container mx-auto py-3 sm:py-6">
<div className="mb-6">
<AIRoadmapAlert isListing />
</div>
{isLoading ? (
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{new Array(21).fill(0).map((_, index) => (
<li
key={index}
className="h-[75px] animate-pulse rounded-md border bg-gray-100"
></li>
))}
</ul>
) : (
<div>
{roadmaps?.length === 0 ? (
<div className="text-center text-gray-800">No roadmaps found</div>
) : (
<>
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{roadmaps.map((roadmap) => {
const roadmapLink = `/ai?id=${roadmap._id}`;
return (
<a
key={roadmap._id}
href={roadmapLink}
className="flex flex-col rounded-md border transition-colors hover:bg-gray-100"
target={'_blank'}
>
<h2 className="flex-grow px-2.5 py-2.5 text-base font-medium leading-tight">
{roadmap.title}
</h2>
<div className="flex items-center justify-between gap-2 px-2.5 py-2">
<span className="flex items-center gap-1.5 text-xs text-gray-400">
<Eye size={15} className="inline-block" />
{Intl.NumberFormat('en-US', {
notation: 'compact',
}).format(roadmap.viewCount)}{' '}
views
</span>
<span className="flex items-center gap-1.5 text-xs text-gray-400">
{getRelativeTimeString(String(roadmap?.createdAt))}
</span>
</div>
</a>
);
})}
</ul>
{hasMorePages && (
<div className="my-5 flex items-center justify-center">
<button
onClick={() => {
setIsLoadingMore(true);
loadAIRoadmaps(currPage + 1).finally(() => {
setIsLoadingMore(false);
});
}}
className="inline-flex items-center gap-1.5 rounded-full bg-black px-3 py-1.5 text-sm font-medium text-white shadow-xl transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isLoadingMore}
>
{isLoadingMore ? (
<Loader2 className="h-4 w-4 animate-spin stroke-[2.5]" />
) : (
<RefreshCcw className="h-4 w-4 stroke-[2.5]" />
)}
Load More
</button>
</div>
)}
</>
)}
</div>
)}
</section>
);
}

@ -0,0 +1,53 @@
import { BadgeCheck, Telescope, Wand } from 'lucide-react';
type AIRoadmapAlertProps = {
isListing?: boolean;
};
export function AIRoadmapAlert(props: AIRoadmapAlertProps) {
const { isListing = false } = props;
return (
<div className="mb-3 w-full rounded-xl bg-yellow-100 px-4 py-3 text-yellow-800">
<h2 className="flex items-center text-base font-semibold text-yellow-800 sm:text-lg">
AI Generated Roadmap{isListing ? 's' : ''}{' '}
<span className="ml-1.5 rounded-md border border-yellow-500 bg-yellow-200 px-1.5 text-xs uppercase tracking-wide text-yellow-800">
Beta
</span>
</h2>
<p className="mb-2 mt-1">
{isListing
? 'These are AI generated roadmaps and are not verified by'
: 'This is an AI generated roadmap and is not verified by'}{' '}
<span className={'font-semibold'}>roadmap.sh</span>. We are currently in
beta and working hard to improve the quality of the generated roadmaps.
</p>
<p className="mb-1.5 mt-2 flex flex-col gap-2 text-sm sm:flex-row">
{isListing ? (
<a
href="/ai"
className="flex items-center gap-1.5 rounded-md border border-yellow-600 px-2 py-1 text-yellow-700 transition-colors hover:bg-yellow-300 hover:text-yellow-800"
>
<Wand size={15} />
Create your own Roadmap with AI
</a>
) : (
<a
href="/ai/explore"
className="flex items-center gap-1.5 rounded-md border border-yellow-600 px-2 py-1 text-yellow-700 transition-colors hover:bg-yellow-300 hover:text-yellow-800"
>
<Telescope size={15} />
Explore other AI Roadmaps
</a>
)}
<a
href="/roadmaps"
className="flex items-center gap-1.5 rounded-md border border-yellow-600 bg-yellow-200 px-2 py-1 text-yellow-800 transition-colors hover:bg-yellow-300"
>
<BadgeCheck size={15} />
Visit Official Roadmaps
</a>
</p>
</div>
);
}

@ -1,14 +1,26 @@
import { type FormEvent, useEffect, useRef, useState } from 'react';
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 { isLoggedIn, removeAuthToken } from '../../lib/jwt';
import {
getOpenAIKey,
isLoggedIn,
removeAuthToken,
visitAIRoadmap,
} from '../../lib/jwt';
import { RoadmapSearch } from './RoadmapSearch.tsx';
import { Spinner } from '../ReactIcons/Spinner.tsx';
import { Ban, Download, PenSquare, Wand } from 'lucide-react';
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';
@ -20,9 +32,55 @@ import {
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';
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<HTMLDivElement>(null);
@ -31,11 +89,21 @@ export function GenerateRoadmap() {
const [hasSubmitted, setHasSubmitted] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState(false);
const [roadmapTopic, setRoadmapTopic] = useState('');
const [generatedRoadmap, setGeneratedRoadmap] = useState('');
const [roadmapTerm, setRoadmapTerm] = useState('');
const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState('');
const [currentRoadmap, setCurrentRoadmap] =
useState<GetAIRoadmapResponse | null>(null);
const [selectedNode, setSelectedNode] = useState<RoadmapNodeDetails | null>(
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 = getOpenAIKey();
const renderRoadmap = async (roadmap: string) => {
const { nodes, edges } = generateAIRoadmapFromText(roadmap);
@ -45,12 +113,7 @@ export function GenerateRoadmap() {
}
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!roadmapTopic) {
return;
}
const loadTermRoadmap = async (term: string) => {
setIsLoading(true);
setHasSubmitted(true);
@ -61,6 +124,7 @@ export function GenerateRoadmap() {
}
deleteUrlParam('id');
setCurrentRoadmap(null);
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-roadmap`,
@ -70,7 +134,7 @@ export function GenerateRoadmap() {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ topic: roadmapTopic }),
body: JSON.stringify({ term }),
},
);
@ -104,13 +168,19 @@ export function GenerateRoadmap() {
const roadmapId = result.match(ROADMAP_ID_REGEX)?.[1] || '';
setUrlParams({ id: roadmapId });
result = result.replace(ROADMAP_ID_REGEX, '');
setCurrentRoadmap({
id: roadmapId,
term: roadmapTerm,
title: term,
data: result,
});
}
await renderRoadmap(result);
},
onStreamEnd: async (result) => {
result = result.replace(ROADMAP_ID_REGEX, '');
setGeneratedRoadmap(result);
setGeneratedRoadmapContent(result);
loadAIRoadmapLimit().finally(() => {});
},
});
@ -118,7 +188,20 @@ export function GenerateRoadmap() {
setIsLoading(false);
};
const editGeneratedRoadmap = async () => {
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!roadmapTerm) {
return;
}
if (roadmapTerm === currentRoadmap?.topic) {
return;
}
loadTermRoadmap(roadmapTerm).finally(() => null);
};
const saveAIRoadmap = async () => {
if (!isLoggedIn()) {
showLoginPopup();
return;
@ -126,12 +209,14 @@ export function GenerateRoadmap() {
pageProgressMessage.set('Redirecting to Editor');
const { nodes, edges } = generateAIRoadmapFromText(generatedRoadmap);
const { nodes, edges } = generateAIRoadmapFromText(generatedRoadmapContent);
const { response, error } = await httpPost<{
roadmapId: string;
}>(`${import.meta.env.PUBLIC_API_URL}/v1-edit-ai-generated-roadmap`, {
title: roadmapTopic,
}>(
`${import.meta.env.PUBLIC_API_URL}/v1-save-ai-roadmap/${currentRoadmap?.id}`,
{
title: roadmapTerm,
nodes: nodes.map((node) => ({
...node,
@ -146,18 +231,22 @@ export function GenerateRoadmap() {
},
})),
edges,
});
},
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
pageProgressMessage.set('');
setIsLoading(false);
return;
}
window.location.href = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${response.roadmapId}`;
setIsLoading(false);
pageProgressMessage.set('');
return response.roadmapId;
};
const downloadGeneratedRoadmap = async () => {
const downloadGeneratedRoadmapContent = async () => {
pageProgressMessage.set('Downloading Roadmap');
const node = document.getElementById('roadmap-container');
@ -167,7 +256,7 @@ export function GenerateRoadmap() {
}
try {
await downloadGeneratedRoadmapImage(roadmapTopic, node);
await downloadGeneratedRoadmapImage(roadmapTerm, node);
pageProgressMessage.set('');
} catch (error) {
console.error(error);
@ -176,19 +265,20 @@ export function GenerateRoadmap() {
};
const loadAIRoadmapLimit = async () => {
const { response, error } = await httpGet<{
limit: number;
used: number;
}>(`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap-limit`);
const { response, error } = await httpGet<GetAIRoadmapLimitResponse>(
`${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 } = response;
const { limit, used, topicLimit, topicUsed } = response;
setRoadmapLimit(limit);
setRoadmapLimitUsed(used);
setRoadmapTopicLimit(topicLimit);
setRoadmapTopicLimitUsed(topicUsed);
};
const loadAIRoadmap = async (roadmapId: string) => {
@ -205,19 +295,65 @@ export function GenerateRoadmap() {
return;
}
const { topic, data } = response;
const { term, title, data } = response;
await renderRoadmap(data);
setRoadmapTopic(topic);
setGeneratedRoadmap(data);
setCurrentRoadmap({
id: roadmapId,
title: title,
term: term,
data,
});
setRoadmapTerm(title);
setGeneratedRoadmapContent(data);
visitAIRoadmap(roadmapId);
};
const handleNodeClick = useCallback(
(e: MouseEvent<HTMLDivElement, globalThis.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 (!roadmapId) {
if (!roadmapId || roadmapId === currentRoadmap?.id) {
return;
}
@ -225,24 +361,63 @@ export function GenerateRoadmap() {
loadAIRoadmap(roadmapId).finally(() => {
pageProgressMessage.set('');
});
}, [roadmapId]);
}, [roadmapId, currentRoadmap]);
if (!hasSubmitted) {
return (
<RoadmapSearch
roadmapTopic={roadmapTopic}
setRoadmapTopic={setRoadmapTopic}
roadmapTerm={roadmapTerm}
setRoadmapTerm={setRoadmapTerm}
handleSubmit={handleSubmit}
limit={roadmapLimit}
limitUsed={roadmapLimitUsed}
loadAIRoadmapLimit={loadAIRoadmapLimit}
onLoadTerm={(term: string) => {
setRoadmapTerm(term);
loadTermRoadmap(term).finally(() => {});
}}
/>
);
}
const pageUrl = `https://roadmap.sh/ai?id=${roadmapId}`;
const canGenerateMore = roadmapLimitUsed < roadmapLimit;
const isLoggedInUser = isLoggedIn();
return (
<>
{isConfiguring && (
<OpenAISettings
onClose={() => {
setIsConfiguring(false);
loadAIRoadmapLimit().finally(() => null);
}}
/>
)}
{selectedNode && currentRoadmap && !isLoading && (
<RoadmapTopicDetail
nodeId={selectedNode.nodeId}
nodeType={selectedNode.nodeType}
nodeTitle={selectedNode.nodeTitle}
parentTitle={selectedNode.parentTitle}
onConfigureOpenAI={() => {
setSelectedNode(null);
setIsConfiguring(true);
}}
onClose={() => {
setSelectedNode(null);
loadAIRoadmapLimit().finally(() => {});
}}
roadmapId={currentRoadmap?.id || ''}
topicLimit={roadmapTopicLimit}
topicLimitUsed={roadmapTopicLimitUsed}
onTopicContentGenerateComplete={async () => {
await loadAIRoadmapLimit();
}}
/>
)}
<section className="flex flex-grow flex-col bg-gray-100">
<div className="flex items-center justify-center border-b bg-white py-3 sm:py-6">
{isLoading && (
@ -252,12 +427,13 @@ export function GenerateRoadmap() {
</span>
)}
{!isLoading && (
<div className="flex max-w-[600px] flex-grow flex-col items-center px-5">
<div className="mt-2 flex w-full items-center justify-between text-sm">
<span className="text-gray-800">
<div className="container flex flex-grow flex-col items-center">
<AIRoadmapAlert />
<div className="mt-2 flex w-full flex-col items-start justify-between gap-2 text-sm sm:flex-row sm:items-center sm:gap-0">
<span>
<span
className={cn(
'inline-block w-[65px] rounded-md border px-0.5 text-center text-sm tabular-nums text-gray-800',
'mr-0.5 inline-block rounded-xl border px-1.5 text-center text-sm tabular-nums text-gray-800',
{
'animate-pulse border-zinc-300 bg-zinc-300 text-zinc-300':
!roadmapLimit,
@ -266,45 +442,68 @@ export function GenerateRoadmap() {
>
{roadmapLimitUsed} of {roadmapLimit}
</span>{' '}
roadmaps generated
{!isLoggedIn() && (
<>
{' '}
roadmaps generated.
</span>
{!isLoggedInUser && (
<button
className="font-medium text-black underline underline-offset-2"
className="rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
onClick={showLoginPopup}
>
Login to increase your limit
Generate more by{' '}
<span className="font-semibold">
signing up (free, takes 2s)
</span>{' '}
or <span className="font-semibold">logging in</span>
</button>
</>
)}
{isLoggedInUser && !openAPIKey && (
<button
onClick={() => setIsConfiguring(true)}
className="text-left rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
>
By-pass all limits by{' '}
<span className="font-semibold">
adding your own OpenAI API key
</span>
</button>
)}
{isLoggedInUser && openAPIKey && (
<button
onClick={() => setIsConfiguring(true)}
className="flex flex-row items-center gap-1 rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
>
<Cog size={15} />
Configure OpenAI key
</button>
)}
</div>
<form
onSubmit={handleSubmit}
className="my-3 flex w-full flex-col sm:flex-row sm:items-center sm:justify-center gap-2"
className="my-3 flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-center"
>
<input
type="text"
autoFocus
placeholder="e.g. Ansible"
placeholder="e.g. Try searching for Ansible or DevOps"
className="flex-grow rounded-md border border-gray-400 px-3 py-2 transition-colors focus:border-black focus:outline-none"
value={roadmapTopic}
value={roadmapTerm}
onInput={(e) =>
setRoadmapTopic((e.target as HTMLInputElement).value)
setRoadmapTerm((e.target as HTMLInputElement).value)
}
/>
<button
type={'submit'}
className={cn(
'flex min-w-[127px] flex-shrink-0 items-center gap-2 rounded-md bg-black px-4 py-2 text-white justify-center',
{
'cursor-not-allowed opacity-50':
!roadmapLimit ||
!roadmapTopic ||
roadmapLimitUsed >= roadmapLimit,
},
'flex min-w-[127px] flex-shrink-0 items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-white',
'disabled:cursor-not-allowed disabled:opacity-50',
)}
disabled={
!roadmapLimit ||
!roadmapTerm ||
roadmapLimitUsed >= roadmapLimit ||
roadmapTerm === currentRoadmap?.term
}
>
{roadmapLimit > 0 && canGenerateMore && (
<>
@ -327,21 +526,48 @@ export function GenerateRoadmap() {
<div className="flex items-center justify-between gap-2">
<button
className="inline-flex items-center justify-center gap-2 rounded-md bg-yellow-400 py-1.5 pl-2.5 pr-3 text-xs font-medium transition-opacity duration-300 hover:bg-yellow-500 sm:text-sm"
onClick={downloadGeneratedRoadmap}
onClick={downloadGeneratedRoadmapContent}
>
<Download size={15} />
<span className="hidden sm:inline">Download</span>
Download
</button>
{roadmapId && (
<ShareRoadmapButton
description={`Check out ${roadmapTopic} roadmap I generated on roadmap.sh`}
description={`Check out ${roadmapTerm} roadmap I generated on roadmap.sh`}
pageUrl={pageUrl}
/>
)}
</div>
<div className="flex items-center justify-between gap-2">
<button
className="inline-flex items-center justify-center gap-2 rounded-md bg-gray-200 py-1.5 pl-2.5 pr-3 text-xs font-medium text-black transition-colors duration-300 hover:bg-gray-300 sm:text-sm"
onClick={editGeneratedRoadmap}
onClick={async () => {
const roadmapId = await saveAIRoadmap();
if (roadmapId) {
window.location.href = `/r?id=${roadmapId}`;
}
}}
disabled={isLoading}
>
<Save size={15} />
<span className="hidden sm:inline">
Save and Start Learning
</span>
<span className="inline sm:hidden">Start Learning</span>
</button>
<button
className="hidden items-center justify-center gap-2 rounded-md bg-gray-200 py-1.5 pl-2.5 pr-3 text-xs font-medium text-black transition-colors duration-300 hover:bg-gray-300 sm:inline-flex sm:text-sm"
onClick={async () => {
const roadmapId = await saveAIRoadmap();
if (roadmapId) {
window.open(
`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmapId}`,
'_blank',
);
}
}}
disabled={isLoading}
>
<PenSquare size={15} />
@ -349,13 +575,16 @@ export function GenerateRoadmap() {
</button>
</div>
</div>
</div>
)}
</div>
<div
ref={roadmapContainerRef}
id="roadmap-container"
onClick={handleNodeClick}
className="relative px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]"
/>
</section>
</>
);
}

@ -0,0 +1,168 @@
import { Modal } from '../Modal.tsx';
import { useEffect, useState } from 'react';
import {
deleteOpenAIKey,
getOpenAIKey,
saveOpenAIKey,
} from '../../lib/jwt.ts';
import { cn } from '../../lib/classname.ts';
import { CloseIcon } from '../ReactIcons/CloseIcon.tsx';
import { useToast } from '../../hooks/use-toast.ts';
import { httpPost } from '../../lib/http.ts';
type OpenAISettingsProps = {
onClose: () => void;
};
export function OpenAISettings(props: OpenAISettingsProps) {
const { onClose } = props;
const [defaultOpenAIKey, setDefaultOpenAIKey] = useState('');
const [hasError, setHasError] = useState(false);
const [openaiApiKey, setOpenaiApiKey] = useState('');
const [isLoading, setIsLoading] = useState(false);
const toast = useToast();
useEffect(() => {
const apiKey = getOpenAIKey();
setOpenaiApiKey(apiKey || '');
setDefaultOpenAIKey(apiKey || '');
}, []);
return (
<Modal onClose={onClose}>
<div className="p-5">
<h2 className="text-xl font-medium text-gray-800">OpenAI Settings</h2>
<div className="mt-4">
<p className="text-gray-700">
AI Roadmap generator uses OpenAI's GPT-4 model to generate roadmaps.
</p>
<p className="mt-2">
<a
className="font-semibold underline underline-offset-2"
href={'https://platform.openai.com/signup'}
target="_blank"
>
Create an account on OpenAI
</a>{' '}
and enter your API key below to enable the AI Roadmap generator
</p>
<form
className="mt-4"
onSubmit={async (e) => {
e.preventDefault();
setHasError(false);
const normalizedKey = openaiApiKey.trim();
if (!normalizedKey) {
deleteOpenAIKey();
toast.success('OpenAI API key removed');
onClose();
return;
}
if (!normalizedKey.startsWith('sk-')) {
setHasError(true);
return;
}
setIsLoading(true);
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-validate-openai-key`,
{
key: normalizedKey,
},
);
if (error) {
setHasError(true);
setIsLoading(false);
return;
}
// Save the API key to cookies
saveOpenAIKey(normalizedKey);
toast.success('OpenAI API key saved');
onClose();
}}
>
<div className="relative">
<input
type="text"
name="openai-api-key"
id="openai-api-key"
className={cn(
'block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-800 transition-colors focus:border-black focus:outline-none',
{
'border-red-500 bg-red-100 focus:border-red-500': hasError,
},
)}
placeholder="Enter your OpenAI API key"
value={openaiApiKey}
onChange={(e) => {
setHasError(false);
setOpenaiApiKey((e.target as HTMLInputElement).value);
}}
/>
{openaiApiKey && (
<button
type={'button'}
onClick={() => {
setOpenaiApiKey('');
}}
className="absolute right-2 top-1/2 flex h-[20px] w-[20px] -translate-y-1/2 items-center justify-center rounded-full bg-gray-400 text-white hover:bg-gray-600"
>
<CloseIcon className="h-[13px] w-[13px] stroke-[3.5]" />
</button>
)}
</div>
{hasError && (
<p className="mt-2 text-sm text-red-500">
Please enter a valid OpenAI API key
</p>
)}
<button
disabled={isLoading}
type="submit"
className={
'mt-2 w-full rounded-md bg-gray-700 px-4 py-2 text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-50'
}
>
{!isLoading && 'Save'}
{isLoading && 'Validating ..'}
</button>
{!defaultOpenAIKey && (
<button
type="button"
onClick={() => {
onClose();
}}
className="mt-1 w-full rounded-md bg-red-500 px-4 py-2 text-white transition-colors hover:bg-black hover:bg-red-700"
>
Cancel
</button>
)}
{defaultOpenAIKey && (
<button
type="button"
onClick={() => {
deleteOpenAIKey();
onClose();
toast.success('OpenAI API key removed');
}}
className="mt-1 w-full rounded-md bg-red-500 px-4 py-2 text-white transition-colors hover:bg-black hover:bg-red-700"
>
Reset to Default Key
</button>
)}
</form>
</div>
</div>
</Modal>
);
}

@ -1,30 +1,55 @@
import { Ban, Wand } from 'lucide-react';
import {
ArrowUpRight,
Ban,
CircleFadingPlus,
Cog,
Telescope,
Wand,
} from 'lucide-react';
import type { FormEvent } from 'react';
import { isLoggedIn } from '../../lib/jwt';
import { getOpenAIKey, isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { cn } from '../../lib/classname.ts';
import { useState } from 'react';
import { OpenAISettings } from './OpenAISettings.tsx';
type RoadmapSearchProps = {
roadmapTopic: string;
setRoadmapTopic: (topic: string) => void;
roadmapTerm: string;
setRoadmapTerm: (topic: string) => void;
handleSubmit: (e: FormEvent<HTMLFormElement>) => void;
loadAIRoadmapLimit: () => void;
onLoadTerm: (topic: string) => void;
limit: number;
limitUsed: number;
};
export function RoadmapSearch(props: RoadmapSearchProps) {
const {
roadmapTopic,
setRoadmapTopic,
roadmapTerm,
setRoadmapTerm,
handleSubmit,
limit = 0,
limitUsed = 0,
onLoadTerm,
loadAIRoadmapLimit,
} = props;
const canGenerateMore = limitUsed < limit;
const [isConfiguring, setIsConfiguring] = useState(false);
const openAPIKey = getOpenAIKey();
const randomTerms = ['OAuth', 'APIs', 'UX Design', 'gRPC'];
return (
<div className="flex flex-grow flex-col items-center justify-center px-4 py-6 sm:px-6">
{isConfiguring && (
<OpenAISettings
onClose={() => {
setIsConfiguring(false);
loadAIRoadmapLimit();
}}
/>
)}
<div className="flex flex-col gap-0 text-center sm:gap-2">
<h1 className="relative text-2xl font-medium sm:text-3xl">
<span className="hidden sm:inline">Generate roadmaps with AI</span>
@ -39,6 +64,7 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
</span>
</p>
</div>
<div className="my-3 flex w-full max-w-[600px] flex-col items-center gap-3 sm:my-5">
<form
onSubmit={(e) => {
if (limit > 0 && canGenerateMore) {
@ -47,53 +73,68 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
e.preventDefault();
}
}}
className="my-3 flex w-full max-w-[600px] flex-col gap-2 sm:my-5 sm:flex-row"
className="flex w-full flex-col gap-2 sm:flex-row"
>
<input
autoFocus
type="text"
placeholder="e.g. Ansible"
placeholder="Enter a topic to generate a roadmap for"
className="w-full rounded-md border border-gray-400 px-3 py-2.5 transition-colors focus:border-black focus:outline-none"
value={roadmapTopic}
onInput={(e) => setRoadmapTopic((e.target as HTMLInputElement).value)}
value={roadmapTerm}
onInput={(e) =>
setRoadmapTerm((e.target as HTMLInputElement).value)
}
/>
<button
className={cn(
'flex min-w-[143px] flex-shrink-0 items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-white',
{
'cursor-not-allowed opacity-50':
!limit || !roadmapTopic || limitUsed >= limit,
},
'flex min-w-[154px] flex-shrink-0 items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-white',
'disabled:cursor-not-allowed disabled:opacity-50',
)}
disabled={!limit || !roadmapTerm || limitUsed >= limit}
>
{limit > 0 && canGenerateMore && (
{(!limit || canGenerateMore) && (
<>
<Wand size={20} />
Generate
</>
)}
{limit === 0 && (
<>
<span>Please wait..</span>
</>
)}
{limit > 0 && !canGenerateMore && (
<span className="flex items-center text-base sm:text-sm">
<span className="flex items-center text-base">
<Ban size={15} className="mr-2" />
Limit reached
</span>
)}
</button>
</form>
<div className="mb-36">
<div className="flex flex-row items-center justify-center gap-2 flex-wrap">
{randomTerms.map((term) => (
<button
key={term}
disabled={!limit || !canGenerateMore}
type="button"
onClick={() => {
onLoadTerm(term);
}}
className="flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-sm transition-colors hover:border-black hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
{term} <ArrowUpRight size={17} />
</button>
))}
<a
href="/ai/explore"
className="flex items-center gap-1.5 rounded-full border border-black bg-gray-700 px-2 py-0.5 text-sm text-white transition-colors hover:border-black hover:bg-black"
>
Explore AI Roadmaps <Telescope size={17} />
</a>
</div>
</div>
<div className="mt-12 flex flex-col items-center gap-4">
<p className="text-gray-500">
<span className="inline sm:hidden">Generated </span>
<span className="hidden sm:inline">You have generated </span>
You have generated{' '}
<span
className={cn(
'inline-block w-[65px] rounded-md border px-0.5 text-center text-sm tabular-nums text-gray-800',
'inline-block min-w-[50px] rounded-xl border px-1.5 text-center text-sm tabular-nums text-gray-800',
{
'animate-pulse border-zinc-300 bg-zinc-300 text-zinc-300':
!limit,
@ -103,16 +144,42 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
{limitUsed} of {limit}
</span>{' '}
roadmaps.
{!isLoggedIn && (
<>
{' '}
</p>
<p className="flex min-h-[26px] items-center text-sm">
{limit > 0 && !isLoggedIn() && (
<button
className="font-semibold text-black underline underline-offset-2"
onClick={showLoginPopup}
className="rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
>
Log in to increase your limit
Generate more by{' '}
<span className="font-semibold">
signing up (free and takes 2 seconds)
</span>{' '}
or <span className="font-semibold">logging in</span>
</button>
)}
</p>
<p className="-mt-[45px] flex min-h-[26px] items-center text-sm">
{limit > 0 && isLoggedIn() && !openAPIKey && (
<button
onClick={() => setIsConfiguring(true)}
className="rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
>
By-pass all limits by{' '}
<span className="font-semibold">
adding your own OpenAI API key
</span>
</button>
)}
{limit > 0 && isLoggedIn() && openAPIKey && (
<button
onClick={() => setIsConfiguring(true)}
className="flex flex-row items-center gap-1 rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
>
<Cog size={15} />
Configure OpenAI key
</button>
</>
)}
</p>
</div>

@ -0,0 +1,241 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useKeydown } from '../../hooks/use-keydown';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { markdownToHtml } from '../../lib/markdown';
import { Ban, Cog, FileText, X } from 'lucide-react';
import { Spinner } from '../ReactIcons/Spinner';
import type { RoadmapNodeDetails } from './GenerateRoadmap';
import { getOpenAIKey, isLoggedIn, removeAuthToken } from '../../lib/jwt';
import { readAIRoadmapContentStream } from '../../helper/read-stream';
import { cn } from '../../lib/classname';
import { showLoginPopup } from '../../lib/popup';
import { OpenAISettings } from './OpenAISettings.tsx';
type RoadmapTopicDetailProps = RoadmapNodeDetails & {
onClose?: () => void;
roadmapId: string;
topicLimitUsed: number;
topicLimit: number;
onTopicContentGenerateComplete?: () => void;
onConfigureOpenAI?: () => void;
};
export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) {
const {
onClose,
roadmapId,
nodeTitle,
parentTitle,
topicLimit,
topicLimitUsed,
onTopicContentGenerateComplete,
onConfigureOpenAI,
} = props;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [topicHtml, setTopicHtml] = useState('');
const topicRef = useRef<HTMLDivElement>(null);
const abortController = useMemo(() => new AbortController(), []);
const generateAiRoadmapTopicContent = async () => {
setIsLoading(true);
setError('');
//
// if (topicLimitUsed >= topicLimit) {
// setError('Maximum limit reached');
// setIsLoading(false);
// return;
// }
if (!roadmapId || !nodeTitle) {
setIsLoading(false);
setError('Invalid roadmap id or node title');
return;
}
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-roadmap-content/${roadmapId}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
nodeTitle,
parentTitle,
}),
signal: abortController.signal,
},
);
if (!response.ok) {
const data = await response.json();
setError(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);
setError('Something went wrong');
return;
}
setIsLoading(false);
await readAIRoadmapContentStream(reader, {
onStream: async (result) => {
setTopicHtml(markdownToHtml(result, false));
},
});
onTopicContentGenerateComplete?.();
};
// Close the topic detail when user clicks outside the topic detail
useOutsideClick(topicRef, () => {
onClose?.();
});
useKeydown('Escape', () => {
onClose?.();
});
useEffect(() => {
if (!topicRef?.current) {
return;
}
topicRef?.current?.focus();
generateAiRoadmapTopicContent().finally(() => {});
return () => {
abortController.abort();
};
}, []);
const hasContent = topicHtml?.length > 0;
const openAIKey = getOpenAIKey();
return (
<div className={'relative z-50'}>
<div
ref={topicRef}
tabIndex={0}
className="fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 focus:outline-0 sm:max-w-[600px] sm:p-6"
>
<div className="flex flex-col items-start gap-2 sm:flex-row">
<span>
<span
className={cn(
'mr-0.5 inline-block rounded-xl border px-1.5 text-center text-sm tabular-nums text-gray-800',
{
'animate-pulse border-zinc-300 bg-zinc-300 text-zinc-300':
!topicLimit,
},
)}
>
{topicLimitUsed} of {topicLimit}
</span>{' '}
topics generated
</span>
{!isLoggedIn() && (
<button
className="rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
onClick={showLoginPopup}
>
Generate more by <span className="font-semibold">logging in</span>
</button>
)}
{isLoggedIn() && !openAIKey && (
<button
className="rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
onClick={onConfigureOpenAI}
>
By-pass all limits by{' '}
<span className="font-semibold">adding your own OpenAI Key</span>
</button>
)}
{isLoggedIn() && openAIKey && (
<button
className="flex items-center gap-1 rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
onClick={onConfigureOpenAI}
>
<Cog className="-mt-0.5 inline-block h-4 w-4" />
Configure OpenAI Key
</button>
)}
</div>
{isLoading && (
<div className="mt-6 flex w-full justify-center">
<Spinner
outerFill="#d1d5db"
className="h-6 w-6 sm:h-12 sm:w-12"
innerFill="#2563eb"
/>
</div>
)}
{!isLoading && !error && (
<>
<div className="mb-2">
<button
type="button"
id="close-topic"
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
onClick={onClose}
>
<X className="h-5 w-5" />
</button>
</div>
{hasContent ? (
<div className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5">
<div
id="topic-content"
dangerouslySetInnerHTML={{ __html: topicHtml }}
/>
</div>
) : (
<div className="flex h-[calc(100%-38px)] flex-col items-center justify-center">
<FileText className="h-16 w-16 text-gray-300" />
<p className="mt-2 text-lg font-medium text-gray-500">
Empty Content
</p>
</div>
)}
</>
)}
{/* Error */}
{!isLoading && error && (
<>
<button
type="button"
id="close-topic"
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
onClick={onClose}
>
<X className="h-5 w-5" />
</button>
<div className="flex h-full flex-col items-center justify-center">
<Ban className="h-16 w-16 text-red-500" />
<p className="mt-2 text-lg font-medium text-red-500">{error}</p>
</div>
</>
)}
</div>
<div className="fixed inset-0 z-30 bg-gray-900 bg-opacity-50 dark:bg-opacity-80"></div>
</div>
);
}

@ -41,3 +41,33 @@ export async function readAIRoadmapStream(
onStreamEnd?.(result);
reader.releaseLock();
}
export async function readAIRoadmapContentStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
{
onStream,
onStreamEnd,
}: {
onStream?: (roadmap: string) => void;
onStreamEnd?: (roadmap: string) => void;
},
) {
const decoder = new TextDecoder('utf-8');
let result = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
if (value) {
result += decoder.decode(value);
onStream?.(result);
}
}
onStream?.(result);
onStreamEnd?.(result);
reader.releaseLock();
}

@ -26,5 +26,9 @@ export function getRelativeTimeString(date: string): string {
relativeTime = rtf.format(-diffInDays, 'day');
}
if (relativeTime === 'this minute') {
return 'just now';
}
return relativeTime;
}

@ -48,3 +48,39 @@ export function removeAuthToken() {
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
}
export function visitAIRoadmap(roadmapId: string) {
const isAlreadyVisited = Number(Cookies.get(`crv-${roadmapId}`) || 0) === 1;
if (isAlreadyVisited) {
return;
}
Cookies.set(`crv-${roadmapId}`, '1', {
path: '/',
expires: 1 / 24, // 1 hour
sameSite: 'lax',
secure: !import.meta.env.DEV,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
}
export function deleteOpenAIKey() {
Cookies.remove('oak', {
path: '/',
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
}
export function saveOpenAIKey(apiKey: string) {
Cookies.set('oak', apiKey, {
path: '/',
expires: 365,
sameSite: 'lax',
secure: true,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
}
export function getOpenAIKey() {
return Cookies.get('oak');
}

@ -8,6 +8,27 @@ export function markdownToHtml(markdown: string, isInline = true): string {
linkify: true,
});
// Solution to open links in new tab in markdown
// otherwise default behaviour is to open in same tab
//
// SOURCE: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
//
const defaultRender =
md.renderer.rules.link_open ||
// @ts-ignore
function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
// @ts-ignore
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
// Add a new `target` attribute, or replace the value of the existing one.
tokens[idx].attrSet('target', '_blank');
// Pass the token to the default renderer.
return defaultRender(tokens, idx, options, env, self);
};
if (isInline) {
return md.renderInline(markdown);
} else {

@ -0,0 +1,10 @@
---
import LoginPopup from '../../components/AuthenticationFlow/LoginPopup.astro';
import { ExploreAIRoadmap } from '../../components/ExploreAIRoadmap/ExploreAIRoadmap';
import AccountLayout from '../../layouts/AccountLayout.astro';
---
<AccountLayout title='Explore Roadmap AI'>
<ExploreAIRoadmap client:load />
<LoginPopup />
</AccountLayout>
Loading…
Cancel
Save