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:
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@@ -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\\(((?:[^()]|\\([^()]*\\))*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
]
|
||||
}
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
|
@@ -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)",
|
||||
|
@@ -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",
|
||||
|
@@ -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"
|
||||
>
|
||||
|
@@ -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[];
|
||||
|
@@ -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)}
|
||||
|
171
src/components/AIChatHistory/AIChatHistory.tsx
Normal file
171
src/components/AIChatHistory/AIChatHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
22
src/components/AIChatHistory/AIChatLayout.tsx
Normal file
22
src/components/AIChatHistory/AIChatLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
116
src/components/AIChatHistory/ChatHistoryAction.tsx
Normal file
116
src/components/AIChatHistory/ChatHistoryAction.tsx
Normal 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>
|
||||
);
|
||||
}
|
28
src/components/AIChatHistory/ChatHistoryError.tsx
Normal file
28
src/components/AIChatHistory/ChatHistoryError.tsx
Normal 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>
|
||||
);
|
||||
}
|
42
src/components/AIChatHistory/ChatHistoryGroup.tsx
Normal file
42
src/components/AIChatHistory/ChatHistoryGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
33
src/components/AIChatHistory/ChatHistoryItem.tsx
Normal file
33
src/components/AIChatHistory/ChatHistoryItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
292
src/components/AIChatHistory/ListChatHistory.tsx
Normal file
292
src/components/AIChatHistory/ListChatHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
35
src/components/AIChatHistory/ListChatHistorySkeleton.tsx
Normal file
35
src/components/AIChatHistory/ListChatHistorySkeleton.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
66
src/components/AIChatHistory/SearchAIChatHistory.tsx
Normal file
66
src/components/AIChatHistory/SearchAIChatHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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)}
|
||||
|
@@ -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',
|
||||
|
@@ -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 } : {}),
|
||||
});
|
||||
};
|
||||
|
@@ -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}
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@ svg text tspan {
|
||||
text-rendering: optimizeSpeed;
|
||||
}
|
||||
|
||||
code {
|
||||
code:not(pre code) {
|
||||
background: #1e1e3f;
|
||||
color: #9efeff;
|
||||
padding: 3px 5px;
|
||||
|
670
src/components/FrameRenderer/RoadmapFloatingChat.tsx
Normal file
670
src/components/FrameRenderer/RoadmapFloatingChat.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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">
|
||||
|
@@ -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>
|
||||
|
28
src/components/Popover.tsx
Normal file
28
src/components/Popover.tsx
Normal 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 };
|
@@ -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"
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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>
|
||||
|
@@ -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 (
|
||||
|
@@ -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')
|
||||
|
183
src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx
Normal file
183
src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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'
|
||||
|
@@ -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" />
|
||||
|
@@ -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) {
|
||||
|
@@ -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.
|
@@ -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)
|
@@ -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.
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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
42
src/helper/grouping.ts
Normal 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[] }
|
||||
>,
|
||||
);
|
||||
}
|
337
src/hooks/use-roadmap-ai-chat.tsx
Normal file
337
src/hooks/use-roadmap-ai-chat.tsx
Normal 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
94
src/lib/chat.ts
Normal 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();
|
||||
}
|
@@ -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');
|
||||
|
@@ -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}`,
|
||||
);
|
||||
|
@@ -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>
|
||||
|
18
src/pages/ai/chat/[chatId].astro
Normal file
18
src/pages/ai/chat/[chatId].astro
Normal 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>
|
@@ -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>
|
@@ -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>
|
||||
|
@@ -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
121
src/queries/chat-history.ts
Normal 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,
|
||||
});
|
||||
}
|
16
src/queries/roadmap-questions.ts
Normal file
16
src/queries/roadmap-questions.ts
Normal 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,
|
||||
});
|
||||
}
|
@@ -2,6 +2,7 @@
|
||||
@import '@roadmapsh/editor/style.css';
|
||||
|
||||
@config '../../tailwind.config.cjs';
|
||||
@plugin 'tailwind-scrollbar';
|
||||
|
||||
@font-face {
|
||||
font-family: 'Balsamiq Sans';
|
||||
|
Reference in New Issue
Block a user