From 7e3508cdf45987f7f45d7daa269a4536dd3c987c Mon Sep 17 00:00:00 2001
From: Arik Chakma
Date: Sat, 3 May 2025 02:12:04 +0600
Subject: [PATCH] feat: implement ai tutor in topics (#8546)
* wip
* feat: implement ai tutor
* fix: add style
* feat: ai course subjects
* fix: remove tree json
* wip
* Topic chat
* Refactor topic popup
* Improve UI for navigation
* Update contribution URL
* Improve topic popup
* Update UI
* feat: predefined messages
* fix: ui changes
* fix: add summarise
* fix: add explain topic
* Topic AI changes
* feat: predefined message group
* Refactor actions logic
* Implement topic ai changes
* Improve actions buttons
* Add new explainer action
---------
Co-authored-by: Kamran Ahmed
---
.astro/types.d.ts | 1 -
.../CustomRoadmap/CustomRoadmap.tsx | 1 +
.../GenerateCourse/AICourseLessonChat.tsx | 2 +-
.../GenerateCourse/GenerateAICourse.tsx | 16 +-
.../TopicDetail/PredefinedActionGroup.tsx | 54 ++
.../TopicDetail/PredefinedActions.tsx | 144 ++++++
.../TopicDetail/ResourceListSeparator.tsx | 2 +-
src/components/TopicDetail/TopicDetail.tsx | 462 +++++++++--------
src/components/TopicDetail/TopicDetailAI.tsx | 475 ++++++++++++++++++
.../TopicDetail/TopicDetailLink.tsx | 48 +-
.../TopicDetail/TopicDetailsTabs.tsx | 68 +++
.../TopicDetail/TopicProgressButton.tsx | 177 ++++---
...the-internet-work@yCnn-NfSxIybUQ2iTuUGq.md | 4 +-
.../content/internet@VlNNwIEDWqQXtqkHWJYzC.md | 2 +-
src/helper/generate-ai-course.ts | 3 +
src/hooks/use-keydown.ts | 4 +-
src/pages/[roadmapId]/index.astro | 3 +
.../[bestPracticeId]/index.astro | 4 +-
src/queries/roadmap-tree.ts | 26 +
19 files changed, 1190 insertions(+), 306 deletions(-)
create mode 100644 src/components/TopicDetail/PredefinedActionGroup.tsx
create mode 100644 src/components/TopicDetail/PredefinedActions.tsx
create mode 100644 src/components/TopicDetail/TopicDetailAI.tsx
create mode 100644 src/components/TopicDetail/TopicDetailsTabs.tsx
create mode 100644 src/queries/roadmap-tree.ts
diff --git a/.astro/types.d.ts b/.astro/types.d.ts
index 03d7cc43f..f964fe0cf 100644
--- a/.astro/types.d.ts
+++ b/.astro/types.d.ts
@@ -1,2 +1 @@
///
-///
\ No newline at end of file
diff --git a/src/components/CustomRoadmap/CustomRoadmap.tsx b/src/components/CustomRoadmap/CustomRoadmap.tsx
index b8bba0b13..a4c94da60 100644
--- a/src/components/CustomRoadmap/CustomRoadmap.tsx
+++ b/src/components/CustomRoadmap/CustomRoadmap.tsx
@@ -118,6 +118,7 @@ export function CustomRoadmap(props: CustomRoadmapProps) {
resourceId={roadmap!._id}
resourceTitle={roadmap!.title}
resourceType="roadmap"
+ renderer='editor'
isEmbed={isEmbed}
canSubmitContribution={false}
/>
diff --git a/src/components/GenerateCourse/AICourseLessonChat.tsx b/src/components/GenerateCourse/AICourseLessonChat.tsx
index 66e1c674f..ae4c3f317 100644
--- a/src/components/GenerateCourse/AICourseLessonChat.tsx
+++ b/src/components/GenerateCourse/AICourseLessonChat.tsx
@@ -399,7 +399,7 @@ type AIChatCardProps = {
html?: string;
};
-function AIChatCard(props: AIChatCardProps) {
+export function AIChatCard(props: AIChatCardProps) {
const { role, content, html: defaultHtml } = props;
const html = useMemo(() => {
diff --git a/src/components/GenerateCourse/GenerateAICourse.tsx b/src/components/GenerateCourse/GenerateAICourse.tsx
index 092cc0964..f7e57f8b2 100644
--- a/src/components/GenerateCourse/GenerateAICourse.tsx
+++ b/src/components/GenerateCourse/GenerateAICourse.tsx
@@ -54,6 +54,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
const params = getUrlParams();
const paramsTerm = params?.term;
const paramsDifficulty = params?.difficulty;
+ const paramsSrc = params?.src || 'search';
if (!paramsTerm || !paramsDifficulty) {
return;
}
@@ -87,6 +88,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
instructions: paramsCustomInstructions,
goal: paramsGoal,
about: paramsAbout,
+ src: paramsSrc,
});
}, [term, difficulty]);
@@ -98,9 +100,18 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
about?: string;
isForce?: boolean;
prompt?: string;
+ src?: string;
}) => {
- const { term, difficulty, isForce, prompt, instructions, goal, about } =
- options;
+ const {
+ term,
+ difficulty,
+ isForce,
+ prompt,
+ instructions,
+ goal,
+ about,
+ src,
+ } = options;
if (!isLoggedIn()) {
window.location.href = '/ai';
@@ -121,6 +132,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
about,
isForce,
prompt,
+ src,
});
};
diff --git a/src/components/TopicDetail/PredefinedActionGroup.tsx b/src/components/TopicDetail/PredefinedActionGroup.tsx
new file mode 100644
index 000000000..adc718e55
--- /dev/null
+++ b/src/components/TopicDetail/PredefinedActionGroup.tsx
@@ -0,0 +1,54 @@
+import type { LucideIcon } from 'lucide-react';
+import { useState, useRef } from 'react';
+import { useOutsideClick } from '../../hooks/use-outside-click';
+import {
+ type PredefinedActionType,
+ PredefinedActionButton,
+} from './PredefinedActions';
+
+type PredefinedActionGroupProps = {
+ label: string;
+ icon: LucideIcon;
+ actions: PredefinedActionType[];
+ onSelect: (action: PredefinedActionType) => void;
+};
+
+export function PredefinedActionGroup(props: PredefinedActionGroupProps) {
+ const { label, icon: Icon, actions, onSelect } = props;
+
+ const [isOpen, setIsOpen] = useState(false);
+ const containerRef = useRef(null);
+
+ useOutsideClick(containerRef, () => {
+ setIsOpen(false);
+ });
+
+ return (
+
+
setIsOpen(!isOpen)}
+ isGroup={true}
+ />
+
+ {isOpen && (
+
+ {actions.map((action) => {
+ return (
+
{
+ onSelect(action);
+ setIsOpen(false);
+ }}
+ />
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/components/TopicDetail/PredefinedActions.tsx b/src/components/TopicDetail/PredefinedActions.tsx
new file mode 100644
index 000000000..b1f90b930
--- /dev/null
+++ b/src/components/TopicDetail/PredefinedActions.tsx
@@ -0,0 +1,144 @@
+import {
+ BabyIcon,
+ BookOpenTextIcon,
+ BrainIcon,
+ ChevronDownIcon,
+ ListIcon,
+ NotebookPenIcon,
+ PencilLine,
+ Star,
+ type LucideIcon
+} from 'lucide-react';
+import { cn } from '../../lib/classname';
+import { PredefinedActionGroup } from './PredefinedActionGroup';
+
+export type PredefinedActionType = {
+ icon: LucideIcon;
+ label: string;
+ prompt?: string;
+ children?: PredefinedActionType[];
+};
+
+export const actions: PredefinedActionType[] = [
+ {
+ icon: BookOpenTextIcon,
+ label: 'Explain',
+ children: [
+ {
+ icon: NotebookPenIcon,
+ label: 'Explain the topic',
+ prompt: 'Explain this topic in detail and include examples',
+ },
+ {
+ icon: ListIcon,
+ label: 'List the key points',
+ prompt: 'List the key points to remember from this topic',
+ },
+ {
+ icon: PencilLine,
+ label: 'Summarize the topic',
+ prompt:
+ 'Briefly explain the topic in a few sentences. Treat it as a brief answer to an interview question. Your response should just be the answer to the question, nothing else.',
+ },
+ {
+ icon: BabyIcon,
+ label: 'Explain like I am five',
+ prompt: 'Explain this topic like I am a 5 years old',
+ },
+ {
+ icon: Star,
+ label: 'Why is it important?',
+ prompt:
+ 'Why is this topic important? What are the real world applications (only add if appropriate)?',
+ },
+ ],
+ },
+ {
+ icon: BrainIcon,
+ label: 'Test my Knowledge',
+ prompt:
+ "Act as an interviewer and test my understanding of this topic. Ask me a single question at a time and evaluate my answer. Question number should be bold. After evaluating my answer, immediately proceed to the next question without asking if I'm ready or want another question. Continue asking questions until I explicitly tell you to stop.",
+ },
+];
+
+export const promptLabelMapping = actions.reduce(
+ (acc, action) => {
+ if (action.prompt) {
+ acc[action.prompt] = action.label;
+ }
+
+ if (action.children) {
+ action.children.forEach((child) => {
+ if (child.prompt) {
+ acc[child.prompt] = child.label;
+ }
+ });
+ }
+
+ return acc;
+ },
+ {} as Record,
+);
+
+type PredefinedActionsProps = {
+ onSelect: (action: PredefinedActionType) => void;
+};
+
+export function PredefinedActions(props: PredefinedActionsProps) {
+ const { onSelect } = props;
+
+ return (
+
+ {actions.map((action) => {
+ if (!action.children) {
+ return (
+
{
+ onSelect(action);
+ }}
+ />
+ );
+ }
+
+ return (
+
+ );
+ })}
+
+ );
+}
+
+type PredefinedActionButtonProps = {
+ label: string;
+ icon?: LucideIcon;
+ onClick: () => void;
+ isGroup?: boolean;
+ className?: string;
+};
+
+export function PredefinedActionButton(props: PredefinedActionButtonProps) {
+ const { label, icon: Icon, onClick, isGroup = false, className } = props;
+
+ return (
+
+ {Icon && }
+ {label}
+ {isGroup && }
+
+ );
+}
diff --git a/src/components/TopicDetail/ResourceListSeparator.tsx b/src/components/TopicDetail/ResourceListSeparator.tsx
index 1b8d8d60f..ce07255e4 100644
--- a/src/components/TopicDetail/ResourceListSeparator.tsx
+++ b/src/components/TopicDetail/ResourceListSeparator.tsx
@@ -27,7 +27,7 @@ export function ResourceListSeparator(props: ResourceSeparatorProps) {
{Icon && }
{text}
-
+
);
}
diff --git a/src/components/TopicDetail/TopicDetail.tsx b/src/components/TopicDetail/TopicDetail.tsx
index 3db935010..a58fcab87 100644
--- a/src/components/TopicDetail/TopicDetail.tsx
+++ b/src/components/TopicDetail/TopicDetail.tsx
@@ -14,7 +14,6 @@ import {
updateResourceProgress as updateResourceProgressApi,
} from '../../lib/resource-progress';
import { pageProgressMessage } from '../../stores/page';
-import { TopicProgressButton } from './TopicProgressButton';
import { showLoginPopup } from '../../lib/popup';
import { useToast } from '../../hooks/use-toast';
import type {
@@ -22,20 +21,33 @@ import type {
RoadmapContentDocument,
} from '../CustomRoadmap/CustomRoadmap';
import { markdownToHtml, sanitizeMarkdown } from '../../lib/markdown';
-import { Ban, Coins, FileText, HeartHandshake, Star, X } from 'lucide-react';
+import { Ban, FileText, HeartHandshake, Star, X } from 'lucide-react';
import { getUrlParams, parseUrl } from '../../lib/browser';
import { Spinner } from '../ReactIcons/Spinner';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
-import { resourceTitleFromId } from '../../lib/roadmap.ts';
+import {
+ resourceTitleFromId,
+ type AllowedRoadmapRenderer,
+} from '../../lib/roadmap.ts';
import { lockBodyScroll } from '../../lib/dom.ts';
import { TopicDetailLink } from './TopicDetailLink.tsx';
import { ResourceListSeparator } from './ResourceListSeparator.tsx';
import { PaidResourceDisclaimer } from './PaidResourceDisclaimer.tsx';
+import {
+ TopicDetailsTabs,
+ type AllowedTopicDetailsTabs,
+} from './TopicDetailsTabs.tsx';
+import { TopicDetailAI } from './TopicDetailAI.tsx';
+import { cn } from '../../lib/classname.ts';
+import type { AIChatHistoryType } from '../GenerateCourse/AICourseLessonChat.tsx';
+import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal.tsx';
+import { TopicProgressButton } from './TopicProgressButton.tsx';
type TopicDetailProps = {
resourceId?: string;
resourceTitle?: string;
resourceType?: ResourceType;
+ renderer?: AllowedRoadmapRenderer;
isEmbed?: boolean;
canSubmitContribution: boolean;
@@ -51,6 +63,14 @@ type PaidResourceType = {
const paidResourcesCache: Record = {};
+export const defaultChatHistory: AIChatHistoryType[] = [
+ {
+ role: 'assistant',
+ content: 'Hey, I am your AI instructor. How can I help you today? 🤖',
+ isDefault: true,
+ },
+];
+
async function fetchRoadmapPaidResources(roadmapId: string) {
if (paidResourcesCache[roadmapId]) {
return paidResourcesCache[roadmapId];
@@ -77,6 +97,7 @@ export function TopicDetail(props: TopicDetailProps) {
canSubmitContribution,
resourceId: defaultResourceId,
isEmbed = false,
+ renderer = 'balsamiq',
resourceTitle,
} = props;
@@ -91,6 +112,13 @@ export function TopicDetail(props: TopicDetailProps) {
const [topicTitle, setTopicTitle] = useState('');
const [topicHtmlTitle, setTopicHtmlTitle] = useState('');
const [links, setLinks] = useState([]);
+ const [activeTab, setActiveTab] =
+ useState('content');
+ const [aiChatHistory, setAiChatHistory] =
+ useState(defaultChatHistory);
+ const [showUpgradeModal, setShowUpgradeModal] = useState(false);
+ const [isCustomResource, setIsCustomResource] = useState(false);
+
const toast = useToast();
const [showPaidResourceDisclaimer, setShowPaidResourceDisclaimer] =
@@ -106,14 +134,16 @@ export function TopicDetail(props: TopicDetailProps) {
const [resourceType, setResourceType] = useState('roadmap');
const [paidResources, setPaidResources] = useState([]);
- // Close the topic detail when user clicks outside the topic detail
- useOutsideClick(topicRef, () => {
+ const handleClose = () => {
setIsActive(false);
- });
+ setShowUpgradeModal(false);
+ setAiChatHistory(defaultChatHistory);
+ setActiveTab('content');
+ };
- useKeydown('Escape', () => {
- setIsActive(false);
- });
+ // Close the topic detail when user clicks outside the topic detail
+ useOutsideClick(topicRef, handleClose);
+ useKeydown('Escape', handleClose);
useEffect(() => {
if (resourceType !== 'roadmap' || !defaultResourceId) {
@@ -177,6 +207,7 @@ export function TopicDetail(props: TopicDetailProps) {
setTopicId(topicId);
setResourceType(resourceType);
setResourceId(resourceId);
+ setIsCustomResource(isCustomResource);
const topicPartial = topicId.replaceAll(':', '/');
let topicUrl =
@@ -335,15 +366,21 @@ export function TopicDetail(props: TopicDetailProps) {
(resource) => resource?.url?.toLowerCase().indexOf('scrimba') !== -1,
);
+ const shouldShowAiTab = !isCustomResource && resourceType === 'roadmap';
+
return (
+ {showUpgradeModal && (
+
setShowUpgradeModal(false)} />
+ )}
+
{isLoading && (
-
+
-
- {/* Actions for the topic */}
-
- {!isEmbed && (
-
{
- setIsActive(false);
- }}
+
+
+ {shouldShowAiTab && (
+
)}
-
-
{
- setIsActive(false);
- }}
+
-
-
-
-
- {/* Topic Content */}
- {hasContent ? (
- <>
-
- {topicTitle &&
{topicTitle} }
-
-
- >
- ) : (
- <>
- {!canSubmitContribution && (
-
)}
- {canSubmitContribution && (
-
-
-
- Help us write this content
-
-
- Write a brief introduction to this topic and submit a
- link to a good article, podcast, video, or any other
- self-vetted resource that helped you understand this
- topic better.
-
-
-
- Help us Write this Content
-
-
- )}
- >
- )}
+
+
+
+
+
- {links.length > 0 && (
- <>
-
-
- {links.map((link) => {
- return (
-
- {
- // if it is one of our roadmaps, we want to track the click
- if (canSubmitContribution) {
- const parsedUrl = parseUrl(link.url);
-
- window.fireEvent({
- category: 'TopicResourceClick',
- action: `Click: ${parsedUrl.hostname}`,
- label: `${resourceType} / ${resourceId} / ${topicId} / ${link.url}`,
- });
- }
- }}
- />
-
- );
- })}
-
- >
+ {activeTab === 'ai' && shouldShowAiTab && (
+ setShowUpgradeModal(true)}
+ onLogin={() => {
+ handleClose();
+ showLoginPopup();
+ }}
+ />
)}
- {paidResourcesForTopic.length > 0 && (
+ {activeTab === 'content' && (
<>
-
-
-
- {paidResourcesForTopic.map((resource) => {
- return (
-
-
-
- );
- })}
-
-
- {hasPaidScrimbaLinks && (
-
-
-
-
- Scrimba is offering{' '}
- 20% off on
- all courses for roadmap.sh users.
-
+ {hasContent ? (
+ <>
+
+ {topicTitle &&
{topicTitle} }
+
-
+ >
+ ) : (
+ <>
+ {!canSubmitContribution && (
+
+ )}
+ {canSubmitContribution && (
+
+
+
+ Help us write this content
+
+
+ Write a brief introduction to this topic and submit
+ a link to a good article, podcast, video, or any
+ other self-vetted resource that helped you
+ understand this topic better.
+
+
+
+ Help us Write this Content
+
+
+ )}
+ >
)}
- {showPaidResourceDisclaimer && (
-
{
- localStorage.setItem(
- PAID_RESOURCE_DISCLAIMER_HIDDEN,
- 'true',
- );
- setShowPaidResourceDisclaimer(false);
- }}
- />
+ {links.length > 0 && (
+ <>
+
+
+ {links.map((link) => {
+ return (
+
+ {
+ // if it is one of our roadmaps, we want to track the click
+ if (canSubmitContribution) {
+ const parsedUrl = parseUrl(link.url);
+
+ window.fireEvent({
+ category: 'TopicResourceClick',
+ action: `Click: ${parsedUrl.hostname}`,
+ label: `${resourceType} / ${resourceId} / ${topicId} / ${link.url}`,
+ });
+ }
+ }}
+ />
+
+ );
+ })}
+
+ >
+ )}
+
+ {paidResourcesForTopic.length > 0 && (
+ <>
+
+
+
+ {paidResourcesForTopic.map((resource) => {
+ return (
+
+
+
+ );
+ })}
+
+
+ {showPaidResourceDisclaimer && (
+ {
+ localStorage.setItem(
+ PAID_RESOURCE_DISCLAIMER_HIDDEN,
+ 'true',
+ );
+ setShowPaidResourceDisclaimer(false);
+ }}
+ />
+ )}
+ >
)}
>
)}
-
- {/* Contribution */}
- {canSubmitContribution &&
- !hasEnoughLinks &&
- contributionUrl &&
- hasContent && (
-
-
- Help us add learning resources
-
-
- This popup should be a brief introductory paragraph for
- the topic and a few links to good articles, videos, or any
- other self-vetted learning resources. Please consider submitting a
- PR to improve this content.
-
-
-
- Help us Improve this Content
-
-
- )}
- {resourceId === 'devops' && (
-
- )}
+
+ {canSubmitContribution &&
+ contributionUrl &&
+ activeTab === 'content' &&
+ hasContent && (
+
+ )}
>
)}
- {/* Error */}
{!isContributing && !isLoading && error && (
<>
{
setIsActive(false);
setIsContributing(false);
diff --git a/src/components/TopicDetail/TopicDetailAI.tsx b/src/components/TopicDetail/TopicDetailAI.tsx
new file mode 100644
index 000000000..0b346f048
--- /dev/null
+++ b/src/components/TopicDetail/TopicDetailAI.tsx
@@ -0,0 +1,475 @@
+import '../GenerateCourse/AICourseLessonChat.css';
+import { useQuery } from '@tanstack/react-query';
+import { useState, useRef, Fragment, useCallback, useEffect } from 'react';
+import { billingDetailsOptions } from '../../queries/billing';
+import { getAiCourseLimitOptions } from '../../queries/ai-course';
+import { queryClient } from '../../stores/query-client';
+import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
+import {
+ BotIcon,
+ Gift,
+ Loader2Icon,
+ LockIcon,
+ SendIcon,
+ Trash2,
+} from 'lucide-react';
+import { showLoginPopup } from '../../lib/popup';
+import { cn } from '../../lib/classname';
+import TextareaAutosize from 'react-textarea-autosize';
+import { flushSync } from 'react-dom';
+import {
+ AIChatCard,
+ type AIChatHistoryType,
+} from '../GenerateCourse/AICourseLessonChat';
+import { useToast } from '../../hooks/use-toast';
+import { readStream } from '../../lib/ai';
+import { markdownToHtmlWithHighlighting } from '../../lib/markdown';
+import type { ResourceType } from '../../lib/resource-progress';
+import { getPercentage } from '../../lib/number';
+import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
+import { defaultChatHistory } from './TopicDetail';
+import { AILimitsPopup } from '../GenerateCourse/AILimitsPopup';
+import { PredefinedActions, promptLabelMapping } from './PredefinedActions';
+
+type TopicDetailAIProps = {
+ resourceId: string;
+ resourceType: ResourceType;
+ topicId: string;
+
+ aiChatHistory: AIChatHistoryType[];
+ setAiChatHistory: (history: AIChatHistoryType[]) => void;
+
+ onUpgrade: () => void;
+ onLogin: () => void;
+};
+
+export function TopicDetailAI(props: TopicDetailAIProps) {
+ const {
+ aiChatHistory,
+ setAiChatHistory,
+ resourceId,
+ resourceType,
+ topicId,
+ onUpgrade,
+ onLogin,
+ } = props;
+
+ const textareaRef = useRef(null);
+ const scrollareaRef = useRef(null);
+ const formRef = useRef(null);
+
+ const sanitizedTopicId = topicId?.includes('@')
+ ? topicId?.split('@')?.[1]
+ : topicId;
+
+ const toast = useToast();
+ const [message, setMessage] = useState('');
+ const [isStreamingMessage, setIsStreamingMessage] = useState(false);
+ const [streamedMessage, setStreamedMessage] = useState('');
+ const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
+ const { data: tokenUsage, isLoading } = useQuery(
+ getAiCourseLimitOptions(),
+ queryClient,
+ );
+
+ const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
+ useQuery(billingDetailsOptions(), queryClient);
+
+ const { data: roadmapTreeMapping, isLoading: isRoadmapTreeMappingLoading } =
+ useQuery(
+ {
+ ...roadmapTreeMappingOptions(resourceId),
+ select: (data) => {
+ const node = data.find(
+ (mapping) => mapping.nodeId === sanitizedTopicId,
+ );
+ return node;
+ },
+ },
+ queryClient,
+ );
+
+ const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
+ const isPaidUser = userBillingDetails?.status === 'active';
+
+ const handleChatSubmit = (overrideMessage?: string) => {
+ const trimmedMessage = (overrideMessage ?? message).trim();
+
+ if (
+ !trimmedMessage ||
+ isStreamingMessage ||
+ !isLoggedIn() ||
+ isLimitExceeded ||
+ isLoading
+ ) {
+ return;
+ }
+
+ const newMessages: AIChatHistoryType[] = [
+ ...aiChatHistory,
+ {
+ role: 'user',
+ content: trimmedMessage,
+ },
+ ];
+
+ flushSync(() => {
+ setAiChatHistory(newMessages);
+ setMessage('');
+ });
+
+ scrollToBottom();
+ completeAITutorChat(newMessages);
+ };
+
+ const scrollToBottom = useCallback(() => {
+ scrollareaRef.current?.scrollTo({
+ top: scrollareaRef.current.scrollHeight,
+ behavior: 'smooth',
+ });
+ }, [scrollareaRef]);
+
+ const completeAITutorChat = async (messages: AIChatHistoryType[]) => {
+ try {
+ setIsStreamingMessage(true);
+
+ const response = await fetch(
+ `${import.meta.env.PUBLIC_API_URL}/v1-topic-detail-chat`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({
+ resourceId,
+ resourceType,
+ topicId: sanitizedTopicId,
+ messages: messages.slice(-10),
+ }),
+ },
+ );
+
+ if (!response.ok) {
+ const data = await response.json();
+
+ toast.error(data?.message || 'Something went wrong');
+ setAiChatHistory([...messages].slice(0, messages.length - 1));
+ setIsStreamingMessage(false);
+
+ if (data.status === 401) {
+ removeAuthToken();
+ window.location.reload();
+ }
+
+ queryClient.invalidateQueries(getAiCourseLimitOptions());
+ return;
+ }
+
+ const reader = response.body?.getReader();
+
+ if (!reader) {
+ setIsStreamingMessage(false);
+ toast.error('Something went wrong');
+ return;
+ }
+
+ await readStream(reader, {
+ onStream: async (content) => {
+ flushSync(() => {
+ setStreamedMessage(content);
+ });
+
+ scrollToBottom();
+ },
+ onStreamEnd: async (content) => {
+ const newMessages: AIChatHistoryType[] = [
+ ...messages,
+ {
+ role: 'assistant',
+ content,
+ html: await markdownToHtmlWithHighlighting(content),
+ },
+ ];
+
+ flushSync(() => {
+ setStreamedMessage('');
+ setIsStreamingMessage(false);
+ setAiChatHistory(newMessages);
+ });
+
+ queryClient.invalidateQueries(getAiCourseLimitOptions());
+ scrollToBottom();
+ },
+ });
+
+ setIsStreamingMessage(false);
+ } catch (error) {
+ toast.error('Something went wrong');
+ setIsStreamingMessage(false);
+ }
+ };
+
+ useEffect(() => {
+ scrollToBottom();
+ }, []);
+
+ const isDataLoading =
+ isLoading || isBillingDetailsLoading || isRoadmapTreeMappingLoading;
+ const usagePercentage = getPercentage(
+ tokenUsage?.used || 0,
+ tokenUsage?.limit || 0,
+ );
+ const hasSubjects =
+ roadmapTreeMapping?.subjects && roadmapTreeMapping?.subjects?.length > 0;
+ const hasChatHistory = aiChatHistory.length > 1;
+
+ return (
+
+ {isDataLoading && (
+
+
+
+ )}
+
+ {showAILimitsPopup && (
+
setShowAILimitsPopup(false)}
+ onUpgrade={() => {
+ setShowAILimitsPopup(false);
+ onUpgrade();
+ }}
+ />
+ )}
+
+ {hasSubjects && (
+
+
+ Complete the following AI Tutor courses
+
+
+
+ {roadmapTreeMapping?.subjects?.map((subject) => {
+ return (
+
+ {subject}
+
+ );
+ })}
+
+
+ )}
+
+
+ {hasSubjects && (
+
+
+ Chat with AI
+ AI Tutor
+
+ )}
+
+ {!hasSubjects && (
+
+
+ AI Tutor
+
+ )}
+
+ {!isDataLoading && (
+
+ {hasChatHistory && (
+ {
+ setAiChatHistory(defaultChatHistory);
+ }}
+ >
+
+
+ )}
+
+ {!isPaidUser && (
+ <>
+ {
+ if (!isLoggedIn()) {
+ onLogin();
+ return;
+ }
+
+ setShowAILimitsPopup(true);
+ }}
+ >
+ {usagePercentage}% {' '}
+ credits used
+
+ {
+ if (!isLoggedIn()) {
+ onLogin();
+ return;
+ }
+
+ onUpgrade();
+ }}
+ >
+
+ Upgrade
+
+ >
+ )}
+
+ )}
+
+
+ {
+ if (!isLoggedIn()) {
+ onLogin();
+ return;
+ }
+
+ if (isLimitExceeded) {
+ onUpgrade();
+ return;
+ }
+
+ if (!action?.prompt) {
+ toast.error('Something went wrong');
+ return;
+ }
+
+ setMessage(action.prompt);
+ handleChatSubmit(action.prompt);
+ }}
+ />
+
+
+
+
+
+ {aiChatHistory.map((chat, index) => {
+ let content = chat.content;
+
+ if (chat.role === 'user' && promptLabelMapping[chat.content]) {
+ content = promptLabelMapping[chat.content];
+ }
+
+ return (
+
+
+
+ );
+ })}
+
+ {isStreamingMessage && !streamedMessage && (
+
+ )}
+
+ {streamedMessage && (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/TopicDetail/TopicDetailLink.tsx b/src/components/TopicDetail/TopicDetailLink.tsx
index d5b05c95e..60f3ea5e7 100644
--- a/src/components/TopicDetail/TopicDetailLink.tsx
+++ b/src/components/TopicDetail/TopicDetailLink.tsx
@@ -1,7 +1,7 @@
import { cn } from '../../lib/classname.ts';
import type { AllowedLinkTypes } from '../CustomRoadmap/CustomRoadmap.tsx';
-const linkTypes: Record = {
+const linkTypes: Record = {
article: 'bg-yellow-300',
course: 'bg-green-400',
opensource: 'bg-black text-white',
@@ -18,6 +18,34 @@ const paidLinkTypes: Record = {
course: 'bg-yellow-300',
};
+type TopicLinkBadgeProps = {
+ isPaid: boolean;
+ discountText?: string;
+ type: AllowedLinkTypes | string;
+ className?: string;
+};
+
+function TopicLinkBadge(props: TopicLinkBadgeProps) {
+ const { isPaid, type, className } = props;
+
+ const linkType = type === 'opensource' ? 'OpenSource' : type;
+ const isDiscount = type.includes('% off');
+
+ return (
+
+
+ {linkType}
+
+
+ );
+}
+
type TopicDetailLinkProps = {
url: string;
onClick?: () => void;
@@ -29,7 +57,7 @@ type TopicDetailLinkProps = {
export function TopicDetailLink(props: TopicDetailLinkProps) {
const { url, onClick, type, title, isPaid = false } = props;
- const linkType = type === 'opensource' ? 'OpenSource' : type;
+ const isScrimbaLink = url.toLowerCase().includes('scrimba.com');
return (
-
- {linkType}
-
+
+ {isScrimbaLink && }
+
{title}
);
diff --git a/src/components/TopicDetail/TopicDetailsTabs.tsx b/src/components/TopicDetail/TopicDetailsTabs.tsx
new file mode 100644
index 000000000..91618122b
--- /dev/null
+++ b/src/components/TopicDetail/TopicDetailsTabs.tsx
@@ -0,0 +1,68 @@
+import { Earth, WandSparkles, type LucideIcon } from 'lucide-react';
+
+export type AllowedTopicDetailsTabs = 'content' | 'ai';
+
+type TopicDetailsTabsProps = {
+ activeTab: AllowedTopicDetailsTabs;
+ setActiveTab: (tab: AllowedTopicDetailsTabs) => void;
+ hasAITutor?: boolean;
+};
+
+export function TopicDetailsTabs(props: TopicDetailsTabsProps) {
+ const { activeTab, setActiveTab, hasAITutor = true } = props;
+
+ return (
+
+ setActiveTab('content')}
+ />
+ setActiveTab('ai')}
+ />
+
+ );
+}
+
+type TopicDetailsTabProps = {
+ isActive: boolean;
+ icon: LucideIcon;
+ label: string;
+ isNew?: boolean;
+ isDisabled?: boolean;
+ onClick: () => void;
+};
+
+function TopicDetailsTab(props: TopicDetailsTabProps) {
+ const { isActive, icon: Icon, label, isNew, isDisabled, onClick } = props;
+
+ return (
+
+
+ {label}
+ {isNew && !isDisabled && (
+
+ New
+
+ )}
+ {isDisabled && (
+
+ Soon
+
+ )}
+
+ );
+}
diff --git a/src/components/TopicDetail/TopicProgressButton.tsx b/src/components/TopicDetail/TopicProgressButton.tsx
index 8cd1866a6..2b0d39816 100644
--- a/src/components/TopicDetail/TopicProgressButton.tsx
+++ b/src/components/TopicDetail/TopicProgressButton.tsx
@@ -16,25 +16,54 @@ import { showLoginPopup } from '../../lib/popup';
import { useToast } from '../../hooks/use-toast';
import { Spinner } from '../ReactIcons/Spinner';
import { ChevronDown } from 'lucide-react';
+import { cn } from '../../lib/classname';
+
+const statusColors: Record = {
+ done: 'bg-green-500',
+ learning: 'bg-yellow-500',
+ pending: 'bg-gray-300',
+ skipped: 'bg-black',
+ removed: '',
+};
type TopicProgressButtonProps = {
topicId: string;
resourceId: string;
resourceType: ResourceType;
+ dropdownClassName?: string;
onClose: () => void;
};
-const statusColors: Record = {
- done: 'bg-green-500',
- learning: 'bg-yellow-500',
- pending: 'bg-gray-300',
- skipped: 'bg-black',
- removed: '',
+type ProgressDropdownItemProps = {
+ status: ResourceProgressType;
+ shortcutKey: string;
+ label: string;
+ onClick: () => void;
};
+function ProgressDropdownItem(props: ProgressDropdownItemProps) {
+ const { status, shortcutKey, label, onClick } = props;
+
+ return (
+
+
+
+ {label}
+
+ {shortcutKey}
+
+ );
+}
+
export function TopicProgressButton(props: TopicProgressButtonProps) {
- const { topicId, resourceId, resourceType, onClose } = props;
+ const { topicId, resourceId, resourceType, onClose, dropdownClassName } =
+ props;
const toast = useToast();
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true);
@@ -66,7 +95,15 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
// Mark as done
useKeydown(
'd',
- () => {
+ (e: KeyboardEvent) => {
+ if (
+ e.target instanceof HTMLTextAreaElement ||
+ e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLSelectElement
+ ) {
+ return;
+ }
+
if (progress === 'done') {
onClose();
return;
@@ -80,7 +117,15 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
// Mark as learning
useKeydown(
'l',
- () => {
+ (e: KeyboardEvent) => {
+ if (
+ e.target instanceof HTMLTextAreaElement ||
+ e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLSelectElement
+ ) {
+ return;
+ }
+
if (progress === 'learning') {
return;
}
@@ -93,7 +138,15 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
// Mark as learning
useKeydown(
's',
- () => {
+ (e: KeyboardEvent) => {
+ if (
+ e.target instanceof HTMLTextAreaElement ||
+ e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLSelectElement
+ ) {
+ return;
+ }
+
if (progress === 'skipped') {
onClose();
return;
@@ -107,9 +160,16 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
// Mark as pending
useKeydown(
'r',
- () => {
+ (e: KeyboardEvent) => {
+ if (
+ e.target instanceof HTMLTextAreaElement ||
+ e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLSelectElement
+ ) {
+ return;
+ }
+
if (progress === 'pending') {
- onClose();
return;
}
@@ -147,6 +207,7 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
console.error(err);
})
.finally(() => {
+ setShowChangeStatus(false);
setIsUpdatingProgress(false);
});
};
@@ -167,15 +228,20 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
if (isUpdatingProgress) {
return (
-
- Updating Status..
+
+ Please wait..
);
}
return (
-
-
+
+
setShowChangeStatus(true)}
+ >
{progress === 'learning' ? 'In Progress' : progress}
-
-
- setShowChangeStatus(true)}
- >
- Update Status
-
+
{showChangeStatus && (
button:first-child]:rounded-t-md [&>button:last-child]:rounded-b-md',
+ dropdownClassName,
+ )}
ref={changeStatusRef!}
>
{allowMarkingDone && (
- handleUpdateResourceProgress('done')}
- >
-
-
- Done
-
- D
-
+ />
)}
{allowMarkingLearning && (
- handleUpdateResourceProgress('learning')}
- >
-
-
- In Progress
-
-
- L
-
+ />
)}
{allowMarkingPending && (
- handleUpdateResourceProgress('pending')}
- >
-
-
- Reset
-
- R
-
+ />
)}
{allowMarkingSkipped && (
- handleUpdateResourceProgress('skipped')}
- >
-
-
- Skip
-
- S
-
+ />
)}
)}
diff --git a/src/data/roadmaps/frontend/content/how-does-the-internet-work@yCnn-NfSxIybUQ2iTuUGq.md b/src/data/roadmaps/frontend/content/how-does-the-internet-work@yCnn-NfSxIybUQ2iTuUGq.md
index 774d46faa..a9b391000 100644
--- a/src/data/roadmaps/frontend/content/how-does-the-internet-work@yCnn-NfSxIybUQ2iTuUGq.md
+++ b/src/data/roadmaps/frontend/content/how-does-the-internet-work@yCnn-NfSxIybUQ2iTuUGq.md
@@ -1,10 +1,10 @@
# How Does The Internet Work
-The Internet works through a global network of interconnected computers and servers, communicating via standardized protocols. Data is broken into packets and routed through various network nodes using the Internet Protocol (IP). These packets travel across different physical infrastructures, including fiber optic cables, satellites, and wireless networks. The Transmission Control Protocol (TCP) ensures reliable delivery and reassembly of packets at their destination. Domain Name System (DNS) servers translate human-readable website names into IP addresses. When you access a website, your device sends a request to the appropriate server, which responds with the requested data. This process, facilitated by routers, switches, and other networking equipment, enables the seamless exchange of information across vast distances, forming the backbone of our digital communications.
+The internet is a global network that connects computers and devices so they can share information with each other. It’s how you browse websites, send emails, watch videos, and use apps. Think of it like a giant web that links everything together.
Visit the following resources to learn more:
-- [@roadmap@Introduction to Internet](https://roadmap.sh/guides/what-is-internet)
+- [@article@Introduction to Internet](https://roadmap.sh/guides/what-is-internet)
- [@article@How does the Internet Work?](https://cs.fyi/guide/how-does-internet-work)
- [@article@How Does the Internet Work? MDN Docs](https://developer.mozilla.org/en-US/docs/Learn/Common_questions/How_does_the_Internet_work)
- [@video@How the Internet Works in 5 Minutes](https://www.youtube.com/watch?v=7_LPdttKXPc)
diff --git a/src/data/roadmaps/frontend/content/internet@VlNNwIEDWqQXtqkHWJYzC.md b/src/data/roadmaps/frontend/content/internet@VlNNwIEDWqQXtqkHWJYzC.md
index de067dbaa..f34a4636f 100644
--- a/src/data/roadmaps/frontend/content/internet@VlNNwIEDWqQXtqkHWJYzC.md
+++ b/src/data/roadmaps/frontend/content/internet@VlNNwIEDWqQXtqkHWJYzC.md
@@ -4,5 +4,5 @@ The Internet is a global network of interconnected computer networks that use th
Visit the following resources to learn more:
-- [@roadmap@Introduction to Internet](https://roadmap.sh/guides/what-is-internet)
+- [@article@Introduction to Internet](https://roadmap.sh/guides/what-is-internet)
- [@article@The Internet](https://en.wikipedia.org/wiki/Internet)
diff --git a/src/helper/generate-ai-course.ts b/src/helper/generate-ai-course.ts
index 713e77e9a..fcd19150b 100644
--- a/src/helper/generate-ai-course.ts
+++ b/src/helper/generate-ai-course.ts
@@ -20,6 +20,7 @@ type GenerateCourseOptions = {
onCourseChange?: (course: AiCourse, rawData: string) => void;
onLoadingChange?: (isLoading: boolean) => void;
onError?: (error: string) => void;
+ src?: string;
};
export async function generateCourse(options: GenerateCourseOptions) {
@@ -37,6 +38,7 @@ export async function generateCourse(options: GenerateCourseOptions) {
instructions,
goal,
about,
+ src = 'search',
} = options;
onLoadingChange?.(true);
@@ -85,6 +87,7 @@ export async function generateCourse(options: GenerateCourseOptions) {
instructions,
goal,
about,
+ src,
}),
credentials: 'include',
},
diff --git a/src/hooks/use-keydown.ts b/src/hooks/use-keydown.ts
index c4f18b00c..1b20668e1 100644
--- a/src/hooks/use-keydown.ts
+++ b/src/hooks/use-keydown.ts
@@ -7,14 +7,14 @@ export function useKeydown(keyName: string, callback: any, deps: any[] = []) {
!keyName.startsWith('mod_') &&
event.key.toLowerCase() === keyName.toLowerCase()
) {
- callback();
+ callback(event);
} else if (
keyName.startsWith('mod_') &&
event.metaKey &&
event.key.toLowerCase() === keyName.replace('mod_', '').toLowerCase()
) {
event.preventDefault();
- callback();
+ callback(event);
}
};
diff --git a/src/pages/[roadmapId]/index.astro b/src/pages/[roadmapId]/index.astro
index c57c1fe03..a46a14ef0 100644
--- a/src/pages/[roadmapId]/index.astro
+++ b/src/pages/[roadmapId]/index.astro
@@ -18,6 +18,7 @@ import RoadmapNote from '../../components/RoadmapNote.astro';
import { RoadmapTitleQuestion } from '../../components/RoadmapTitleQuestion';
import ResourceProgressStats from '../../components/ResourceProgressStats.astro';
import { getProjectsByRoadmapId } from '../../lib/project';
+import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification';
export const prerender = true;
@@ -101,6 +102,7 @@ const courses = roadmapData.courses || [];
resourceTitle={roadmapData.title}
resourceId={roadmapId}
resourceType='roadmap'
+ renderer={roadmapData.renderer}
client:idle
canSubmitContribution={true}
/>
@@ -183,5 +185,6 @@ const courses = roadmapData.courses || [];
+
diff --git a/src/pages/best-practices/[bestPracticeId]/index.astro b/src/pages/best-practices/[bestPracticeId]/index.astro
index d577a913c..08af742a4 100644
--- a/src/pages/best-practices/[bestPracticeId]/index.astro
+++ b/src/pages/best-practices/[bestPracticeId]/index.astro
@@ -14,6 +14,7 @@ import {
type BestPracticeFrontmatter,
getAllBestPractices,
} from '../../../lib/best-practice';
+import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification';
export const prerender = true;
@@ -107,6 +108,7 @@ const ogImageUrl = getOpenGraphImageUrl({
resourceId={bestPracticeId}
resourceTitle={bestPracticeData.title}
resourceType='best-practice'
+ renderer={'balsamiq'}
client:idle
canSubmitContribution={true}
/>
@@ -136,6 +138,6 @@ const ogImageUrl = getOpenGraphImageUrl({
/>
{bestPracticeData.isUpcoming && }
-
+
diff --git a/src/queries/roadmap-tree.ts b/src/queries/roadmap-tree.ts
new file mode 100644
index 000000000..a94259ceb
--- /dev/null
+++ b/src/queries/roadmap-tree.ts
@@ -0,0 +1,26 @@
+import { queryOptions } from '@tanstack/react-query';
+import { httpGet } from '../lib/query-http';
+
+export interface RoadmapTreeDocument {
+ _id?: string;
+ roadmapId: string;
+ mapping: {
+ _id?: string;
+ nodeId: string;
+ text: string;
+ subjects: string[];
+ }[];
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export function roadmapTreeMappingOptions(roadmapId: string) {
+ return queryOptions({
+ queryKey: ['roadmap-tree-mapping', { roadmapId }],
+ queryFn: () => {
+ return httpGet(
+ `${import.meta.env.PUBLIC_API_URL}/v1-roadmap-tree-mapping/${roadmapId}`,
+ );
+ },
+ });
+}