1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-03 14:22:41 +02:00

Merge branch 'master' into feat/ai-document

This commit is contained in:
Arik Chakma
2025-06-13 13:28:42 +06:00
54 changed files with 2916 additions and 585 deletions

10
.vscode/settings.json vendored
View File

@@ -2,5 +2,13 @@
"prettier.documentSelectors": ["**/*.astro"],
"[astro]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
},
"tailwindCSS.experimental.classRegex": [
["\\b\\w+[cC]lassName\\s*=\\s*[\"']([^\"']*)[\"']"],
["\\b\\w+[cC]lassName\\s*=\\s*`([^`]*)`"],
["[\\w]+[cC]lassName[\"']?\\s*:\\s*[\"']([^\"']*)[\"']"],
["[\\w]+[cC]lassName[\"']?\\s*:\\s*`([^`]*)`"],
["cva\\(((?:[^()]|\\([^()]*\\))*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(((?:[^()]|\\([^()]*\\))*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
]
}

View File

@@ -39,6 +39,7 @@
"@nanostores/react": "^1.0.0",
"@napi-rs/image": "^1.9.2",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-popover": "^1.1.14",
"@resvg/resvg-js": "^2.6.2",
"@roadmapsh/editor": "workspace:*",
"@tailwindcss/vite": "^4.1.7",
@@ -120,6 +121,7 @@
"prettier": "^3.5.3",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwind-scrollbar": "^4.0.2",
"tsx": "^4.19.4"
}
}

View File

@@ -2,22 +2,11 @@
"aStaDENn5PhEa-cFvNzXa": {
"title": "Mathematics",
"description": "Mathematics is the foundation of AI and Data Science. It is essential to have a good understanding of mathematics to excel in these fields.",
"links": [
{
"title": "Mathematics for Machine Learning",
"url": "https://imp.i384100.net/baqMYv",
"type": "article"
},
{
"title": "Algebra and Differential Calculus",
"url": "https://imp.i384100.net/LX5M7M",
"type": "article"
}
]
"links": []
},
"4WZL_fzJ3cZdWLLDoWN8D": {
"title": "Statistics",
"description": "Statistics is the science of collecting, analyzing, interpreting, presenting, and organizing data. It is a branch of mathematics that deals with the collection, analysis, interpretation, presentation, and organization of data. It is used in a wide range of fields, including science, engineering, medicine, and social science. Statistics is used to make informed decisions, to predict future events, and to test hypotheses. It is also used to summarize data, to describe relationships between variables, and to make inferences about populations based on samples.\n\nLearn more from the resources given on the roadmap.",
"description": "Statistics is the science of collecting, analyzing, interpreting, presenting, and organizing data. It is a branch of mathematics that deals with the collection, analysis, interpretation, presentation, and organization of data. It is used in a wide range of fields, including science, engineering, medicine, and social science. Statistics is used to make informed decisions, to predict future events, and to test hypotheses. It is also used to summarize data, to describe relationships between variables, and to make inferences about populations based on samples.",
"links": []
},
"gWMvD83hVXeTmCuHGIiOL": {
@@ -331,24 +320,8 @@
},
"kBdt_t2SvVsY3blfubWIz": {
"title": "Machine Learning",
"description": "Machine learning is a field of artificial intelligence that uses statistical techniques to give computer systems the ability to \"learn\" (e.g., progressively improve performance on a specific task) from data, without being explicitly programmed. The name machine learning was coined in 1959 by Arthur Samuel. Evolved from the study of pattern recognition and computational learning theory in artificial intelligence, machine learning explores the study and construction of algorithms that can learn from and make predictions on data such algorithms overcome following strictly static program instructions by making data-driven predictions or decisions, through building a model from sample inputs. Machine learning is employed in a range of computing tasks where designing and programming explicit algorithms with good performance is difficult or infeasible; example applications include email filtering, detection of network intruders, and computer vision.\n\nLearn more from the following resources:",
"links": [
{
"title": "Advantages and Disadvantages of AI",
"url": "https://medium.com/@laners.org/advantages-and-disadvantages-of-artificial-intelligence-cd6e42819b20",
"type": "article"
},
{
"title": "Reinforcement Learning 101",
"url": "https://medium.com/towards-data-science/reinforcement-learning-101-e24b50e1d292",
"type": "article"
},
{
"title": "Understanding AUC-ROC Curve",
"url": "https://medium.com/towards-data-science/understanding-auc-roc-curve-68b2303cc9c5",
"type": "article"
}
]
"description": "Machine learning is a field of artificial intelligence that uses statistical techniques to give computer systems the ability to \"learn\" (e.g., progressively improve performance on a specific task) from data, without being explicitly programmed. The name machine learning was coined in 1959 by Arthur Samuel. Evolved from the study of pattern recognition and computational learning theory in artificial intelligence, machine learning explores the study and construction of algorithms that can learn from and make predictions on data such algorithms overcome following strictly static program instructions by making data-driven predictions or decisions, through building a model from sample inputs. Machine learning is employed in a range of computing tasks where designing and programming explicit algorithms with good performance is difficult or infeasible; example applications include email filtering, detection of network intruders, and computer vision.",
"links": []
},
"FdBih8tlGPPy97YWq463y": {
"title": "Classic ML (Sup., Unsup.), Advanced ML (Ensembles, NNs)",

View File

@@ -2370,7 +2370,7 @@
},
"RuXuHQhMt2nywk43LgGeJ": {
"title": "Static Library",
"description": "Static libraries in iOS development are collections of compiled code that are linked directly into an app's executable at build time. They contain object code that becomes part of the final application binary, increasing its size but potentially improving load time performance. Static libraries are typically distributed as .a files, often accompanied by header files that define their public interfaces. When using static libraries, the entire library code is included in the app, even if only a portion is used, which can lead to larger app sizes. However, this approach ensures that all necessary code is available within the app, eliminating runtime dependencies. Static libraries are particularly useful for distributing closed-source code or when aiming to minimize runtime overhead. They offer simplicity in distribution and version management but may require recompilation of the entire app when the library is updated. In iOS development, static libraries are gradually being replaced by more flexible options like dynamic frameworks and XCFrameworks, especially for larger or frequently updated libraries.\n\nLearn more from the following resources:",
"description": "Static libraries in iOS development are collections of compiled code that are linked directly into an app's executable at build time. They contain object code that becomes part of the final application binary, increasing its size but potentially improving load time performance. Static libraries are typically distributed as .a files, often accompanied by header files that define their public interfaces. Using static libraries ensures that all necessary code is available within the app, eliminating runtime dependencies. Static libraries are particularly useful for distributing closed-source code or when aiming to minimize runtime overhead. They offer simplicity in distribution and version management but may require recompilation of the entire app when the library is updated. In iOS development, static libraries are gradually being replaced by more flexible options like dynamic frameworks and XCFrameworks, especially for larger or frequently updated libraries.\n\nLearn more from the following resources:",
"links": [
{
"title": "Static Library in iOS",

View File

@@ -7,14 +7,7 @@ import {
SendIcon,
TrashIcon,
} from 'lucide-react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import AutogrowTextarea from 'react-textarea-autosize';
import { QuickHelpPrompts } from './QuickHelpPrompts';
@@ -25,7 +18,6 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { billingDetailsOptions } from '../../queries/billing';
import { useToast } from '../../hooks/use-toast';
import { readStream } from '../../lib/ai';
import { markdownToHtml } from '../../lib/markdown';
import { ChatHistory } from './ChatHistory';
import { PersonalizedResponseForm } from './PersonalizedResponseForm';
@@ -38,31 +30,47 @@ import {
type MessagePartRenderer,
} from '../../lib/render-chat-message';
import { RoadmapRecommendations } from '../RoadmapAIChat/RoadmapRecommendations';
import type { RoadmapAIChatHistoryType } from '../RoadmapAIChat/RoadmapAIChat';
import { AIChatCourse } from './AIChatCouse';
import { getTailwindScreenDimension } from '../../lib/is-mobile';
import type { TailwindScreenDimensions } from '../../lib/is-mobile';
import { showLoginPopup } from '../../lib/popup';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { readChatStream } from '../../lib/chat';
import { chatHistoryOptions } from '../../queries/chat-history';
import { cn } from '../../lib/classname';
import type { RoadmapAIChatHistoryType } from '../../hooks/use-roadmap-ai-chat';
export const aiChatRenderer: Record<string, MessagePartRenderer> = {
'roadmap-recommendations': (options) => {
return <RoadmapRecommendations {...options} />;
},
'generate-course': (options) => {
return <AIChatCourse {...options} />;
},
};
type AIChatProps = {
messages?: RoadmapAIChatHistoryType[];
chatHistoryId?: string;
setChatHistoryId?: (chatHistoryId: string) => void;
onUpgrade?: () => void;
};
export function AIChat(props: AIChatProps) {
const {
messages: defaultMessages,
chatHistoryId: defaultChatHistoryId,
setChatHistoryId: setDefaultChatHistoryId,
onUpgrade,
} = props;
export function AIChat() {
const toast = useToast();
const [deviceType, setDeviceType] = useState<TailwindScreenDimensions>();
useLayoutEffect(() => {
setDeviceType(getTailwindScreenDimension());
}, []);
const [message, setMessage] = useState('');
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
const [streamedMessage, setStreamedMessage] =
useState<React.ReactNode | null>(null);
const [aiChatHistory, setAiChatHistory] = useState<
RoadmapAIChatHistoryType[]
>([]);
>(defaultMessages ?? []);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [isPersonalizedResponseFormOpen, setIsPersonalizedResponseFormOpen] =
useState(false);
const [isUploadResumeModalOpen, setIsUploadResumeModalOpen] = useState(false);
@@ -89,6 +97,34 @@ export function AIChat() {
userResumeOptions(),
queryClient,
);
const { mutate: deleteChatMessage, isPending: isDeletingChatMessage } =
useMutation(
{
mutationFn: (messages: RoadmapAIChatHistoryType[]) => {
if (!defaultChatHistoryId) {
return Promise.resolve({
status: 200,
message: 'Chat history not found',
});
}
return httpPost(`/v1-delete-chat-message/${defaultChatHistoryId}`, {
messages,
});
},
onSuccess: () => {
textareaMessageRef.current?.focus();
queryClient.invalidateQueries(
chatHistoryOptions(defaultChatHistoryId),
);
},
onError: (error) => {
toast.error(error?.message || 'Failed to delete message');
},
},
queryClient,
);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
@@ -101,7 +137,7 @@ export function AIChat() {
if (isLimitExceeded) {
if (!isPaidUser) {
setShowUpgradeModal(true);
onUpgrade?.();
}
toast.error('Limit reached for today. Please wait until tomorrow.');
@@ -136,29 +172,39 @@ export function AIChat() {
completeAIChat(newMessages);
};
const scrollToBottom = useCallback(() => {
const canScrollToBottom = useCallback(() => {
const scrollableContainer = scrollableContainerRef?.current;
if (!scrollableContainer) {
return;
return false;
}
scrollableContainer.scrollTo({
top: scrollableContainer.scrollHeight,
behavior: 'smooth',
});
}, [scrollableContainerRef]);
const paddingBottom = parseInt(
getComputedStyle(scrollableContainer).paddingBottom,
);
const renderer: Record<string, MessagePartRenderer> = useMemo(() => {
return {
'roadmap-recommendations': (options) => {
return <RoadmapRecommendations {...options} />;
},
'generate-course': (options) => {
return <AIChatCourse {...options} />;
},
};
const distanceFromBottom =
scrollableContainer.scrollHeight -
(scrollableContainer.scrollTop + scrollableContainer.clientHeight) -
paddingBottom;
return distanceFromBottom > -(paddingBottom - 80);
}, []);
const scrollToBottom = useCallback(
(behavior: 'instant' | 'smooth' = 'smooth') => {
const scrollableContainer = scrollableContainerRef?.current;
if (!scrollableContainer) {
return;
}
scrollableContainer.scrollTo({
top: scrollableContainer.scrollHeight,
behavior: behavior === 'instant' ? 'instant' : 'smooth',
});
},
[scrollableContainerRef],
);
const completeAIChat = async (
messages: RoadmapAIChatHistoryType[],
force: boolean = false,
@@ -172,6 +218,7 @@ export function AIChat() {
},
credentials: 'include',
body: JSON.stringify({
chatHistoryId: defaultChatHistoryId,
messages: messages.slice(-10),
force,
}),
@@ -190,28 +237,26 @@ export function AIChat() {
}
}
const reader = response.body?.getReader();
if (!reader) {
const stream = response.body;
if (!stream) {
setIsStreamingMessage(false);
toast.error('Something went wrong');
return;
}
await readStream(reader, {
onStream: async (content) => {
const jsx = await renderMessage(content, renderer, {
await readChatStream(stream, {
onMessage: async (content) => {
const jsx = await renderMessage(content, aiChatRenderer, {
isLoading: true,
});
flushSync(() => {
setStreamedMessage(jsx);
});
scrollToBottom();
setShowScrollToBottomButton(canScrollToBottom());
},
onStreamEnd: async (content) => {
const jsx = await renderMessage(content, renderer, {
onMessageEnd: async (content) => {
const jsx = await renderMessage(content, aiChatRenderer, {
isLoading: false,
});
@@ -231,7 +276,20 @@ export function AIChat() {
});
queryClient.invalidateQueries(getAiCourseLimitOptions());
scrollToBottom();
queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0] === 'list-chat-history';
},
});
},
onDetails: (details) => {
const detailsJson = JSON.parse(details);
const chatHistoryId = detailsJson?.chatHistoryId;
if (!chatHistoryId) {
return;
}
setDefaultChatHistoryId?.(chatHistoryId);
},
});
@@ -272,17 +330,7 @@ export function AIChat() {
}
timeoutId = setTimeout(() => {
const paddingBottom = parseInt(
getComputedStyle(scrollableContainer).paddingBottom,
);
const distanceFromBottom =
scrollableContainer.scrollHeight -
// scroll from the top + the container height
(scrollableContainer.scrollTop + scrollableContainer.clientHeight) -
paddingBottom;
setShowScrollToBottomButton(distanceFromBottom > -(paddingBottom - 80));
setShowScrollToBottomButton(canScrollToBottom());
}, 100);
};
@@ -303,7 +351,7 @@ export function AIChat() {
(index: number) => {
if (isLimitExceeded) {
if (!isPaidUser) {
setShowUpgradeModal(true);
onUpgrade?.();
}
toast.error('Limit reached for today. Please wait until tomorrow.');
@@ -325,6 +373,7 @@ export function AIChat() {
(index: number) => {
const filteredChatHistory = aiChatHistory.filter((_, i) => i !== index);
setAiChatHistory(filteredChatHistory);
deleteChatMessage(filteredChatHistory);
},
[aiChatHistory],
);
@@ -337,29 +386,40 @@ export function AIChat() {
isUserPersonaLoading ||
isUserResumeLoading;
useEffect(() => {
scrollToBottom('instant');
}, []);
const shouldShowUpgradeBanner = !isPaidUser && aiChatHistory.length > 0;
return (
<div
className="ai-chat relative flex min-h-screen w-full flex-col gap-2 overflow-y-auto bg-gray-100 pb-55"
ref={scrollableContainerRef}
>
<div className="relative mx-auto w-full max-w-3xl grow px-4">
{shouldShowQuickHelpPrompts && (
<QuickHelpPrompts
onQuestionClick={(question) => {
textareaMessageRef.current?.focus();
setMessage(question);
}}
/>
)}
{!shouldShowQuickHelpPrompts && (
<ChatHistory
chatHistory={aiChatHistory}
isStreamingMessage={isStreamingMessage}
streamedMessage={streamedMessage}
onDelete={handleDelete}
onRegenerate={handleRegenerate}
/>
<div className="ai-chat relative flex grow flex-col gap-2 bg-gray-100">
<div
className={cn(
'scrollbar-none absolute inset-0 overflow-y-auto pb-55',
shouldShowUpgradeBanner ? 'pb-60' : 'pb-55',
)}
ref={scrollableContainerRef}
>
<div className="relative mx-auto w-full max-w-3xl grow px-4">
{shouldShowQuickHelpPrompts && (
<QuickHelpPrompts
onQuestionClick={(question) => {
textareaMessageRef.current?.focus();
setMessage(question);
}}
/>
)}
{!shouldShowQuickHelpPrompts && (
<ChatHistory
chatHistory={aiChatHistory}
isStreamingMessage={isStreamingMessage}
streamedMessage={streamedMessage}
onDelete={handleDelete}
onRegenerate={handleRegenerate}
/>
)}
</div>
</div>
{isPersonalizedResponseFormOpen && (
@@ -378,12 +438,8 @@ export function AIChat() {
/>
)}
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
<div
className="pointer-events-none fixed right-0 bottom-0 left-0 mx-auto w-full max-w-3xl px-4 lg:left-[var(--ai-sidebar-width)]"
className="pointer-events-none absolute right-0 bottom-0 left-0 mx-auto w-full max-w-3xl px-4"
ref={chatContainerRef}
>
<div className="mb-2 flex items-center justify-between gap-2">
@@ -392,6 +448,11 @@ export function AIChat() {
icon={PersonStandingIcon}
label="Personalize"
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsPersonalizedResponseFormOpen(true);
}}
/>
@@ -399,6 +460,11 @@ export function AIChat() {
icon={FileUpIcon}
label={isUploading ? 'Processing...' : 'Upload Resume'}
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsUploadResumeModalOpen(true);
}}
isLoading={isUploading}
@@ -413,12 +479,13 @@ export function AIChat() {
onClick={scrollToBottom}
/>
)}
{aiChatHistory.length > 0 && (
{aiChatHistory.length > 0 && !isPaidUser && (
<QuickActionButton
icon={TrashIcon}
label="Clear Chat"
onClick={() => {
setAiChatHistory([]);
deleteChatMessage([]);
}}
/>
)}
@@ -470,7 +537,7 @@ export function AIChat() {
<button
type="button"
onClick={() => {
setShowUpgradeModal(true);
onUpgrade?.();
}}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
>

View File

@@ -8,8 +8,8 @@ import {
RotateCwIcon,
} from 'lucide-react';
import { useCopyText } from '../../hooks/use-copy-text';
import type { RoadmapAIChatHistoryType } from '../RoadmapAIChat/RoadmapAIChat';
import { Tooltip } from '../Tooltip';
import type { RoadmapAIChatHistoryType } from '../../hooks/use-roadmap-ai-chat';
type ChatHistoryProps = {
chatHistory: RoadmapAIChatHistoryType[];

View File

@@ -57,6 +57,7 @@ export function QuickHelpPrompts(props: QuickHelpPromptsProps) {
<div className="mt-6 flex flex-wrap items-center gap-2">
{quickActions.map((action, index) => (
<button
key={action.label}
className={cn(
'pointer-events-auto flex shrink-0 cursor-pointer items-center gap-2 rounded-lg border bg-white px-2 py-1.5 text-sm hover:bg-gray-100 hover:text-black',
selectedActionIndex === index
@@ -73,6 +74,7 @@ export function QuickHelpPrompts(props: QuickHelpPromptsProps) {
<div className="mt-6 divide-y divide-gray-200">
{selectedAction.questions.map((question) => (
<button
type="button"
key={question}
className="block w-full cursor-pointer p-2 text-left text-sm text-gray-500 hover:bg-gray-100 hover:text-black"
onClick={() => onQuestionClick(question)}

View File

@@ -0,0 +1,171 @@
import { useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { chatHistoryOptions } from '../../queries/chat-history';
import { AIChat, aiChatRenderer } from '../AIChat/AIChat';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useState, useCallback } from 'react';
import { AIChatLayout } from './AIChatLayout';
import { ListChatHistory } from './ListChatHistory';
import { billingDetailsOptions } from '../../queries/billing';
import { ChatHistoryError } from './ChatHistoryError';
import { useClientMount } from '../../hooks/use-client-mount';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
type AIChatHistoryProps = {
chatHistoryId?: string;
};
export function AIChatHistory(props: AIChatHistoryProps) {
const { chatHistoryId: defaultChatHistoryId } = props;
const isClientMounted = useClientMount();
const [keyTrigger, setKeyTrigger] = useState(0);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [isChatHistoryLoading, setIsChatHistoryLoading] = useState(true);
const [chatHistoryId, setChatHistoryId] = useState<string | undefined>(
defaultChatHistoryId || undefined,
);
const { data, error: chatHistoryError } = useQuery(
chatHistoryOptions(chatHistoryId, aiChatRenderer),
queryClient,
);
const {
data: userBillingDetails,
isLoading: isBillingDetailsLoading,
error: billingDetailsError,
} = useQuery(billingDetailsOptions(), queryClient);
const handleChatHistoryClick = useCallback(
(nextChatHistoryId: string | null) => {
setKeyTrigger((key) => key + 1);
if (nextChatHistoryId === null) {
setChatHistoryId(undefined);
window.history.replaceState(null, '', '/ai/chat');
return;
}
// show loader only if the chat history hasn't been fetched before (avoids UI flash)
const hasAlreadyFetched = queryClient.getQueryData(
chatHistoryOptions(nextChatHistoryId).queryKey,
);
if (!hasAlreadyFetched) {
setIsChatHistoryLoading(true);
}
setChatHistoryId(nextChatHistoryId);
window.history.replaceState(null, '', `/ai/chat/${nextChatHistoryId}`);
},
[],
);
const handleDelete = useCallback(
(deletedChatHistoryId: string) => {
if (deletedChatHistoryId !== chatHistoryId) {
return;
}
setChatHistoryId(undefined);
window.history.replaceState(null, '', '/ai/chat');
setKeyTrigger((key) => key + 1);
},
[chatHistoryId],
);
const isPaidUser = userBillingDetails?.status === 'active';
const hasError = chatHistoryError || billingDetailsError;
const showLoader = isChatHistoryLoading && !hasError;
const showError = !isChatHistoryLoading && Boolean(hasError);
useEffect(() => {
if (!chatHistoryId) {
setIsChatHistoryLoading(false);
return;
}
if (!data) {
return;
}
setIsChatHistoryLoading(false);
}, [data, chatHistoryId]);
useEffect(() => {
if (!hasError) {
return;
}
setIsChatHistoryLoading(false);
}, [hasError]);
if (!isClientMounted || isBillingDetailsLoading) {
return (
<AIChatLayout>
<div className="relative flex grow">
<div className="absolute inset-0 z-20 flex items-center justify-center">
<Loader2Icon className="h-8 w-8 animate-spin stroke-[2.5] text-gray-400/80" />
</div>
</div>
</AIChatLayout>
);
}
return (
<AIChatLayout>
<div className="relative flex grow">
<ListChatHistory
activeChatHistoryId={chatHistoryId}
onChatHistoryClick={handleChatHistoryClick}
onDelete={handleDelete}
isPaidUser={isPaidUser}
onUpgrade={() => {
setShowUpgradeModal(true);
}}
/>
<div className="relative flex grow">
{showLoader && (
<div className="absolute inset-0 z-20 flex items-center justify-center">
<Loader2Icon className="h-8 w-8 animate-spin stroke-[2.5] text-gray-400/80" />
</div>
)}
{showError && (
<div className="absolute inset-0 z-20 flex items-center justify-center">
<ChatHistoryError error={hasError} className="mt-0" />
</div>
)}
{!showLoader && !showError && (
<AIChat
key={keyTrigger}
messages={data?.messages}
chatHistoryId={chatHistoryId}
setChatHistoryId={(id) => {
setChatHistoryId(id);
window.history.replaceState(null, '', `/ai/chat/${id}`);
queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0] === 'list-chat-history';
},
});
}}
onUpgrade={() => {
setShowUpgradeModal(true);
}}
/>
)}
</div>
</div>
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
</AIChatLayout>
);
}

View File

@@ -0,0 +1,22 @@
import { AITutorLayout } from '../AITutor/AITutorLayout';
import { CheckSubscriptionVerification } from '../Billing/CheckSubscriptionVerification';
import { Loader2Icon } from 'lucide-react';
type AIChatLayoutProps = {
children: React.ReactNode;
};
export function AIChatLayout(props: AIChatLayoutProps) {
const { children } = props;
return (
<AITutorLayout
activeTab="chat"
wrapperClassName="flex-row p-0 lg:p-0 overflow-hidden"
containerClassName="h-[calc(100vh-49px)] overflow-hidden"
>
{children}
<CheckSubscriptionVerification />
</AITutorLayout>
);
}

View File

@@ -0,0 +1,116 @@
import { EllipsisVerticalIcon, Loader2Icon, Trash2Icon } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../DropdownMenu';
import { queryClient } from '../../stores/query-client';
import { useMutation } from '@tanstack/react-query';
import { httpDelete } from '../../lib/query-http';
import { listChatHistoryOptions } from '../../queries/chat-history';
import { useState } from 'react';
import { useToast } from '../../hooks/use-toast';
type ChatHistoryActionProps = {
chatHistoryId: string;
onDelete?: () => void;
};
export function ChatHistoryAction(props: ChatHistoryActionProps) {
const { chatHistoryId, onDelete } = props;
const toast = useToast();
const [isOpen, setIsOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const { mutate: deleteChatHistory, isPending: isDeletingLoading } =
useMutation(
{
mutationFn: (chatHistoryId: string) => {
return httpDelete(`/v1-delete-chat/${chatHistoryId}`);
},
onSettled: () => {
return queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0] === 'list-chat-history';
},
});
},
onSuccess: () => {
toast.success('Chat history deleted');
setIsOpen(false);
onDelete?.();
},
onError: (error) => {
toast.error(error?.message || 'Failed to delete chat history');
},
},
queryClient,
);
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger className="rounded-lg p-2 opacity-0 group-hover/item:opacity-100 hover:bg-gray-100 focus:outline-none data-[state=open]:bg-gray-100 data-[state=open]:opacity-100">
<EllipsisVerticalIcon className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[9999]">
{!isDeleting && (
<DropdownMenuItem
className="cursor-pointer text-red-500 focus:bg-red-50 focus:text-red-500"
onSelect={(e) => {
e.preventDefault();
setIsDeleting(true);
}}
disabled={isDeletingLoading}
>
{isDeletingLoading ? (
<>
<Loader2Icon className="h-4 w-4 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2Icon className="h-4 w-4" />
Delete
</>
)}
</DropdownMenuItem>
)}
{isDeleting && (
<DropdownMenuItem
asChild
className="focus:bg-transparent"
onSelect={(e) => {
e.preventDefault();
}}
disabled={isDeletingLoading}
>
<div className="flex w-full items-center justify-between gap-1.5">
Are you sure?
<div className="flex items-center gap-2">
<button
onClick={() => {
deleteChatHistory(chatHistoryId);
setIsDeleting(false);
}}
className="cursor-pointer text-red-500 underline hover:text-red-800"
disabled={isDeletingLoading}
>
Yes
</button>
<button
onClick={() => setIsDeleting(false)}
className="cursor-pointer text-red-500 underline hover:text-red-800"
disabled={isDeletingLoading}
>
No
</button>
</div>
</div>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,28 @@
import { AlertCircleIcon } from 'lucide-react';
import { cn } from '../../lib/classname';
type ChatHistoryErrorProps = {
error: Error | null;
className?: string;
};
export function ChatHistoryError(props: ChatHistoryErrorProps) {
const { error, className } = props;
return (
<div
className={cn(
'mt-10 flex max-w-md flex-col items-center justify-center text-center',
className,
)}
>
<AlertCircleIcon className="h-8 w-8 text-red-500" />
<h3 className="mt-4 text-sm font-medium text-gray-900">
Something went wrong
</h3>
<p className="mt-0.5 text-xs text-balance text-gray-500">
{error?.message}
</p>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import type { ChatHistoryWithoutMessages } from '../../queries/chat-history';
import { ChatHistoryItem } from './ChatHistoryItem';
type ChatHistoryGroupProps = {
title: string;
histories: ChatHistoryWithoutMessages[];
activeChatHistoryId?: string;
onChatHistoryClick: (id: string) => void;
onDelete: (id: string) => void;
};
export function ChatHistoryGroup(props: ChatHistoryGroupProps) {
const {
title,
histories,
activeChatHistoryId,
onChatHistoryClick,
onDelete,
} = props;
return (
<div>
<h2 className="ml-2 text-xs text-gray-500">{title}</h2>
<ul className="mt-1 space-y-0.5">
{histories.map((chatHistory) => {
return (
<ChatHistoryItem
key={chatHistory._id}
chatHistory={chatHistory}
isActive={activeChatHistoryId === chatHistory._id}
onChatHistoryClick={onChatHistoryClick}
onDelete={() => {
onDelete?.(chatHistory._id);
}}
/>
);
})}
</ul>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { cn } from '../../lib/classname';
import type { ChatHistoryDocument } from '../../queries/chat-history';
import { ChatHistoryAction } from './ChatHistoryAction';
type ChatHistoryItemProps = {
chatHistory: Omit<ChatHistoryDocument, 'messages'>;
isActive: boolean;
onChatHistoryClick: (chatHistoryId: string) => void;
onDelete?: () => void;
};
export function ChatHistoryItem(props: ChatHistoryItemProps) {
const { chatHistory, isActive, onChatHistoryClick, onDelete } = props;
return (
<li key={chatHistory._id} className="group/item relative text-sm">
<button
className="block w-full truncate rounded-lg p-2 pr-10 text-left hover:bg-gray-100 data-[active=true]:bg-gray-100"
data-active={isActive}
onClick={() => onChatHistoryClick(chatHistory._id)}
>
{chatHistory.title}
</button>
<div className="absolute inset-y-0 right-2 flex items-center">
<ChatHistoryAction
chatHistoryId={chatHistory._id}
onDelete={onDelete}
/>
</div>
</li>
);
}

View File

@@ -0,0 +1,292 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { listChatHistoryOptions } from '../../queries/chat-history';
import { queryClient } from '../../stores/query-client';
import {
Loader2Icon,
LockIcon,
PanelLeftCloseIcon,
PanelLeftIcon,
PlusIcon,
} from 'lucide-react';
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { ListChatHistorySkeleton } from './ListChatHistorySkeleton';
import { ChatHistoryError } from './ChatHistoryError';
import { cn } from '../../lib/classname';
import { getTailwindScreenDimension } from '../../lib/is-mobile';
import { groupChatHistory } from '../../helper/grouping';
import { SearchAIChatHistory } from './SearchAIChatHistory';
import { ChatHistoryGroup } from './ChatHistoryGroup';
import { isLoggedIn } from '../../lib/jwt';
import { CheckIcon } from '../ReactIcons/CheckIcon';
type ListChatHistoryProps = {
activeChatHistoryId?: string;
onChatHistoryClick: (chatHistoryId: string | null) => void;
onDelete?: (chatHistoryId: string) => void;
isPaidUser?: boolean;
onUpgrade?: () => void;
};
export function ListChatHistory(props: ListChatHistoryProps) {
const {
activeChatHistoryId,
onChatHistoryClick,
onDelete,
isPaidUser,
onUpgrade,
} = props;
const [isOpen, setIsOpen] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [isMobile, setIsMobile] = useState(false);
useLayoutEffect(() => {
const deviceType = getTailwindScreenDimension();
const isMediumSize = ['sm', 'md'].includes(deviceType);
// Only set initial state from localStorage if not on mobile
if (!isMediumSize) {
const storedState = localStorage.getItem('chat-history-sidebar-open');
setIsOpen(storedState === null ? true : storedState === 'true');
} else {
setIsOpen(!isMediumSize);
}
setIsMobile(isMediumSize);
}, []);
// Save state to localStorage when it changes, but only if not on mobile
useEffect(() => {
if (!isMobile) {
localStorage.setItem('chat-history-sidebar-open', isOpen.toString());
}
}, [isOpen, isMobile]);
const [query, setQuery] = useState('');
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
error,
isLoading: isLoadingInfiniteQuery,
} = useInfiniteQuery(listChatHistoryOptions({ query }), queryClient);
useEffect(() => {
if (!data) {
return;
}
setIsLoading(false);
}, [data?.pages]);
const groupedChatHistory = useMemo(() => {
const allHistories = data?.pages?.flatMap((page) => page.data);
return groupChatHistory(allHistories ?? []);
}, [data?.pages]);
if (!isLoggedIn()) {
return null;
}
if (!isOpen) {
return (
<div className="absolute top-2 left-2 z-20 flex items-center gap-1">
<button
className="flex size-8 items-center justify-center rounded-lg p-1 hover:bg-gray-200"
onClick={() => {
setIsOpen(true);
}}
>
<PanelLeftIcon className="h-4.5 w-4.5" />
</button>
<button
className="flex size-8 items-center justify-center rounded-lg p-1 hover:bg-gray-200"
onClick={() => {
if (isMobile) {
setIsOpen(false);
}
onChatHistoryClick(null);
}}
>
<PlusIcon className="h-4.5 w-4.5" />
</button>
</div>
);
}
const isEmptyHistory = Object.values(groupedChatHistory ?? {}).every(
(group) => group.histories.length === 0,
);
const classNames = cn(
'flex w-[255px] shrink-0 flex-col justify-start border-r border-gray-200 bg-white p-2',
'max-md:absolute max-md:inset-0 max-md:z-20 max-md:w-full',
!isOpen && 'hidden',
);
const closeButton = (
<button
className="flex size-8 items-center justify-center rounded-lg p-1 text-gray-500 hover:bg-gray-100 hover:text-black"
onClick={() => {
setIsOpen(false);
}}
>
<PanelLeftCloseIcon className="h-4.5 w-4.5" />
</button>
);
if (!isPaidUser) {
return (
<UpgradeToProMessage
className={classNames}
closeButton={closeButton}
onUpgrade={onUpgrade}
/>
);
}
return (
<div className={classNames}>
{isLoading && <ListChatHistorySkeleton />}
{!isLoading && isError && <ChatHistoryError error={error} />}
{!isLoading && !isError && (
<>
<div>
<div className="mb-4 flex items-center justify-between">
<h1 className="font-medium text-gray-900">Chat History</h1>
{closeButton}
</div>
<button
className="flex w-full items-center justify-center gap-2 rounded-lg bg-black p-2 text-sm text-white hover:opacity-80"
onClick={() => {
if (isMobile) {
setIsOpen(false);
}
onChatHistoryClick(null);
}}
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm">New Chat</span>
</button>
<SearchAIChatHistory
onSearch={setQuery}
isLoading={isLoadingInfiniteQuery}
/>
</div>
<div className="scrollbar-track-transparent scrollbar-thin scrollbar-thumb-gray-300 -mx-2 mt-6 grow space-y-4 overflow-y-scroll px-2">
{isEmptyHistory && !isLoadingInfiniteQuery && (
<div className="flex items-center justify-center">
<p className="text-sm text-gray-500">No chat history</p>
</div>
)}
{Object.entries(groupedChatHistory ?? {}).map(([key, value]) => {
if (value.histories.length === 0) {
return null;
}
return (
<ChatHistoryGroup
key={key}
title={value.title}
histories={value.histories}
activeChatHistoryId={activeChatHistoryId}
onChatHistoryClick={(id) => {
if (isMobile) {
setIsOpen(false);
}
onChatHistoryClick(id);
}}
onDelete={(id) => {
onDelete?.(id);
}}
/>
);
})}
{hasNextPage && (
<div className="mt-4">
<button
className="flex w-full items-center justify-center gap-2 text-sm text-gray-500 hover:text-black"
onClick={() => {
fetchNextPage();
}}
disabled={isFetchingNextPage}
>
{isFetchingNextPage && (
<>
<Loader2Icon className="h-4 w-4 animate-spin" />
Loading more...
</>
)}
{!isFetchingNextPage && 'Load More'}
</button>
</div>
)}
</div>
</>
)}
</div>
);
}
type UpgradeToProMessageProps = {
className?: string;
onUpgrade?: () => void;
closeButton?: React.ReactNode;
};
export function UpgradeToProMessage(props: UpgradeToProMessageProps) {
const { className, onUpgrade, closeButton } = props;
return (
<div className={cn('relative flex flex-col', className)}>
<div className="mb-4 flex items-center justify-between">
{closeButton}
</div>
<div className="flex grow flex-col items-center justify-center px-4">
<div className="flex flex-col items-center">
<div className="mb-3 rounded-full bg-yellow-100 p-3">
<LockIcon className="size-6 text-yellow-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">
Unlock History
</h2>
<p className="mt-2 text-center text-sm text-balance text-gray-600">
Save conversations and pick up right where you left off.
</p>
</div>
<div className="my-5 w-full space-y-2">
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
<CheckIcon additionalClasses="size-4 text-green-500" />
<span className="text-sm text-gray-600">Unlimited history</span>
</div>
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
<CheckIcon additionalClasses="size-4 text-green-500" />
<span className="text-sm text-gray-600">Search old chats</span>
</div>
</div>
<button
type="button"
className="w-full cursor-pointer rounded-lg bg-yellow-400 px-4 py-2 text-sm font-medium text-black hover:bg-yellow-500"
onClick={() => {
onUpgrade?.();
}}
>
Upgrade to Pro
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
export function ListChatHistorySkeleton() {
return (
<>
<div>
<div className="mb-4 flex items-center justify-between gap-2">
<div className="h-6 w-1/2 animate-pulse rounded bg-gray-200" />
<div className="size-8 animate-pulse rounded-md bg-gray-200" />
</div>
<div className="h-9 w-full animate-pulse rounded-lg bg-gray-200" />
<div className="relative mt-2">
<div className="h-9 w-full animate-pulse rounded-lg bg-gray-200" />
</div>
</div>
<div className="scrollbar-track-transparent scrollbar-thin scrollbar-thumb-gray-300 -mx-2 mt-6 grow space-y-4 overflow-y-scroll px-2">
{['Today', 'Last 7 Days', 'Older'].map((group) => (
<div key={group}>
<div className="h-4 w-16 animate-pulse rounded bg-gray-200" />
<ul className="mt-1 space-y-0.5">
{[1, 2, 3].map((i) => (
<li
key={i}
className="h-9 animate-pulse rounded-lg bg-gray-100"
></li>
))}
</ul>
</div>
))}
</div>
</>
);
}

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { useDebounceValue } from '../../hooks/use-debounce';
import { Loader2Icon, XIcon, SearchIcon } from 'lucide-react';
import { cn } from '../../lib/classname';
type SearchAIChatHistoryProps = {
onSearch: (search: string) => void;
isLoading?: boolean;
className?: string;
inputClassName?: string;
};
export function SearchAIChatHistory(props: SearchAIChatHistoryProps) {
const { onSearch, isLoading, className, inputClassName } = props;
const [search, setSearch] = useState('');
const debouncedSearch = useDebounceValue(search, 300);
useEffect(() => {
onSearch(debouncedSearch);
}, [debouncedSearch, onSearch]);
return (
<form
className={cn('relative mt-2 flex grow items-center', className)}
onSubmit={(e) => {
e.preventDefault();
onSearch(search);
}}
>
<input
type="text"
placeholder="Search folder by name"
className={cn(
'block h-9 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 pr-7 pl-8 text-sm outline-none placeholder:text-zinc-500 focus:border-zinc-500',
inputClassName,
)}
required
minLength={3}
maxLength={255}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="absolute top-1/2 left-2.5 -translate-y-1/2">
{isLoading ? (
<Loader2Icon className="size-4 animate-spin text-gray-500" />
) : (
<SearchIcon className="size-4 text-gray-500" />
)}
</div>
{search && (
<div className="absolute inset-y-0 right-1 flex items-center">
<button
onClick={() => {
setSearch('');
}}
className="rounded-lg p-1 hover:bg-gray-100"
>
<XIcon className="size-4 text-gray-500" />
</button>
</div>
)}
</form>
);
}

View File

@@ -35,11 +35,6 @@ export function AITutorLayout(props: AITutorLayoutProps) {
'flex flex-grow flex-row lg:h-screen',
containerClassName,
)}
style={
{
'--ai-sidebar-width': '255px',
} as React.CSSProperties
}
>
<AITutorSidebar
onClose={() => setIsSidebarFloating(false)}

View File

@@ -94,7 +94,7 @@ export function AITutorSidebar(props: AITutorSidebarProps) {
<aside
className={cn(
'flex w-[var(--ai-sidebar-width)] shrink-0 flex-col border-r border-slate-200',
'flex w-[255px] shrink-0 flex-col border-r border-slate-200',
isFloating
? 'fixed top-0 bottom-0 left-0 z-50 flex border-r-0 bg-white shadow-xl'
: 'hidden lg:flex',

View File

@@ -23,6 +23,8 @@ declare global {
window.fireEvent = (props) => {
const { action, category, label, value, callback } = props;
const eventId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
if (['course', 'ai_tutor'].includes(category)) {
const url = new URL(import.meta.env.PUBLIC_API_URL);
url.pathname = '/api/_t';
@@ -30,6 +32,7 @@ window.fireEvent = (props) => {
url.searchParams.set('category', category);
url.searchParams.set('label', label ?? '');
url.searchParams.set('value', value ?? '');
url.searchParams.set('event_id', eventId);
httpPost(url.toString(), {}).catch(console.error);
}
@@ -49,6 +52,8 @@ window.fireEvent = (props) => {
event_category: category,
event_label: label,
value: value,
event_id: eventId,
source: 'client',
...(callback ? { event_callback: callback } : {}),
});
};

View File

@@ -185,6 +185,7 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) {
bodyClassName="p-4 sm:p-6 bg-white"
wrapperClassName="h-auto rounded-xl max-w-3xl w-full min-h-[540px] mx-2 sm:mx-4"
overlayClassName="items-start md:items-center"
hasCloseButton={true}
>
<div onClick={(e) => e.stopPropagation()}>
{errorContent}

View File

@@ -9,8 +9,8 @@ import {
type ResourceType,
} from '../../lib/resource-progress';
import { httpGet } from '../../lib/http';
import { ProgressNudge } from '../FrameRenderer/ProgressNudge';
import { getUrlParams } from '../../lib/browser.ts';
import { RoadmapFloatingChat } from '../FrameRenderer/RoadmapFloatingChat.tsx';
type EditorRoadmapProps = {
resourceId: string;
@@ -99,7 +99,7 @@ export function EditorRoadmap(props: EditorRoadmapProps) {
dimensions={dimensions}
resourceId={resourceId}
/>
<ProgressNudge resourceId={resourceId} resourceType={resourceType} />
<RoadmapFloatingChat roadmapId={resourceId} />
</div>
);
}

View File

@@ -4,7 +4,7 @@ svg text tspan {
text-rendering: optimizeSpeed;
}
code {
code:not(pre code) {
background: #1e1e3f;
color: #9efeff;
padding: 3px 5px;

View File

@@ -0,0 +1,670 @@
import { useQuery } from '@tanstack/react-query';
import type { JSONContent } from '@tiptap/core';
import {
BookOpen,
ChevronDown,
Loader2Icon,
MessageCirclePlus,
PauseCircleIcon,
PersonStanding,
Plus,
SendIcon,
SquareArrowOutUpRight,
Trash2,
Wand2,
X,
} from 'lucide-react';
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { useKeydown } from '../../hooks/use-keydown';
import {
roadmapAIChatRenderer,
useRoadmapAIChat,
} from '../../hooks/use-roadmap-ai-chat';
import { cn } from '../../lib/classname';
import { lockBodyScroll } from '../../lib/dom';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { slugify } from '../../lib/slugger';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { billingDetailsOptions } from '../../queries/billing';
import { chatHistoryOptions } from '../../queries/chat-history';
import { roadmapJSONOptions } from '../../queries/roadmap';
import { roadmapQuestionsOptions } from '../../queries/roadmap-questions';
import { queryClient } from '../../stores/query-client';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard';
import { RoadmapAIChatHistory } from '../RoadmapAIChatHistory/RoadmapAIChatHistory';
import { CLOSE_TOPIC_DETAIL_EVENT } from '../TopicDetail/TopicDetail';
import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal';
type ChatHeaderButtonProps = {
onClick?: () => void;
href?: string;
icon: React.ReactNode;
children?: React.ReactNode;
className?: string;
target?: string;
};
function ChatHeaderButton(props: ChatHeaderButtonProps) {
const { onClick, href, icon, children, className, target } = props;
const classNames = cn(
'flex shrink-0 items-center gap-1.5 text-xs text-gray-600 transition-colors hover:text-gray-900 min-w-8',
className,
);
if (!onClick && !href) {
return (
<span className={classNames}>
{icon}
{children && <span className="hidden sm:block">{children}</span>}
</span>
);
}
if (href) {
return (
<a
href={href}
target={target}
rel="noopener noreferrer"
className={classNames}
>
{icon}
{children && <span className="hidden sm:block">{children}</span>}
</a>
);
}
return (
<button onClick={onClick} className={classNames}>
{icon}
{children && <span className="hidden sm:block">{children}</span>}
</button>
);
}
type UpgradeMessageProps = {
onUpgradeClick?: () => void;
};
function UpgradeMessage(props: UpgradeMessageProps) {
const { onUpgradeClick } = props;
return (
<div className="border-t border-gray-200 bg-black px-3 py-3">
<div className="flex items-center gap-2.5">
<Wand2 className="h-4 w-4 flex-shrink-0 text-white" />
<div className="flex-1 text-sm">
<p className="mb-1 font-medium text-white">
You've reached your AI usage limit
</p>
<p className="text-xs text-gray-300">
Upgrade to Pro for relaxed limits and advanced features
</p>
</div>
<button
className="flex-shrink-0 rounded-md bg-white px-3 py-1.5 text-xs font-medium text-black transition-colors hover:bg-gray-100"
onClick={onUpgradeClick}
>
Upgrade to Pro
</button>
</div>
</div>
);
}
type UsageButtonProps = {
percentageUsed: number;
onUpgradeClick?: () => void;
};
function UsageButton(props: UsageButtonProps) {
const { percentageUsed, onUpgradeClick } = props;
return (
<button
onClick={onUpgradeClick}
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-all hover:bg-yellow-200"
>
<div className="hidden items-center gap-1.5 sm:flex">
<div className="h-1.5 w-6 overflow-hidden rounded-full bg-gray-200">
<div
className={cn(
'h-full transition-all duration-300',
percentageUsed >= 90
? 'bg-red-500'
: percentageUsed >= 70
? 'bg-yellow-500'
: 'bg-green-500',
)}
style={{ width: `${Math.min(percentageUsed, 100)}%` }}
/>
</div>
<span className="text-yellow-700">{percentageUsed}% used</span>
</div>
<span className="font-semibold text-yellow-800 underline underline-offset-2">
Upgrade
</span>
</button>
);
}
type RoadmapChatProps = {
roadmapId: string;
};
export function RoadmapFloatingChat(props: RoadmapChatProps) {
const { roadmapId } = props;
const [isOpen, setIsOpen] = useState(false);
const scrollareaRef = useRef<HTMLDivElement>(null);
const [inputValue, setInputValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const [isPersonalizeOpen, setIsPersonalizeOpen] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
// Fetch questions from API
const { data: questionsData } = useQuery(
roadmapQuestionsOptions(roadmapId),
queryClient,
);
// Randomly select 4 questions to display
const defaultQuestions = useMemo(() => {
if (!questionsData?.questions || questionsData.questions.length === 0) {
return [];
}
const shuffled = [...questionsData.questions].sort(
() => 0.5 - Math.random(),
);
return shuffled.slice(0, 4);
}, [questionsData]);
const { data: roadmapDetail, isLoading: isRoadmapDetailLoading } = useQuery(
roadmapJSONOptions(roadmapId),
queryClient,
);
const isAuthenticatedUser = isLoggedIn();
const { data: tokenUsage, isLoading: isTokenUsageLoading } = useQuery(
getAiCourseLimitOptions(),
queryClient,
);
const isLimitExceeded =
isAuthenticatedUser && (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const percentageUsed = Math.round(
((tokenUsage?.used || 0) / (tokenUsage?.limit || 0)) * 100,
);
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isPaidUser = userBillingDetails?.status === 'active';
const totalTopicCount = useMemo(() => {
const allowedTypes = ['topic', 'subtopic', 'todo'];
return (
roadmapDetail?.json?.nodes.filter((node) =>
allowedTypes.includes(node.type || ''),
).length ?? 0
);
}, [roadmapDetail]);
const onSelectTopic = (topicId: string, topicTitle: string) => {
// For now just scroll to bottom and close overlay
const topicSlug = slugify(topicTitle) + '@' + topicId;
window.dispatchEvent(
new CustomEvent('roadmap.node.click', {
detail: {
resourceType: 'roadmap',
resourceId: roadmapId,
topicId: topicSlug,
isCustomResource: false,
},
}),
);
// ensure chat visible
flushSync(() => {
setIsOpen(true);
});
};
const [isChatHistoryLoading, setIsChatHistoryLoading] = useState(true);
const [activeChatHistoryId, setActiveChatHistoryId] = useState<
string | undefined
>();
const { data: chatHistory } = useQuery(
chatHistoryOptions(
activeChatHistoryId,
roadmapAIChatRenderer({
roadmapId,
totalTopicCount,
onSelectTopic,
}),
),
queryClient,
);
const {
aiChatHistory,
isStreamingMessage,
streamedMessage,
showScrollToBottom,
setShowScrollToBottom,
handleChatSubmit,
handleAbort,
scrollToBottom,
clearChat,
setAiChatHistory,
} = useRoadmapAIChat({
activeChatHistoryId,
roadmapId,
totalTopicCount,
scrollareaRef,
onSelectTopic,
onChatHistoryIdChange: (chatHistoryId) => {
setActiveChatHistoryId(chatHistoryId);
},
});
useEffect(() => {
if (!chatHistory) {
return;
}
setAiChatHistory(chatHistory?.messages ?? []);
setIsChatHistoryLoading(false);
setTimeout(() => {
scrollToBottom('instant');
}, 0);
}, [chatHistory]);
useEffect(() => {
if (activeChatHistoryId) {
return;
}
setAiChatHistory([]);
setIsChatHistoryLoading(false);
}, [activeChatHistoryId, setAiChatHistory, setIsChatHistoryLoading]);
useEffect(() => {
lockBodyScroll(isOpen);
}, [isOpen]);
useKeydown('Escape', () => {
setIsOpen(false);
});
useEffect(() => {
// it means user came back to the AI chat from the topic detail
const handleCloseTopicDetail = () => {
lockBodyScroll(isOpen);
};
window.addEventListener(CLOSE_TOPIC_DETAIL_EVENT, handleCloseTopicDetail);
return () => {
window.removeEventListener(
CLOSE_TOPIC_DETAIL_EVENT,
handleCloseTopicDetail,
);
};
}, [isOpen, isPersonalizeOpen]);
function textToJSON(text: string): JSONContent {
return {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
};
}
const submitInput = () => {
if (!isLoggedIn()) {
setIsOpen(false);
showLoginPopup();
return;
}
const trimmed = inputValue.trim();
if (!trimmed) {
return;
}
const json: JSONContent = textToJSON(trimmed);
setInputValue('');
handleChatSubmit(json, isRoadmapDetailLoading);
};
const hasMessages = aiChatHistory.length > 0;
const newTabUrl = `/${roadmapId}/ai${activeChatHistoryId ? `?chatId=${activeChatHistoryId}` : ''}`;
return (
<>
{isOpen && (
<div
onClick={() => {
setIsOpen(false);
}}
className="fixed inset-0 z-50 bg-black opacity-50"
></div>
)}
{showUpgradeModal && (
<UpgradeAccountModal
onClose={() => {
setShowUpgradeModal(false);
}}
/>
)}
{isPersonalizeOpen && (
<UpdatePersonaModal
roadmapId={roadmapId}
onClose={() => {
setIsPersonalizeOpen(false);
}}
/>
)}
<div
className={cn(
'animate-fade-slide-up fixed bottom-5 left-1/2 z-91 max-h-[95vh] max-w-[968px] -translate-x-1/4 transform flex-col gap-1.5 overflow-hidden px-4 transition-all duration-300 sm:max-h-[50vh] lg:flex',
isOpen ? 'h-full w-full' : 'w-auto',
)}
>
{isOpen && (
<>
<div className="relative flex h-full w-full flex-col overflow-hidden rounded-lg bg-white shadow-lg">
{isChatHistoryLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white">
<div className="flex items-center rounded-md border border-gray-200 py-2 pr-3 pl-2">
<Loader2Icon className="size-4 animate-spin stroke-[2.5] text-gray-400" />
<span className="ml-2 text-sm text-gray-500">
Loading history..
</span>
</div>
</div>
)}
<div className="flex items-center justify-between px-3 py-2">
<div className="flex">
<ChatHeaderButton
icon={<BookOpen className="h-3.5 w-3.5" />}
className="pointer-events-none text-sm"
>
{chatHistory?.title || 'AI Tutor'}
</ChatHeaderButton>
</div>
<div className="flex gap-1.5">
{isPaidUser && activeChatHistoryId && (
<ChatHeaderButton
onClick={() => {
setActiveChatHistoryId(undefined);
inputRef.current?.focus();
}}
icon={<Plus className="h-3.5 w-3.5" />}
className="justify-center rounded-md bg-gray-200 px-2 py-1 text-xs text-black hover:bg-gray-300"
/>
)}
<RoadmapAIChatHistory
roadmapId={roadmapId}
activeChatHistoryId={activeChatHistoryId}
onChatHistoryClick={(chatHistoryId) => {
setIsChatHistoryLoading(true);
setActiveChatHistoryId(chatHistoryId);
setShowScrollToBottom(false);
}}
onDelete={(chatHistoryId) => {
if (activeChatHistoryId === chatHistoryId) {
setActiveChatHistoryId(undefined);
}
}}
onUpgrade={() => {
setShowUpgradeModal(true);
}}
/>
<ChatHeaderButton
href={newTabUrl}
target="_blank"
icon={<SquareArrowOutUpRight className="h-3.5 w-3.5" />}
className="hidden justify-center rounded-md bg-gray-200 px-1 py-1 text-gray-500 hover:bg-gray-300 sm:flex"
/>
<ChatHeaderButton
onClick={() => setIsOpen(false)}
icon={<X className="h-3.5 w-3.5" />}
className="flex items-center justify-center rounded-md bg-red-100 px-1 py-1 text-red-500 hover:bg-red-200"
/>
</div>
</div>
<div
className="flex flex-1 flex-grow flex-col overflow-y-auto px-3 py-2"
ref={scrollareaRef}
>
<div className="flex flex-col gap-2 text-sm">
<RoadmapAIChatCard
role="assistant"
jsx={
<span className="mt-[2px]">
Hey, I am your AI tutor. How can I help you today? 👋
</span>
}
isIntro
/>
{/* Show default questions only when there's no chat history */}
{aiChatHistory.length === 0 &&
defaultQuestions.length > 0 && (
<div className="mt-0.5 mb-1">
<p className="mb-2 text-xs font-normal text-gray-500">
Some questions you might have about this roadmap:
</p>
<div className="flex flex-col justify-end gap-1">
{defaultQuestions.map((question, index) => (
<button
key={`default-question-${index}`}
className="flex h-full self-start rounded-md bg-yellow-500/10 px-3 py-2 text-left text-sm text-black hover:bg-yellow-500/20"
onClick={() => {
if (!isLoggedIn()) {
setIsOpen(false);
showLoginPopup();
return;
}
if (isLimitExceeded) {
setShowUpgradeModal(true);
setIsOpen(false);
return;
}
handleChatSubmit(
textToJSON(question),
isRoadmapDetailLoading,
);
}}
>
{question}
</button>
))}
</div>
</div>
)}
{aiChatHistory.map((chat, index) => (
<Fragment key={`chat-${index}`}>
<RoadmapAIChatCard {...chat} />
</Fragment>
))}
{isStreamingMessage && !streamedMessage && (
<RoadmapAIChatCard role="assistant" html="Thinking..." />
)}
{streamedMessage && (
<RoadmapAIChatCard role="assistant" jsx={streamedMessage} />
)}
</div>
{/* Scroll to bottom button */}
{showScrollToBottom && (
<button
onClick={() => {
scrollToBottom('instant');
setShowScrollToBottom(false);
}}
className="sticky bottom-0 mx-auto mt-2 flex items-center gap-1.5 rounded-full bg-gray-900 px-3 py-1.5 text-xs text-white shadow-lg transition-all hover:bg-gray-800"
>
<ChevronDown className="h-3 w-3" />
Scroll to bottom
</button>
)}
</div>
{isLimitExceeded && (
<UpgradeMessage
onUpgradeClick={() => {
setShowUpgradeModal(true);
setIsOpen(false);
}}
/>
)}
{!isLimitExceeded && (
<>
<div className="flex flex-row justify-between border-t border-gray-200 px-3 pt-2">
<div className="flex gap-2">
<ChatHeaderButton
onClick={() => {
if (!isLoggedIn()) {
setIsOpen(false);
showLoginPopup();
return;
}
setIsPersonalizeOpen(true);
}}
icon={<PersonStanding 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"
>
Personalize
</ChatHeaderButton>
{!isPaidUser && isAuthenticatedUser && (
<UsageButton
percentageUsed={percentageUsed}
onUpgradeClick={() => {
setShowUpgradeModal(true);
setIsOpen(false);
}}
/>
)}
</div>
{hasMessages && !isPaidUser && (
<ChatHeaderButton
onClick={() => {
setInputValue('');
clearChat();
}}
icon={<Trash2 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"
>
Clear
</ChatHeaderButton>
)}
</div>
<div className="relative flex items-center text-sm">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
autoFocus
disabled={isLimitExceeded}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (isStreamingMessage) {
return;
}
submitInput();
}
}}
placeholder={
isLimitExceeded
? 'You have reached the usage limit for today..'
: 'Ask me anything about this roadmap...'
}
className={cn(
'w-full resize-none px-3 py-4 outline-none',
isLimitExceeded && 'bg-gray-100 text-gray-400',
)}
/>
<button
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-zinc-500 hover:text-black disabled:opacity-50"
disabled={isRoadmapDetailLoading || isLimitExceeded}
onClick={() => {
if (isStreamingMessage) {
handleAbort();
return;
}
submitInput();
}}
>
{isStreamingMessage ? (
<PauseCircleIcon className="h-4 w-4" />
) : (
<SendIcon className="h-4 w-4" />
)}
</button>
</div>
</>
)}
</div>
</>
)}
{!isOpen && (
<button
className={cn(
'relative mx-auto flex w-max flex-shrink-0 cursor-pointer items-center justify-center gap-2 rounded-full bg-stone-900 py-2.5 pr-8 pl-6 text-center text-white shadow-2xl transition-all duration-300 hover:scale-101 hover:bg-stone-800',
)}
onClick={() => {
setIsOpen(true);
setTimeout(() => {
scrollToBottom('instant');
setShowScrollToBottom(false);
}, 0);
}}
>
{!hasMessages ? (
<>
<Wand2 className="h-4 w-4 text-yellow-400" />
<span className="mr-1 text-sm font-semibold text-yellow-400">
AI Tutor
</span>
<span className={'hidden text-white sm:block'}>
Have a question? Type here
</span>
<span className={'block text-white sm:hidden'}>
Ask anything
</span>
</>
) : (
<>
<MessageCirclePlus className="size-5 text-yellow-400" />
<span className="mr-1 text-sm font-medium text-white">
Continue chatting..
</span>
</>
)}
</button>
)}
</div>
</>
);
}

View File

@@ -25,6 +25,7 @@ import { AICourseFooter } from './AICourseFooter';
import { ForkCourseAlert } from './ForkCourseAlert';
import { ForkCourseConfirmation } from './ForkCourseConfirmation';
import { useAuth } from '../../hooks/use-auth';
import { getPercentage } from '../../lib/number';
type AICourseContentProps = {
courseSlug?: string;
@@ -134,8 +135,9 @@ export function AICourseContent(props: AICourseContentProps) {
);
const totalDoneLessons = (course?.done || []).length;
const finishedPercentage = Math.round(
(totalDoneLessons / totalCourseLessons) * 100,
const finishedPercentage = getPercentage(
totalDoneLessons,
totalCourseLessons,
);
const modals = (
@@ -313,7 +315,7 @@ export function AICourseContent(props: AICourseContentProps) {
</span>
)}
{finishedPercentage > 0 && (
{finishedPercentage > 0 && !isLoading && (
<>
<span className="text-gray-400"></span>
<span className="font-medium text-green-600">

View File

@@ -2,6 +2,7 @@ import { type ReactNode, useRef } from 'react';
import { useOutsideClick } from '../hooks/use-outside-click';
import { useKeydown } from '../hooks/use-keydown';
import { cn } from '../lib/classname';
import { X } from 'lucide-react';
type ModalProps = {
onClose: () => void;
@@ -9,6 +10,7 @@ type ModalProps = {
overlayClassName?: string;
bodyClassName?: string;
wrapperClassName?: string;
hasCloseButton?: boolean;
};
export function Modal(props: ModalProps) {
@@ -18,6 +20,7 @@ export function Modal(props: ModalProps) {
bodyClassName,
wrapperClassName,
overlayClassName,
hasCloseButton = true,
} = props;
const popupBodyEl = useRef<HTMLDivElement>(null);
@@ -33,7 +36,7 @@ export function Modal(props: ModalProps) {
return (
<div
className={cn(
'fixed left-0 right-0 top-0 z-99 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50',
'fixed top-0 right-0 left-0 z-99 flex h-full items-center justify-center overflow-x-hidden overflow-y-auto bg-black/50',
overlayClassName,
)}
>
@@ -50,6 +53,14 @@ export function Modal(props: ModalProps) {
bodyClassName,
)}
>
{hasCloseButton && (
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-300 hover:text-gray-700"
>
<X className="h-5 w-5" />
</button>
)}
{children}
</div>
</div>

View File

@@ -0,0 +1,28 @@
import * as PopoverPrimitive from '@radix-ui/react-popover';
import * as React from 'react';
import { cn } from '../lib/classname';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-lg border border-gray-200 bg-white p-2 text-black shadow-sm outline-none',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverContent, PopoverTrigger };

View File

@@ -24,10 +24,12 @@ type AIChatActionButtonsProps = {
onTellUsAboutYourSelf: () => void;
onClearChat: () => void;
messageCount: number;
showClearChat: boolean;
};
export function AIChatActionButtons(props: AIChatActionButtonsProps) {
const { onTellUsAboutYourSelf, onClearChat, messageCount } = props;
const { onTellUsAboutYourSelf, onClearChat, messageCount, showClearChat } =
props;
return (
<div className="flex gap-2 px-4 pt-2">
@@ -36,7 +38,7 @@ export function AIChatActionButtons(props: AIChatActionButtonsProps) {
label="Tell us about your self"
onClick={onTellUsAboutYourSelf}
/>
{messageCount > 0 && (
{showClearChat && messageCount > 0 && (
<AIChatActionButton
icon={Trash2}
label="Clear chat"

View File

@@ -1,8 +1,17 @@
import './RoadmapAIChat.css';
import { useQuery } from '@tanstack/react-query';
import { roadmapJSONOptions } from '../../queries/roadmap';
import { queryClient } from '../../stores/query-client';
import type { Editor, JSONContent } from '@tiptap/core';
import {
Bot,
Frown,
HistoryIcon,
Loader2Icon,
LockIcon,
PauseCircleIcon,
SendIcon,
XIcon,
} from 'lucide-react';
import {
Fragment,
useCallback,
@@ -12,68 +21,42 @@ import {
useRef,
useState,
} from 'react';
import {
Bot,
Frown,
Loader2Icon,
LockIcon,
PauseCircleIcon,
SendIcon,
} from 'lucide-react';
import { ChatEditor } from '../ChatEditor/ChatEditor';
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
import { type AllowedAIChatRole } from '../GenerateCourse/AICourseLessonChat';
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
import type { JSONContent, Editor } from '@tiptap/core';
import { flushSync } from 'react-dom';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { readStream } from '../../lib/ai';
import { useToast } from '../../hooks/use-toast';
import { userResourceProgressOptions } from '../../queries/resource-progress';
import { ChatRoadmapRenderer } from './ChatRoadmapRenderer';
import {
renderMessage,
type MessagePartRenderer,
} from '../../lib/render-chat-message';
import { RoadmapAIChatCard } from './RoadmapAIChatCard';
import { UserProgressList } from './UserProgressList';
import { UserProgressActionList } from './UserProgressActionList';
import { RoadmapTopicList } from './RoadmapTopicList';
import { ShareResourceLink } from './ShareResourceLink';
import { RoadmapRecommendations } from './RoadmapRecommendations';
import { RoadmapAIChatHeader } from './RoadmapAIChatHeader';
import { showLoginPopup } from '../../lib/popup';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { billingDetailsOptions } from '../../queries/billing';
import { TopicDetail } from '../TopicDetail/TopicDetail';
import { slugify } from '../../lib/slugger';
import { AIChatActionButtons } from './AIChatActionButtons';
roadmapAIChatRenderer,
useRoadmapAIChat,
type RoadmapAIChatHistoryType,
} from '../../hooks/use-roadmap-ai-chat';
import { useToast } from '../../hooks/use-toast';
import { deleteUrlParam, getUrlParams } from '../../lib/browser';
import { cn } from '../../lib/classname';
import { lockBodyScroll } from '../../lib/dom';
import {
getTailwindScreenDimension,
type TailwindScreenDimensions,
} from '../../lib/is-mobile';
import { ChatPersona } from '../UserPersona/ChatPersona';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { slugify } from '../../lib/slugger';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { billingDetailsOptions } from '../../queries/billing';
import { chatHistoryOptions } from '../../queries/chat-history';
import { userResourceProgressOptions } from '../../queries/resource-progress';
import { roadmapJSONOptions } from '../../queries/roadmap';
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
import { userRoadmapPersonaOptions } from '../../queries/user-persona';
import { queryClient } from '../../stores/query-client';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { ChatEditor } from '../ChatEditor/ChatEditor';
import { TopicDetail } from '../TopicDetail/TopicDetail';
import { ChatPersona } from '../UserPersona/ChatPersona';
import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal';
import { lockBodyScroll } from '../../lib/dom';
import { AIChatActionButtons } from './AIChatActionButtons';
import { ChatRoadmapRenderer } from './ChatRoadmapRenderer';
import { RoadmapAIChatCard } from './RoadmapAIChatCard';
import { RoadmapAIChatHeader } from './RoadmapAIChatHeader';
import { TutorIntroMessage } from './TutorIntroMessage';
export type RoadmapAIChatHistoryType = {
role: AllowedAIChatRole;
isDefault?: boolean;
// these two will be used only into the backend
// for transforming the raw message into the final message
content?: string;
json?: JSONContent;
// these two will be used only into the frontend
// for rendering the message
html?: string;
jsx?: React.ReactNode;
};
export type RoadmapAIChatTab = 'chat' | 'topic';
type RoadmapAIChatProps = {
@@ -101,13 +84,10 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
null,
);
const [activeTab, setActiveTab] = useState<RoadmapAIChatTab>('chat');
const [activeChatHistoryId, setActiveChatHistoryId] = useState<
string | undefined
>();
const [aiChatHistory, setAiChatHistory] = useState<
RoadmapAIChatHistoryType[]
>([]);
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
const [streamedMessage, setStreamedMessage] =
useState<React.ReactNode | null>(null);
const [showUpdatePersonaModal, setShowUpdatePersonaModal] = useState(false);
const { data: roadmapDetail, error: roadmapDetailError } = useQuery(
@@ -146,6 +126,15 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
const roadmapContainerRef = useRef<HTMLDivElement>(null);
const totalTopicCount = useMemo(() => {
const allowedTypes = ['topic', 'subtopic', 'todo'];
return (
roadmapDetail?.json?.nodes.filter((node) =>
allowedTypes.includes(node.type || ''),
).length ?? 0
);
}, [roadmapDetail]);
useEffect(() => {
if (!roadmapDetail || !roadmapContainerRef.current) {
return;
@@ -155,54 +144,23 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
}, [roadmapDetail]);
useEffect(() => {
const params = getUrlParams();
const queryChatId = params.chatId;
if (!roadmapTreeData || !roadmapDetail || isUserPersonaLoading) {
return;
}
if (queryChatId) {
setIsChatHistoryLoading(true);
setActiveChatHistoryId(queryChatId);
deleteUrlParam('chatId');
}
setIsLoading(false);
}, [roadmapTreeData, roadmapDetail, isUserPersonaLoading]);
const abortControllerRef = useRef<AbortController | null>(null);
const handleChatSubmit = (json: JSONContent) => {
if (
!json ||
isStreamingMessage ||
!isLoggedIn() ||
isLoading ||
abortControllerRef.current
) {
return;
}
abortControllerRef.current = new AbortController();
const html = htmlFromTiptapJSON(json);
const newMessages: RoadmapAIChatHistoryType[] = [
...aiChatHistory,
{
role: 'user',
json,
html,
},
];
flushSync(() => {
setAiChatHistory(newMessages);
editorRef.current?.commands.setContent('<p></p>');
});
scrollToBottom();
completeAITutorChat(newMessages, abortControllerRef.current);
};
const scrollToBottom = useCallback(() => {
scrollareaRef.current?.scrollTo({
top: scrollareaRef.current.scrollHeight,
behavior: 'smooth',
});
}, [scrollareaRef]);
const handleSelectTopic = useCallback(
const onSelectTopic = useCallback(
(topicId: string, topicTitle: string) => {
flushSync(() => {
setSelectedTopicId(topicId);
@@ -229,169 +187,60 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
[roadmapId, deviceType],
);
const totalTopicCount = useMemo(() => {
const allowedTypes = ['topic', 'subtopic', 'todo'];
return (
roadmapDetail?.json?.nodes.filter((node) =>
allowedTypes.includes(node.type || ''),
).length ?? 0
);
}, [roadmapDetail]);
const [isChatHistoryLoading, setIsChatHistoryLoading] = useState(true);
const { data: chatHistory } = useQuery(
chatHistoryOptions(
activeChatHistoryId,
roadmapAIChatRenderer({
roadmapId,
totalTopicCount,
onSelectTopic,
}),
),
queryClient,
);
const renderer: Record<string, MessagePartRenderer> = useMemo(() => {
return {
'user-progress': () => {
return (
<UserProgressList
totalTopicCount={totalTopicCount}
roadmapId={roadmapId}
/>
);
},
'update-progress': (options) => {
return <UserProgressActionList roadmapId={roadmapId} {...options} />;
},
'roadmap-topics': (options) => {
return (
<RoadmapTopicList
roadmapId={roadmapId}
onTopicClick={(topicId, text) => {
const title = text.split(' > ').pop();
if (!title) {
return;
}
handleSelectTopic(topicId, title);
}}
{...options}
/>
);
},
'resource-progress-link': () => {
return <ShareResourceLink roadmapId={roadmapId} />;
},
'roadmap-recommendations': (options) => {
return <RoadmapRecommendations {...options} />;
},
};
}, [roadmapId, handleSelectTopic, totalTopicCount]);
const completeAITutorChat = async (
messages: RoadmapAIChatHistoryType[],
abortController?: AbortController,
) => {
try {
setIsStreamingMessage(true);
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-chat-roadmap`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
signal: abortController?.signal,
body: JSON.stringify({
roadmapId,
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) => {
if (abortController?.signal.aborted) {
return;
}
const jsx = await renderMessage(content, renderer, {
isLoading: true,
});
flushSync(() => {
setStreamedMessage(jsx);
});
scrollToBottom();
},
onStreamEnd: async (content) => {
if (abortController?.signal.aborted) {
return;
}
const jsx = await renderMessage(content, renderer, {
isLoading: false,
});
const newMessages: RoadmapAIChatHistoryType[] = [
...messages,
{
role: 'assistant',
content,
jsx,
},
];
flushSync(() => {
setStreamedMessage(null);
setIsStreamingMessage(false);
setAiChatHistory(newMessages);
});
queryClient.invalidateQueries(getAiCourseLimitOptions());
scrollToBottom();
},
});
setIsStreamingMessage(false);
abortControllerRef.current = null;
} catch (error) {
setIsStreamingMessage(false);
setStreamedMessage(null);
abortControllerRef.current = null;
if (abortController?.signal.aborted) {
return;
}
toast.error('Something went wrong');
}
};
const handleAbort = () => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
setIsStreamingMessage(false);
setStreamedMessage(null);
setAiChatHistory([...aiChatHistory].slice(0, aiChatHistory.length - 1));
};
const {
aiChatHistory,
isStreamingMessage,
streamedMessage,
abortControllerRef,
handleChatSubmit,
handleAbort,
clearChat,
scrollToBottom,
setAiChatHistory,
} = useRoadmapAIChat({
activeChatHistoryId,
roadmapId,
totalTopicCount,
scrollareaRef,
onSelectTopic,
onChatHistoryIdChange: (chatHistoryId) => {
setActiveChatHistoryId(chatHistoryId);
},
});
useEffect(() => {
scrollToBottom();
}, []);
if (!chatHistory) {
return;
}
setAiChatHistory(chatHistory?.messages ?? []);
setIsChatHistoryLoading(false);
setTimeout(() => {
scrollToBottom('instant');
}, 0);
}, [chatHistory]);
useEffect(() => {
if (activeChatHistoryId) {
return;
}
setAiChatHistory([]);
setIsChatHistoryLoading(false);
}, [activeChatHistoryId, setAiChatHistory, setIsChatHistoryLoading]);
if (roadmapDetailError) {
return (
@@ -442,7 +291,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
roadmapId={roadmapId}
nodes={roadmapDetail?.json.nodes}
edges={roadmapDetail?.json.edges}
onSelectTopic={handleSelectTopic}
onSelectTopic={onSelectTopic}
/>
{/* floating chat button */}
@@ -498,19 +347,37 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
onTabChange={(tab) => {
setActiveTab(tab);
if (tab === 'topic' && selectedTopicId && selectedTopicTitle) {
handleSelectTopic(selectedTopicId, selectedTopicTitle);
scrollToBottom();
}
}}
onCloseTopic={() => {
setSelectedTopicId(null);
setSelectedTopicTitle(null);
setActiveTab('chat');
flushSync(() => {
setActiveTab('chat');
});
scrollToBottom();
}}
onCloseChat={() => {
setIsChatMobileVisible(false);
setActiveTab('chat');
}}
selectedTopicId={selectedTopicId}
roadmapId={roadmapId}
activeChatHistoryId={activeChatHistoryId}
onChatHistoryClick={(chatHistoryId) => {
setIsChatHistoryLoading(true);
setActiveChatHistoryId(chatHistoryId);
}}
onNewChat={() => {
document.title = 'Roadmap AI Chat';
setActiveChatHistoryId(undefined);
}}
onDeleteChatHistory={(chatHistoryId) => {
if (activeChatHistoryId === chatHistoryId) {
setActiveChatHistoryId(undefined);
}
}}
/>
{activeTab === 'topic' && selectedTopicId && (
@@ -537,60 +404,77 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
{activeTab === 'chat' && (
<>
{!!chatHistory && isPaidUser && !isChatHistoryLoading && (
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 text-sm text-gray-500">
<h3 className="flex min-w-0 items-center gap-2">
<HistoryIcon className="size-4 shrink-0" />
<span className="truncate">{chatHistory.title}</span>
</h3>
<button
onClick={() => {
setActiveChatHistoryId(undefined);
}}
className="text-sm text-gray-500 hover:text-gray-700"
>
<XIcon className="size-4" />
</button>
</div>
)}
<div className="relative grow overflow-y-auto" ref={scrollareaRef}>
{isLoading && (
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-1.5 px-3 text-sm text-gray-500">
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
<span>Loading Roadmap</span>
</div>
</div>
{isLoading && <Loader />}
{isChatHistoryLoading && (
<Loader message="Loading chat history" />
)}
{shouldShowChatPersona && !isLoading && (
{shouldShowChatPersona && !isLoading && !isChatHistoryLoading && (
<ChatPersona roadmapId={roadmapId} />
)}
{!isLoading && !shouldShowChatPersona && (
<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"
jsx={
<TutorIntroMessage roadmap={roadmapDetail?.json!} />
}
isIntro
/>
{aiChatHistory.map((chat, index) => {
return (
<Fragment key={`chat-${index}`}>
<RoadmapAIChatCard {...chat} />
</Fragment>
);
})}
{isStreamingMessage && !streamedMessage && (
{!isLoading &&
!isChatHistoryLoading &&
!shouldShowChatPersona && (
<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="Thinking..."
jsx={
<TutorIntroMessage roadmap={roadmapDetail?.json!} />
}
isIntro
/>
)}
{streamedMessage && (
<RoadmapAIChatCard
role="assistant"
jsx={streamedMessage}
/>
)}
{aiChatHistory.map(
(chat: RoadmapAIChatHistoryType, index: number) => {
return (
<Fragment key={`chat-${index}`}>
<RoadmapAIChatCard {...chat} />
</Fragment>
);
},
)}
{isStreamingMessage && !streamedMessage && (
<RoadmapAIChatCard
role="assistant"
html="Thinking..."
/>
)}
{streamedMessage && (
<RoadmapAIChatCard
role="assistant"
jsx={streamedMessage}
/>
)}
</div>
</div>
</div>
</div>
)}
)}
</div>
{!isLoading && !shouldShowChatPersona && (
{!isLoading && !isChatHistoryLoading && !shouldShowChatPersona && (
<div className="flex flex-col border-t border-gray-200">
{!isLimitExceeded && (
<AIChatActionButtons
@@ -598,9 +482,8 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
setShowUpdatePersonaModal(true);
}}
messageCount={aiChatHistory.length}
onClearChat={() => {
setAiChatHistory([]);
}}
showClearChat={!isPaidUser}
onClearChat={clearChat}
/>
)}
@@ -624,7 +507,10 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
return;
}
handleChatSubmit(content);
flushSync(() => {
editorRef.current?.commands.setContent('<p></p>');
});
handleChatSubmit(content, isDataLoading);
}}
/>
@@ -670,7 +556,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
return;
}
handleChatSubmit(json);
flushSync(() => {
editorRef.current?.commands.setContent('<p></p>');
});
handleChatSubmit(json, isDataLoading);
}}
>
{isStreamingMessage ? (
@@ -706,26 +596,19 @@ function isEmptyContent(content: JSONContent) {
);
}
export function htmlFromTiptapJSON(json: JSONContent) {
const content = json.content;
type LoaderProps = {
message?: string;
};
let text = '';
for (const child of content || []) {
switch (child.type) {
case 'text':
text += child.text;
break;
case 'paragraph':
text += `<p>${htmlFromTiptapJSON(child)}</p>`;
break;
case 'variable':
const label = child?.attrs?.label || '';
text += `<span class="chat-variable">${label}</span>`;
break;
default:
break;
}
}
function Loader(props: LoaderProps) {
const { message } = props;
return text;
return (
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-1.5 px-3 text-sm text-gray-500">
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
<span>{message ?? 'Loading Roadmap'}</span>
</div>
</div>
);
}

View File

@@ -3,27 +3,14 @@ import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { billingDetailsOptions } from '../../queries/billing';
import { isLoggedIn } from '../../lib/jwt';
import { BookIcon, BotIcon, GiftIcon, XIcon } from 'lucide-react';
import { BookIcon, BotIcon, GiftIcon, PlusIcon, XIcon } from 'lucide-react';
import type { RoadmapAIChatTab } from './RoadmapAIChat';
import { useState } from 'react';
import { getPercentage } from '../../lib/number';
import { AILimitsPopup } from '../GenerateCourse/AILimitsPopup';
import { cn } from '../../lib/classname';
import { useKeydown } from '../../hooks/use-keydown';
type RoadmapAIChatHeaderProps = {
isLoading: boolean;
onLogin: () => void;
onUpgrade: () => void;
onCloseChat: () => void;
activeTab: RoadmapAIChatTab;
onTabChange: (tab: RoadmapAIChatTab) => void;
onCloseTopic: () => void;
selectedTopicId: string | null;
};
import { RoadmapAIChatHistory } from '../RoadmapAIChatHistory/RoadmapAIChatHistory';
type TabButtonProps = {
icon: React.ReactNode;
@@ -65,6 +52,26 @@ function TabButton(props: TabButtonProps) {
);
}
type RoadmapAIChatHeaderProps = {
isLoading: boolean;
onLogin: () => void;
onUpgrade: () => void;
onCloseChat: () => void;
activeTab: RoadmapAIChatTab;
onTabChange: (tab: RoadmapAIChatTab) => void;
onCloseTopic: () => void;
selectedTopicId: string | null;
roadmapId: string;
activeChatHistoryId?: string;
onChatHistoryClick: (chatHistoryId: string) => void;
onNewChat: () => void;
onDeleteChatHistory: (chatHistoryId: string) => void;
};
export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
const {
onLogin,
@@ -76,6 +83,11 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
onTabChange,
onCloseTopic,
selectedTopicId,
roadmapId,
activeChatHistoryId,
onChatHistoryClick,
onNewChat,
onDeleteChatHistory,
} = props;
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
@@ -146,15 +158,18 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
{!isDataLoading && isLoggedIn() && (
<div className="flex gap-1.5 pr-4">
{isPaidUser && (
<button
className="flex items-center gap-1 rounded-md bg-gray-200 px-2 py-1 text-xs text-black hover:bg-gray-300"
onClick={onNewChat}
>
<PlusIcon className="size-4" />
New Chat
</button>
)}
{!isPaidUser && (
<>
<button
className="hidden rounded-md bg-gray-200 px-2 py-1 text-sm hover:bg-gray-300 2xl:block"
onClick={handleCreditsClick}
>
<span className="font-medium">{usagePercentage}%</span> limit
used
</button>
<button
className="flex items-center gap-1 rounded-md bg-yellow-400 px-2 py-1 text-sm text-black hover:bg-yellow-500"
onClick={handleUpgradeClick}
@@ -162,14 +177,21 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
<GiftIcon className="size-4" />
Upgrade
</button>
<button
className="hidden items-center gap-1 rounded-md bg-gray-200 px-2 py-1 text-sm text-black hover:bg-gray-300 max-xl:flex"
onClick={onCloseChat}
>
<XIcon className="size-3.5" strokeWidth={2.5} />
</button>
</>
)}
<RoadmapAIChatHistory
roadmapId={roadmapId}
onChatHistoryClick={onChatHistoryClick}
activeChatHistoryId={activeChatHistoryId}
onDelete={onDeleteChatHistory}
onUpgrade={onUpgrade}
/>
<button
className="hidden items-center gap-1 rounded-md bg-gray-200 px-2 py-1 text-sm text-black hover:bg-gray-300 max-xl:flex"
onClick={onCloseChat}
>
<XIcon className="size-3.5" strokeWidth={2.5} />
</button>
</div>
)}
</div>

View File

@@ -67,7 +67,7 @@ export function RoadmapTopicList(props: RoadmapTopicListProps) {
return (
<div className="relative my-6 flex flex-wrap gap-1 first:mt-0 last:mb-0">
{progressItemWithText.map((item) => {
const labelParts = item.text.split(' > ');
const labelParts = item.text.split(' > ').slice(-2);
const labelPartCount = labelParts.length;
return (

View File

@@ -92,6 +92,10 @@ export function UserProgressActionList(props: UserProgressActionListProps) {
);
},
onSuccess: () => {
updateUserProgress.forEach((item) => {
renderTopicProgress(item.id, item.action);
});
return queryClient.invalidateQueries(
userResourceProgressOptions('roadmap', roadmapId),
);
@@ -173,7 +177,7 @@ export function UserProgressActionList(props: UserProgressActionListProps) {
<button
className="z-50 flex items-center gap-1 rounded-md bg-green-600 px-2 py-1 text-xs font-medium text-white hover:bg-green-700 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70"
disabled={isBulkUpdating || isLoading}
disabled={isBulkUpdating || isLoading || isBulkUpdateSuccess}
onClick={() => {
const done = updateUserProgress
.filter((item) => item.action === 'done')

View File

@@ -0,0 +1,183 @@
import { HistoryIcon, Loader2Icon } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '../Popover';
import { useEffect, useMemo, useState } from 'react';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { listChatHistoryOptions } from '../../queries/chat-history';
import { isLoggedIn } from '../../lib/jwt';
import { groupChatHistory } from '../../helper/grouping';
import { ChatHistoryGroup } from '../AIChatHistory/ChatHistoryGroup';
import { queryClient } from '../../stores/query-client';
import { SearchAIChatHistory } from '../AIChatHistory/SearchAIChatHistory';
import { billingDetailsOptions } from '../../queries/billing';
import { UpgradeToProMessage } from '../AIChatHistory/ListChatHistory';
import { showLoginPopup } from '../../lib/popup';
type RoadmapAIChatHistoryProps = {
roadmapId: string;
activeChatHistoryId?: string;
activeChatHistoryTitle?: string;
onChatHistoryClick: (id: string) => void;
onDelete?: (id: string) => void;
onUpgrade?: () => void;
};
export function RoadmapAIChatHistory(props: RoadmapAIChatHistoryProps) {
const {
roadmapId,
activeChatHistoryId,
activeChatHistoryTitle,
onChatHistoryClick,
onDelete,
onUpgrade,
} = props;
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [query, setQuery] = useState('');
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isPaidUser = userBillingDetails?.status === 'active';
const {
data: chatHistory,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
isLoading: isLoadingInfiniteQuery,
} = useInfiniteQuery(
{
...listChatHistoryOptions({
roadmapId,
query,
}),
enabled: !!roadmapId && isLoggedIn() && isOpen && isPaidUser,
},
queryClient,
);
// no initial spinner if not paid user
// because we won't fetch the data
useEffect(() => {
if (!isPaidUser) {
setIsLoading(false);
}
}, [isPaidUser]);
useEffect(() => {
if (!chatHistory || isBillingDetailsLoading) {
return;
}
setIsLoading(false);
}, [chatHistory, isBillingDetailsLoading]);
const groupedChatHistory = useMemo(() => {
const allHistories = chatHistory?.pages?.flatMap((page) => page.data);
return groupChatHistory(allHistories ?? []);
}, [chatHistory?.pages]);
const isEmptyHistory = Object.values(groupedChatHistory ?? {}).every(
(group) => group.histories.length === 0,
);
return (
<Popover
open={isOpen}
onOpenChange={(open) => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsOpen(open);
}}
>
<PopoverTrigger className="flex items-center justify-center gap-2 rounded-md bg-gray-200 px-3 py-1.5 text-xs text-gray-900 hover:bg-gray-300 hover:text-black">
<HistoryIcon className="size-3.5" />
{activeChatHistoryTitle || 'Chat History'}
</PopoverTrigger>
<PopoverContent
className="z-[999] flex max-h-[400px] w-80 flex-col overflow-hidden p-0 shadow-lg"
align="end"
sideOffset={4}
>
{isLoading && (
<div className="flex items-center justify-center py-10">
<Loader2Icon className="size-6 animate-spin stroke-[2.5] text-gray-400" />
</div>
)}
{!isLoading && !isPaidUser && (
<UpgradeToProMessage
className="mt-0 px-10 py-10"
onUpgrade={() => {
setIsOpen(false);
onUpgrade?.();
}}
/>
)}
{!isLoading && isPaidUser && (
<>
<SearchAIChatHistory
onSearch={setQuery}
isLoading={isLoadingInfiniteQuery}
className="mt-0"
inputClassName="border-x-0 border-t-0 border-b border-b-gray-200 rounded-none focus:border-b-gray-200"
/>
<div className="scrollbar-track-transparent scrollbar-thin scrollbar-thumb-gray-300 grow space-y-4 overflow-y-auto p-2 pt-4">
{isEmptyHistory && (
<div className="flex items-center justify-center py-10">
<p className="text-sm text-gray-500">No chat history</p>
</div>
)}
{Object.entries(groupedChatHistory ?? {}).map(([key, value]) => {
if (value.histories.length === 0) {
return null;
}
return (
<ChatHistoryGroup
key={key}
title={value.title}
histories={value.histories}
activeChatHistoryId={activeChatHistoryId}
onChatHistoryClick={(id) => {
setIsOpen(false);
onChatHistoryClick(id);
}}
onDelete={(id) => {
setIsOpen(false);
onDelete?.(id);
}}
/>
);
})}
{hasNextPage && (
<div className="mt-4">
<button
className="flex w-full items-center justify-center gap-2 text-sm text-gray-500 hover:text-black"
onClick={() => {
fetchNextPage();
}}
disabled={isFetchingNextPage}
>
{isFetchingNextPage && (
<>
<Loader2Icon className="h-4 w-4 animate-spin" />
Loading more...
</>
)}
{!isFetchingNextPage && 'Load More'}
</button>
</div>
)}
</div>
</>
)}
</PopoverContent>
</Popover>
);
}

View File

@@ -157,7 +157,7 @@ const hasProjects = projectCount > 0;
<TabLink
url={`/${roadmapId}/ai`}
icon={Bot}
text='AI Mentor'
text='AI Tutor'
mobileText='AI'
isActive={false}
badgeText='New'

View File

@@ -54,6 +54,8 @@ type PaidResourceType = {
const paidResourcesCache: Record<string, PaidResourceType[]> = {};
export const CLOSE_TOPIC_DETAIL_EVENT = 'close-topic-detail';
export const defaultChatHistory: AIChatHistoryType[] = [
{
role: 'assistant',
@@ -158,10 +160,15 @@ export function TopicDetail(props: TopicDetailProps) {
const handleClose = () => {
onClose?.();
setIsActive(false);
setIsContributing(false);
setShowUpgradeModal(false);
setAiChatHistory(defaultChatHistory);
setActiveTab('content');
setShowSubjectSearchModal(false);
lockBodyScroll(false);
window.dispatchEvent(new Event(CLOSE_TOPIC_DETAIL_EVENT));
};
// Close the topic detail when user clicks outside the topic detail
@@ -372,10 +379,9 @@ export function TopicDetail(props: TopicDetailProps) {
useEffect(() => {
if (isActive) {
lockBodyScroll(true);
topicRef?.current?.focus();
}
lockBodyScroll(isActive);
}, [isActive]);
if (!isActive) {
@@ -393,6 +399,10 @@ export function TopicDetail(props: TopicDetailProps) {
const shouldShowAiTab = !isCustomResource && resourceType === 'roadmap';
const hasDataCampResources = paidResources.some((resource) =>
resource.title.toLowerCase().includes('datacamp'),
);
return (
<div className={cn('relative z-92', wrapperClassName)}>
<div
@@ -542,6 +552,29 @@ export function TopicDetail(props: TopicDetailProps) {
</>
)}
{resourceId === 'ai-data-scientist' &&
hasDataCampResources && (
<div className="mt-5 rounded-md bg-yellow-100 px-4 py-3 text-sm text-gray-600">
<p className="text-balance">
Follow the resources listed on the roadmap or check
out the premium courses by DataCamp listed below.
</p>
<p className="mt-3 text-balance">
They also have an{' '}
<a
href="https://datacamp.pxf.io/POk5PY"
className="font-medium text-blue-600 underline hover:text-blue-800"
target="_blank"
>
Associate Data Scientist in Python
</a>{' '}
track that covers all the key data scientist skills in
one place.
</p>
</div>
)}
{links.length > 0 && (
<>
<ResourceListSeparator
@@ -660,8 +693,7 @@ export function TopicDetail(props: TopicDetailProps) {
id="close-topic"
className="absolute top-2.5 right-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
onClick={() => {
setIsActive(false);
setIsContributing(false);
handleClose();
}}
>
<X className="h-5 w-5" />

View File

@@ -7,6 +7,8 @@ import { Modal } from '../Modal';
import { UserPersonaForm, type UserPersonaFormData } from './UserPersonaForm';
import { httpPost } from '../../lib/query-http';
import { useToast } from '../../hooks/use-toast';
import { Spinner } from '../ReactIcons/Spinner';
import { X } from 'lucide-react';
type UpdatePersonaModalProps = {
roadmapId: string;
@@ -21,7 +23,7 @@ export function UpdatePersonaModal(props: UpdatePersonaModalProps) {
roadmapJSONOptions(roadmapId),
queryClient,
);
const { data: userPersona } = useQuery(
const { data: userPersona, isLoading: isLoadingUserPersona } = useQuery(
userRoadmapPersonaOptions(roadmapId),
queryClient,
);
@@ -58,6 +60,20 @@ export function UpdatePersonaModal(props: UpdatePersonaModalProps) {
wrapperClassName="max-w-[450px]"
bodyClassName="p-4"
>
<button
onClick={onClose}
className="absolute top-2.5 right-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
>
<X className="h-5 w-5" />
</button>
{isLoadingUserPersona && (
<div className="absolute inset-0 z-50 flex h-full flex-row items-center justify-center gap-3 bg-white">
<Spinner isDualRing={false} className="h-4 w-4" />
<p className="text-base text-gray-500">Loading...</p>
</div>
)}
<div className="mb-4 text-left">
<h2 className="text-lg font-semibold">Tell us more about yourself</h2>
<p className="mt-1 text-sm text-balance text-gray-500">
@@ -67,9 +83,15 @@ export function UpdatePersonaModal(props: UpdatePersonaModalProps) {
</div>
<UserPersonaForm
key={userPersona ? 'loaded' : 'loading'}
className="space-y-4"
roadmapTitle={roadmapTitle}
defaultValues={userPersona ?? undefined}
defaultValues={{
expertise: userPersona?.expertise ?? '',
goal: userPersona?.goal ?? '',
commit: userPersona?.commit ?? '',
about: userPersona?.about ?? '',
}}
onSubmit={(data) => {
const trimmedGoal = data?.goal?.trim();
if (!trimmedGoal) {

View File

@@ -1,9 +1,3 @@
# Machine Learning
Machine learning is a field of artificial intelligence that uses statistical techniques to give computer systems the ability to "learn" (e.g., progressively improve performance on a specific task) from data, without being explicitly programmed. The name machine learning was coined in 1959 by Arthur Samuel. Evolved from the study of pattern recognition and computational learning theory in artificial intelligence, machine learning explores the study and construction of algorithms that can learn from and make predictions on data such algorithms overcome following strictly static program instructions by making data-driven predictions or decisions, through building a model from sample inputs. Machine learning is employed in a range of computing tasks where designing and programming explicit algorithms with good performance is difficult or infeasible; example applications include email filtering, detection of network intruders, and computer vision.
Learn more from the following resources:
- [@article@Advantages and Disadvantages of AI](https://medium.com/@laners.org/advantages-and-disadvantages-of-artificial-intelligence-cd6e42819b20)
- [@article@Reinforcement Learning 101](https://medium.com/towards-data-science/reinforcement-learning-101-e24b50e1d292)
- [@article@Understanding AUC-ROC Curve](https://medium.com/towards-data-science/understanding-auc-roc-curve-68b2303cc9c5)
Machine learning is a field of artificial intelligence that uses statistical techniques to give computer systems the ability to "learn" (e.g., progressively improve performance on a specific task) from data, without being explicitly programmed. The name machine learning was coined in 1959 by Arthur Samuel. Evolved from the study of pattern recognition and computational learning theory in artificial intelligence, machine learning explores the study and construction of algorithms that can learn from and make predictions on data such algorithms overcome following strictly static program instructions by making data-driven predictions or decisions, through building a model from sample inputs. Machine learning is employed in a range of computing tasks where designing and programming explicit algorithms with good performance is difficult or infeasible; example applications include email filtering, detection of network intruders, and computer vision.

View File

@@ -1,6 +1,3 @@
# Mathematics
Mathematics is the foundation of AI and Data Science. It is essential to have a good understanding of mathematics to excel in these fields.
- [Mathematics for Machine Learning](https://imp.i384100.net/baqMYv)
- [Algebra and Differential Calculus](https://imp.i384100.net/LX5M7M)

View File

@@ -1,5 +1,3 @@
# Statistics
Statistics is the science of collecting, analyzing, interpreting, presenting, and organizing data. It is a branch of mathematics that deals with the collection, analysis, interpretation, presentation, and organization of data. It is used in a wide range of fields, including science, engineering, medicine, and social science. Statistics is used to make informed decisions, to predict future events, and to test hypotheses. It is also used to summarize data, to describe relationships between variables, and to make inferences about populations based on samples.
Learn more from the resources given on the roadmap.
Statistics is the science of collecting, analyzing, interpreting, presenting, and organizing data. It is a branch of mathematics that deals with the collection, analysis, interpretation, presentation, and organization of data. It is used in a wide range of fields, including science, engineering, medicine, and social science. Statistics is used to make informed decisions, to predict future events, and to test hypotheses. It is also used to summarize data, to describe relationships between variables, and to make inferences about populations based on samples.

View File

@@ -1,8 +1,8 @@
# Static Library
Static libraries in iOS development are collections of compiled code that are linked directly into an app's executable at build time. They contain object code that becomes part of the final application binary, increasing its size but potentially improving load time performance. Static libraries are typically distributed as .a files, often accompanied by header files that define their public interfaces. When using static libraries, the entire library code is included in the app, even if only a portion is used, which can lead to larger app sizes. However, this approach ensures that all necessary code is available within the app, eliminating runtime dependencies. Static libraries are particularly useful for distributing closed-source code or when aiming to minimize runtime overhead. They offer simplicity in distribution and version management but may require recompilation of the entire app when the library is updated. In iOS development, static libraries are gradually being replaced by more flexible options like dynamic frameworks and XCFrameworks, especially for larger or frequently updated libraries.
Static libraries in iOS development are collections of compiled code that are linked directly into an app's executable at build time. They contain object code that becomes part of the final application binary, increasing its size but potentially improving load time performance. Static libraries are typically distributed as .a files, often accompanied by header files that define their public interfaces. Using static libraries ensures that all necessary code is available within the app, eliminating runtime dependencies. Static libraries are particularly useful for distributing closed-source code or when aiming to minimize runtime overhead. They offer simplicity in distribution and version management but may require recompilation of the entire app when the library is updated. In iOS development, static libraries are gradually being replaced by more flexible options like dynamic frameworks and XCFrameworks, especially for larger or frequently updated libraries.
Learn more from the following resources:
- [@article@Static Library in iOS](https://swiftpublished.com/article/static-library-in-ios-part1)
- [@video@ How to Create an XCFramework - Frameworks and Static Libraries ](https://www.youtube.com/watch?v=40EmwraG4-k)
- [@video@ How to Create an XCFramework - Frameworks and Static Libraries ](https://www.youtube.com/watch?v=40EmwraG4-k)

View File

@@ -6,4 +6,9 @@ Linux serves as a prevalent OS choice for networking due to its robust, customiz
- ARP: As per its name, it provides address resolution, translating IP addresses into MAC (Media Access Control) addresses, facilitating more direct network communication.
- RARP: It is the Reverse Address Resolution Protocol, working in the opposite way to ARP. It converts MAC addresses into IP addresses, which is useful in scenarios when a computer knows its MAC address but needs to find out its IP address.
Knowledge of these components is indispensable in diagnosing and managing networking issues in Linux.
Knowledge of these components is indispensable in diagnosing and managing networking issues in Linux.
Visit the following resources to learn more:
- [@video@ARP Explained - Address Resolution Protocol](https://www.youtube.com/watch?v=cn8Zxh9bPio)
- [@video@What is Ethernet?](https://www.youtube.com/watch?v=HLziLmaYsO0)

View File

@@ -1,4 +1,4 @@
# gPRC
# gRPC
gRPC is a platform agnostic serialization protocol that is used to communicate between services. Designed by Google in 2015, it is a modern alternative to REST APIs. It is a binary protocol that uses HTTP/2 as a transport layer. It is a high performance, open source, general-purpose RPC framework that puts mobile and HTTP/2 first.

42
src/helper/grouping.ts Normal file
View File

@@ -0,0 +1,42 @@
import { DateTime } from 'luxon';
import type { ChatHistoryWithoutMessages } from '../queries/chat-history';
export function groupChatHistory(chatHistories: ChatHistoryWithoutMessages[]) {
const today = DateTime.now().startOf('day');
return chatHistories?.reduce(
(acc, chatHistory) => {
const updatedAt = DateTime.fromJSDate(
new Date(chatHistory.updatedAt),
).startOf('day');
const diffInDays = Math.abs(updatedAt.diff(today, 'days').days);
if (diffInDays === 0) {
acc.today.histories.push(chatHistory);
} else if (diffInDays <= 7) {
acc.last7Days.histories.push(chatHistory);
} else {
acc.older.histories.push(chatHistory);
}
return acc;
},
{
today: {
title: 'Today',
histories: [],
},
last7Days: {
title: 'Last 7 Days',
histories: [],
},
older: {
title: 'Older',
histories: [],
},
} as Record<
string,
{ title: string; histories: ChatHistoryWithoutMessages[] }
>,
);
}

View File

@@ -0,0 +1,337 @@
import { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import type { JSONContent } from '@tiptap/core';
import { flushSync } from 'react-dom';
import { removeAuthToken } from '../lib/jwt';
import { readStream } from '../lib/ai';
import { useToast } from './use-toast';
import { getAiCourseLimitOptions } from '../queries/ai-course';
import { queryClient } from '../stores/query-client';
import {
renderMessage,
type MessagePartRenderer,
} from '../lib/render-chat-message';
import { UserProgressList } from '../components/RoadmapAIChat/UserProgressList';
import { UserProgressActionList } from '../components/RoadmapAIChat/UserProgressActionList';
import { RoadmapTopicList } from '../components/RoadmapAIChat/RoadmapTopicList';
import { ShareResourceLink } from '../components/RoadmapAIChat/ShareResourceLink';
import { RoadmapRecommendations } from '../components/RoadmapAIChat/RoadmapRecommendations';
import type { AllowedAIChatRole } from '../components/GenerateCourse/AICourseLessonChat';
import { readChatStream } from '../lib/chat';
type RoadmapAIChatRendererOptions = {
totalTopicCount: number;
roadmapId: string;
onSelectTopic: (topicId: string, topicTitle: string) => void;
};
export function roadmapAIChatRenderer(
options: RoadmapAIChatRendererOptions,
): Record<string, MessagePartRenderer> {
const { totalTopicCount, roadmapId, onSelectTopic } = options;
return {
'user-progress': () => (
<UserProgressList
totalTopicCount={totalTopicCount}
roadmapId={roadmapId}
/>
),
'update-progress': (opts) => (
<UserProgressActionList roadmapId={roadmapId} {...opts} />
),
'roadmap-topics': (opts) => (
<RoadmapTopicList
roadmapId={roadmapId}
onTopicClick={(topicId, text) => {
const title = text.split(' > ').pop();
if (!title) {
return;
}
onSelectTopic(topicId, title);
}}
{...opts}
/>
),
'resource-progress-link': () => <ShareResourceLink roadmapId={roadmapId} />,
'roadmap-recommendations': (opts) => <RoadmapRecommendations {...opts} />,
};
}
export type RoadmapAIChatHistoryType = {
role: AllowedAIChatRole;
isDefault?: boolean;
content?: string;
json?: JSONContent;
html?: string;
jsx?: React.ReactNode;
};
type Options = {
activeChatHistoryId?: string;
roadmapId: string;
totalTopicCount: number;
scrollareaRef: React.RefObject<HTMLDivElement | null>;
onSelectTopic: (topicId: string, topicTitle: string) => void;
onChatHistoryIdChange?: (chatHistoryId: string) => void;
};
export function useRoadmapAIChat(options: Options) {
const {
activeChatHistoryId,
roadmapId,
totalTopicCount,
scrollareaRef,
onSelectTopic,
onChatHistoryIdChange,
} = options;
const toast = useToast();
const [aiChatHistory, setAiChatHistory] = useState<
RoadmapAIChatHistoryType[]
>([]);
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
const [streamedMessage, setStreamedMessage] =
useState<React.ReactNode | null>(null);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const scrollToBottom = useCallback(
(behavior: 'smooth' | 'instant' = 'smooth') => {
scrollareaRef.current?.scrollTo({
top: scrollareaRef.current.scrollHeight,
behavior,
});
},
[scrollareaRef],
);
// Check if user has scrolled away from bottom
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 && aiChatHistory.length > 0);
}, [aiChatHistory.length]);
useEffect(() => {
const scrollArea = scrollareaRef.current;
if (!scrollArea) {
return;
}
scrollArea.addEventListener('scroll', checkScrollPosition);
return () => scrollArea.removeEventListener('scroll', checkScrollPosition);
}, [checkScrollPosition]);
// When user is already at the bottom and there is new message
// being streamed, we keep scrolling to bottom to show the new message
// unless user has scrolled up at which point we stop scrolling to bottom
useEffect(() => {
if (isStreamingMessage || streamedMessage) {
const scrollArea = scrollareaRef.current;
if (!scrollArea) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = scrollArea;
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 100;
if (isNearBottom) {
scrollToBottom('instant');
setShowScrollToBottom(false);
}
}
}, [isStreamingMessage, streamedMessage, scrollToBottom]);
const renderer: Record<string, MessagePartRenderer> = useMemo(
() => roadmapAIChatRenderer({ roadmapId, totalTopicCount, onSelectTopic }),
[roadmapId, onSelectTopic, totalTopicCount],
);
const completeAITutorChat = async (
messages: RoadmapAIChatHistoryType[],
abortController?: AbortController,
) => {
try {
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-chat-roadmap`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
signal: abortController?.signal,
body: JSON.stringify({
roadmapId,
messages,
...(activeChatHistoryId
? { chatHistoryId: activeChatHistoryId }
: {}),
}),
},
);
if (!response.ok) {
const data = await response.json();
toast.error(data?.message || 'Something went wrong');
setAiChatHistory(messages.slice(0, -1));
setIsStreamingMessage(false);
if (data.status === 401) {
removeAuthToken();
window.location.reload();
}
queryClient.invalidateQueries(getAiCourseLimitOptions());
return;
}
const stream = response.body;
if (!stream) {
setIsStreamingMessage(false);
toast.error('Something went wrong');
return;
}
await readChatStream(stream, {
onMessage: async (content) => {
if (abortController?.signal.aborted) {
return;
}
const jsx = await renderMessage(content, renderer, {
isLoading: true,
});
flushSync(() => {
setStreamedMessage(jsx);
});
},
onMessageEnd: async (content) => {
if (abortController?.signal.aborted) {
return;
}
const jsx = await renderMessage(content, renderer, {
isLoading: false,
});
const newMessages = [
...messages,
{ role: 'assistant' as AllowedAIChatRole, content, jsx },
];
flushSync(() => {
setStreamedMessage(null);
setIsStreamingMessage(false);
setAiChatHistory(newMessages);
});
queryClient.invalidateQueries(getAiCourseLimitOptions());
queryClient.invalidateQueries({
predicate: (query) => {
return (
query.queryKey[0] === 'list-chat-history' &&
(query.queryKey[1] as { roadmapId: string })?.roadmapId ===
roadmapId
);
},
});
},
onDetails: (details) => {
const detailsJson = JSON.parse(details);
const chatHistoryId = detailsJson?.chatHistoryId;
if (!chatHistoryId) {
return;
}
onChatHistoryIdChange?.(chatHistoryId);
},
});
setIsStreamingMessage(false);
abortControllerRef.current = null;
} catch (error) {
setIsStreamingMessage(false);
setStreamedMessage(null);
abortControllerRef.current = null;
if (!abortController?.signal.aborted) {
toast.error('Something went wrong');
}
}
};
const handleChatSubmit = useCallback(
(json: JSONContent, isLoading: boolean) => {
if (
!json ||
isStreamingMessage ||
isLoading ||
abortControllerRef.current
) {
return;
}
abortControllerRef.current = new AbortController();
const html = htmlFromTiptapJSON(json);
const newMessages = [
...aiChatHistory,
{ role: 'user' as AllowedAIChatRole, json, html },
];
setIsStreamingMessage(true);
flushSync(() => setAiChatHistory(newMessages));
scrollToBottom('instant');
completeAITutorChat(newMessages, abortControllerRef.current);
},
[aiChatHistory, isStreamingMessage, scrollToBottom],
);
const handleAbort = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
setIsStreamingMessage(false);
setStreamedMessage(null);
setAiChatHistory(aiChatHistory.slice(0, -1));
}, [aiChatHistory]);
const clearChat = useCallback(() => {
setAiChatHistory([]);
setStreamedMessage(null);
setIsStreamingMessage(false);
scrollToBottom('instant');
setShowScrollToBottom(false);
}, []);
return {
aiChatHistory,
isStreamingMessage,
streamedMessage,
showScrollToBottom,
setShowScrollToBottom,
abortControllerRef,
handleChatSubmit,
handleAbort,
clearChat,
scrollToBottom,
setAiChatHistory,
};
}
export function htmlFromTiptapJSON(json: JSONContent): string {
const content = json.content;
let text = '';
for (const child of content || []) {
switch (child.type) {
case 'text':
text += child.text;
break;
case 'paragraph':
text += `<p>${htmlFromTiptapJSON(child)}</p>`;
break;
case 'variable':
const label = child?.attrs?.label || '';
text += `<span class="chat-variable">${label}</span>`;
break;
}
}
return text;
}

94
src/lib/chat.ts Normal file
View File

@@ -0,0 +1,94 @@
export const CHAT_RESPONSE_PREFIX = {
message: '0',
details: 'd',
} as const;
const NEWLINE = '\n'.charCodeAt(0);
function concatChunks(chunks: Uint8Array[], totalLength: number) {
const concatenatedChunks = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
concatenatedChunks.set(chunk, offset);
offset += chunk.length;
}
chunks.length = 0;
return concatenatedChunks;
}
export async function readChatStream(
stream: ReadableStream<Uint8Array>,
{
onMessage,
onMessageEnd,
onDetails,
}: {
onMessage?: (message: string) => Promise<void>;
onMessageEnd?: (message: string) => Promise<void>;
onDetails?: (details: string) => Promise<void> | void;
},
) {
const reader = stream.getReader();
const decoder = new TextDecoder('utf-8');
const chunks: Uint8Array[] = [];
let totalLength = 0;
let result = '';
while (true) {
const { value } = await reader.read();
if (value) {
chunks.push(value);
totalLength += value.length;
if (value[value.length - 1] !== NEWLINE) {
// if the last character is not a new line, we need to wait for the next chunk
continue;
}
}
if (chunks.length === 0) {
// end of stream
break;
}
const concatenatedChunks = concatChunks(chunks, totalLength);
totalLength = 0;
const streamParts = decoder
.decode(concatenatedChunks, { stream: true })
.split('\n')
.filter((line) => line !== '')
.map((line) => {
const separatorIndex = line.indexOf(':');
if (separatorIndex === -1) {
throw new Error('Invalid line: ' + line + '. No separator found.');
}
const prefix = line.slice(0, separatorIndex);
const content = line.slice(separatorIndex + 1);
switch (prefix) {
case CHAT_RESPONSE_PREFIX.message:
return { type: 'message', content: JSON.parse(content) };
case CHAT_RESPONSE_PREFIX.details:
return { type: 'details', content };
default:
throw new Error('Invalid prefix: ' + prefix);
}
});
for (const part of streamParts) {
if (part.type === 'message') {
result += part.content;
await onMessage?.(result);
} else if (part.type === 'details') {
await onDetails?.(part.content);
}
}
}
await onMessageEnd?.(result);
reader.releaseLock();
}

View File

@@ -9,7 +9,9 @@ export function replaceChildren(parentNode: Element, newChild: Element) {
export function lockBodyScroll(shouldLock: boolean) {
const isClient = document && 'body' in document;
if (!isClient) return;
if (!isClient) {
return;
}
if (shouldLock) {
document.body.classList.add('overflow-hidden');

View File

@@ -13,7 +13,24 @@ const __dirname = path.dirname(__filename);
// hack to make it work. TODO: Fix
const projectRoot = path.resolve(__dirname, '../..').replace(/dist$/, '');
export async function fetchRoadmapJson(roadmapId: string) {
type RoadmapJson = {
_id: string;
title: string;
description: string;
slug: string;
nodes: {
type: 'topic' | 'subtopic' | 'paragraph';
data: { label: string };
}[];
edges: unknown[];
draft: boolean;
createdAt: string;
updatedAt: string;
};
export async function fetchRoadmapJson(
roadmapId: string,
): Promise<RoadmapJson> {
const response = await fetch(
`https://roadmap.sh/api/v1-official-roadmap/${roadmapId}`,
);

View File

@@ -1,7 +1,15 @@
---
import { type RoadmapFrontmatter, getRoadmapIds } from '../../lib/roadmap';
import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification';
import { RoadmapAIChat } from '../../components/RoadmapAIChat/RoadmapAIChat';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
import { AITutorLayout } from '../../components/AITutor/AITutorLayout';
import { getRoadmapById, getRoadmapIds } from '../../lib/roadmap';
export const prerender = true;
type Props = {
roadmapId: string;
};
export const prerender = false;
export async function getStaticPaths() {
const roadmapIds = await getRoadmapIds();
@@ -11,19 +19,25 @@ export async function getStaticPaths() {
}));
}
interface Params extends Record<string, string | undefined> {
roadmapId: string;
}
const { roadmapId } = Astro.params as Props;
const { roadmapId } = Astro.params as Params;
const roadmapFile = await import(
`../../data/roadmaps/${roadmapId}/${roadmapId}.md`
);
const roadmapDetail = await getRoadmapById(roadmapId);
const roadmapData = roadmapFile.frontmatter as RoadmapFrontmatter;
if (roadmapData.renderer !== 'editor') {
return Astro.rewrite(`/404`);
}
return Astro.rewrite(`/ai/chat/${roadmapId}`);
const canonicalUrl = `https://roadmap.sh/${roadmapId}/ai`;
const roadmapBriefTitle = roadmapDetail.frontmatter.briefTitle;
---
<SkeletonLayout
title={`${roadmapBriefTitle} AI Mentor`}
description=`Learn anything ${roadmapBriefTitle} with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.`
canonicalUrl={canonicalUrl}
>
<AITutorLayout
activeTab='chat'
wrapperClassName='flex-row p-0 lg:p-0 overflow-hidden'
client:load
>
<RoadmapAIChat roadmapId={roadmapId} client:load />
<CheckSubscriptionVerification client:load />
</AITutorLayout>
</SkeletonLayout>

View File

@@ -0,0 +1,18 @@
---
import SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
import { AIChatHistory } from '../../../components/AIChatHistory/AIChatHistory';
type Props = {
chatId: string;
};
const { chatId } = Astro.params as Props;
---
<SkeletonLayout
title='AI Chat'
noIndex={true}
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
>
<AIChatHistory client:load chatHistoryId={chatId} />
</SkeletonLayout>

View File

@@ -1,33 +0,0 @@
---
import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification';
import { RoadmapAIChat } from '../../../components/RoadmapAIChat/RoadmapAIChat';
import SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
import { AITutorLayout } from '../../../components/AITutor/AITutorLayout';
import { getRoadmapById } from '../../../lib/roadmap';
type Props = {
roadmapId: string;
};
const { roadmapId } = Astro.params as Props;
const roadmapDetail = await getRoadmapById(roadmapId);
const canonicalUrl = `https://roadmap.sh/${roadmapId}/ai`;
const roadmapBriefTitle = roadmapDetail.frontmatter.briefTitle;
---
<SkeletonLayout
title={`${roadmapBriefTitle} AI Mentor`}
description=`Learn anything ${roadmapBriefTitle} with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.`
canonicalUrl={canonicalUrl}
>
<AITutorLayout
activeTab='chat'
wrapperClassName='flex-row p-0 lg:p-0 overflow-hidden'
client:load
>
<RoadmapAIChat roadmapId={roadmapId} client:load />
<CheckSubscriptionVerification client:load />
</AITutorLayout>
</SkeletonLayout>

View File

@@ -1,8 +1,7 @@
---
import SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
import { AIChat } from '../../../components/AIChat/AIChat';
import { AITutorLayout } from '../../../components/AITutor/AITutorLayout';
import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification';
import { AIChatLayout } from '../../../components/AIChatHistory/AIChatLayout';
import { AIChatHistory } from '../../../components/AIChatHistory/AIChatHistory';
---
<SkeletonLayout
@@ -10,13 +9,5 @@ import { CheckSubscriptionVerification } from '../../../components/Billing/Check
noIndex={true}
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
>
<AITutorLayout
activeTab='chat'
wrapperClassName='flex-row p-0 lg:p-0 overflow-hidden'
client:load
containerClassName='h-[calc(100vh-49px)] overflow-hidden'
>
<AIChat client:load />
<CheckSubscriptionVerification client:load />
</AITutorLayout>
<AIChatHistory client:load />
</SkeletonLayout>

View File

@@ -48,7 +48,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
Get AI-Powered Learning Guidance
</h2>
<p class='mb-6 text-sm text-gray-600 sm:text-base'>
Our AI Mentor analyzes your experience, suggests relevant roadmaps,
Our AI Tutor analyzes your experience, suggests relevant roadmaps,
and provides detailed answers to help you progress in your tech
career.
</p>
@@ -57,7 +57,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
class='inline-flex items-center gap-2 rounded-xl bg-black py-2 px-4 sm:px-6 sm:py-3 text-sm font-medium text-white transition-colors hover:opacity-80 sm:text-base'
>
<MessageCircle className='size-3 sm:size-5 fill-current' />
Chat with AI Mentor
Chat with AI Tutor
</a>
</div>
</div>

121
src/queries/chat-history.ts Normal file
View File

@@ -0,0 +1,121 @@
import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
import { httpGet } from '../lib/query-http';
import { isLoggedIn } from '../lib/jwt';
import { markdownToHtml } from '../lib/markdown';
import { aiChatRenderer } from '../components/AIChat/AIChat';
import {
type MessagePartRenderer,
renderMessage,
} from '../lib/render-chat-message';
import {
htmlFromTiptapJSON,
type RoadmapAIChatHistoryType,
} from '../hooks/use-roadmap-ai-chat';
import type { JSONContent } from '@tiptap/core';
export type ChatHistoryMessage = {
_id: string;
role: 'user' | 'assistant';
content: string;
json?: JSONContent;
};
export interface ChatHistoryDocument {
_id: string;
userId: string;
roadmapId?: string;
title: string;
messages: ChatHistoryMessage[];
createdAt: Date;
updatedAt: Date;
}
export function chatHistoryOptions(
chatHistoryId?: string,
renderer?: Record<string, MessagePartRenderer>,
) {
return queryOptions({
queryKey: ['chat-history-details', chatHistoryId],
queryFn: async () => {
const data = await httpGet<ChatHistoryDocument>(
`/v1-chat-history/${chatHistoryId}`,
);
if (data.title) {
document.title = data.title;
}
const messages: RoadmapAIChatHistoryType[] = [];
for (const message of data.messages) {
messages.push({
role: message.role,
content: message.content,
...(message.role === 'user' &&
!message?.json && {
html: markdownToHtml(message.content),
}),
...(message.role === 'user' &&
message?.json && {
html: htmlFromTiptapJSON(message.json),
}),
...(message.role === 'assistant' && {
jsx: await renderMessage(message.content, renderer ?? {}, {
isLoading: false,
}),
}),
});
}
return {
...data,
messages,
};
},
enabled: !!isLoggedIn() && !!chatHistoryId,
});
}
type ListChatHistoryQuery = {
perPage?: string;
currPage?: string;
query?: string;
roadmapId?: string;
};
export type ChatHistoryWithoutMessages = Omit<ChatHistoryDocument, 'messages'>;
type ListChatHistoryResponse = {
data: ChatHistoryWithoutMessages[];
totalCount: number;
totalPages: number;
currPage: number;
perPage: number;
};
export function listChatHistoryOptions(
query: ListChatHistoryQuery = {
query: '',
roadmapId: '',
},
) {
return infiniteQueryOptions({
queryKey: ['list-chat-history', query],
queryFn: ({ pageParam }) => {
return httpGet<ListChatHistoryResponse>('/v1-list-chat-history', {
...(query?.query ? { query: query.query } : {}),
...(query?.roadmapId ? { roadmapId: query.roadmapId } : {}),
...(pageParam ? { currPage: pageParam } : {}),
perPage: '21',
});
},
enabled: !!isLoggedIn(),
getNextPageParam: (lastPage, pages) => {
return lastPage.currPage < lastPage.totalPages
? lastPage.currPage + 1
: undefined;
},
initialPageParam: 1,
});
}

View File

@@ -0,0 +1,16 @@
import { queryOptions } from '@tanstack/react-query';
import { httpGet } from '../lib/query-http';
export interface RoadmapQuestionsResponse {
questions: string[];
}
export function roadmapQuestionsOptions(roadmapId: string) {
return queryOptions({
queryKey: ['roadmap-questions', roadmapId],
queryFn: () => {
return httpGet<RoadmapQuestionsResponse>(`/v1-official-roadmap-questions/${roadmapId}`);
},
refetchOnMount: false,
});
}

View File

@@ -2,6 +2,7 @@
@import '@roadmapsh/editor/style.css';
@config '../../tailwind.config.cjs';
@plugin 'tailwind-scrollbar';
@font-face {
font-family: 'Balsamiq Sans';