Adds AI roadmap generator (#5289)
* feat: implement roadmap generator * feat: add roadmap stream * Update UI * fix: add fingerprint visitor id * feat: implement edit generated roadmap * feat: implement ai roadmap download * feat: add limit count * fix: add limit check * fix: download image button * feat: implement roadmap generator * feat: add roadmap stream * Update UI * fix: add fingerprint visitor id * feat: implement edit generated roadmap * feat: implement ai roadmap download * feat: add limit count * fix: add limit check * fix: download image button * UI Updates * Update UI for roadmap search * Update UI for roadmap limit * Update UI for roadmap * UI responsiveness on AI roadmap generator --------- Co-authored-by: Arik Chakma <arikchangma@gmail.com>pull/5291/head
parent
44d3724880
commit
d5fdc62343
11 changed files with 688 additions and 38 deletions
After Width: | Height: | Size: 31 KiB |
@ -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; |
||||
} |
@ -0,0 +1,361 @@ |
||||
import { type FormEvent, 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 { RoadmapSearch } from './RoadmapSearch.tsx'; |
||||
import { Spinner } from '../ReactIcons/Spinner.tsx'; |
||||
import { Ban, Download, PenSquare, 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'; |
||||
|
||||
const ROADMAP_ID_REGEX = new RegExp('@ROADMAPID:(\\w+)@'); |
||||
|
||||
export function GenerateRoadmap() { |
||||
const roadmapContainerRef = useRef<HTMLDivElement>(null); |
||||
|
||||
const { id: roadmapId } = getUrlParams() as { id: string }; |
||||
const toast = useToast(); |
||||
|
||||
const [hasSubmitted, setHasSubmitted] = useState<boolean>(false); |
||||
const [isLoading, setIsLoading] = useState(false); |
||||
const [roadmapTopic, setRoadmapTopic] = useState(''); |
||||
const [generatedRoadmap, setGeneratedRoadmap] = useState(''); |
||||
|
||||
const [roadmapLimit, setRoadmapLimit] = useState(0); |
||||
const [roadmapLimitUsed, setRoadmapLimitUsed] = useState(0); |
||||
|
||||
const renderRoadmap = async (roadmap: string) => { |
||||
const { nodes, edges } = generateAIRoadmapFromText(roadmap); |
||||
const svg = await renderFlowJSON({ nodes, edges }); |
||||
if (roadmapContainerRef?.current) { |
||||
replaceChildren(roadmapContainerRef?.current, svg); |
||||
} |
||||
}; |
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { |
||||
e.preventDefault(); |
||||
if (!roadmapTopic) { |
||||
return; |
||||
} |
||||
|
||||
setIsLoading(true); |
||||
setHasSubmitted(true); |
||||
|
||||
if (roadmapLimitUsed >= roadmapLimit) { |
||||
toast.error('You have reached your limit of generating roadmaps'); |
||||
setIsLoading(false); |
||||
return; |
||||
} |
||||
|
||||
deleteUrlParam('id'); |
||||
|
||||
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({ topic: roadmapTopic }), |
||||
}, |
||||
); |
||||
|
||||
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, ''); |
||||
} |
||||
|
||||
await renderRoadmap(result); |
||||
}, |
||||
onStreamEnd: async (result) => { |
||||
result = result.replace(ROADMAP_ID_REGEX, ''); |
||||
setGeneratedRoadmap(result); |
||||
loadAIRoadmapLimit().finally(() => {}); |
||||
}, |
||||
}); |
||||
|
||||
setIsLoading(false); |
||||
}; |
||||
|
||||
const editGeneratedRoadmap = async () => { |
||||
if (!isLoggedIn()) { |
||||
showLoginPopup(); |
||||
return; |
||||
} |
||||
|
||||
pageProgressMessage.set('Redirecting to Editor'); |
||||
|
||||
const { nodes, edges } = generateAIRoadmapFromText(generatedRoadmap); |
||||
|
||||
const { response, error } = await httpPost<{ |
||||
roadmapId: string; |
||||
}>(`${import.meta.env.PUBLIC_API_URL}/v1-edit-ai-generated-roadmap`, { |
||||
title: roadmapTopic, |
||||
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'); |
||||
setIsLoading(false); |
||||
return; |
||||
} |
||||
|
||||
window.location.href = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${response.roadmapId}`; |
||||
}; |
||||
|
||||
const downloadGeneratedRoadmap = async () => { |
||||
pageProgressMessage.set('Downloading Roadmap'); |
||||
|
||||
const node = document.getElementById('roadmap-container'); |
||||
if (!node) { |
||||
toast.error('Something went wrong'); |
||||
return; |
||||
} |
||||
|
||||
try { |
||||
await downloadGeneratedRoadmapImage(roadmapTopic, node); |
||||
pageProgressMessage.set(''); |
||||
} catch (error) { |
||||
console.error(error); |
||||
toast.error('Something went wrong'); |
||||
} |
||||
}; |
||||
|
||||
const loadAIRoadmapLimit = async () => { |
||||
const { response, error } = await httpGet<{ |
||||
limit: number; |
||||
used: number; |
||||
}>(`${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; |
||||
setRoadmapLimit(limit); |
||||
setRoadmapLimitUsed(used); |
||||
}; |
||||
|
||||
const loadAIRoadmap = async (roadmapId: string) => { |
||||
pageProgressMessage.set('Loading Roadmap'); |
||||
|
||||
const { response, error } = await httpGet<{ |
||||
topic: 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 { topic, data } = response; |
||||
await renderRoadmap(data); |
||||
|
||||
setRoadmapTopic(topic); |
||||
setGeneratedRoadmap(data); |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
loadAIRoadmapLimit().finally(() => {}); |
||||
}, []); |
||||
|
||||
useEffect(() => { |
||||
if (!roadmapId) { |
||||
return; |
||||
} |
||||
|
||||
setHasSubmitted(true); |
||||
loadAIRoadmap(roadmapId).finally(() => { |
||||
pageProgressMessage.set(''); |
||||
}); |
||||
}, [roadmapId]); |
||||
|
||||
if (!hasSubmitted) { |
||||
return ( |
||||
<RoadmapSearch |
||||
roadmapTopic={roadmapTopic} |
||||
setRoadmapTopic={setRoadmapTopic} |
||||
handleSubmit={handleSubmit} |
||||
limit={roadmapLimit} |
||||
limitUsed={roadmapLimitUsed} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
const pageUrl = `https://roadmap.sh/ai?id=${roadmapId}`; |
||||
const canGenerateMore = roadmapLimitUsed < roadmapLimit; |
||||
|
||||
return ( |
||||
<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 && ( |
||||
<span className="flex items-center gap-2 rounded-full bg-black px-3 py-1 text-white"> |
||||
<Spinner isDualRing={false} innerFill={'white'} /> |
||||
Generating roadmap .. |
||||
</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"> |
||||
<span |
||||
className={cn( |
||||
'inline-block w-[65px] rounded-md border px-0.5 text-center text-sm tabular-nums text-gray-800', |
||||
{ |
||||
'animate-pulse border-zinc-300 bg-zinc-300 text-zinc-300': |
||||
!roadmapLimit, |
||||
}, |
||||
)} |
||||
> |
||||
{roadmapLimitUsed} of {roadmapLimit} |
||||
</span>{' '} |
||||
roadmaps generated |
||||
{!isLoggedIn() && ( |
||||
<> |
||||
{' '} |
||||
<button |
||||
className="font-medium text-black underline underline-offset-2" |
||||
onClick={showLoginPopup} |
||||
> |
||||
Login to increase your limit |
||||
</button> |
||||
</> |
||||
)} |
||||
</span> |
||||
</div> |
||||
<form |
||||
onSubmit={handleSubmit} |
||||
className="my-3 flex w-full flex-col sm:flex-row sm:items-center sm:justify-center gap-2" |
||||
> |
||||
<input |
||||
type="text" |
||||
autoFocus |
||||
placeholder="e.g. Ansible" |
||||
className="flex-grow rounded-md border border-gray-400 px-3 py-2 transition-colors focus:border-black focus:outline-none" |
||||
value={roadmapTopic} |
||||
onInput={(e) => |
||||
setRoadmapTopic((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, |
||||
}, |
||||
)} |
||||
> |
||||
{roadmapLimit > 0 && canGenerateMore && ( |
||||
<> |
||||
<Wand size={20} /> |
||||
Generate |
||||
</> |
||||
)} |
||||
|
||||
{roadmapLimit === 0 && <span>Please wait..</span>} |
||||
|
||||
{roadmapLimit > 0 && !canGenerateMore && ( |
||||
<span className="flex items-center text-sm"> |
||||
<Ban size={15} className="mr-2" /> |
||||
Limit reached |
||||
</span> |
||||
)} |
||||
</button> |
||||
</form> |
||||
<div className="flex w-full items-center justify-between gap-2"> |
||||
<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} |
||||
> |
||||
<Download size={15} /> |
||||
<span className="hidden sm:inline">Download</span> |
||||
</button> |
||||
{roadmapId && ( |
||||
<ShareRoadmapButton |
||||
description={`Check out ${roadmapTopic} roadmap I generated on roadmap.sh`} |
||||
pageUrl={pageUrl} |
||||
/> |
||||
)} |
||||
</div> |
||||
<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} |
||||
disabled={isLoading} |
||||
> |
||||
<PenSquare size={15} /> |
||||
Edit in Editor |
||||
</button> |
||||
</div> |
||||
</div> |
||||
)} |
||||
</div> |
||||
<div |
||||
ref={roadmapContainerRef} |
||||
id="roadmap-container" |
||||
className="relative px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]" |
||||
/> |
||||
</section> |
||||
); |
||||
} |
@ -0,0 +1,121 @@ |
||||
import { Ban, Wand } from 'lucide-react'; |
||||
import type { FormEvent } from 'react'; |
||||
import { isLoggedIn } from '../../lib/jwt'; |
||||
import { showLoginPopup } from '../../lib/popup'; |
||||
import { cn } from '../../lib/classname.ts'; |
||||
|
||||
type RoadmapSearchProps = { |
||||
roadmapTopic: string; |
||||
setRoadmapTopic: (topic: string) => void; |
||||
handleSubmit: (e: FormEvent<HTMLFormElement>) => void; |
||||
limit: number; |
||||
limitUsed: number; |
||||
}; |
||||
|
||||
export function RoadmapSearch(props: RoadmapSearchProps) { |
||||
const { |
||||
roadmapTopic, |
||||
setRoadmapTopic, |
||||
handleSubmit, |
||||
limit = 0, |
||||
limitUsed = 0, |
||||
} = props; |
||||
|
||||
const canGenerateMore = limitUsed < limit; |
||||
|
||||
return ( |
||||
<div className="flex flex-grow flex-col items-center justify-center px-4 py-6 sm:px-6"> |
||||
<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> |
||||
<span className="inline sm:hidden">AI Roadmap Generator</span> |
||||
</h1> |
||||
<p className="text-base text-gray-500 sm:text-lg"> |
||||
<span className="hidden sm:inline"> |
||||
Enter a topic and let the AI generate a roadmap for you |
||||
</span> |
||||
<span className="inline sm:hidden"> |
||||
Enter a topic to generate a roadmap |
||||
</span> |
||||
</p> |
||||
</div> |
||||
<form |
||||
onSubmit={(e) => { |
||||
if (limit > 0 && canGenerateMore) { |
||||
handleSubmit(e); |
||||
} else { |
||||
e.preventDefault(); |
||||
} |
||||
}} |
||||
className="my-3 flex w-full max-w-[600px] flex-col gap-2 sm:my-5 sm:flex-row" |
||||
> |
||||
<input |
||||
autoFocus |
||||
type="text" |
||||
placeholder="e.g. Ansible" |
||||
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)} |
||||
/> |
||||
<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, |
||||
}, |
||||
)} |
||||
> |
||||
{limit > 0 && canGenerateMore && ( |
||||
<> |
||||
<Wand size={20} /> |
||||
Generate |
||||
</> |
||||
)} |
||||
|
||||
{limit === 0 && ( |
||||
<> |
||||
<span>Please wait..</span> |
||||
</> |
||||
)} |
||||
|
||||
{limit > 0 && !canGenerateMore && ( |
||||
<span className="flex items-center text-base sm:text-sm"> |
||||
<Ban size={15} className="mr-2" /> |
||||
Limit reached |
||||
</span> |
||||
)} |
||||
</button> |
||||
</form> |
||||
<div className="mb-36"> |
||||
<p className="text-gray-500"> |
||||
<span className="inline sm:hidden">Generated </span> |
||||
<span className="hidden sm:inline">You have generated </span> |
||||
<span |
||||
className={cn( |
||||
'inline-block w-[65px] rounded-md border px-0.5 text-center text-sm tabular-nums text-gray-800', |
||||
{ |
||||
'animate-pulse border-zinc-300 bg-zinc-300 text-zinc-300': |
||||
!limit, |
||||
}, |
||||
)} |
||||
> |
||||
{limitUsed} of {limit} |
||||
</span>{' '} |
||||
roadmaps. |
||||
{!isLoggedIn && ( |
||||
<> |
||||
{' '} |
||||
<button |
||||
className="font-semibold text-black underline underline-offset-2" |
||||
onClick={showLoginPopup} |
||||
> |
||||
Log in to increase your limit |
||||
</button> |
||||
</> |
||||
)} |
||||
</p> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,43 @@ |
||||
const NEW_LINE = '\n'.charCodeAt(0); |
||||
|
||||
export async function readAIRoadmapStream( |
||||
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; |
||||
} |
||||
|
||||
// We will call the renderRoadmap callback whenever we encounter
|
||||
// a new line with the result until the new line
|
||||
// otherwise, we will keep appending the result to the previous result
|
||||
if (value) { |
||||
let start = 0; |
||||
for (let i = 0; i < value.length; i++) { |
||||
if (value[i] === NEW_LINE) { |
||||
result += decoder.decode(value.slice(start, i + 1)); |
||||
onStream?.(result); |
||||
start = i + 1; |
||||
} |
||||
} |
||||
if (start < value.length) { |
||||
result += decoder.decode(value.slice(start)); |
||||
} |
||||
} |
||||
} |
||||
|
||||
onStream?.(result); |
||||
onStreamEnd?.(result); |
||||
reader.releaseLock(); |
||||
} |
@ -0,0 +1,10 @@ |
||||
--- |
||||
import LoginPopup from '../../components/AuthenticationFlow/LoginPopup.astro'; |
||||
import { GenerateRoadmap } from '../../components/GenerateRoadmap/GenerateRoadmap'; |
||||
import AccountLayout from '../../layouts/AccountLayout.astro'; |
||||
--- |
||||
|
||||
<AccountLayout title='Roadmap AI'> |
||||
<GenerateRoadmap client:load /> |
||||
<LoginPopup /> |
||||
</AccountLayout> |
Loading…
Reference in new issue