feat: histories in global chat and roadmap chat (#8775)
* feat: ai chat history * wip * wip * wip * wip * wip * wip * wip * wip * wip: skeleton loading * wip * wip * wip * wip * wip * wip * wip * wip * fix: chat history * wip * wip * fix: responsiveness * wip * wip * Chat history UI * Update chat history * wip * Update chat history * Update chat history * Fix ai chat not working * Update * wip * feat: show chat history always * feat: upgrade to pro * wip * Update history design * UI design improvement for empty sidebar * feat: chat history title * Fix, delete chat throwing error * Plus icon when chat is closed * fix: action z-index * Improve skeleton and logged out user workflow * Chat history improvements * Add plus for chat icons --------- Co-authored-by: Arik Chakma <arikchangma@gmail.com>
This commit is contained in:
1 parent
7d91696c85
commit
0f99964bd6
32 files changed
+1995
-325
No files matched your search
@@ -24,10 +24,12 @@ type AIChatActionButtonsProps = {
|
||||
onTellUsAboutYourSelf: () => void;
|
||||
onClearChat: () => void;
|
||||
messageCount: number;
|
||||
showClearChat: boolean;
|
||||
};
|
||||
|
||||
export function AIChatActionButtons(props: AIChatActionButtonsProps) {
|
||||
const { onTellUsAboutYourSelf, onClearChat, messageCount } = props;
|
||||
const { onTellUsAboutYourSelf, onClearChat, messageCount, showClearChat } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 px-4 pt-2">
|
||||
@@ -36,7 +38,7 @@ export function AIChatActionButtons(props: AIChatActionButtonsProps) {
|
||||
label="Tell us about your self"
|
||||
onClick={onTellUsAboutYourSelf}
|
||||
/>
|
||||
{messageCount > 0 && (
|
||||
{showClearChat && messageCount > 0 && (
|
||||
<AIChatActionButton
|
||||
icon={Trash2}
|
||||
label="Clear chat"
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import './RoadmapAIChat.css';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { roadmapJSONOptions } from '../../queries/roadmap';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import type { Editor, JSONContent } from '@tiptap/core';
|
||||
import {
|
||||
Bot,
|
||||
Frown,
|
||||
HistoryIcon,
|
||||
Loader2Icon,
|
||||
LockIcon,
|
||||
PauseCircleIcon,
|
||||
SendIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
@@ -12,45 +21,41 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Bot,
|
||||
Frown,
|
||||
Loader2Icon,
|
||||
LockIcon,
|
||||
PauseCircleIcon,
|
||||
SendIcon,
|
||||
} from 'lucide-react';
|
||||
import { ChatEditor } from '../ChatEditor/ChatEditor';
|
||||
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import type { JSONContent, Editor } from '@tiptap/core';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import {
|
||||
roadmapAIChatRenderer,
|
||||
useRoadmapAIChat,
|
||||
type RoadmapAIChatHistoryType,
|
||||
} from '../../hooks/use-roadmap-ai-chat';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { userResourceProgressOptions } from '../../queries/resource-progress';
|
||||
import { ChatRoadmapRenderer } from './ChatRoadmapRenderer';
|
||||
import { RoadmapAIChatCard } from './RoadmapAIChatCard';
|
||||
import { RoadmapAIChatHeader } from './RoadmapAIChatHeader';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { TopicDetail } from '../TopicDetail/TopicDetail';
|
||||
import { slugify } from '../../lib/slugger';
|
||||
import { AIChatActionButtons } from './AIChatActionButtons';
|
||||
import { deleteUrlParam, getUrlParams } from '../../lib/browser';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { lockBodyScroll } from '../../lib/dom';
|
||||
import {
|
||||
getTailwindScreenDimension,
|
||||
type TailwindScreenDimensions,
|
||||
} from '../../lib/is-mobile';
|
||||
import { ChatPersona } from '../UserPersona/ChatPersona';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { slugify } from '../../lib/slugger';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { chatHistoryOptions } from '../../queries/chat-history';
|
||||
import { userResourceProgressOptions } from '../../queries/resource-progress';
|
||||
import { roadmapJSONOptions } from '../../queries/roadmap';
|
||||
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
||||
import { userRoadmapPersonaOptions } from '../../queries/user-persona';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { ChatEditor } from '../ChatEditor/ChatEditor';
|
||||
import { TopicDetail } from '../TopicDetail/TopicDetail';
|
||||
import { ChatPersona } from '../UserPersona/ChatPersona';
|
||||
import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal';
|
||||
import { lockBodyScroll } from '../../lib/dom';
|
||||
import { AIChatActionButtons } from './AIChatActionButtons';
|
||||
import { ChatRoadmapRenderer } from './ChatRoadmapRenderer';
|
||||
import { RoadmapAIChatCard } from './RoadmapAIChatCard';
|
||||
import { RoadmapAIChatHeader } from './RoadmapAIChatHeader';
|
||||
import { TutorIntroMessage } from './TutorIntroMessage';
|
||||
import {
|
||||
useRoadmapAIChat,
|
||||
type RoadmapAIChatHistoryType,
|
||||
} from '../../hooks/use-roadmap-ai-chat';
|
||||
|
||||
export type RoadmapAIChatTab = 'chat' | 'topic';
|
||||
|
||||
@@ -79,6 +84,9 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
null,
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<RoadmapAIChatTab>('chat');
|
||||
const [activeChatHistoryId, setActiveChatHistoryId] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
|
||||
const [showUpdatePersonaModal, setShowUpdatePersonaModal] = useState(false);
|
||||
|
||||
@@ -136,10 +144,19 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
}, [roadmapDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = getUrlParams();
|
||||
const queryChatId = params.chatId;
|
||||
|
||||
if (!roadmapTreeData || !roadmapDetail || isUserPersonaLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (queryChatId) {
|
||||
setIsChatHistoryLoading(true);
|
||||
setActiveChatHistoryId(queryChatId);
|
||||
deleteUrlParam('chatId');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [roadmapTreeData, roadmapDetail, isUserPersonaLoading]);
|
||||
|
||||
@@ -170,6 +187,19 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
[roadmapId, deviceType],
|
||||
);
|
||||
|
||||
const [isChatHistoryLoading, setIsChatHistoryLoading] = useState(true);
|
||||
const { data: chatHistory } = useQuery(
|
||||
chatHistoryOptions(
|
||||
activeChatHistoryId,
|
||||
roadmapAIChatRenderer({
|
||||
roadmapId,
|
||||
totalTopicCount,
|
||||
onSelectTopic,
|
||||
}),
|
||||
),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const {
|
||||
aiChatHistory,
|
||||
isStreamingMessage,
|
||||
@@ -179,13 +209,39 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
handleAbort,
|
||||
clearChat,
|
||||
scrollToBottom,
|
||||
setAiChatHistory,
|
||||
} = useRoadmapAIChat({
|
||||
activeChatHistoryId,
|
||||
roadmapId,
|
||||
totalTopicCount,
|
||||
scrollareaRef,
|
||||
onSelectTopic,
|
||||
onChatHistoryIdChange: (chatHistoryId) => {
|
||||
setActiveChatHistoryId(chatHistoryId);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatHistory) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAiChatHistory(chatHistory?.messages ?? []);
|
||||
setIsChatHistoryLoading(false);
|
||||
setTimeout(() => {
|
||||
scrollToBottom('instant');
|
||||
}, 0);
|
||||
}, [chatHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeChatHistoryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAiChatHistory([]);
|
||||
setIsChatHistoryLoading(false);
|
||||
}, [activeChatHistoryId, setAiChatHistory, setIsChatHistoryLoading]);
|
||||
|
||||
if (roadmapDetailError) {
|
||||
return (
|
||||
<div className="flex flex-grow flex-col items-center justify-center">
|
||||
@@ -307,6 +363,21 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
setActiveTab('chat');
|
||||
}}
|
||||
selectedTopicId={selectedTopicId}
|
||||
roadmapId={roadmapId}
|
||||
activeChatHistoryId={activeChatHistoryId}
|
||||
onChatHistoryClick={(chatHistoryId) => {
|
||||
setIsChatHistoryLoading(true);
|
||||
setActiveChatHistoryId(chatHistoryId);
|
||||
}}
|
||||
onNewChat={() => {
|
||||
document.title = 'Roadmap AI Chat';
|
||||
setActiveChatHistoryId(undefined);
|
||||
}}
|
||||
onDeleteChatHistory={(chatHistoryId) => {
|
||||
if (activeChatHistoryId === chatHistoryId) {
|
||||
setActiveChatHistoryId(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{activeTab === 'topic' && selectedTopicId && (
|
||||
@@ -333,62 +404,77 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
|
||||
{activeTab === 'chat' && (
|
||||
<>
|
||||
{!!chatHistory && isPaidUser && !isChatHistoryLoading && (
|
||||
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 text-sm text-gray-500">
|
||||
<h3 className="flex min-w-0 items-center gap-2">
|
||||
<HistoryIcon className="size-4 shrink-0" />
|
||||
<span className="truncate">{chatHistory.title}</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveChatHistoryId(undefined);
|
||||
}}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative grow overflow-y-auto" ref={scrollareaRef}>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-1.5 px-3 text-sm text-gray-500">
|
||||
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
|
||||
<span>Loading Roadmap</span>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && <Loader />}
|
||||
{isChatHistoryLoading && (
|
||||
<Loader message="Loading chat history" />
|
||||
)}
|
||||
|
||||
{shouldShowChatPersona && !isLoading && (
|
||||
{shouldShowChatPersona && !isLoading && !isChatHistoryLoading && (
|
||||
<ChatPersona roadmapId={roadmapId} />
|
||||
)}
|
||||
|
||||
{!isLoading && !shouldShowChatPersona && (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<div className="relative flex grow flex-col justify-end">
|
||||
<div className="flex flex-col justify-end gap-2 px-3 py-2">
|
||||
<RoadmapAIChatCard
|
||||
role="assistant"
|
||||
jsx={
|
||||
<TutorIntroMessage roadmap={roadmapDetail?.json!} />
|
||||
}
|
||||
isIntro
|
||||
/>
|
||||
|
||||
{aiChatHistory.map(
|
||||
(chat: RoadmapAIChatHistoryType, index: number) => {
|
||||
return (
|
||||
<Fragment key={`chat-${index}`}>
|
||||
<RoadmapAIChatCard {...chat} />
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
)}
|
||||
|
||||
{isStreamingMessage && !streamedMessage && (
|
||||
{!isLoading &&
|
||||
!isChatHistoryLoading &&
|
||||
!shouldShowChatPersona && (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<div className="relative flex grow flex-col justify-end">
|
||||
<div className="flex flex-col justify-end gap-2 px-3 py-2">
|
||||
<RoadmapAIChatCard
|
||||
role="assistant"
|
||||
html="Thinking..."
|
||||
jsx={
|
||||
<TutorIntroMessage roadmap={roadmapDetail?.json!} />
|
||||
}
|
||||
isIntro
|
||||
/>
|
||||
)}
|
||||
|
||||
{streamedMessage && (
|
||||
<RoadmapAIChatCard
|
||||
role="assistant"
|
||||
jsx={streamedMessage}
|
||||
/>
|
||||
)}
|
||||
{aiChatHistory.map(
|
||||
(chat: RoadmapAIChatHistoryType, index: number) => {
|
||||
return (
|
||||
<Fragment key={`chat-${index}`}>
|
||||
<RoadmapAIChatCard {...chat} />
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
)}
|
||||
|
||||
{isStreamingMessage && !streamedMessage && (
|
||||
<RoadmapAIChatCard
|
||||
role="assistant"
|
||||
html="Thinking..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{streamedMessage && (
|
||||
<RoadmapAIChatCard
|
||||
role="assistant"
|
||||
jsx={streamedMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLoading && !shouldShowChatPersona && (
|
||||
{!isLoading && !isChatHistoryLoading && !shouldShowChatPersona && (
|
||||
<div className="flex flex-col border-t border-gray-200">
|
||||
{!isLimitExceeded && (
|
||||
<AIChatActionButtons
|
||||
@@ -396,6 +482,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
setShowUpdatePersonaModal(true);
|
||||
}}
|
||||
messageCount={aiChatHistory.length}
|
||||
showClearChat={!isPaidUser}
|
||||
onClearChat={clearChat}
|
||||
/>
|
||||
)}
|
||||
@@ -508,3 +595,20 @@ function isEmptyContent(content: JSONContent) {
|
||||
(!firstContent?.content || firstContent?.content?.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
type LoaderProps = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function Loader(props: LoaderProps) {
|
||||
const { message } = props;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-1.5 px-3 text-sm text-gray-500">
|
||||
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
|
||||
<span>{message ?? 'Loading Roadmap'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,27 +3,14 @@ import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { BookIcon, BotIcon, GiftIcon, XIcon } from 'lucide-react';
|
||||
import { BookIcon, BotIcon, GiftIcon, PlusIcon, XIcon } from 'lucide-react';
|
||||
import type { RoadmapAIChatTab } from './RoadmapAIChat';
|
||||
import { useState } from 'react';
|
||||
import { getPercentage } from '../../lib/number';
|
||||
import { AILimitsPopup } from '../GenerateCourse/AILimitsPopup';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
|
||||
type RoadmapAIChatHeaderProps = {
|
||||
isLoading: boolean;
|
||||
|
||||
onLogin: () => void;
|
||||
onUpgrade: () => void;
|
||||
|
||||
onCloseChat: () => void;
|
||||
|
||||
activeTab: RoadmapAIChatTab;
|
||||
onTabChange: (tab: RoadmapAIChatTab) => void;
|
||||
onCloseTopic: () => void;
|
||||
selectedTopicId: string | null;
|
||||
};
|
||||
import { RoadmapAIChatHistory } from '../RoadmapAIChatHistory/RoadmapAIChatHistory';
|
||||
|
||||
type TabButtonProps = {
|
||||
icon: React.ReactNode;
|
||||
@@ -65,6 +52,26 @@ function TabButton(props: TabButtonProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type RoadmapAIChatHeaderProps = {
|
||||
isLoading: boolean;
|
||||
|
||||
onLogin: () => void;
|
||||
onUpgrade: () => void;
|
||||
|
||||
onCloseChat: () => void;
|
||||
|
||||
activeTab: RoadmapAIChatTab;
|
||||
onTabChange: (tab: RoadmapAIChatTab) => void;
|
||||
onCloseTopic: () => void;
|
||||
selectedTopicId: string | null;
|
||||
|
||||
roadmapId: string;
|
||||
activeChatHistoryId?: string;
|
||||
onChatHistoryClick: (chatHistoryId: string) => void;
|
||||
onNewChat: () => void;
|
||||
onDeleteChatHistory: (chatHistoryId: string) => void;
|
||||
};
|
||||
|
||||
export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
|
||||
const {
|
||||
onLogin,
|
||||
@@ -76,6 +83,11 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
|
||||
onTabChange,
|
||||
onCloseTopic,
|
||||
selectedTopicId,
|
||||
roadmapId,
|
||||
activeChatHistoryId,
|
||||
onChatHistoryClick,
|
||||
onNewChat,
|
||||
onDeleteChatHistory,
|
||||
} = props;
|
||||
|
||||
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
|
||||
@@ -146,15 +158,18 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
|
||||
|
||||
{!isDataLoading && isLoggedIn() && (
|
||||
<div className="flex gap-1.5 pr-4">
|
||||
{isPaidUser && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-md bg-gray-200 px-2 py-1 text-xs text-black hover:bg-gray-300"
|
||||
onClick={onNewChat}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
New Chat
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isPaidUser && (
|
||||
<>
|
||||
<button
|
||||
className="hidden rounded-md bg-gray-200 px-2 py-1 text-sm hover:bg-gray-300 2xl:block"
|
||||
onClick={handleCreditsClick}
|
||||
>
|
||||
<span className="font-medium">{usagePercentage}%</span> limit
|
||||
used
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-md bg-yellow-400 px-2 py-1 text-sm text-black hover:bg-yellow-500"
|
||||
onClick={handleUpgradeClick}
|
||||
@@ -162,14 +177,21 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
|
||||
<GiftIcon className="size-4" />
|
||||
Upgrade
|
||||
</button>
|
||||
<button
|
||||
className="hidden items-center gap-1 rounded-md bg-gray-200 px-2 py-1 text-sm text-black hover:bg-gray-300 max-xl:flex"
|
||||
onClick={onCloseChat}
|
||||
>
|
||||
<XIcon className="size-3.5" strokeWidth={2.5} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<RoadmapAIChatHistory
|
||||
roadmapId={roadmapId}
|
||||
onChatHistoryClick={onChatHistoryClick}
|
||||
activeChatHistoryId={activeChatHistoryId}
|
||||
onDelete={onDeleteChatHistory}
|
||||
onUpgrade={onUpgrade}
|
||||
/>
|
||||
<button
|
||||
className="hidden items-center gap-1 rounded-md bg-gray-200 px-2 py-1 text-sm text-black hover:bg-gray-300 max-xl:flex"
|
||||
onClick={onCloseChat}
|
||||
>
|
||||
<XIcon className="size-3.5" strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user