1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-02 22:02:39 +02:00
This commit is contained in:
Arik Chakma
2025-06-24 21:39:53 +06:00
parent ae790470fe
commit 0af6f9e987
6 changed files with 363 additions and 61 deletions

View File

@@ -11,30 +11,15 @@ type AIGuideCardProps = {
export function AIGuideCard(props: AIGuideCardProps) {
const { guide, showActions = true } = props;
const guideDepthColor =
{
essentials: 'text-green-700',
detailed: 'text-blue-700',
complete: 'text-purple-700',
}[guide.depth] || 'text-gray-700';
return (
<div className="relative flex flex-grow flex-col">
<a
href={`/ai/guide/${guide.slug}`}
className="group relative flex h-full min-h-[120px] w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white p-3 text-left hover:border-gray-300 hover:bg-gray-50 sm:p-4"
>
<div className="mb-2 flex items-center justify-between sm:mb-3">
<span
className={`rounded-full text-xs font-medium capitalize opacity-80 ${guideDepthColor}`}
>
{guide.depth}
</span>
</div>
<div className="relative max-h-[180px] min-h-[140px] overflow-y-hidden sm:max-h-[200px] sm:min-h-[160px]">
<div
className="prose prose-sm prose-pre:bg-gray-100 [&_h1]:hidden [&_h1:first-child]:block [&_h1:first-child]:text-base [&_h1:first-child]:font-bold [&_h1:first-child]:leading-[1.35] [&_h1:first-child]:text-pretty sm:[&_h1:first-child]:text-lg [&_h2]:hidden [&_h3]:hidden [&_h4]:hidden [&_h5]:hidden [&_h6]:hidden"
className="prose prose-sm prose-pre:bg-gray-100 [&_h1]:hidden [&_h1:first-child]:block [&_h1:first-child]:text-base [&_h1:first-child]:leading-[1.35] [&_h1:first-child]:font-bold [&_h1:first-child]:text-pretty sm:[&_h1:first-child]:text-lg [&_h2]:hidden [&_h3]:hidden [&_h4]:hidden [&_h5]:hidden [&_h6]:hidden"
dangerouslySetInnerHTML={{ __html: guide.html }}
/>

View File

@@ -10,6 +10,7 @@ import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { aiRoadmapOptions } from '../../queries/ai-roadmap';
import { GenerateAIRoadmap } from './GenerateAIRoadmap';
import { AIRoadmapContent } from './AIRoadmapContent';
import { AIRoadmapChat } from './AIRoadmapChat';
type AIRoadmapProps = {
roadmapSlug?: string;
@@ -22,12 +23,11 @@ export function AIRoadmap(props: AIRoadmapProps) {
const toast = useToast();
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
// only fetch the guide if the guideSlug is provided
// otherwise we are still generating the guide
const { data: aiRoadmap, isLoading: isLoadingBySlug } = useQuery(
aiRoadmapOptions(roadmapSlug, containerRef),
aiRoadmapOptions(roadmapSlug),
queryClient,
);
@@ -62,8 +62,7 @@ export function AIRoadmap(props: AIRoadmapProps) {
<div className="grow overflow-y-auto p-4 pt-0">
{roadmapSlug && (
<AIRoadmapContent
svg={aiRoadmap?.svg || null}
containerRef={containerRef}
svgHtml={aiRoadmap?.svgHtml || ''}
isLoading={isLoadingBySlug || isRegenerating}
/>
)}
@@ -71,13 +70,11 @@ export function AIRoadmap(props: AIRoadmapProps) {
<GenerateAIRoadmap onRoadmapSlugChange={setRoadmapSlug} />
)}
</div>
{/* <AIGuideChat
guideSlug={guideSlug}
isGuideLoading={!aiGuide}
<AIRoadmapChat
roadmapSlug={roadmapSlug}
isRoadmapLoading={!aiRoadmap}
onUpgrade={() => setShowUpgradeModal(true)}
randomQuestions={randomQuestions}
isQuestionsLoading={isAiGuideSuggestionsLoading}
/> */}
/>
</AITutorLayout>
);
}

View File

@@ -0,0 +1,338 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { useChat, type ChatMessage } from '../../hooks/use-chat';
import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard';
import {
ArrowDownIcon,
BotIcon,
LockIcon,
MessageCircleIcon,
PauseCircleIcon,
SendIcon,
Trash2Icon,
XIcon,
} from 'lucide-react';
import { ChatHeaderButton } from '../FrameRenderer/RoadmapFloatingChat';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { flushSync } from 'react-dom';
import { markdownToHtml } from '../../lib/markdown';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { billingDetailsOptions } from '../../queries/billing';
import { LoadingChip } from '../LoadingChip';
import { getTailwindScreenDimension } from '../../lib/is-mobile';
type AIRoadmapChatProps = {
roadmapSlug?: string;
isRoadmapLoading?: boolean;
onUpgrade?: () => void;
};
export function AIRoadmapChat(props: AIRoadmapChatProps) {
const { roadmapSlug, isRoadmapLoading, onUpgrade } = props;
const scrollareaRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState('');
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [isChatOpen, setIsChatOpen] = useState(true);
const [isMobile, setIsMobile] = useState(false);
const {
data: tokenUsage,
isLoading: isTokenUsageLoading,
refetch: refetchTokenUsage,
} = useQuery(getAiCourseLimitOptions(), queryClient);
const {
data: userBillingDetails,
isLoading: isBillingDetailsLoading,
refetch: refetchBillingDetails,
} = useQuery(billingDetailsOptions(), queryClient);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
const {
messages,
status,
streamedMessageHtml,
sendMessages,
setMessages,
stop,
} = useChat({
endpoint: `${import.meta.env.PUBLIC_API_URL}/v1-ai-roadmap-chat`,
onError: (error) => {
console.error(error);
},
data: {
roadmapSlug,
},
onFinish: () => {
refetchTokenUsage();
},
});
const scrollToBottom = useCallback(
(behavior: 'smooth' | 'instant' = 'smooth') => {
scrollareaRef.current?.scrollTo({
top: scrollareaRef.current.scrollHeight,
behavior,
});
},
[scrollareaRef],
);
const isStreamingMessage = status === 'streaming';
const hasMessages = messages.length > 0;
const handleSubmitInput = useCallback(
(defaultInputValue?: string) => {
const message = defaultInputValue || inputValue;
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (isStreamingMessage) {
return;
}
const newMessages: ChatMessage[] = [
...messages,
{
role: 'user',
content: message,
html: markdownToHtml(message),
},
];
flushSync(() => {
setMessages(newMessages);
});
sendMessages(newMessages);
setInputValue('');
},
[inputValue, isStreamingMessage, messages, sendMessages, setMessages],
);
const checkScrollPosition = useCallback(() => {
const scrollArea = scrollareaRef.current;
if (!scrollArea) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = scrollArea;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50; // 50px threshold
setShowScrollToBottom(!isAtBottom && messages.length > 0);
}, [messages.length]);
useEffect(() => {
const scrollArea = scrollareaRef.current;
if (!scrollArea) {
return;
}
scrollArea.addEventListener('scroll', checkScrollPosition);
return () => scrollArea.removeEventListener('scroll', checkScrollPosition);
}, [checkScrollPosition]);
const isLoading =
isRoadmapLoading || isTokenUsageLoading || isBillingDetailsLoading;
useLayoutEffect(() => {
const deviceType = getTailwindScreenDimension();
const isMediumSize = ['sm', 'md'].includes(deviceType);
if (!isMediumSize) {
const storedState = localStorage.getItem('chat-history-sidebar-open');
setIsChatOpen(storedState === null ? true : storedState === 'true');
} else {
setIsChatOpen(!isMediumSize);
}
setIsMobile(isMediumSize);
}, []);
useEffect(() => {
if (!isMobile) {
localStorage.setItem('chat-history-sidebar-open', isChatOpen.toString());
}
}, [isChatOpen, isMobile]);
if (!isChatOpen) {
return (
<div className="absolute inset-x-0 bottom-0 flex justify-center p-2">
<button
className="flex items-center justify-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow"
onClick={() => {
setIsChatOpen(true);
}}
>
<MessageCircleIcon className="h-4 w-4" />
<span className="text-sm">Open Chat</span>
</button>
</div>
);
}
return (
<div className="absolute inset-0 flex h-full w-full max-w-full flex-col overflow-hidden border-l border-gray-200 bg-white md:relative md:max-w-[40%]">
<div className="flex items-center justify-between gap-2 border-b border-gray-200 bg-white p-2">
<h2 className="flex items-center gap-2 text-sm font-medium">
<BotIcon className="h-4 w-4" />
AI Roadmap
</h2>
<button
className="mr-2 flex size-5 items-center justify-center rounded-md text-gray-500 hover:bg-gray-300 md:hidden"
onClick={() => {
setIsChatOpen(false);
}}
>
<XIcon className="h-3.5 w-3.5" />
</button>
</div>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
<LoadingChip message="Loading..." />
</div>
)}
{!isLoading && (
<>
<div className="relative grow overflow-y-auto" ref={scrollareaRef}>
<div className="absolute inset-0 flex flex-col">
<div className="relative flex grow flex-col justify-end">
<div className="flex flex-col justify-end gap-2 px-3 py-2">
<RoadmapAIChatCard
role="assistant"
html="Hello, how can I help you today?"
isIntro
/>
{messages.map((chat, index) => {
return (
<RoadmapAIChatCard key={`chat-${index}`} {...chat} />
);
})}
{status === 'streaming' && !streamedMessageHtml && (
<RoadmapAIChatCard role="assistant" html="Thinking..." />
)}
{status === 'streaming' && streamedMessageHtml && (
<RoadmapAIChatCard
role="assistant"
html={streamedMessageHtml}
/>
)}
</div>
</div>
</div>
</div>
{(hasMessages || showScrollToBottom) && (
<div className="flex flex-row justify-between gap-2 border-t border-gray-200 px-3 py-2">
<ChatHeaderButton
icon={<Trash2Icon className="h-3.5 w-3.5" />}
className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300"
onClick={() => {
setMessages([]);
}}
>
Clear
</ChatHeaderButton>
{showScrollToBottom && (
<ChatHeaderButton
icon={<ArrowDownIcon className="h-3.5 w-3.5" />}
className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300"
onClick={() => {
scrollToBottom('smooth');
}}
>
Scroll to bottom
</ChatHeaderButton>
)}
</div>
)}
<div className="relative flex items-center border-t border-gray-200 text-sm">
{isLimitExceeded && isLoggedIn() && (
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
<LockIcon
className="size-4 cursor-not-allowed"
strokeWidth={2.5}
/>
<p className="cursor-not-allowed">
Limit reached for today
{isPaidUser ? '. Please wait until tomorrow.' : ''}
</p>
{!isPaidUser && (
<button
onClick={() => {
onUpgrade?.();
}}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
>
Upgrade for more
</button>
)}
</div>
)}
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (isStreamingMessage) {
return;
}
handleSubmitInput();
}
}}
placeholder="Ask me anything about this roadmap..."
className="w-full resize-none px-3 py-4 outline-none"
/>
<button
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-zinc-500 hover:text-black disabled:opacity-50"
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (status !== 'idle') {
stop();
return;
}
handleSubmitInput();
}}
>
{isStreamingMessage ? (
<PauseCircleIcon className="h-4 w-4" />
) : (
<SendIcon className="h-4 w-4" />
)}
</button>
</div>
</>
)}
</div>
);
}

View File

@@ -1,16 +1,13 @@
import { cn } from '../../lib/classname';
import { LoadingChip } from '../LoadingChip';
import { useEffect, type RefObject } from 'react';
import { replaceChildren } from '../../lib/dom';
type AIRoadmapContentProps = {
svg: SVGElement | null;
isLoading?: boolean;
containerRef: RefObject<HTMLDivElement | null>;
svgHtml: string;
};
export function AIRoadmapContent(props: AIRoadmapContentProps) {
const { svg, isLoading, containerRef } = props;
const { isLoading, svgHtml } = props;
return (
<div
@@ -20,12 +17,12 @@ export function AIRoadmapContent(props: AIRoadmapContentProps) {
)}
>
<div
ref={containerRef}
id="roadmap-container"
className="relative min-h-[400px] px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]"
className="relative min-h-[400px] [&>svg]:mx-auto"
dangerouslySetInnerHTML={{ __html: svgHtml }}
/>
{isLoading && !svg && (
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<LoadingChip message="Please wait..." />
</div>

View File

@@ -6,7 +6,7 @@ import { LoadingChip } from '../LoadingChip';
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
import { getQuestionAnswerChatMessages } from '../../lib/ai-questions';
import { aiRoadmapOptions, generateAIRoadmap } from '../../queries/ai-roadmap';
import { replaceChildren } from '../../lib/dom';
import { AIRoadmapContent } from './AIRoadmapContent';
type GenerateAIRoadmapProps = {
onRoadmapSlugChange?: (roadmapSlug: string) => void;
@@ -19,9 +19,9 @@ export function GenerateAIRoadmap(props: GenerateAIRoadmapProps) {
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState('');
const [svgHtml, setSvgHtml] = useState('');
const [content, setContent] = useState('');
const svgRef = useRef<SVGElement | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<string | null>(null);
useEffect(() => {
const params = getUrlParams();
@@ -74,7 +74,7 @@ export function GenerateAIRoadmap(props: GenerateAIRoadmapProps) {
data: content,
questionAndAnswers,
viewCount: 0,
svg: svgRef.current,
svgHtml: svgRef.current || '',
lastVisitedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
@@ -92,10 +92,9 @@ export function GenerateAIRoadmap(props: GenerateAIRoadmapProps) {
onError: setError,
onStreamingChange: setIsStreaming,
onRoadmapSvgChange: (svg) => {
svgRef.current = svg;
if (containerRef.current) {
replaceChildren(containerRef.current, svg);
}
const svgHtml = svg.outerHTML;
svgRef.current = svgHtml;
setSvgHtml(svgHtml);
},
});
};
@@ -112,11 +111,5 @@ export function GenerateAIRoadmap(props: GenerateAIRoadmapProps) {
);
}
return (
<div
ref={containerRef}
id="roadmap-container"
className="relative min-h-[400px] px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]"
/>
);
return <AIRoadmapContent isLoading={isLoading} svgHtml={svgHtml} />;
}

View File

@@ -19,13 +19,10 @@ export interface AIRoadmapDocument {
}
export type AIRoadmapResponse = AIRoadmapDocument & {
svg?: SVGElement | null;
svgHtml?: string;
};
export function aiRoadmapOptions(
roadmapSlug?: string,
containerRef?: RefObject<HTMLDivElement | null>,
) {
export function aiRoadmapOptions(roadmapSlug?: string) {
return queryOptions<AIRoadmapResponse>({
queryKey: ['ai-roadmap', roadmapSlug],
queryFn: async () => {
@@ -36,13 +33,11 @@ export function aiRoadmapOptions(
const result = generateAICourseRoadmapStructure(res.data);
const { nodes, edges } = generateAIRoadmapFromText(result);
const svg = await renderFlowJSON({ nodes, edges });
if (containerRef?.current) {
replaceChildren(containerRef.current, svg);
}
const svgHtml = svg.outerHTML;
return {
...res,
svg,
svgHtml,
};
},
enabled: !!roadmapSlug,
@@ -52,10 +47,7 @@ export function aiRoadmapOptions(
import { queryClient } from '../stores/query-client';
import { getAiCourseLimitOptions } from '../queries/ai-course';
import { readChatStream } from '../lib/chat';
import { markdownToHtmlWithHighlighting } from '../lib/markdown';
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
import type { RefObject } from 'react';
import { replaceChildren } from '../lib/dom';
type RoadmapDetails = {
roadmapId: string;