mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-12 17:51:53 +08:00
Compare commits
30 Commits
1fbc167494
...
feat/chat-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9414b23b68 | ||
|
|
c1415e55b7 | ||
|
|
aeb184cd05 | ||
|
|
7ed5d90c13 | ||
|
|
0ff370c684 | ||
|
|
8cf0a7b927 | ||
|
|
4a396ce3db | ||
|
|
ab888e8f73 | ||
|
|
b177b0a6e4 | ||
|
|
06fe7083a1 | ||
|
|
ca96e11efe | ||
|
|
ba0c60696a | ||
|
|
5394ecf518 | ||
|
|
44bb6f04b4 | ||
|
|
b574f1ebf5 | ||
|
|
278ff39c91 | ||
|
|
a054982f63 | ||
|
|
f93f8cb6ba | ||
|
|
9ed6f00aba | ||
|
|
71c23fca5c | ||
|
|
4a9a8f6e91 | ||
|
|
d19298a457 | ||
|
|
e84e5934c4 | ||
|
|
6d052ff446 | ||
|
|
1f8850878d | ||
|
|
799f6eebbb | ||
|
|
6a413cdaef | ||
|
|
cfb06adc6a | ||
|
|
c3160bdf3c | ||
|
|
c55e037e86 |
@@ -119,6 +119,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"
|
||||
}
|
||||
}
|
||||
|
||||
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
@@ -267,6 +267,9 @@ importers:
|
||||
prettier-plugin-tailwindcss:
|
||||
specifier: ^0.6.11
|
||||
version: 0.6.11(prettier-plugin-astro@0.14.1)(prettier@3.5.3)
|
||||
tailwind-scrollbar:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2(react@19.1.0)(tailwindcss@4.1.7)
|
||||
tsx:
|
||||
specifier: ^4.19.4
|
||||
version: 4.19.4
|
||||
@@ -3496,6 +3499,11 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
prism-react-renderer@2.4.1:
|
||||
resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==}
|
||||
peerDependencies:
|
||||
react: '>=16.0.0'
|
||||
|
||||
prismjs@1.30.0:
|
||||
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3919,6 +3927,12 @@ packages:
|
||||
tailwind-merge@3.3.0:
|
||||
resolution: {integrity: sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==}
|
||||
|
||||
tailwind-scrollbar@4.0.2:
|
||||
resolution: {integrity: sha512-wAQiIxAPqk0MNTPptVe/xoyWi27y+NRGnTwvn4PQnbvB9kp8QUBiGl/wsfoVBHnQxTmhXJSNt9NHTmcz9EivFA==}
|
||||
engines: {node: '>=12.13.0'}
|
||||
peerDependencies:
|
||||
tailwindcss: 4.x
|
||||
|
||||
tailwindcss@4.1.5:
|
||||
resolution: {integrity: sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA==}
|
||||
|
||||
@@ -7548,6 +7562,12 @@ snapshots:
|
||||
|
||||
prettier@3.5.3: {}
|
||||
|
||||
prism-react-renderer@2.4.1(react@19.1.0):
|
||||
dependencies:
|
||||
'@types/prismjs': 1.26.5
|
||||
clsx: 2.1.1
|
||||
react: 19.1.0
|
||||
|
||||
prismjs@1.30.0: {}
|
||||
|
||||
prompts@2.4.2:
|
||||
@@ -8144,6 +8164,13 @@ snapshots:
|
||||
|
||||
tailwind-merge@3.3.0: {}
|
||||
|
||||
tailwind-scrollbar@4.0.2(react@19.1.0)(tailwindcss@4.1.7):
|
||||
dependencies:
|
||||
prism-react-renderer: 2.4.1(react@19.1.0)
|
||||
tailwindcss: 4.1.7
|
||||
transitivePeerDependencies:
|
||||
- react
|
||||
|
||||
tailwindcss@4.1.5: {}
|
||||
|
||||
tailwindcss@4.1.7: {}
|
||||
|
||||
@@ -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 type { RoadmapAIChatHistoryType } from '../../hooks/use-roadmap-ai-chat';
|
||||
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';
|
||||
|
||||
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,25 @@ export function AIChat() {
|
||||
userResumeOptions(),
|
||||
queryClient,
|
||||
);
|
||||
const { mutate: deleteChatMessage, isPending: isDeletingChatMessage } =
|
||||
useMutation(
|
||||
{
|
||||
mutationFn: (messages: RoadmapAIChatHistoryType[]) => {
|
||||
return httpPost(`/v1-delete-chat-message/${defaultChatHistoryId}`, {
|
||||
messages,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
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 +128,7 @@ export function AIChat() {
|
||||
|
||||
if (isLimitExceeded) {
|
||||
if (!isPaidUser) {
|
||||
setShowUpgradeModal(true);
|
||||
onUpgrade?.();
|
||||
}
|
||||
|
||||
toast.error('Limit reached for today. Please wait until tomorrow.');
|
||||
@@ -136,29 +163,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 +209,7 @@ export function AIChat() {
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
chatHistoryId: defaultChatHistoryId,
|
||||
messages: messages.slice(-10),
|
||||
force,
|
||||
}),
|
||||
@@ -198,20 +236,19 @@ export function AIChat() {
|
||||
return;
|
||||
}
|
||||
|
||||
await readStream(reader, {
|
||||
onStream: async (content) => {
|
||||
const jsx = await renderMessage(content, renderer, {
|
||||
await readChatStream(reader, {
|
||||
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 +268,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 +322,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 +343,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 +365,7 @@ export function AIChat() {
|
||||
(index: number) => {
|
||||
const filteredChatHistory = aiChatHistory.filter((_, i) => i !== index);
|
||||
setAiChatHistory(filteredChatHistory);
|
||||
deleteChatMessage(filteredChatHistory);
|
||||
},
|
||||
[aiChatHistory],
|
||||
);
|
||||
@@ -337,29 +378,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,14 +430,28 @@ 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}
|
||||
>
|
||||
{shouldShowUpgradeBanner && (
|
||||
<div className="mb-2 rounded-lg border border-yellow-200 bg-yellow-50 p-3 text-sm text-yellow-800">
|
||||
<div className="pointer-events-auto flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<LockIcon className="size-4" strokeWidth={2.5} />
|
||||
<p>Your chat history is not saved.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpgrade?.()}
|
||||
className="shrink-0 cursor-pointer rounded-md bg-yellow-200 px-2 py-1 text-xs font-medium text-yellow-800 hover:bg-yellow-200"
|
||||
>
|
||||
Upgrade to Pro
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<QuickActionButton
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 } 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),
|
||||
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">
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
329
src/components/AIChatHistory/ListChatHistory.tsx
Normal file
329
src/components/AIChatHistory/ListChatHistory.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
listChatHistoryOptions,
|
||||
type ChatHistoryWithoutMessages,
|
||||
} from '../../queries/chat-history';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { ChatHistoryItem } from './ChatHistoryItem';
|
||||
import {
|
||||
Loader2Icon,
|
||||
LockIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { useDebounceValue } from '../../hooks/use-debounce';
|
||||
import { ListChatHistorySkeleton } from './ListChatHistorySkeleton';
|
||||
import { ChatHistoryError } from './ChatHistoryError';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { getTailwindScreenDimension } from '../../lib/is-mobile';
|
||||
|
||||
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);
|
||||
|
||||
setIsOpen(!isMediumSize);
|
||||
setIsMobile(isMediumSize);
|
||||
}, []);
|
||||
|
||||
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 today = DateTime.now().startOf('day');
|
||||
const allHistories = data?.pages?.flatMap((page) => page.data);
|
||||
|
||||
return allHistories?.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[] }
|
||||
>,
|
||||
);
|
||||
}, [data?.pages]);
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<div className="absolute top-2 left-2 z-20">
|
||||
<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>
|
||||
</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 (
|
||||
<div className={cn(classNames, 'relative')}>
|
||||
<div className="absolute top-2 right-2">{closeButton}</div>
|
||||
|
||||
<div className="flex grow flex-col items-center justify-center">
|
||||
<LockIcon className="size-8 text-gray-500" />
|
||||
<p className="mt-4 text-center text-sm text-balance text-gray-500">
|
||||
Upgrade to Pro to keep your chat history.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 shrink-0 cursor-pointer rounded-md bg-yellow-200 px-2.5 py-1.5 text-sm font-medium text-yellow-800 hover:bg-yellow-200"
|
||||
onClick={() => {
|
||||
onUpgrade?.();
|
||||
}}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<SearchInput
|
||||
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 (
|
||||
<div key={key}>
|
||||
<h2 className="ml-2 text-xs text-gray-500">{value.title}</h2>
|
||||
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{value.histories.map((chatHistory) => {
|
||||
return (
|
||||
<ChatHistoryItem
|
||||
key={chatHistory._id}
|
||||
chatHistory={chatHistory}
|
||||
isActive={activeChatHistoryId === chatHistory._id}
|
||||
onChatHistoryClick={(id) => {
|
||||
if (isMobile) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
onChatHistoryClick(id);
|
||||
}}
|
||||
onDelete={() => {
|
||||
onDelete?.(chatHistory._id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{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 SearchInputProps = {
|
||||
onSearch: (search: string) => void;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
function SearchInput(props: SearchInputProps) {
|
||||
const { onSearch, isLoading } = props;
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounceValue(search, 300);
|
||||
|
||||
useEffect(() => {
|
||||
onSearch(debouncedSearch);
|
||||
}, [debouncedSearch, onSearch]);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="relative mt-2 flex grow items-center"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSearch(search);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search folder by name"
|
||||
className="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"
|
||||
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>
|
||||
);
|
||||
}
|
||||
38
src/components/AIChatHistory/ListChatHistorySkeleton.tsx
Normal file
38
src/components/AIChatHistory/ListChatHistorySkeleton.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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 className="absolute top-1/2 left-2.5 -translate-y-1/2">
|
||||
<div className="h-4 w-4 animate-pulse rounded-full bg-gray-300" />
|
||||
</div>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
56
src/lib/chat.ts
Normal file
56
src/lib/chat.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export const CHAT_RESPONSE_PREFIX = {
|
||||
message: '0:',
|
||||
details: 'd:',
|
||||
} as const;
|
||||
|
||||
export async function readChatStream(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
{
|
||||
onMessage,
|
||||
onMessageEnd,
|
||||
onDetails,
|
||||
}: {
|
||||
onMessage?: (message: string) => Promise<void>;
|
||||
onMessageEnd?: (message: string) => Promise<void>;
|
||||
onDetails?: (details: string) => Promise<void> | void;
|
||||
},
|
||||
) {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let result = '';
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
const textWithNewLine = decoder.decode(value);
|
||||
const text = textWithNewLine.replace(/\n$/, '');
|
||||
|
||||
if (text.startsWith(CHAT_RESPONSE_PREFIX.message)) {
|
||||
const textWithoutPrefix = text.replace(CHAT_RESPONSE_PREFIX.message, '');
|
||||
|
||||
// basically we need to split the text by new line
|
||||
// and send it to the onMessage callback
|
||||
// so that we don't have broken tags for our rendering
|
||||
let start = 0;
|
||||
for (let i = 0; i < textWithoutPrefix.length; i++) {
|
||||
if (textWithoutPrefix[i] === '\n') {
|
||||
result += textWithoutPrefix.slice(start, i + 1);
|
||||
await onMessage?.(result);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (start < textWithoutPrefix.length) {
|
||||
result += textWithoutPrefix.slice(start);
|
||||
}
|
||||
} else if (text.startsWith(CHAT_RESPONSE_PREFIX.details)) {
|
||||
const textWithoutPrefix = text.replace(CHAT_RESPONSE_PREFIX.details, '');
|
||||
await onDetails?.(textWithoutPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
await onMessageEnd?.(result);
|
||||
reader.releaseLock();
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
---
|
||||
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';
|
||||
|
||||
type Props = {
|
||||
roadmapId: string;
|
||||
};
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
@@ -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>
|
||||
|
||||
101
src/queries/chat-history.ts
Normal file
101
src/queries/chat-history.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
|
||||
import { httpGet } from '../lib/query-http';
|
||||
import { isLoggedIn } from '../lib/jwt';
|
||||
import type { RoadmapAIChatHistoryType } from '../components/RoadmapAIChat/RoadmapAIChat';
|
||||
import { markdownToHtml } from '../lib/markdown';
|
||||
import { aiChatRenderer } from '../components/AIChat/AIChat';
|
||||
import { renderMessage } from '../lib/render-chat-message';
|
||||
|
||||
export type ChatHistoryMessage = {
|
||||
_id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
};
|
||||
|
||||
export interface ChatHistoryDocument {
|
||||
_id: string;
|
||||
|
||||
userId: string;
|
||||
title: string;
|
||||
messages: ChatHistoryMessage[];
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export function chatHistoryOptions(chatHistoryId?: string) {
|
||||
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' && {
|
||||
html: markdownToHtml(message.content),
|
||||
}),
|
||||
...(message.role === 'assistant' && {
|
||||
jsx: await renderMessage(message.content, aiChatRenderer, {
|
||||
isLoading: false,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
messages,
|
||||
};
|
||||
},
|
||||
enabled: !!isLoggedIn() && !!chatHistoryId,
|
||||
});
|
||||
}
|
||||
|
||||
type ListChatHistoryQuery = {
|
||||
perPage?: string;
|
||||
currPage?: string;
|
||||
query?: 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: '',
|
||||
},
|
||||
) {
|
||||
return infiniteQueryOptions({
|
||||
queryKey: ['list-chat-history', query],
|
||||
queryFn: ({ pageParam }) => {
|
||||
return httpGet<ListChatHistoryResponse>('/v1-list-chat-history', {
|
||||
...(query?.query ? { query: query.query } : {}),
|
||||
...(pageParam ? { currPage: pageParam } : {}),
|
||||
perPage: '21',
|
||||
});
|
||||
},
|
||||
enabled: !!isLoggedIn(),
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
return lastPage.currPage < lastPage.totalPages
|
||||
? lastPage.currPage + 1
|
||||
: undefined;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
});
|
||||
}
|
||||
@@ -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