1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-25 00:21:28 +02:00

feat: add floating chat on roadmap pages (#8765)

* Add floating chat

* Refactor roadmap ai chat to hook

* Chat inside floating chat

* Fix bulk update not working

* Add floating chat widget

* Add chat header buttons

* Show a default set of questions

* Populate chat questions at bottom

* Handle chat submission

* Add personalize popup

* Fix body scroll locking issue

* Add scroll to bottom functionality

* Fix focus issue on persona form

* Fix responsiveness of the floating chat

* Final implementation

* Height fixes

* Fix floating ui

* Upgrade flow in floating chat

* Upgrade responsive UI

* Authetnicated checks

* Responsive bottom bar
This commit is contained in:
Kamran Ahmed
2025-06-10 19:43:06 +01:00
committed by GitHub
parent b1223a90e5
commit 02e7373bcd
16 changed files with 1017 additions and 292 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

@@ -185,6 +185,7 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) {
bodyClassName="p-4 sm:p-6 bg-white"
wrapperClassName="h-auto rounded-xl max-w-3xl w-full min-h-[540px] mx-2 sm:mx-4"
overlayClassName="items-start md:items-center"
hasCloseButton={true}
>
<div onClick={(e) => e.stopPropagation()}>
{errorContent}

View File

@@ -9,8 +9,8 @@ import {
type ResourceType,
} from '../../lib/resource-progress';
import { httpGet } from '../../lib/http';
import { ProgressNudge } from '../FrameRenderer/ProgressNudge';
import { getUrlParams } from '../../lib/browser.ts';
import { RoadmapFloatingChat } from '../FrameRenderer/RoadmapFloatingChat.tsx';
type EditorRoadmapProps = {
resourceId: string;
@@ -99,7 +99,7 @@ export function EditorRoadmap(props: EditorRoadmapProps) {
dimensions={dimensions}
resourceId={resourceId}
/>
<ProgressNudge resourceId={resourceId} resourceType={resourceType} />
<RoadmapFloatingChat roadmapId={resourceId} />
</div>
);
}

View File

@@ -4,7 +4,7 @@ svg text tspan {
text-rendering: optimizeSpeed;
}
code {
code:not(pre code) {
background: #1e1e3f;
color: #9efeff;
padding: 3px 5px;

View File

@@ -0,0 +1,589 @@
import { useQuery } from '@tanstack/react-query';
import type { JSONContent } from '@tiptap/core';
import {
BookOpen,
ChevronDown,
MessageCirclePlus,
PauseCircleIcon,
PersonStanding,
SendIcon,
SquareArrowOutUpRight,
Trash2,
Wand2,
X,
} from 'lucide-react';
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { useKeydown } from '../../hooks/use-keydown';
import {
useRoadmapAIChat,
type RoadmapAIChatHistoryType,
} from '../../hooks/use-roadmap-ai-chat';
import { cn } from '../../lib/classname';
import { lockBodyScroll } from '../../lib/dom';
import { slugify } from '../../lib/slugger';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { billingDetailsOptions } from '../../queries/billing';
import { roadmapJSONOptions } from '../../queries/roadmap';
import { roadmapQuestionsOptions } from '../../queries/roadmap-questions';
import { queryClient } from '../../stores/query-client';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard';
import { CLOSE_TOPIC_DETAIL_EVENT } from '../TopicDetail/TopicDetail';
import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
type ChatHeaderButtonProps = {
onClick?: () => void;
href?: string;
icon: React.ReactNode;
children?: React.ReactNode;
className?: string;
target?: string;
};
function ChatHeaderButton(props: ChatHeaderButtonProps) {
const { onClick, href, icon, children, className, target } = props;
const classNames = cn(
'flex items-center gap-1.5 text-xs text-gray-600 transition-colors hover:text-gray-900',
className,
);
if (!onClick && !href) {
return (
<span className={classNames}>
{icon}
{children && <span className="hidden sm:block">{children}</span>}
</span>
);
}
if (href) {
return (
<a
href={href}
target={target}
rel="noopener noreferrer"
className={classNames}
>
{icon}
{children && <span className="hidden sm:block">{children}</span>}
</a>
);
}
return (
<button onClick={onClick} className={classNames}>
{icon}
{children && <span className="hidden sm:block">{children}</span>}
</button>
);
}
type UpgradeMessageProps = {
onUpgradeClick?: () => void;
};
function UpgradeMessage(props: UpgradeMessageProps) {
const { onUpgradeClick } = props;
return (
<div className="border-t border-gray-200 bg-black px-3 py-3">
<div className="flex items-center gap-2.5">
<Wand2 className="h-4 w-4 flex-shrink-0 text-white" />
<div className="flex-1 text-sm">
<p className="mb-1 font-medium text-white">
You've reached your AI usage limit
</p>
<p className="text-xs text-gray-300">
Upgrade to Pro for relaxed limits and advanced features
</p>
</div>
<button
className="flex-shrink-0 rounded-md bg-white px-3 py-1.5 text-xs font-medium text-black transition-colors hover:bg-gray-100"
onClick={onUpgradeClick}
>
Upgrade to Pro
</button>
</div>
</div>
);
}
type UsageButtonProps = {
percentageUsed: number;
onUpgradeClick?: () => void;
};
function UsageButton(props: UsageButtonProps) {
const { percentageUsed, onUpgradeClick } = props;
return (
<button
onClick={onUpgradeClick}
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-all hover:bg-yellow-200"
>
<div className="hidden items-center gap-1.5 sm:flex">
<div className="h-1.5 w-6 overflow-hidden rounded-full bg-gray-200">
<div
className={cn(
'h-full transition-all duration-300',
percentageUsed >= 90
? 'bg-red-500'
: percentageUsed >= 70
? 'bg-yellow-500'
: 'bg-green-500',
)}
style={{ width: `${Math.min(percentageUsed, 100)}%` }}
/>
</div>
<span className="text-yellow-700">{percentageUsed}% used</span>
</div>
<span className="font-semibold text-yellow-800 underline underline-offset-2">
Upgrade
</span>
</button>
);
}
type RoadmapChatProps = {
roadmapId: string;
};
export function RoadmapFloatingChat(props: RoadmapChatProps) {
const { roadmapId } = props;
const [isOpen, setIsOpen] = useState(false);
const scrollareaRef = useRef<HTMLDivElement>(null);
const [inputValue, setInputValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const [isPersonalizeOpen, setIsPersonalizeOpen] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
// Fetch questions from API
const { data: questionsData } = useQuery(
roadmapQuestionsOptions(roadmapId),
queryClient,
);
// Randomly select 4 questions to display
const defaultQuestions = useMemo(() => {
if (!questionsData?.questions || questionsData.questions.length === 0) {
return [];
}
const shuffled = [...questionsData.questions].sort(
() => 0.5 - Math.random(),
);
return shuffled.slice(0, 4);
}, [questionsData]);
const { data: roadmapDetail, isLoading: isRoadmapDetailLoading } = useQuery(
roadmapJSONOptions(roadmapId),
queryClient,
);
const isAuthenticatedUser = isLoggedIn();
const { data: tokenUsage, isLoading: isTokenUsageLoading } = useQuery(
getAiCourseLimitOptions(),
queryClient,
);
const isLimitExceeded =
isAuthenticatedUser && (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const percentageUsed = Math.round(
((tokenUsage?.used || 0) / (tokenUsage?.limit || 0)) * 100,
);
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isPaidUser = userBillingDetails?.status === 'active';
const totalTopicCount = useMemo(() => {
const allowedTypes = ['topic', 'subtopic', 'todo'];
return (
roadmapDetail?.json?.nodes.filter((node) =>
allowedTypes.includes(node.type || ''),
).length ?? 0
);
}, [roadmapDetail]);
const onSelectTopic = (topicId: string, topicTitle: string) => {
// For now just scroll to bottom and close overlay
const topicSlug = slugify(topicTitle) + '@' + topicId;
window.dispatchEvent(
new CustomEvent('roadmap.node.click', {
detail: {
resourceType: 'roadmap',
resourceId: roadmapId,
topicId: topicSlug,
isCustomResource: false,
},
}),
);
// ensure chat visible
flushSync(() => {
setIsOpen(true);
});
};
const {
aiChatHistory,
isStreamingMessage,
streamedMessage,
showScrollToBottom,
setShowScrollToBottom,
handleChatSubmit,
handleAbort,
scrollToBottom,
clearChat,
} = useRoadmapAIChat({
roadmapId,
totalTopicCount,
scrollareaRef,
onSelectTopic,
});
useEffect(() => {
lockBodyScroll(isOpen);
}, [isOpen]);
useKeydown('Escape', () => {
setIsOpen(false);
});
useEffect(() => {
// it means user came back to the AI chat from the topic detail
const handleCloseTopicDetail = () => {
lockBodyScroll(isOpen);
};
window.addEventListener(CLOSE_TOPIC_DETAIL_EVENT, handleCloseTopicDetail);
return () => {
window.removeEventListener(
CLOSE_TOPIC_DETAIL_EVENT,
handleCloseTopicDetail,
);
};
}, [isOpen, isPersonalizeOpen]);
function textToJSON(text: string): JSONContent {
return {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
};
}
const submitInput = () => {
if (!isLoggedIn()) {
setIsOpen(false);
showLoginPopup();
return;
}
const trimmed = inputValue.trim();
if (!trimmed) {
return;
}
const json: JSONContent = textToJSON(trimmed);
setInputValue('');
handleChatSubmit(json, isRoadmapDetailLoading);
};
const hasMessages = aiChatHistory.length > 0;
return (
<>
{isOpen && (
<div
onClick={() => {
setIsOpen(false);
}}
className="fixed inset-0 z-50 bg-black opacity-50"
></div>
)}
{showUpgradeModal && (
<UpgradeAccountModal
onClose={() => {
setShowUpgradeModal(false);
}}
/>
)}
{isPersonalizeOpen && (
<UpdatePersonaModal
roadmapId={roadmapId}
onClose={() => {
setIsPersonalizeOpen(false);
}}
/>
)}
<div
className={cn(
'animate-fade-slide-up fixed bottom-5 left-1/2 z-91 max-h-[95vh] max-w-[968px] -translate-x-1/4 transform flex-col gap-1.5 overflow-hidden px-4 transition-all duration-300 sm:max-h-[50vh] lg:flex',
isOpen ? 'h-full w-full' : 'w-auto',
)}
>
{isOpen && (
<>
<div className="flex h-full w-full flex-col overflow-hidden rounded-lg bg-white shadow-lg">
{/* Messages area */}
<div className="flex items-center justify-between px-3 py-2">
<div className="flex">
<ChatHeaderButton
icon={<BookOpen className="h-3.5 w-3.5" />}
className="text-sm"
>
AI Tutor
</ChatHeaderButton>
</div>
<div className="flex gap-1.5">
<ChatHeaderButton
href={`/${roadmapId}/ai`}
target="_blank"
icon={<SquareArrowOutUpRight className="h-3.5 w-3.5" />}
className="hidden rounded-md py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300 sm:flex"
>
Open in new tab
</ChatHeaderButton>
<ChatHeaderButton
onClick={() => setIsOpen(false)}
icon={<X className="h-3.5 w-3.5" />}
className="rounded-md bg-red-100 px-1 py-1 text-red-500 hover:bg-red-200"
/>
</div>
</div>
<div
className="flex flex-1 flex-grow flex-col overflow-y-auto px-3 py-2"
ref={scrollareaRef}
>
<div className="flex flex-col gap-2 text-sm">
<RoadmapAIChatCard
role="assistant"
jsx={
<span className="mt-[2px]">
Hey, I am your AI tutor. How can I help you today? 👋
</span>
}
isIntro
/>
{/* Show default questions only when there's no chat history */}
{aiChatHistory.length === 0 &&
defaultQuestions.length > 0 && (
<div className="mt-0.5 mb-1">
<p className="mb-2 text-xs font-normal text-gray-500">
Some questions you might have about this roadmap:
</p>
<div className="flex flex-col justify-end gap-1">
{defaultQuestions.map((question, index) => (
<button
key={`default-question-${index}`}
className="flex h-full self-start rounded-md bg-yellow-500/10 px-3 py-2 text-left text-sm text-black hover:bg-yellow-500/20"
onClick={() => {
if (!isLoggedIn()) {
setIsOpen(false);
showLoginPopup();
return;
}
if (isLimitExceeded) {
setShowUpgradeModal(true);
setIsOpen(false);
return;
}
handleChatSubmit(
textToJSON(question),
isRoadmapDetailLoading,
);
}}
>
{question}
</button>
))}
</div>
</div>
)}
{aiChatHistory.map(
(chat: RoadmapAIChatHistoryType, index: number) => (
<Fragment key={`chat-${index}`}>
<RoadmapAIChatCard {...chat} />
</Fragment>
),
)}
{isStreamingMessage && !streamedMessage && (
<RoadmapAIChatCard role="assistant" html="Thinking..." />
)}
{streamedMessage && (
<RoadmapAIChatCard role="assistant" jsx={streamedMessage} />
)}
</div>
{/* Scroll to bottom button */}
{showScrollToBottom && (
<button
onClick={() => {
scrollToBottom('instant');
setShowScrollToBottom(false);
}}
className="sticky bottom-0 mx-auto mt-2 flex items-center gap-1.5 rounded-full bg-gray-900 px-3 py-1.5 text-xs text-white shadow-lg transition-all hover:bg-gray-800"
>
<ChevronDown className="h-3 w-3" />
Scroll to bottom
</button>
)}
</div>
{/* Input area */}
{isLimitExceeded && (
<UpgradeMessage
onUpgradeClick={() => {
setShowUpgradeModal(true);
setIsOpen(false);
}}
/>
)}
{!isLimitExceeded && (
<>
<div className="flex flex-row justify-between border-t border-gray-200 px-3 pt-2">
<div className="flex gap-2">
<ChatHeaderButton
onClick={() => {
if (!isLoggedIn()) {
setIsOpen(false);
showLoginPopup();
return;
}
setIsPersonalizeOpen(true);
}}
icon={<PersonStanding className="h-3.5 w-3.5" />}
className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300"
>
Personalize
</ChatHeaderButton>
{!isPaidUser && isAuthenticatedUser && (
<UsageButton
percentageUsed={percentageUsed}
onUpgradeClick={() => {
setShowUpgradeModal(true);
setIsOpen(false);
}}
/>
)}
</div>
{hasMessages && (
<ChatHeaderButton
onClick={() => {
setInputValue('');
clearChat();
}}
icon={<Trash2 className="h-3.5 w-3.5" />}
className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300"
>
Clear
</ChatHeaderButton>
)}
</div>
<div className="relative flex items-center text-sm">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
autoFocus
disabled={isLimitExceeded}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (isStreamingMessage) {
return;
}
submitInput();
}
}}
placeholder={
isLimitExceeded
? 'You have reached the usage limit for today..'
: 'Ask me anything about this roadmap...'
}
className={cn(
'w-full resize-none px-3 py-4 outline-none',
isLimitExceeded && 'bg-gray-100 text-gray-400',
)}
/>
<button
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-zinc-500 hover:text-black disabled:opacity-50"
disabled={isRoadmapDetailLoading || isLimitExceeded}
onClick={() => {
if (isStreamingMessage) {
handleAbort();
return;
}
submitInput();
}}
>
{isStreamingMessage ? (
<PauseCircleIcon className="h-4 w-4" />
) : (
<SendIcon className="h-4 w-4" />
)}
</button>
</div>
</>
)}
</div>
</>
)}
{!isOpen && (
<button
className={cn(
'relative mx-auto flex flex-shrink-0 cursor-pointer items-center justify-center gap-2 rounded-full bg-stone-900 py-2.5 pr-8 pl-6 text-center text-white shadow-2xl transition-all duration-300 hover:scale-101 hover:bg-stone-800 w-max',
)}
onClick={() => {
setIsOpen(true);
setTimeout(() => {
scrollToBottom('instant');
setShowScrollToBottom(false);
}, 0);
}}
>
{!hasMessages ? (
<>
<Wand2 className="h-4 w-4 text-yellow-400" />
<span className="mr-1 text-sm font-semibold text-yellow-400">
AI Tutor
</span>
<span className={'text-white hidden sm:block'}>
Have a question? Type here
</span>
<span className={'text-white block sm:hidden'}>
Ask anything
</span>
</>
) : (
<>
<MessageCirclePlus className="size-5 text-yellow-400" />
<span className="mr-1 text-sm font-medium text-white">
Continue chatting..
</span>
</>
)}
</button>
)}
</div>
</>
);
}

View File

@@ -2,6 +2,7 @@ import { type ReactNode, useRef } from 'react';
import { useOutsideClick } from '../hooks/use-outside-click';
import { useKeydown } from '../hooks/use-keydown';
import { cn } from '../lib/classname';
import { X } from 'lucide-react';
type ModalProps = {
onClose: () => void;
@@ -9,6 +10,7 @@ type ModalProps = {
overlayClassName?: string;
bodyClassName?: string;
wrapperClassName?: string;
hasCloseButton?: boolean;
};
export function Modal(props: ModalProps) {
@@ -18,6 +20,7 @@ export function Modal(props: ModalProps) {
bodyClassName,
wrapperClassName,
overlayClassName,
hasCloseButton = true,
} = props;
const popupBodyEl = useRef<HTMLDivElement>(null);
@@ -33,7 +36,7 @@ export function Modal(props: ModalProps) {
return (
<div
className={cn(
'fixed left-0 right-0 top-0 z-99 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50',
'fixed top-0 right-0 left-0 z-99 flex h-full items-center justify-center overflow-x-hidden overflow-y-auto bg-black/50',
overlayClassName,
)}
>
@@ -50,6 +53,14 @@ export function Modal(props: ModalProps) {
bodyClassName,
)}
>
{hasCloseButton && (
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-300 hover:text-gray-700"
>
<X className="h-5 w-5" />
</button>
)}
{children}
</div>
</div>

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

@@ -67,7 +67,7 @@ export function RoadmapTopicList(props: RoadmapTopicListProps) {
return (
<div className="relative my-6 flex flex-wrap gap-1 first:mt-0 last:mb-0">
{progressItemWithText.map((item) => {
const labelParts = item.text.split(' > ');
const labelParts = item.text.split(' > ').slice(-2);
const labelPartCount = labelParts.length;
return (

View File

@@ -92,6 +92,10 @@ export function UserProgressActionList(props: UserProgressActionListProps) {
);
},
onSuccess: () => {
updateUserProgress.forEach((item) => {
renderTopicProgress(item.id, item.action);
});
return queryClient.invalidateQueries(
userResourceProgressOptions('roadmap', roadmapId),
);
@@ -173,7 +177,7 @@ export function UserProgressActionList(props: UserProgressActionListProps) {
<button
className="z-50 flex items-center gap-1 rounded-md bg-green-600 px-2 py-1 text-xs font-medium text-white hover:bg-green-700 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70"
disabled={isBulkUpdating || isLoading}
disabled={isBulkUpdating || isLoading || isBulkUpdateSuccess}
onClick={() => {
const done = updateUserProgress
.filter((item) => item.action === 'done')

View File

@@ -54,6 +54,8 @@ type PaidResourceType = {
const paidResourcesCache: Record<string, PaidResourceType[]> = {};
export const CLOSE_TOPIC_DETAIL_EVENT = 'close-topic-detail';
export const defaultChatHistory: AIChatHistoryType[] = [
{
role: 'assistant',
@@ -158,10 +160,15 @@ export function TopicDetail(props: TopicDetailProps) {
const handleClose = () => {
onClose?.();
setIsActive(false);
setIsContributing(false);
setShowUpgradeModal(false);
setAiChatHistory(defaultChatHistory);
setActiveTab('content');
setShowSubjectSearchModal(false);
lockBodyScroll(false);
window.dispatchEvent(new Event(CLOSE_TOPIC_DETAIL_EVENT));
};
// Close the topic detail when user clicks outside the topic detail
@@ -372,10 +379,9 @@ export function TopicDetail(props: TopicDetailProps) {
useEffect(() => {
if (isActive) {
lockBodyScroll(true);
topicRef?.current?.focus();
}
lockBodyScroll(isActive);
}, [isActive]);
if (!isActive) {
@@ -660,8 +666,7 @@ export function TopicDetail(props: TopicDetailProps) {
id="close-topic"
className="absolute top-2.5 right-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
onClick={() => {
setIsActive(false);
setIsContributing(false);
handleClose();
}}
>
<X className="h-5 w-5" />

View File

@@ -7,6 +7,8 @@ import { Modal } from '../Modal';
import { UserPersonaForm, type UserPersonaFormData } from './UserPersonaForm';
import { httpPost } from '../../lib/query-http';
import { useToast } from '../../hooks/use-toast';
import { Spinner } from '../ReactIcons/Spinner';
import { X } from 'lucide-react';
type UpdatePersonaModalProps = {
roadmapId: string;
@@ -21,7 +23,7 @@ export function UpdatePersonaModal(props: UpdatePersonaModalProps) {
roadmapJSONOptions(roadmapId),
queryClient,
);
const { data: userPersona } = useQuery(
const { data: userPersona, isLoading: isLoadingUserPersona } = useQuery(
userRoadmapPersonaOptions(roadmapId),
queryClient,
);
@@ -58,6 +60,20 @@ export function UpdatePersonaModal(props: UpdatePersonaModalProps) {
wrapperClassName="max-w-[450px]"
bodyClassName="p-4"
>
<button
onClick={onClose}
className="absolute top-2.5 right-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
>
<X className="h-5 w-5" />
</button>
{isLoadingUserPersona && (
<div className="absolute inset-0 z-50 flex h-full flex-row items-center justify-center gap-3 bg-white">
<Spinner isDualRing={false} className="h-4 w-4" />
<p className="text-base text-gray-500">Loading...</p>
</div>
)}
<div className="mb-4 text-left">
<h2 className="text-lg font-semibold">Tell us more about yourself</h2>
<p className="mt-1 text-sm text-balance text-gray-500">
@@ -67,9 +83,15 @@ export function UpdatePersonaModal(props: UpdatePersonaModalProps) {
</div>
<UserPersonaForm
key={userPersona ? 'loaded' : 'loading'}
className="space-y-4"
roadmapTitle={roadmapTitle}
defaultValues={userPersona ?? undefined}
defaultValues={{
expertise: userPersona?.expertise ?? '',
goal: userPersona?.goal ?? '',
commit: userPersona?.commit ?? '',
about: userPersona?.about ?? '',
}}
onSubmit={(data) => {
const trimmedGoal = data?.goal?.trim();
if (!trimmedGoal) {

View File

@@ -0,0 +1,280 @@
import { useCallback, useMemo, useRef, useState, useEffect } 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 [showScrollToBottom, setShowScrollToBottom] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const scrollToBottom = useCallback(
(behavior: 'smooth' | 'instant' = 'smooth') => {
scrollareaRef.current?.scrollTo({
top: scrollareaRef.current.scrollHeight,
behavior,
});
},
[scrollareaRef],
);
// Check if user has scrolled away from bottom
const checkScrollPosition = useCallback(() => {
const scrollArea = scrollareaRef.current;
if (!scrollArea) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = scrollArea;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50; // 50px threshold
setShowScrollToBottom(!isAtBottom && aiChatHistory.length > 0);
}, [aiChatHistory.length]);
useEffect(() => {
const scrollArea = scrollareaRef.current;
if (!scrollArea) {
return;
}
scrollArea.addEventListener('scroll', checkScrollPosition);
return () => scrollArea.removeEventListener('scroll', checkScrollPosition);
}, [checkScrollPosition]);
// When user is already at the bottom and there is new message
// being streamed, we keep scrolling to bottom to show the new message
// unless user has scrolled up at which point we stop scrolling to bottom
useEffect(() => {
if (isStreamingMessage || streamedMessage) {
const scrollArea = scrollareaRef.current;
if (!scrollArea) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = scrollArea;
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 100;
if (isNearBottom) {
scrollToBottom('instant');
setShowScrollToBottom(false);
}
}
}, [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} />,
}),
[roadmapId, onSelectTopic, totalTopicCount],
);
const completeAITutorChat = async (
messages: RoadmapAIChatHistoryType[],
abortController?: AbortController,
) => {
try {
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 },
];
setIsStreamingMessage(true);
flushSync(() => setAiChatHistory(newMessages));
scrollToBottom('instant');
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([]);
setStreamedMessage(null);
setIsStreamingMessage(false);
scrollToBottom('instant');
setShowScrollToBottom(false);
}, []);
return {
aiChatHistory,
isStreamingMessage,
streamedMessage,
showScrollToBottom,
setShowScrollToBottom,
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;
}

View File

@@ -9,7 +9,9 @@ export function replaceChildren(parentNode: Element, newChild: Element) {
export function lockBodyScroll(shouldLock: boolean) {
const isClient = document && 'body' in document;
if (!isClient) return;
if (!isClient) {
return;
}
if (shouldLock) {
document.body.classList.add('overflow-hidden');

View File

@@ -13,7 +13,24 @@ const __dirname = path.dirname(__filename);
// hack to make it work. TODO: Fix
const projectRoot = path.resolve(__dirname, '../..').replace(/dist$/, '');
export async function fetchRoadmapJson(roadmapId: string) {
type RoadmapJson = {
_id: string;
title: string;
description: string;
slug: string;
nodes: {
type: 'topic' | 'subtopic' | 'paragraph';
data: { label: string };
}[];
edges: unknown[];
draft: boolean;
createdAt: string;
updatedAt: string;
};
export async function fetchRoadmapJson(
roadmapId: string,
): Promise<RoadmapJson> {
const response = await fetch(
`https://roadmap.sh/api/v1-official-roadmap/${roadmapId}`,
);

View File

@@ -0,0 +1,16 @@
import { queryOptions } from '@tanstack/react-query';
import { httpGet } from '../lib/query-http';
export interface RoadmapQuestionsResponse {
questions: string[];
}
export function roadmapQuestionsOptions(roadmapId: string) {
return queryOptions({
queryKey: ['roadmap-questions', roadmapId],
queryFn: () => {
return httpGet<RoadmapQuestionsResponse>(`/v1-official-roadmap-questions/${roadmapId}`);
},
refetchOnMount: false,
});
}