mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-25 00:21:28 +02:00
wip
This commit is contained in:
@@ -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([]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
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>
|
||||
);
|
||||
}
|
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">
|
||||
<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>
|
||||
);
|
||||
}
|
42
src/components/AIChatHistory/ListChatHistory.tsx
Normal file
42
src/components/AIChatHistory/ListChatHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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',
|
||||
|
@@ -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 } : {}),
|
||||
|
Reference in New Issue
Block a user