1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-25 00:21:28 +02:00
Files
developer-roadmap/src/components/RoadmapAIChat/RoadmapAIChat.tsx
2025-05-27 08:02:34 +06:00

704 lines
22 KiB
TypeScript

import './RoadmapAIChat.css';
import { useQuery } from '@tanstack/react-query';
import { roadmapJSONOptions } from '../../queries/roadmap';
import { queryClient } from '../../stores/query-client';
import {
Fragment,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
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 { type AllowedAIChatRole } from '../GenerateCourse/AICourseLessonChat';
import { isLoggedIn, removeAuthToken } 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';
import { billingDetailsOptions } from '../../queries/billing';
import { TopicDetail } from '../TopicDetail/TopicDetail';
import { slugify } from '../../lib/slugger';
import { AIChatActionButtons } from './AIChatActionButtons';
import { cn } from '../../lib/classname';
import {
getTailwindScreenDimension,
type TailwindScreenDimensions,
} from '../../lib/is-mobile';
import { UserPersonaForm } from '../UserPersona/UserPersonaForm';
import { ChatPersona } from '../UserPersona/ChatPersona';
import { userPersonaOptions } from '../../queries/user-persona';
export type RoamdapAIChatHistoryType = {
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;
};
export type RoadmapAIChatTab = 'chat' | 'topic';
type RoadmapAIChatProps = {
roadmapId: string;
};
export function RoadmapAIChat(props: RoadmapAIChatProps) {
const { roadmapId } = props;
const toast = useToast();
const editorRef = useRef<Editor | null>(null);
const scrollareaRef = useRef<HTMLDivElement>(null);
const [deviceType, setDeviceType] = useState<TailwindScreenDimensions>();
useLayoutEffect(() => {
setDeviceType(getTailwindScreenDimension());
}, []);
const [isChatMobileVisible, setIsChatMobileVisible] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [selectedTopicId, setSelectedTopicId] = useState<string | null>(null);
const [selectedTopicTitle, setSelectedTopicTitle] = useState<string | null>(
null,
);
const [activeTab, setActiveTab] = useState<RoadmapAIChatTab>('chat');
const [aiChatHistory, setAiChatHistory] = useState<
RoamdapAIChatHistoryType[]
>([]);
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
const [streamedMessage, setStreamedMessage] =
useState<React.ReactNode | null>(null);
const { data: roadmapDetail, error: roadmapDetailError } = useQuery(
roadmapJSONOptions(roadmapId),
queryClient,
);
const { data: roadmapTreeData, isLoading: roadmapTreeLoading } = useQuery(
roadmapTreeMappingOptions(roadmapId),
queryClient,
);
const { isLoading: userResourceProgressLoading } = useQuery(
userResourceProgressOptions('roadmap', roadmapId),
queryClient,
);
const { data: tokenUsage, isLoading: isTokenUsageLoading } = useQuery(
getAiCourseLimitOptions(),
queryClient,
);
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const { data: userPersona, isLoading: isUserPersonaLoading } = useQuery(
userPersonaOptions(roadmapId),
queryClient,
);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
const roadmapContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!roadmapDetail || !roadmapContainerRef.current) {
return;
}
roadmapContainerRef.current.replaceChildren(roadmapDetail.svg);
}, [roadmapDetail]);
useEffect(() => {
if (!roadmapTreeData || !roadmapDetail || isUserPersonaLoading) {
return;
}
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: RoamdapAIChatHistoryType[] = [
...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(
(topicId: string, topicTitle: string) => {
flushSync(() => {
setSelectedTopicId(topicId);
setSelectedTopicTitle(topicTitle);
setActiveTab('topic');
if (['sm', 'md', 'lg', 'xl'].includes(deviceType || 'xl')) {
setIsChatMobileVisible(true);
}
});
const topicWithSlug = slugify(topicTitle) + '@' + topicId;
window.dispatchEvent(
new CustomEvent('roadmap.node.click', {
detail: {
resourceType: 'roadmap',
resourceId: roadmapId,
topicId: topicWithSlug,
isCustomResource: false,
},
}),
);
},
[roadmapId, deviceType],
);
const renderer: Record<string, MessagePartRenderer> = useMemo(() => {
return {
'user-progress': () => {
return <UserProgressList 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 roadmapId={roadmapId} {...options} />;
},
};
}, [roadmapId, handleSelectTopic]);
const completeAITutorChat = async (
messages: RoamdapAIChatHistoryType[],
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: RoamdapAIChatHistoryType[] = [
...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();
}, []);
if (roadmapDetailError) {
return (
<div className="flex flex-grow flex-col items-center justify-center">
<Frown className="mb-4 size-16" />
<h1 className="mb-2 text-2xl font-bold">There was an error</h1>
<p className="max-w-sm text-balance text-gray-500">
{roadmapDetailError.message}
</p>
</div>
);
}
const isDataLoading =
isLoading ||
roadmapTreeLoading ||
userResourceProgressLoading ||
isTokenUsageLoading ||
isBillingDetailsLoading ||
isUserPersonaLoading;
const shouldShowChatPersona =
!isLoading && !isUserPersonaLoading && !userPersona && isLoggedIn();
return (
<div className="flex flex-grow flex-row">
<div className="relative h-full flex-grow overflow-y-scroll">
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
{isLoading && (
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
<Loader2Icon className="size-6 animate-spin stroke-[2.5]" />
</div>
)}
{roadmapDetail?.json && !isLoading && (
<div className="relative mx-auto max-w-[968px] px-4">
<ChatRoadmapRenderer
roadmapId={roadmapId}
nodes={roadmapDetail?.json.nodes}
edges={roadmapDetail?.json.edges}
onSelectTopic={handleSelectTopic}
/>
{/* floating chat button */}
{!isChatMobileVisible && (
<div className="fixed bottom-4 left-1/2 z-50 block -translate-x-1/2 xl:hidden">
<button
onClick={() => {
setActiveTab('chat');
setIsChatMobileVisible(true);
}}
className="relative overflow-hidden rounded-full bg-stone-900 px-4 py-2 text-center text-white shadow-2xl hover:bg-stone-800"
>
<span className="relative z-20 flex items-center gap-2 text-sm">
<Bot className="size-5 text-yellow-400" />
<span>Chat with Roadmap</span>
</span>
</button>
</div>
)}
</div>
)}
</div>
{isChatMobileVisible && (
<div
onClick={() => {
setIsChatMobileVisible(false);
}}
className="fixed inset-0 z-50 bg-black/50"
/>
)}
<div
className={cn(
'h-full flex-grow flex-col border-l border-gray-200 bg-white',
{
'relative hidden max-w-[40%] xl:flex': !isChatMobileVisible,
'fixed inset-y-0 right-0 z-50 w-full max-w-[520px]':
isChatMobileVisible,
flex: isChatMobileVisible,
},
)}
>
<RoadmapAIChatHeader
isLoading={isDataLoading}
onLogin={() => {
showLoginPopup();
}}
onUpgrade={() => {
setShowUpgradeModal(true);
}}
activeTab={activeTab}
onTabChange={(tab) => {
setActiveTab(tab);
if (tab === 'topic' && selectedTopicId && selectedTopicTitle) {
handleSelectTopic(selectedTopicId, selectedTopicTitle);
}
}}
onCloseTopic={() => {
setSelectedTopicId(null);
setSelectedTopicTitle(null);
setActiveTab('chat');
}}
onCloseChat={() => {
setIsChatMobileVisible(false);
setActiveTab('chat');
}}
selectedTopicId={selectedTopicId}
/>
{activeTab === 'topic' && selectedTopicId && (
<TopicDetail
resourceId={selectedTopicId}
resourceType="roadmap"
renderer="editor"
defaultActiveTab="ai"
hasUpgradeButtons={false}
canSubmitContribution={false}
wrapperClassName="grow flex flex-col overflow-y-auto"
bodyClassName="static mx-auto h-auto grow sm:max-w-full sm:p-4"
overlayClassName="hidden"
closeButtonClassName="hidden"
onClose={() => {
setSelectedTopicId(null);
setSelectedTopicTitle(null);
setActiveTab('chat');
}}
shouldCloseOnBackdropClick={false}
shouldCloseOnEscape={false}
/>
)}
{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>
)}
{shouldShowChatPersona && !isLoading && (
<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">
{aiChatHistory.map((chat, index) => {
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>
{!isLoading && !shouldShowChatPersona && (
<div className="flex flex-col border-t border-gray-200">
{!isLimitExceeded && (
<AIChatActionButtons
onTellUsAboutYourSelf={() => {}}
messageCount={aiChatHistory.length}
onClearChat={() => {
setAiChatHistory([]);
}}
/>
)}
<div className="relative flex items-start text-sm">
<ChatEditor
editorRef={editorRef}
roadmapId={roadmapId}
onSubmit={(content) => {
if (
isStreamingMessage ||
abortControllerRef.current ||
!isLoggedIn() ||
isDataLoading ||
isEmptyContent(content)
) {
return;
}
handleChatSubmit(content);
}}
/>
{isLimitExceeded && isLoggedIn() && (
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
<LockIcon
className="size-4 cursor-not-allowed"
strokeWidth={2.5}
/>
<p className="cursor-not-allowed">
Limit reached for today
{isPaidUser ? '. Please wait until tomorrow.' : ''}
</p>
{!isPaidUser && (
<button
onClick={() => {
setShowUpgradeModal(true);
}}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
>
Upgrade for more
</button>
)}
</div>
)}
{!isLoggedIn() && (
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
<LockIcon
className="size-4 cursor-not-allowed"
strokeWidth={2.5}
/>
<p className="cursor-not-allowed">
Please login to continue
</p>
<button
onClick={() => {
showLoginPopup();
}}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
>
Login / Register
</button>
</div>
)}
<button
className="flex aspect-square size-[36px] items-center justify-center p-2 text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50"
onClick={(e) => {
if (isStreamingMessage || abortControllerRef.current) {
handleAbort();
return;
}
const json = editorRef.current?.getJSON();
if (!json || isEmptyContent(json)) {
toast.error('Please enter a message');
return;
}
handleChatSubmit(json);
}}
>
{isStreamingMessage ? (
<PauseCircleIcon className="size-4 stroke-[2.5]" />
) : (
<SendIcon className="size-4 stroke-[2.5]" />
)}
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}
function isEmptyContent(content: JSONContent) {
if (!content) {
return true;
}
// because they wrap the content in type doc
const firstContent = content.content?.[0];
if (!firstContent) {
return true;
}
return (
firstContent.type === 'paragraph' &&
(!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;
}