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:
@@ -3,6 +3,6 @@
|
||||
"enabled": false
|
||||
},
|
||||
"_variables": {
|
||||
"lastUpdateCheck": 1748277554631
|
||||
"lastUpdateCheck": 1749494681580
|
||||
}
|
||||
}
|
1
.astro/types.d.ts
vendored
1
.astro/types.d.ts
vendored
@@ -1,2 +1 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="content.d.ts" />
|
@@ -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}
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@ svg text tspan {
|
||||
text-rendering: optimizeSpeed;
|
||||
}
|
||||
|
||||
code {
|
||||
code:not(pre code) {
|
||||
background: #1e1e3f;
|
||||
color: #9efeff;
|
||||
padding: 3px 5px;
|
||||
|
589
src/components/FrameRenderer/RoadmapFloatingChat.tsx
Normal file
589
src/components/FrameRenderer/RoadmapFloatingChat.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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 (
|
||||
|
@@ -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')
|
||||
|
@@ -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" />
|
||||
|
@@ -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) {
|
||||
|
280
src/hooks/use-roadmap-ai-chat.tsx
Normal file
280
src/hooks/use-roadmap-ai-chat.tsx
Normal 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;
|
||||
}
|
@@ -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');
|
||||
|
@@ -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}`,
|
||||
);
|
||||
|
16
src/queries/roadmap-questions.ts
Normal file
16
src/queries/roadmap-questions.ts
Normal 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,
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user