@@ -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' && (
<>
- {isLoading && (
-
-
-
- Loading Roadmap
-
-
+ {isLoading &&
}
+ {isChatHistoryLoading && (
+
)}
- {shouldShowChatPersona && !isLoading && (
+ {shouldShowChatPersona && !isLoading && !isChatHistoryLoading && (
)}
- {!isLoading && !shouldShowChatPersona && (
-
-
-
-
- }
- isIntro
- />
-
- {aiChatHistory.map(
- (chat: RoadmapAIChatHistoryType, index: number) => {
- return (
-
-
-
- );
- },
- )}
-
- {isStreamingMessage && !streamedMessage && (
+ {!isLoading &&
+ !isChatHistoryLoading &&
+ !shouldShowChatPersona && (
+
+
+
+ }
+ isIntro
/>
- )}
- {streamedMessage && (
-
- )}
+ {aiChatHistory.map(
+ (chat: RoadmapAIChatHistoryType, index: number) => {
+ return (
+
+
+
+ );
+ },
+ )}
+
+ {isStreamingMessage && !streamedMessage && (
+
+ )}
+
+ {streamedMessage && (
+
+ )}
+
-
- )}
+ )}
- {!isLoading && !shouldShowChatPersona && (
+ {!isLoading && !isChatHistoryLoading && !shouldShowChatPersona && (
{!isLimitExceeded && (
)}
@@ -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 (
+
+
+
+ {message ?? 'Loading Roadmap'}
+
+
+ );
+}
diff --git a/src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx b/src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx
index 726fd6eb8..43a38a510 100644
--- a/src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx
+++ b/src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx
@@ -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) {
>
)}
-
+
)}
diff --git a/src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx b/src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx
index 8a36604db..0af0408e5 100644
--- a/src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx
+++ b/src/components/RoadmapAIChatHistory/RoadmapAIChatHistory.tsx
@@ -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) {
-
+ {isLoading && (
+
+
+
+ )}
+ {!isLoading && (
+ <>
+
-
- {isEmptyHistory && (
-
-
No chat history
+
+ {isEmptyHistory && (
+
+ )}
+
+ {Object.entries(groupedChatHistory ?? {}).map(([key, value]) => {
+ if (value.histories.length === 0) {
+ return null;
+ }
+
+ return (
+
{
+ setIsOpen(false);
+ onChatHistoryClick(id);
+ }}
+ onDelete={(id) => {
+ setIsOpen(false);
+ onDelete?.(id);
+ }}
+ />
+ );
+ })}
+
+ {hasNextPage && (
+
+
+
+ )}
- )}
- {Object.entries(groupedChatHistory ?? {}).map(([key, value]) => {
- if (value.histories.length === 0) {
- return null;
- }
-
- return (
-
{
- onChatHistoryClick(id);
- }}
- onDelete={(id) => {
- onDelete?.(id);
- }}
- />
- );
- })}
-
- {hasNextPage && (
-
+
- )}
-
-
-
+ >
+ )}
);
diff --git a/src/hooks/use-roadmap-ai-chat.tsx b/src/hooks/use-roadmap-ai-chat.tsx
index cdc440265..dda428bcd 100644
--- a/src/hooks/use-roadmap-ai-chat.tsx
+++ b/src/hooks/use-roadmap-ai-chat.tsx
@@ -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;
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(null);
@@ -146,33 +149,7 @@ export function useRoadmapAIChat(options: Options) {
}, [isStreamingMessage, streamedMessage, scrollToBottom]);
const renderer: Record = useMemo(
- () => ({
- 'user-progress': () => (
-
- ),
- 'update-progress': (opts) => (
-
- ),
- 'roadmap-topics': (opts) => (
- {
- const title = text.split(' > ').pop();
- if (title) {
- onSelectTopic(topicId, title);
- }
- }}
- {...opts}
- />
- ),
- 'resource-progress-link': () => (
-
- ),
- 'roadmap-recommendations': (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 || []) {
diff --git a/src/queries/chat-history.ts b/src/queries/chat-history.ts
index e65834339..927f44ce4 100644
--- a/src/queries/chat-history.ts
+++ b/src/queries/chat-history.ts
@@ -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,