From cd6232035fdf4019f57a6b62483ac51e41cd19f2 Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Mon, 11 Mar 2024 11:14:32 +0600 Subject: [PATCH] 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 --- .../ExploreAIRoadmap/ExploreAIRoadmap.tsx | 149 +++++ .../GenerateRoadmap/AIRoadmapAlert.tsx | 53 ++ .../GenerateRoadmap/GenerateRoadmap.tsx | 531 +++++++++++++----- .../GenerateRoadmap/OpenAISettings.tsx | 168 ++++++ .../GenerateRoadmap/RoadmapSearch.tsx | 199 ++++--- .../GenerateRoadmap/RoadmapTopicDetail.tsx | 241 ++++++++ src/helper/read-stream.ts | 30 + src/lib/date.ts | 4 + src/lib/jwt.ts | 36 ++ src/lib/markdown.ts | 21 + src/pages/ai/explore.astro | 10 + 11 files changed, 1225 insertions(+), 217 deletions(-) create mode 100644 src/components/ExploreAIRoadmap/ExploreAIRoadmap.tsx create mode 100644 src/components/GenerateRoadmap/AIRoadmapAlert.tsx create mode 100644 src/components/GenerateRoadmap/OpenAISettings.tsx create mode 100644 src/components/GenerateRoadmap/RoadmapTopicDetail.tsx create mode 100644 src/pages/ai/explore.astro diff --git a/src/components/ExploreAIRoadmap/ExploreAIRoadmap.tsx b/src/components/ExploreAIRoadmap/ExploreAIRoadmap.tsx new file mode 100644 index 000000000..151e7da72 --- /dev/null +++ b/src/components/ExploreAIRoadmap/ExploreAIRoadmap.tsx @@ -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([]); + const [currPage, setCurrPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + + const loadAIRoadmaps = useCallback( + async (currPage: number) => { + const { response, error } = await httpGet( + `${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 ( +
+
+ +
+ + {isLoading ? ( +
    + {new Array(21).fill(0).map((_, index) => ( +
  • + ))} +
+ ) : ( +
+ {roadmaps?.length === 0 ? ( +
No roadmaps found
+ ) : ( + <> + + {hasMorePages && ( +
+ +
+ )} + + )} +
+ )} +
+ ); +} diff --git a/src/components/GenerateRoadmap/AIRoadmapAlert.tsx b/src/components/GenerateRoadmap/AIRoadmapAlert.tsx new file mode 100644 index 000000000..4a76c9d7d --- /dev/null +++ b/src/components/GenerateRoadmap/AIRoadmapAlert.tsx @@ -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 ( +
+

+ AI Generated Roadmap{isListing ? 's' : ''}{' '} + + Beta + +

+

+ {isListing + ? 'These are AI generated roadmaps and are not verified by' + : 'This is an AI generated roadmap and is not verified by'}{' '} + roadmap.sh. We are currently in + beta and working hard to improve the quality of the generated roadmaps. +

+

+ {isListing ? ( + + + Create your own Roadmap with AI + + ) : ( + + + Explore other AI Roadmaps + + )} + + + Visit Official Roadmaps + +

+
+ ); +} diff --git a/src/components/GenerateRoadmap/GenerateRoadmap.tsx b/src/components/GenerateRoadmap/GenerateRoadmap.tsx index 7b1269fc9..1f273ead9 100644 --- a/src/components/GenerateRoadmap/GenerateRoadmap.tsx +++ b/src/components/GenerateRoadmap/GenerateRoadmap.tsx @@ -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(null); @@ -31,11 +89,21 @@ export function GenerateRoadmap() { const [hasSubmitted, setHasSubmitted] = useState(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(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 = getOpenAIKey(); const renderRoadmap = async (roadmap: string) => { const { nodes, edges } = generateAIRoadmapFromText(roadmap); @@ -45,12 +113,7 @@ export function GenerateRoadmap() { } }; - const handleSubmit = async (e: FormEvent) => { - 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) => { + e.preventDefault(); + if (!roadmapTerm) { + return; + } + + if (roadmapTerm === currentRoadmap?.topic) { + return; + } + + loadTermRoadmap(roadmapTerm).finally(() => null); + }; + + const saveAIRoadmap = async () => { if (!isLoggedIn()) { showLoginPopup(); return; @@ -126,38 +209,44 @@ 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, - nodes: nodes.map((node) => ({ - ...node, + }>( + `${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, + // 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, - }, - })), - edges, - }); + style: { + ...node.style, + width: undefined, + height: undefined, + }, + })), + 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( + `${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) => { + 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,137 +361,230 @@ export function GenerateRoadmap() { loadAIRoadmap(roadmapId).finally(() => { pageProgressMessage.set(''); }); - }, [roadmapId]); + }, [roadmapId, currentRoadmap]); if (!hasSubmitted) { return ( { + setRoadmapTerm(term); + loadTermRoadmap(term).finally(() => {}); + }} /> ); } const pageUrl = `https://roadmap.sh/ai?id=${roadmapId}`; const canGenerateMore = roadmapLimitUsed < roadmapLimit; + const isLoggedInUser = isLoggedIn(); return ( -
-
- {isLoading && ( - - - Generating roadmap .. - - )} - {!isLoading && ( -
-
- - - {roadmapLimitUsed} of {roadmapLimit} - {' '} - roadmaps generated - {!isLoggedIn() && ( - <> - {' '} - - + <> + {isConfiguring && ( + { + 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 && ( +
+ +
+ + + {roadmapLimitUsed} of {roadmapLimit} + {' '} + roadmaps generated. + + {!isLoggedInUser && ( + )} - -
-
- - setRoadmapTopic((e.target as HTMLInputElement).value) - } - /> - )} - {roadmapLimit === 0 && Please wait..} - - {roadmapLimit > 0 && !canGenerateMore && ( - - - Limit reached - - )} - -
-
-
- - {roadmapId && ( - + {isLoggedInUser && openAPIKey && ( + )}
- + + setRoadmapTerm((e.target as HTMLInputElement).value) + } + /> + + +
+
+ + {roadmapId && ( + + )} +
+ +
+ + + +
+
-
- )} -
-
-
+ )} +
+
+
+ ); } diff --git a/src/components/GenerateRoadmap/OpenAISettings.tsx b/src/components/GenerateRoadmap/OpenAISettings.tsx new file mode 100644 index 000000000..2b30e3f98 --- /dev/null +++ b/src/components/GenerateRoadmap/OpenAISettings.tsx @@ -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 ( + +
+

OpenAI Settings

+
+

+ AI Roadmap generator uses OpenAI's GPT-4 model to generate roadmaps. +

+ +

+ + Create an account on OpenAI + {' '} + and enter your API key below to enable the AI Roadmap generator +

+ +
{ + 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(); + }} + > +
+ { + setHasError(false); + setOpenaiApiKey((e.target as HTMLInputElement).value); + }} + /> + + {openaiApiKey && ( + + )} +
+ {hasError && ( +

+ Please enter a valid OpenAI API key +

+ )} + + {!defaultOpenAIKey && ( + + )} + {defaultOpenAIKey && ( + + )} +
+
+
+
+ ); +} diff --git a/src/components/GenerateRoadmap/RoadmapSearch.tsx b/src/components/GenerateRoadmap/RoadmapSearch.tsx index 0b2ec2bac..6e85b43ba 100644 --- a/src/components/GenerateRoadmap/RoadmapSearch.tsx +++ b/src/components/GenerateRoadmap/RoadmapSearch.tsx @@ -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) => 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 (
+ {isConfiguring && ( + { + setIsConfiguring(false); + loadAIRoadmapLimit(); + }} + /> + )}

Generate roadmaps with AI @@ -39,61 +64,77 @@ export function RoadmapSearch(props: RoadmapSearchProps) {

-
{ - 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" - > - setRoadmapTopic((e.target as HTMLInputElement).value)} - /> - -
-
+ {limit > 0 && !canGenerateMore && ( + + + Limit reached + + )} + + +
+ {randomTerms.map((term) => ( + + ))} + + Explore AI Roadmaps + +
+
+

- Generated - You have generated + You have generated{' '} {' '} roadmaps. - {!isLoggedIn && ( - <> - {' '} - - +

+

+ {limit > 0 && !isLoggedIn() && ( + + )} +

+

+ {limit > 0 && isLoggedIn() && !openAPIKey && ( + + )} + + {limit > 0 && isLoggedIn() && openAPIKey && ( + )}

diff --git a/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx b/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx new file mode 100644 index 000000000..f3db8525c --- /dev/null +++ b/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx @@ -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(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 ( +
+
+
+ + + {topicLimitUsed} of {topicLimit} + {' '} + topics generated + + {!isLoggedIn() && ( + + )} + {isLoggedIn() && !openAIKey && ( + + )} + {isLoggedIn() && openAIKey && ( + + )} +
+ + {isLoading && ( +
+ +
+ )} + + {!isLoading && !error && ( + <> +
+ +
+ + {hasContent ? ( +
+
+
+ ) : ( +
+ +

+ Empty Content +

+
+ )} + + )} + + {/* Error */} + {!isLoading && error && ( + <> + +
+ +

{error}

+
+ + )} +
+
+
+ ); +} diff --git a/src/helper/read-stream.ts b/src/helper/read-stream.ts index 422c89c85..2df821443 100644 --- a/src/helper/read-stream.ts +++ b/src/helper/read-stream.ts @@ -41,3 +41,33 @@ export async function readAIRoadmapStream( onStreamEnd?.(result); reader.releaseLock(); } + +export async function readAIRoadmapContentStream( + reader: ReadableStreamDefaultReader, + { + 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(); +} diff --git a/src/lib/date.ts b/src/lib/date.ts index 07a0b4d40..4df386da3 100644 --- a/src/lib/date.ts +++ b/src/lib/date.ts @@ -26,5 +26,9 @@ export function getRelativeTimeString(date: string): string { relativeTime = rtf.format(-diffInDays, 'day'); } + if (relativeTime === 'this minute') { + return 'just now'; + } + return relativeTime; } diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts index 911c46312..d3206b083 100644 --- a/src/lib/jwt.ts +++ b/src/lib/jwt.ts @@ -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'); +} diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 474c08174..b7114f012 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -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 { diff --git a/src/pages/ai/explore.astro b/src/pages/ai/explore.astro new file mode 100644 index 000000000..8d8461f69 --- /dev/null +++ b/src/pages/ai/explore.astro @@ -0,0 +1,10 @@ +--- +import LoginPopup from '../../components/AuthenticationFlow/LoginPopup.astro'; +import { ExploreAIRoadmap } from '../../components/ExploreAIRoadmap/ExploreAIRoadmap'; +import AccountLayout from '../../layouts/AccountLayout.astro'; +--- + + + + +