1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-25 00:21:28 +02:00
This commit is contained in:
Arik Chakma
2025-06-09 21:56:36 +06:00
parent 799f6eebbb
commit 1f8850878d
8 changed files with 324 additions and 73 deletions

View File

@@ -7,14 +7,7 @@ import {
SendIcon,
TrashIcon,
} from 'lucide-react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import AutogrowTextarea from 'react-textarea-autosize';
import { QuickHelpPrompts } from './QuickHelpPrompts';
@@ -43,6 +36,7 @@ import { AIChatCourse } from './AIChatCouse';
import { showLoginPopup } from '../../lib/popup';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { readChatStream } from '../../lib/chat';
import { chatHistoryOptions } from '../../queries/chat-history';
export const aiChatRenderer: Record<string, MessagePartRenderer> = {
'roadmap-recommendations': (options) => {
@@ -103,6 +97,25 @@ export function AIChat(props: AIChatProps) {
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';
@@ -150,17 +163,38 @@ export function AIChat(props: AIChatProps) {
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 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[],
@@ -211,8 +245,7 @@ export function AIChat(props: AIChatProps) {
flushSync(() => {
setStreamedMessage(jsx);
});
scrollToBottom();
setShowScrollToBottomButton(canScrollToBottom());
},
onMessageEnd: async (content) => {
const jsx = await renderMessage(content, aiChatRenderer, {
@@ -235,7 +268,6 @@ export function AIChat(props: AIChatProps) {
});
queryClient.invalidateQueries(getAiCourseLimitOptions());
scrollToBottom();
},
onDetails: (details) => {
const detailsJson = JSON.parse(details);
@@ -245,7 +277,6 @@ export function AIChat(props: AIChatProps) {
}
setDefaultChatHistoryId?.(chatHistoryId);
window.history.replaceState({}, '', `/ai/chat/${chatHistoryId}`);
},
});
@@ -286,17 +317,7 @@ export function AIChat(props: AIChatProps) {
}
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);
};
@@ -339,6 +360,7 @@ export function AIChat(props: AIChatProps) {
(index: number) => {
const filteredChatHistory = aiChatHistory.filter((_, i) => i !== index);
setAiChatHistory(filteredChatHistory);
deleteChatMessage(filteredChatHistory);
},
[aiChatHistory],
);
@@ -351,29 +373,35 @@ export function AIChat(props: AIChatProps) {
isUserPersonaLoading ||
isUserResumeLoading;
useEffect(() => {
scrollToBottom('instant');
}, []);
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 min-h-screen grow flex-col gap-2 bg-gray-100">
<div
className="absolute inset-0 overflow-y-auto 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 && (
@@ -397,7 +425,7 @@ export function AIChat(props: AIChatProps) {
)}
<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">
@@ -433,6 +461,7 @@ export function AIChat(props: AIChatProps) {
label="Clear Chat"
onClick={() => {
setAiChatHistory([]);
deleteChatMessage([]);
}}
/>
)}

View File

@@ -5,6 +5,7 @@ import { AIChat } from '../AIChat/AIChat';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { AIChatLayout } from './AIChatLayout';
import { ListChatHistory } from './ListChatHistory';
type AIChatHistoryProps = {
chatHistoryId?: string;
@@ -18,7 +19,10 @@ export function AIChatHistory(props: AIChatHistoryProps) {
defaultChatHistoryId || undefined,
);
const { data } = useQuery(chatHistoryOptions(chatHistoryId), queryClient);
const { data, isLoading: isChatHistoryLoading } = useQuery(
chatHistoryOptions(chatHistoryId),
queryClient,
);
useEffect(() => {
if (!data) {
@@ -28,20 +32,52 @@ export function AIChatHistory(props: AIChatHistoryProps) {
setIsLoading(false);
}, [data]);
const isDataLoading = isLoading || isChatHistoryLoading;
return (
<AIChatLayout>
{isLoading && (
<div className="flex flex-1 items-center justify-center">
<Loader2Icon className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoading && (
<AIChat
messages={data?.messages}
chatHistoryId={chatHistoryId}
setChatHistoryId={setChatHistoryId}
<div className="flex grow">
<ListChatHistory
activeChatHistoryId={chatHistoryId}
onChatHistoryClick={(chatHistoryId) => {
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="flex grow">
{isDataLoading && (
<div className="flex flex-1 items-center justify-center">
<Loader2Icon className="h-4 w-4 animate-spin" />
</div>
)}
{!isDataLoading && (
<AIChat
key={chatHistoryId}
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>
</AIChatLayout>
);
}

View File

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

View File

@@ -0,0 +1,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">
<button
className="block w-full truncate rounded-lg p-2 py-1.5 pr-10 text-left hover:bg-gray-100 data-[active=true]:bg-gray-100"
data-active={isActive}
onClick={() => onChatHistoryClick(chatHistory._id)}
>
{chatHistory.title}
</button>
<div className="absolute inset-y-0 right-2 flex items-center">
<ChatHistoryAction
chatHistoryId={chatHistory._id}
onDelete={onDelete}
/>
</div>
</li>
);
}

View File

@@ -0,0 +1,42 @@
import { useQuery } from '@tanstack/react-query';
import {
listChatHistoryOptions,
type ChatHistoryDocument,
} from '../../queries/chat-history';
import { queryClient } from '../../stores/query-client';
import { cn } from '../../lib/classname';
import { ChatHistoryItem } from './ChatHistoryItem';
type ListChatHistoryProps = {
activeChatHistoryId?: string;
onChatHistoryClick: (chatHistoryId: string) => void;
onDelete?: (chatHistoryId: string) => void;
};
export function ListChatHistory(props: ListChatHistoryProps) {
const { activeChatHistoryId, onChatHistoryClick, onDelete } = props;
const { data } = useQuery(listChatHistoryOptions(), queryClient);
return (
<div className="w-[255px] shrink-0 border-r border-slate-200 bg-white p-2">
<ul className="space-y-0.5">
{data?.data?.map((chatHistory) => {
const isActive = activeChatHistoryId === chatHistory._id;
return (
<ChatHistoryItem
key={chatHistory._id}
chatHistory={chatHistory}
isActive={isActive}
onChatHistoryClick={onChatHistoryClick}
onDelete={() => {
onDelete?.(chatHistory._id);
}}
/>
);
})}
</ul>
</div>
);
}

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ export interface ChatHistoryDocument {
export function chatHistoryOptions(chatHistoryId?: string) {
return queryOptions({
queryKey: ['chat-history', chatHistoryId],
queryKey: ['chat-history-details', chatHistoryId],
queryFn: async () => {
const data = await httpGet<ChatHistoryDocument>(
`/v1-chat-history/${chatHistoryId}`,
@@ -82,7 +82,7 @@ export function listChatHistoryOptions(
},
) {
return queryOptions({
queryKey: ['chat-history', query],
queryKey: ['list-chat-history', query],
queryFn: () => {
return httpGet<ListChatHistoryResponse>('/v1-list-chat-history', {
...(query?.query ? { query: query.query } : {}),