From 1ae167e413856996eb32b3ea0ea34eafe8cd20c2 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed Date: Mon, 9 Jun 2025 21:22:37 +0100 Subject: [PATCH] Refactor roadmap ai chat to hook --- .astro/settings.json | 2 +- .astro/types.d.ts | 1 - .../RoadmapAIChat/RoadmapAIChat.tsx | 331 +++--------------- src/hooks/use-roadmap-ai-chat.tsx | 224 ++++++++++++ 4 files changed, 280 insertions(+), 278 deletions(-) create mode 100644 src/hooks/use-roadmap-ai-chat.tsx diff --git a/.astro/settings.json b/.astro/settings.json index 4d5033b8e..501b47e44 100644 --- a/.astro/settings.json +++ b/.astro/settings.json @@ -3,6 +3,6 @@ "enabled": false }, "_variables": { - "lastUpdateCheck": 1748277554631 + "lastUpdateCheck": 1749494681580 } } \ No newline at end of file diff --git a/.astro/types.d.ts b/.astro/types.d.ts index 03d7cc43f..f964fe0cf 100644 --- a/.astro/types.d.ts +++ b/.astro/types.d.ts @@ -1,2 +1 @@ /// -/// \ No newline at end of file diff --git a/src/components/RoadmapAIChat/RoadmapAIChat.tsx b/src/components/RoadmapAIChat/RoadmapAIChat.tsx index 6a6b2d80a..0f026ed20 100644 --- a/src/components/RoadmapAIChat/RoadmapAIChat.tsx +++ b/src/components/RoadmapAIChat/RoadmapAIChat.tsx @@ -22,25 +22,14 @@ import { } from 'lucide-react'; import { ChatEditor } from '../ChatEditor/ChatEditor'; import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree'; -import { type AllowedAIChatRole } from '../GenerateCourse/AICourseLessonChat'; -import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; +import { isLoggedIn } from '../../lib/jwt'; import type { JSONContent, Editor } from '@tiptap/core'; import { flushSync } from 'react-dom'; import { getAiCourseLimitOptions } from '../../queries/ai-course'; -import { readStream } from '../../lib/ai'; import { useToast } from '../../hooks/use-toast'; import { userResourceProgressOptions } from '../../queries/resource-progress'; import { ChatRoadmapRenderer } from './ChatRoadmapRenderer'; -import { - renderMessage, - type MessagePartRenderer, -} from '../../lib/render-chat-message'; import { RoadmapAIChatCard } from './RoadmapAIChatCard'; -import { UserProgressList } from './UserProgressList'; -import { UserProgressActionList } from './UserProgressActionList'; -import { RoadmapTopicList } from './RoadmapTopicList'; -import { ShareResourceLink } from './ShareResourceLink'; -import { RoadmapRecommendations } from './RoadmapRecommendations'; import { RoadmapAIChatHeader } from './RoadmapAIChatHeader'; import { showLoginPopup } from '../../lib/popup'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; @@ -58,21 +47,10 @@ import { userRoadmapPersonaOptions } from '../../queries/user-persona'; import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal'; import { lockBodyScroll } from '../../lib/dom'; import { TutorIntroMessage } from './TutorIntroMessage'; - -export type RoadmapAIChatHistoryType = { - role: AllowedAIChatRole; - isDefault?: boolean; - - // these two will be used only into the backend - // for transforming the raw message into the final message - content?: string; - json?: JSONContent; - - // these two will be used only into the frontend - // for rendering the message - html?: string; - jsx?: React.ReactNode; -}; +import { + useRoadmapAIChat, + type RoadmapAIChatHistoryType, +} from '../../hooks/use-roadmap-ai-chat'; export type RoadmapAIChatTab = 'chat' | 'topic'; @@ -102,12 +80,6 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { ); const [activeTab, setActiveTab] = useState('chat'); - const [aiChatHistory, setAiChatHistory] = useState< - RoadmapAIChatHistoryType[] - >([]); - const [isStreamingMessage, setIsStreamingMessage] = useState(false); - const [streamedMessage, setStreamedMessage] = - useState(null); const [showUpdatePersonaModal, setShowUpdatePersonaModal] = useState(false); const { data: roadmapDetail, error: roadmapDetailError } = useQuery( @@ -146,6 +118,15 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { const roadmapContainerRef = useRef(null); + const totalTopicCount = useMemo(() => { + const allowedTypes = ['topic', 'subtopic', 'todo']; + return ( + roadmapDetail?.json?.nodes.filter((node) => + allowedTypes.includes(node.type || ''), + ).length ?? 0 + ); + }, [roadmapDetail]); + useEffect(() => { if (!roadmapDetail || !roadmapContainerRef.current) { return; @@ -162,47 +143,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { setIsLoading(false); }, [roadmapTreeData, roadmapDetail, isUserPersonaLoading]); - const abortControllerRef = useRef(null); - const handleChatSubmit = (json: JSONContent) => { - if ( - !json || - isStreamingMessage || - !isLoggedIn() || - isLoading || - abortControllerRef.current - ) { - return; - } - - abortControllerRef.current = new AbortController(); - - const html = htmlFromTiptapJSON(json); - const newMessages: RoadmapAIChatHistoryType[] = [ - ...aiChatHistory, - { - role: 'user', - json, - html, - }, - ]; - - flushSync(() => { - setAiChatHistory(newMessages); - editorRef.current?.commands.setContent('

'); - }); - - scrollToBottom(); - completeAITutorChat(newMessages, abortControllerRef.current); - }; - - const scrollToBottom = useCallback(() => { - scrollareaRef.current?.scrollTo({ - top: scrollareaRef.current.scrollHeight, - behavior: 'smooth', - }); - }, [scrollareaRef]); - - const handleSelectTopic = useCallback( + const onSelectTopic = useCallback( (topicId: string, topicTitle: string) => { flushSync(() => { setSelectedTopicId(topicId); @@ -229,169 +170,21 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { [roadmapId, deviceType], ); - const totalTopicCount = useMemo(() => { - const allowedTypes = ['topic', 'subtopic', 'todo']; - return ( - roadmapDetail?.json?.nodes.filter((node) => - allowedTypes.includes(node.type || ''), - ).length ?? 0 - ); - }, [roadmapDetail]); - - const renderer: Record = useMemo(() => { - return { - 'user-progress': () => { - return ( - - ); - }, - 'update-progress': (options) => { - return ; - }, - 'roadmap-topics': (options) => { - return ( - { - const title = text.split(' > ').pop(); - if (!title) { - return; - } - - handleSelectTopic(topicId, title); - }} - {...options} - /> - ); - }, - 'resource-progress-link': () => { - return ; - }, - 'roadmap-recommendations': (options) => { - return ; - }, - }; - }, [roadmapId, handleSelectTopic, totalTopicCount]); - - const completeAITutorChat = async ( - messages: RoadmapAIChatHistoryType[], - abortController?: AbortController, - ) => { - try { - setIsStreamingMessage(true); - - const response = await fetch( - `${import.meta.env.PUBLIC_API_URL}/v1-chat-roadmap`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - signal: abortController?.signal, - body: JSON.stringify({ - roadmapId, - messages: messages.slice(-10), - }), - }, - ); - - if (!response.ok) { - const data = await response.json(); - - toast.error(data?.message || 'Something went wrong'); - setAiChatHistory([...messages].slice(0, messages.length - 1)); - setIsStreamingMessage(false); - - if (data.status === 401) { - removeAuthToken(); - window.location.reload(); - } - - queryClient.invalidateQueries(getAiCourseLimitOptions()); - return; - } - - const reader = response.body?.getReader(); - - if (!reader) { - setIsStreamingMessage(false); - toast.error('Something went wrong'); - return; - } - - await readStream(reader, { - onStream: async (content) => { - if (abortController?.signal.aborted) { - return; - } - - const jsx = await renderMessage(content, renderer, { - isLoading: true, - }); - - flushSync(() => { - setStreamedMessage(jsx); - }); - - scrollToBottom(); - }, - onStreamEnd: async (content) => { - if (abortController?.signal.aborted) { - return; - } - - const jsx = await renderMessage(content, renderer, { - isLoading: false, - }); - const newMessages: RoadmapAIChatHistoryType[] = [ - ...messages, - { - role: 'assistant', - content, - jsx, - }, - ]; - - flushSync(() => { - setStreamedMessage(null); - setIsStreamingMessage(false); - setAiChatHistory(newMessages); - }); - - queryClient.invalidateQueries(getAiCourseLimitOptions()); - scrollToBottom(); - }, - }); - - setIsStreamingMessage(false); - abortControllerRef.current = null; - } catch (error) { - setIsStreamingMessage(false); - setStreamedMessage(null); - abortControllerRef.current = null; - - if (abortController?.signal.aborted) { - return; - } - toast.error('Something went wrong'); - } - }; - - const handleAbort = () => { - abortControllerRef.current?.abort(); - abortControllerRef.current = null; - setIsStreamingMessage(false); - setStreamedMessage(null); - setAiChatHistory([...aiChatHistory].slice(0, aiChatHistory.length - 1)); - }; - - useEffect(() => { - scrollToBottom(); - }, []); + const { + aiChatHistory, + isStreamingMessage, + streamedMessage, + abortControllerRef, + handleChatSubmit, + handleAbort, + clearChat, + scrollToBottom, + } = useRoadmapAIChat({ + roadmapId, + totalTopicCount, + scrollareaRef, + onSelectTopic, + }); if (roadmapDetailError) { return ( @@ -442,7 +235,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { roadmapId={roadmapId} nodes={roadmapDetail?.json.nodes} edges={roadmapDetail?.json.edges} - onSelectTopic={handleSelectTopic} + onSelectTopic={onSelectTopic} /> {/* floating chat button */} @@ -498,13 +291,16 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { onTabChange={(tab) => { setActiveTab(tab); if (tab === 'topic' && selectedTopicId && selectedTopicTitle) { - handleSelectTopic(selectedTopicId, selectedTopicTitle); + scrollToBottom(); } }} onCloseTopic={() => { setSelectedTopicId(null); setSelectedTopicTitle(null); - setActiveTab('chat'); + flushSync(() => { + setActiveTab('chat'); + }); + scrollToBottom(); }} onCloseChat={() => { setIsChatMobileVisible(false); @@ -563,13 +359,15 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { isIntro /> - {aiChatHistory.map((chat, index) => { - return ( - - - - ); - })} + {aiChatHistory.map( + (chat: RoadmapAIChatHistoryType, index: number) => { + return ( + + + + ); + }, + )} {isStreamingMessage && !streamedMessage && ( { - setAiChatHistory([]); - }} + onClearChat={clearChat} /> )} @@ -624,7 +420,10 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { return; } - handleChatSubmit(content); + flushSync(() => { + editorRef.current?.commands.setContent('

'); + }); + handleChatSubmit(content, isDataLoading); }} /> @@ -670,7 +469,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) { return; } - handleChatSubmit(json); + flushSync(() => { + editorRef.current?.commands.setContent('

'); + }); + + handleChatSubmit(json, isDataLoading); }} > {isStreamingMessage ? ( @@ -705,27 +508,3 @@ function isEmptyContent(content: JSONContent) { (!firstContent?.content || firstContent?.content?.length === 0) ); } - -export function htmlFromTiptapJSON(json: JSONContent) { - const content = json.content; - - let text = ''; - for (const child of content || []) { - switch (child.type) { - case 'text': - text += child.text; - break; - case 'paragraph': - text += `

${htmlFromTiptapJSON(child)}

`; - break; - case 'variable': - const label = child?.attrs?.label || ''; - text += `${label}`; - break; - default: - break; - } - } - - return text; -} diff --git a/src/hooks/use-roadmap-ai-chat.tsx b/src/hooks/use-roadmap-ai-chat.tsx new file mode 100644 index 000000000..db9612fff --- /dev/null +++ b/src/hooks/use-roadmap-ai-chat.tsx @@ -0,0 +1,224 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import type { JSONContent } from '@tiptap/core'; +import { flushSync } from 'react-dom'; +import { removeAuthToken } from '../lib/jwt'; +import { readStream } from '../lib/ai'; +import { useToast } from './use-toast'; +import { getAiCourseLimitOptions } from '../queries/ai-course'; +import { queryClient } from '../stores/query-client'; +import { + renderMessage, + type MessagePartRenderer, +} from '../lib/render-chat-message'; +import { UserProgressList } from '../components/RoadmapAIChat/UserProgressList'; +import { UserProgressActionList } from '../components/RoadmapAIChat/UserProgressActionList'; +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'; + +export type RoadmapAIChatHistoryType = { + role: AllowedAIChatRole; + isDefault?: boolean; + content?: string; + json?: JSONContent; + html?: string; + jsx?: React.ReactNode; +}; + +type Options = { + roadmapId: string; + totalTopicCount: number; + scrollareaRef: React.RefObject; + onSelectTopic: (topicId: string, topicTitle: string) => void; +}; + +export function useRoadmapAIChat(options: Options) { + const { roadmapId, totalTopicCount, scrollareaRef, onSelectTopic } = options; + const toast = useToast(); + + const [aiChatHistory, setAiChatHistory] = useState< + RoadmapAIChatHistoryType[] + >([]); + const [isStreamingMessage, setIsStreamingMessage] = useState(false); + const [streamedMessage, setStreamedMessage] = + useState(null); + const abortControllerRef = useRef(null); + + const scrollToBottom = useCallback(() => { + scrollareaRef.current?.scrollTo({ + top: scrollareaRef.current.scrollHeight, + behavior: 'instant', + }); + }, [scrollareaRef]); + + 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) => , + }), + [roadmapId, onSelectTopic, totalTopicCount], + ); + + const completeAITutorChat = async ( + messages: RoadmapAIChatHistoryType[], + abortController?: AbortController, + ) => { + try { + setIsStreamingMessage(true); + const response = await fetch( + `${import.meta.env.PUBLIC_API_URL}/v1-chat-roadmap`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + signal: abortController?.signal, + body: JSON.stringify({ roadmapId, messages: messages.slice(-10) }), + }, + ); + + if (!response.ok) { + const data = await response.json(); + toast.error(data?.message || 'Something went wrong'); + setAiChatHistory(messages.slice(0, -1)); + setIsStreamingMessage(false); + if (data.status === 401) { + removeAuthToken(); + window.location.reload(); + } + queryClient.invalidateQueries(getAiCourseLimitOptions()); + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + setIsStreamingMessage(false); + toast.error('Something went wrong'); + return; + } + + await readStream(reader, { + onStream: async (content) => { + if (abortController?.signal.aborted) return; + const jsx = await renderMessage(content, renderer, { + isLoading: true, + }); + flushSync(() => setStreamedMessage(jsx)); + }, + onStreamEnd: async (content) => { + if (abortController?.signal.aborted) return; + const jsx = await renderMessage(content, renderer, { + isLoading: false, + }); + const newMessages = [ + ...messages, + { role: 'assistant' as AllowedAIChatRole, content, jsx }, + ]; + flushSync(() => { + setStreamedMessage(null); + setIsStreamingMessage(false); + setAiChatHistory(newMessages); + }); + queryClient.invalidateQueries(getAiCourseLimitOptions()); + }, + }); + + setIsStreamingMessage(false); + abortControllerRef.current = null; + } catch (error) { + setIsStreamingMessage(false); + setStreamedMessage(null); + abortControllerRef.current = null; + if (!abortController?.signal.aborted) { + toast.error('Something went wrong'); + } + } + }; + + const handleChatSubmit = useCallback( + (json: JSONContent, isLoading: boolean) => { + if ( + !json || + isStreamingMessage || + isLoading || + abortControllerRef.current + ) { + return; + } + + abortControllerRef.current = new AbortController(); + const html = htmlFromTiptapJSON(json); + const newMessages = [ + ...aiChatHistory, + { role: 'user' as AllowedAIChatRole, json, html }, + ]; + + flushSync(() => setAiChatHistory(newMessages)); + scrollToBottom(); + completeAITutorChat(newMessages, abortControllerRef.current); + }, + [aiChatHistory, isStreamingMessage, scrollToBottom], + ); + + const handleAbort = useCallback(() => { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + setIsStreamingMessage(false); + setStreamedMessage(null); + setAiChatHistory(aiChatHistory.slice(0, -1)); + }, [aiChatHistory]); + + const clearChat = useCallback(() => setAiChatHistory([]), []); + + return { + aiChatHistory, + isStreamingMessage, + streamedMessage, + abortControllerRef, + handleChatSubmit, + handleAbort, + clearChat, + scrollToBottom, + }; +} + +function htmlFromTiptapJSON(json: JSONContent): string { + const content = json.content; + let text = ''; + for (const child of content || []) { + switch (child.type) { + case 'text': + text += child.text; + break; + case 'paragraph': + text += `

${htmlFromTiptapJSON(child)}

`; + break; + case 'variable': + const label = child?.attrs?.label || ''; + text += `${label}`; + break; + } + } + return text; +}