1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-03 06:12:53 +02:00

Refactor roadmap ai chat to hook

This commit is contained in:
Kamran Ahmed
2025-06-09 21:22:37 +01:00
parent e08c436870
commit 1ae167e413
4 changed files with 280 additions and 278 deletions

View File

@@ -3,6 +3,6 @@
"enabled": false
},
"_variables": {
"lastUpdateCheck": 1748277554631
"lastUpdateCheck": 1749494681580
}
}

1
.astro/types.d.ts vendored
View File

@@ -1,2 +1 @@
/// <reference types="astro/client" />
/// <reference path="content.d.ts" />

View File

@@ -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<RoadmapAIChatTab>('chat');
const [aiChatHistory, setAiChatHistory] = useState<
RoadmapAIChatHistoryType[]
>([]);
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
const [streamedMessage, setStreamedMessage] =
useState<React.ReactNode | null>(null);
const [showUpdatePersonaModal, setShowUpdatePersonaModal] = useState(false);
const { data: roadmapDetail, error: roadmapDetailError } = useQuery(
@@ -146,6 +118,15 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
const roadmapContainerRef = useRef<HTMLDivElement>(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<AbortController | null>(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('<p></p>');
});
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<string, MessagePartRenderer> = useMemo(() => {
return {
'user-progress': () => {
return (
<UserProgressList
totalTopicCount={totalTopicCount}
roadmapId={roadmapId}
/>
);
},
'update-progress': (options) => {
return <UserProgressActionList roadmapId={roadmapId} {...options} />;
},
'roadmap-topics': (options) => {
return (
<RoadmapTopicList
roadmapId={roadmapId}
onTopicClick={(topicId, text) => {
const title = text.split(' > ').pop();
if (!title) {
return;
}
handleSelectTopic(topicId, title);
}}
{...options}
/>
);
},
'resource-progress-link': () => {
return <ShareResourceLink roadmapId={roadmapId} />;
},
'roadmap-recommendations': (options) => {
return <RoadmapRecommendations {...options} />;
},
};
}, [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 (
<Fragment key={`chat-${index}`}>
<RoadmapAIChatCard {...chat} />
</Fragment>
);
})}
{aiChatHistory.map(
(chat: RoadmapAIChatHistoryType, index: number) => {
return (
<Fragment key={`chat-${index}`}>
<RoadmapAIChatCard {...chat} />
</Fragment>
);
},
)}
{isStreamingMessage && !streamedMessage && (
<RoadmapAIChatCard
@@ -598,9 +396,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
setShowUpdatePersonaModal(true);
}}
messageCount={aiChatHistory.length}
onClearChat={() => {
setAiChatHistory([]);
}}
onClearChat={clearChat}
/>
)}
@@ -624,7 +420,10 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
return;
}
handleChatSubmit(content);
flushSync(() => {
editorRef.current?.commands.setContent('<p></p>');
});
handleChatSubmit(content, isDataLoading);
}}
/>
@@ -670,7 +469,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
return;
}
handleChatSubmit(json);
flushSync(() => {
editorRef.current?.commands.setContent('<p></p>');
});
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 += `<p>${htmlFromTiptapJSON(child)}</p>`;
break;
case 'variable':
const label = child?.attrs?.label || '';
text += `<span class="chat-variable">${label}</span>`;
break;
default:
break;
}
}
return text;
}

View File

@@ -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<HTMLDivElement | null>;
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<React.ReactNode | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const scrollToBottom = useCallback(() => {
scrollareaRef.current?.scrollTo({
top: scrollareaRef.current.scrollHeight,
behavior: 'instant',
});
}, [scrollareaRef]);
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} />,
}),
[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 += `<p>${htmlFromTiptapJSON(child)}</p>`;
break;
case 'variable':
const label = child?.attrs?.label || '';
text += `<span class="chat-variable">${label}</span>`;
break;
}
}
return text;
}