mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-03 06:12:53 +02:00
wip
This commit is contained in:
@@ -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"
|
||||
|
@@ -48,9 +48,12 @@ import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal';
|
||||
import { lockBodyScroll } from '../../lib/dom';
|
||||
import { TutorIntroMessage } from './TutorIntroMessage';
|
||||
import {
|
||||
roadmapAIChatRenderer,
|
||||
useRoadmapAIChat,
|
||||
type RoadmapAIChatHistoryType,
|
||||
} from '../../hooks/use-roadmap-ai-chat';
|
||||
import { chatHistoryOptions } from '../../queries/chat-history';
|
||||
import { deleteUrlParam, getUrlParams } from '../../lib/browser';
|
||||
|
||||
export type RoadmapAIChatTab = 'chat' | 'topic';
|
||||
|
||||
@@ -79,6 +82,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 +142,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 +185,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 +207,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">
|
||||
@@ -308,6 +362,20 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
}}
|
||||
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 && (
|
||||
@@ -335,61 +403,59 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
{activeTab === 'chat' && (
|
||||
<>
|
||||
<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
|
||||
@@ -397,6 +463,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
setShowUpdatePersonaModal(true);
|
||||
}}
|
||||
messageCount={aiChatHistory.length}
|
||||
showClearChat={!isPaidUser}
|
||||
onClearChat={clearChat}
|
||||
/>
|
||||
)}
|
||||
@@ -509,3 +576,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>
|
||||
);
|
||||
}
|
||||
|
@@ -12,22 +12,6 @@ import { cn } from '../../lib/classname';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { RoadmapAIChatHistory } from '../RoadmapAIChatHistory/RoadmapAIChatHistory';
|
||||
|
||||
type RoadmapAIChatHeaderProps = {
|
||||
isLoading: boolean;
|
||||
|
||||
onLogin: () => void;
|
||||
onUpgrade: () => void;
|
||||
|
||||
onCloseChat: () => void;
|
||||
|
||||
activeTab: RoadmapAIChatTab;
|
||||
onTabChange: (tab: RoadmapAIChatTab) => void;
|
||||
onCloseTopic: () => void;
|
||||
selectedTopicId: string | null;
|
||||
|
||||
roadmapId: string;
|
||||
};
|
||||
|
||||
type TabButtonProps = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
@@ -68,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,
|
||||
@@ -80,6 +84,10 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
|
||||
onCloseTopic,
|
||||
selectedTopicId,
|
||||
roadmapId,
|
||||
activeChatHistoryId,
|
||||
onChatHistoryClick,
|
||||
onNewChat,
|
||||
onDeleteChatHistory,
|
||||
} = props;
|
||||
|
||||
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
|
||||
@@ -175,7 +183,13 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
|
||||
</>
|
||||
)}
|
||||
|
||||
<RoadmapAIChatHistory roadmapId={roadmapId} />
|
||||
<RoadmapAIChatHistory
|
||||
roadmapId={roadmapId}
|
||||
onChatHistoryClick={onChatHistoryClick}
|
||||
activeChatHistoryId={activeChatHistoryId}
|
||||
onNewChat={onNewChat}
|
||||
onDelete={onDeleteChatHistory}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { HistoryIcon, Loader2Icon, PlusIcon } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../Popover';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { listChatHistoryOptions } from '../../queries/chat-history';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
@@ -14,13 +14,20 @@ type RoadmapAIChatHistoryProps = {
|
||||
activeChatHistoryId?: string;
|
||||
onChatHistoryClick: (id: string) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
onNewChat?: () => void;
|
||||
};
|
||||
|
||||
export function RoadmapAIChatHistory(props: RoadmapAIChatHistoryProps) {
|
||||
const { roadmapId, activeChatHistoryId, onChatHistoryClick, onDelete } =
|
||||
props;
|
||||
const {
|
||||
roadmapId,
|
||||
activeChatHistoryId,
|
||||
onChatHistoryClick,
|
||||
onDelete,
|
||||
onNewChat,
|
||||
} = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const {
|
||||
@@ -40,6 +47,14 @@ export function RoadmapAIChatHistory(props: RoadmapAIChatHistoryProps) {
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatHistory) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [chatHistory]);
|
||||
|
||||
const groupedChatHistory = useMemo(() => {
|
||||
const allHistories = chatHistory?.pages?.flatMap((page) => page.data);
|
||||
return groupChatHistory(allHistories ?? []);
|
||||
@@ -54,72 +69,89 @@ export function RoadmapAIChatHistory(props: RoadmapAIChatHistoryProps) {
|
||||
<HistoryIcon className="size-4" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-96 overflow-hidden p-0"
|
||||
className="flex max-h-[400px] w-80 flex-col overflow-hidden p-0"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<SearchAIChatHistory
|
||||
onSearch={setQuery}
|
||||
isLoading={isLoadingInfiniteQuery}
|
||||
className="mt-0"
|
||||
inputClassName="border-x-0 border-t-0 border-b border-b-gray-200 rounded-none focus:border-b-gray-200"
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2Icon className="size-6 animate-spin stroke-[2.5]" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<>
|
||||
<SearchAIChatHistory
|
||||
onSearch={setQuery}
|
||||
isLoading={isLoadingInfiniteQuery}
|
||||
className="mt-0"
|
||||
inputClassName="border-x-0 border-t-0 border-b border-b-gray-200 rounded-none focus:border-b-gray-200"
|
||||
/>
|
||||
|
||||
<div className="scrollbar-track-transparent scrollbar-thin scrollbar-thumb-gray-300 grow space-y-4 overflow-y-auto p-2 pt-4">
|
||||
{isEmptyHistory && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<p className="text-sm text-gray-500">No chat history</p>
|
||||
<div className="scrollbar-track-transparent scrollbar-thin scrollbar-thumb-gray-300 grow space-y-4 overflow-y-auto p-2 pt-4">
|
||||
{isEmptyHistory && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<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 (
|
||||
<ChatHistoryGroup
|
||||
key={key}
|
||||
title={value.title}
|
||||
histories={value.histories}
|
||||
activeChatHistoryId={activeChatHistoryId}
|
||||
onChatHistoryClick={(id) => {
|
||||
setIsOpen(false);
|
||||
onChatHistoryClick(id);
|
||||
}}
|
||||
onDelete={(id) => {
|
||||
setIsOpen(false);
|
||||
onDelete?.(id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{Object.entries(groupedChatHistory ?? {}).map(([key, value]) => {
|
||||
if (value.histories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatHistoryGroup
|
||||
key={key}
|
||||
title={value.title}
|
||||
histories={value.histories}
|
||||
activeChatHistoryId={activeChatHistoryId}
|
||||
onChatHistoryClick={(id) => {
|
||||
onChatHistoryClick(id);
|
||||
}}
|
||||
onDelete={(id) => {
|
||||
onDelete?.(id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-center border-t border-gray-200">
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 text-sm text-gray-500 hover:text-black"
|
||||
className="flex w-full items-center justify-center gap-2 p-2 text-sm text-gray-500 hover:bg-gray-200 hover:text-black"
|
||||
onClick={() => {
|
||||
fetchNextPage();
|
||||
setIsOpen(false);
|
||||
onNewChat?.();
|
||||
}}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage && (
|
||||
<>
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
Loading more...
|
||||
</>
|
||||
)}
|
||||
{!isFetchingNextPage && 'Load More'}
|
||||
<PlusIcon className="size-4" />
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center border-t border-gray-200">
|
||||
<button className="flex w-full items-center justify-center gap-2 p-2 text-sm text-gray-500 hover:bg-gray-200 hover:text-black">
|
||||
<PlusIcon className="size-4" />
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
@@ -16,6 +16,7 @@ import { RoadmapTopicList } from '../components/RoadmapAIChat/RoadmapTopicList';
|
||||
import { ShareResourceLink } from '../components/RoadmapAIChat/ShareResourceLink';
|
||||
import { RoadmapRecommendations } from '../components/RoadmapAIChat/RoadmapRecommendations';
|
||||
import type { AllowedAIChatRole } from '../components/GenerateCourse/AICourseLessonChat';
|
||||
import { readChatStream } from '../lib/chat';
|
||||
|
||||
type RoadmapAIChatRendererOptions = {
|
||||
totalTopicCount: number;
|
||||
@@ -67,26 +68,28 @@ export type RoadmapAIChatHistoryType = {
|
||||
};
|
||||
|
||||
type Options = {
|
||||
activeChatHistoryId?: string;
|
||||
roadmapId: string;
|
||||
totalTopicCount: number;
|
||||
scrollareaRef: React.RefObject<HTMLDivElement | null>;
|
||||
onSelectTopic: (topicId: string, topicTitle: string) => void;
|
||||
defaultMessages?: RoadmapAIChatHistoryType[];
|
||||
onChatHistoryIdChange?: (chatHistoryId: string) => void;
|
||||
};
|
||||
|
||||
export function useRoadmapAIChat(options: Options) {
|
||||
const {
|
||||
activeChatHistoryId,
|
||||
roadmapId,
|
||||
totalTopicCount,
|
||||
scrollareaRef,
|
||||
onSelectTopic,
|
||||
defaultMessages,
|
||||
onChatHistoryIdChange,
|
||||
} = options;
|
||||
const toast = useToast();
|
||||
|
||||
const [aiChatHistory, setAiChatHistory] = useState<
|
||||
RoadmapAIChatHistoryType[]
|
||||
>(defaultMessages ?? []);
|
||||
>([]);
|
||||
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
|
||||
const [streamedMessage, setStreamedMessage] =
|
||||
useState<React.ReactNode | null>(null);
|
||||
@@ -146,33 +149,7 @@ export function useRoadmapAIChat(options: Options) {
|
||||
}, [isStreamingMessage, streamedMessage, scrollToBottom]);
|
||||
|
||||
const renderer: Record<string, MessagePartRenderer> = useMemo(
|
||||
() => ({
|
||||
'user-progress': () => (
|
||||
<UserProgressList
|
||||
totalTopicCount={totalTopicCount}
|
||||
roadmapId={roadmapId}
|
||||
/>
|
||||
),
|
||||
'update-progress': (opts) => (
|
||||
<UserProgressActionList roadmapId={roadmapId} {...opts} />
|
||||
),
|
||||
'roadmap-topics': (opts) => (
|
||||
<RoadmapTopicList
|
||||
roadmapId={roadmapId}
|
||||
onTopicClick={(topicId, text) => {
|
||||
const title = text.split(' > ').pop();
|
||||
if (title) {
|
||||
onSelectTopic(topicId, title);
|
||||
}
|
||||
}}
|
||||
{...opts}
|
||||
/>
|
||||
),
|
||||
'resource-progress-link': () => (
|
||||
<ShareResourceLink roadmapId={roadmapId} />
|
||||
),
|
||||
'roadmap-recommendations': (opts) => <RoadmapRecommendations {...opts} />,
|
||||
}),
|
||||
() => roadmapAIChatRenderer({ roadmapId, totalTopicCount, onSelectTopic }),
|
||||
[roadmapId, onSelectTopic, totalTopicCount],
|
||||
);
|
||||
|
||||
@@ -188,7 +165,13 @@ export function useRoadmapAIChat(options: Options) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
signal: abortController?.signal,
|
||||
body: JSON.stringify({ roadmapId, messages: messages.slice(-10) }),
|
||||
body: JSON.stringify({
|
||||
roadmapId,
|
||||
messages,
|
||||
...(activeChatHistoryId
|
||||
? { chatHistoryId: activeChatHistoryId }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -212,16 +195,24 @@ export function useRoadmapAIChat(options: Options) {
|
||||
return;
|
||||
}
|
||||
|
||||
await readStream(reader, {
|
||||
onStream: async (content) => {
|
||||
if (abortController?.signal.aborted) return;
|
||||
await readChatStream(reader, {
|
||||
onMessage: async (content) => {
|
||||
if (abortController?.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jsx = await renderMessage(content, renderer, {
|
||||
isLoading: true,
|
||||
});
|
||||
flushSync(() => setStreamedMessage(jsx));
|
||||
flushSync(() => {
|
||||
setStreamedMessage(jsx);
|
||||
});
|
||||
},
|
||||
onStreamEnd: async (content) => {
|
||||
if (abortController?.signal.aborted) return;
|
||||
onMessageEnd: async (content) => {
|
||||
if (abortController?.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jsx = await renderMessage(content, renderer, {
|
||||
isLoading: false,
|
||||
});
|
||||
@@ -235,6 +226,24 @@ export function useRoadmapAIChat(options: Options) {
|
||||
setAiChatHistory(newMessages);
|
||||
});
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
return (
|
||||
query.queryKey[0] === 'list-chat-history' &&
|
||||
(query.queryKey[1] as { roadmapId: string })?.roadmapId ===
|
||||
roadmapId
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
onDetails: (details) => {
|
||||
const detailsJson = JSON.parse(details);
|
||||
const chatHistoryId = detailsJson?.chatHistoryId;
|
||||
if (!chatHistoryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChatHistoryIdChange?.(chatHistoryId);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -303,10 +312,11 @@ export function useRoadmapAIChat(options: Options) {
|
||||
handleAbort,
|
||||
clearChat,
|
||||
scrollToBottom,
|
||||
setAiChatHistory,
|
||||
};
|
||||
}
|
||||
|
||||
function htmlFromTiptapJSON(json: JSONContent): string {
|
||||
export function htmlFromTiptapJSON(json: JSONContent): string {
|
||||
const content = json.content;
|
||||
let text = '';
|
||||
for (const child of content || []) {
|
||||
|
@@ -7,12 +7,17 @@ import {
|
||||
type MessagePartRenderer,
|
||||
renderMessage,
|
||||
} from '../lib/render-chat-message';
|
||||
import type { RoadmapAIChatHistoryType } from '../hooks/use-roadmap-ai-chat';
|
||||
import {
|
||||
htmlFromTiptapJSON,
|
||||
type RoadmapAIChatHistoryType,
|
||||
} from '../hooks/use-roadmap-ai-chat';
|
||||
import type { JSONContent } from '@tiptap/core';
|
||||
|
||||
export type ChatHistoryMessage = {
|
||||
_id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
json?: JSONContent;
|
||||
};
|
||||
|
||||
export interface ChatHistoryDocument {
|
||||
@@ -47,9 +52,14 @@ export function chatHistoryOptions(
|
||||
messages.push({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
...(message.role === 'user' && {
|
||||
html: markdownToHtml(message.content),
|
||||
}),
|
||||
...(message.role === 'user' &&
|
||||
!message?.json && {
|
||||
html: markdownToHtml(message.content),
|
||||
}),
|
||||
...(message.role === 'user' &&
|
||||
message?.json && {
|
||||
html: htmlFromTiptapJSON(message.json),
|
||||
}),
|
||||
...(message.role === 'assistant' && {
|
||||
jsx: await renderMessage(message.content, renderer ?? {}, {
|
||||
isLoading: false,
|
||||
|
Reference in New Issue
Block a user