1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-03 06:12:53 +02:00

Merge global history

This commit is contained in:
Kamran Ahmed
2025-06-11 19:57:20 +01:00
5 changed files with 115 additions and 94 deletions

View File

@@ -18,7 +18,6 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client'; import { queryClient } from '../../stores/query-client';
import { billingDetailsOptions } from '../../queries/billing'; import { billingDetailsOptions } from '../../queries/billing';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import { readStream } from '../../lib/ai';
import { markdownToHtml } from '../../lib/markdown'; import { markdownToHtml } from '../../lib/markdown';
import { ChatHistory } from './ChatHistory'; import { ChatHistory } from './ChatHistory';
import { PersonalizedResponseForm } from './PersonalizedResponseForm'; import { PersonalizedResponseForm } from './PersonalizedResponseForm';
@@ -443,7 +442,7 @@ export function AIChat(props: AIChatProps) {
<div className="pointer-events-auto flex items-center justify-between gap-2"> <div className="pointer-events-auto flex items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LockIcon className="size-4" strokeWidth={2.5} /> <LockIcon className="size-4" strokeWidth={2.5} />
<p>Upgrade to Pro to keep your conversations.</p> <p>Your chat history is not saved.</p>
</div> </div>
<button <button
type="button" type="button"

View File

@@ -3,11 +3,12 @@ import { queryClient } from '../../stores/query-client';
import { chatHistoryOptions } from '../../queries/chat-history'; import { chatHistoryOptions } from '../../queries/chat-history';
import { AIChat, aiChatRenderer } from '../AIChat/AIChat'; import { AIChat, aiChatRenderer } from '../AIChat/AIChat';
import { Loader2Icon } from 'lucide-react'; import { Loader2Icon } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { AIChatLayout } from './AIChatLayout'; import { AIChatLayout } from './AIChatLayout';
import { ListChatHistory } from './ListChatHistory'; import { ListChatHistory } from './ListChatHistory';
import { billingDetailsOptions } from '../../queries/billing'; import { billingDetailsOptions } from '../../queries/billing';
import { ChatHistoryError } from './ChatHistoryError'; import { ChatHistoryError } from './ChatHistoryError';
import { useClientMount } from '../../hooks/use-client-mount';
type AIChatHistoryProps = { type AIChatHistoryProps = {
chatHistoryId?: string; chatHistoryId?: string;
@@ -16,8 +17,8 @@ type AIChatHistoryProps = {
export function AIChatHistory(props: AIChatHistoryProps) { export function AIChatHistory(props: AIChatHistoryProps) {
const { chatHistoryId: defaultChatHistoryId } = props; const { chatHistoryId: defaultChatHistoryId } = props;
const isClientMounted = useClientMount();
const [keyTrigger, setKeyTrigger] = useState(0); const [keyTrigger, setKeyTrigger] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [isChatHistoryLoading, setIsChatHistoryLoading] = useState(true); const [isChatHistoryLoading, setIsChatHistoryLoading] = useState(true);
const [chatHistoryId, setChatHistoryId] = useState<string | undefined>( const [chatHistoryId, setChatHistoryId] = useState<string | undefined>(
defaultChatHistoryId || undefined, defaultChatHistoryId || undefined,
@@ -27,16 +28,60 @@ export function AIChatHistory(props: AIChatHistoryProps) {
chatHistoryOptions(chatHistoryId, aiChatRenderer), chatHistoryOptions(chatHistoryId, aiChatRenderer),
queryClient, queryClient,
); );
const { const {
data: userBillingDetails, data: userBillingDetails,
isLoading: isBillingDetailsLoading, isLoading: isBillingDetailsLoading,
error: billingDetailsError, error: billingDetailsError,
} = useQuery(billingDetailsOptions(), queryClient); } = 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 isPaidUser = userBillingDetails?.status === 'active';
const hasError = chatHistoryError || billingDetailsError;
const showLoader = isChatHistoryLoading && !hasError;
const showError = !isChatHistoryLoading && Boolean(hasError);
useEffect(() => { useEffect(() => {
if (!chatHistoryId) { if (!chatHistoryId) {
setIsLoading(false);
setIsChatHistoryLoading(false); setIsChatHistoryLoading(false);
return; return;
} }
@@ -45,107 +90,70 @@ export function AIChatHistory(props: AIChatHistoryProps) {
return; return;
} }
setIsLoading(false);
setIsChatHistoryLoading(false); setIsChatHistoryLoading(false);
}, [data, chatHistoryId]); }, [data, chatHistoryId]);
const showGlobalLoader = isLoading || isBillingDetailsLoading;
const hasError = chatHistoryError || billingDetailsError;
useEffect(() => { useEffect(() => {
if (!hasError) { if (!hasError) {
return; return;
} }
setIsLoading(false);
setIsChatHistoryLoading(false); setIsChatHistoryLoading(false);
}, [hasError]); }, [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 ( return (
<AIChatLayout> <AIChatLayout>
<div className="relative flex grow"> <div className="relative flex grow">
{showGlobalLoader && ( {isPaidUser && (
<div className="absolute inset-0 z-20 flex items-center justify-center"> <ListChatHistory
<Loader2Icon className="h-8 w-8 animate-spin stroke-[2.5]" /> activeChatHistoryId={chatHistoryId}
</div> onChatHistoryClick={handleChatHistoryClick}
onDelete={handleDelete}
/>
)} )}
{!showGlobalLoader && ( <div className="relative flex grow">
<> {showLoader && (
{isPaidUser && ( <div className="absolute inset-0 z-20 flex items-center justify-center">
<ListChatHistory <Loader2Icon className="h-8 w-8 animate-spin stroke-[2.5] text-gray-400/80" />
activeChatHistoryId={chatHistoryId}
onChatHistoryClick={(chatHistoryId) => {
setKeyTrigger((keyTrigger) => keyTrigger + 1);
if (chatHistoryId === null) {
setChatHistoryId(undefined);
window.history.replaceState(null, '', '/ai/chat');
return;
}
// so that we can show the loading state when the chat history is not fetched yet
// it will help us to avoid the flash of content
const hasAlreadyFetched = queryClient.getQueryData(
chatHistoryOptions(chatHistoryId).queryKey,
);
if (!hasAlreadyFetched) {
setIsChatHistoryLoading(true);
}
setChatHistoryId(chatHistoryId);
window.history.replaceState(
null,
'',
`/ai/chat/${chatHistoryId}`,
);
}}
onDelete={(deletedChatHistoryId) => {
const isCurrentChatHistory =
deletedChatHistoryId === chatHistoryId;
if (!isCurrentChatHistory) {
return;
}
setChatHistoryId(undefined);
window.history.replaceState(null, '', '/ai/chat');
}}
/>
)}
<div className="relative flex grow">
{isChatHistoryLoading && !hasError && (
<div className="absolute inset-0 z-20 flex items-center justify-center">
<Loader2Icon className="h-8 w-8 animate-spin stroke-[2.5]" />
</div>
)}
{!isChatHistoryLoading && hasError && (
<div className="absolute inset-0 z-20 flex items-center justify-center">
<ChatHistoryError error={hasError} className="mt-0" />
</div>
)}
{!isChatHistoryLoading && !hasError && (
<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';
},
});
}}
/>
)}
</div> </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';
},
});
}}
/>
)}
</div>
</div> </div>
</AIChatLayout> </AIChatLayout>
); );

View File

@@ -36,6 +36,7 @@ export function ListChatHistory(props: ListChatHistoryProps) {
useLayoutEffect(() => { useLayoutEffect(() => {
const deviceType = getTailwindScreenDimension(); const deviceType = getTailwindScreenDimension();
const isMediumSize = ['sm', 'md'].includes(deviceType); const isMediumSize = ['sm', 'md'].includes(deviceType);
setIsOpen(!isMediumSize); setIsOpen(!isMediumSize);
setIsMobile(isMediumSize); setIsMobile(isMediumSize);
}, []); }, []);
@@ -111,7 +112,7 @@ export function ListChatHistory(props: ListChatHistoryProps) {
</div> </div>
<button <button
className="flex w-full items-center justify-center gap-2 rounded-lg bg-black p-2 text-sm text-white" className="flex w-full items-center hover:opacity-80 justify-center gap-2 rounded-lg bg-black p-2 text-sm text-white"
onClick={() => { onClick={() => {
if (isMobile) { if (isMobile) {
setIsOpen(false); setIsOpen(false);
@@ -130,7 +131,7 @@ export function ListChatHistory(props: ListChatHistoryProps) {
</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"> <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 && ( {isEmptyHistory && !isLoadingInfiniteQuery && (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<p className="text-sm text-gray-500">No chat history</p> <p className="text-sm text-gray-500">No chat history</p>
</div> </div>

View File

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

View File

@@ -3,14 +3,22 @@ import { CheckSubscriptionVerification } from '../../components/Billing/CheckSub
import { RoadmapAIChat } from '../../components/RoadmapAIChat/RoadmapAIChat'; import { RoadmapAIChat } from '../../components/RoadmapAIChat/RoadmapAIChat';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro'; import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
import { AITutorLayout } from '../../components/AITutor/AITutorLayout'; import { AITutorLayout } from '../../components/AITutor/AITutorLayout';
import { getRoadmapById } from '../../lib/roadmap'; import { getRoadmapById, getRoadmapIds } from '../../lib/roadmap';
export const prerender = false;
type Props = { type Props = {
roadmapId: string; roadmapId: string;
}; };
export const prerender = false;
export async function getStaticPaths() {
const roadmapIds = await getRoadmapIds();
return roadmapIds.map((roadmapId) => ({
params: { roadmapId },
}));
}
const { roadmapId } = Astro.params as Props; const { roadmapId } = Astro.params as Props;
const roadmapDetail = await getRoadmapById(roadmapId); const roadmapDetail = await getRoadmapById(roadmapId);