Files
developer-roadmap/src/components/TopicDetail/TopicDetailAI.tsx
T

418 lines
13 KiB
TypeScript

import '../ChatMessages/AIChat.css';
import { useQuery } from '@tanstack/react-query';
import {
BotIcon,
ChevronRightIcon,
Gift,
Loader2Icon,
LockIcon,
SendIcon,
Trash2,
WandSparkles,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { useToast } from '../../hooks/use-toast';
import { cn } from '../../lib/classname';
import { isLoggedIn } from '../../lib/jwt';
import { getPercentage } from '../../lib/number';
import { showLoginPopup } from '../../lib/popup';
import type { ResourceType } from '../../lib/resource-progress';
import { aiLimitOptions } from '../../queries/ai-course';
import { billingDetailsOptions } from '../../queries/billing';
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
import { queryClient } from '../../stores/query-client';
import { AILimitsPopup } from '../GenerateCourse/AILimitsPopup';
import { PredefinedActions } from './PredefinedActions';
import type { ChatStatus, UIMessage } from 'ai';
import type { UseChatHelpers } from '@ai-sdk/react';
import { useAIChatScroll } from '../../hooks/use-ai-chat-scroll';
import { TopicChatMessages } from '../ChatMessages/TopicChatMessages';
type TopicDetailAIProps = {
resourceId: string;
resourceType: ResourceType;
topicId: string;
hasUpgradeButtons?: boolean;
messages: UIMessage[];
sendMessage: UseChatHelpers<UIMessage>['sendMessage'];
setMessages: UseChatHelpers<UIMessage>['setMessages'];
status: ChatStatus;
onUpgrade: () => void;
onLogin: () => void;
onShowSubjectSearchModal: () => void;
};
export function TopicDetailAI(props: TopicDetailAIProps) {
const {
messages,
sendMessage,
setMessages,
status,
resourceId,
resourceType,
topicId,
hasUpgradeButtons = true,
onUpgrade,
onLogin,
onShowSubjectSearchModal,
} = props;
const textareaRef = useRef<HTMLTextAreaElement>(null);
const formRef = useRef<HTMLFormElement>(null);
const sanitizedTopicId = topicId?.includes('@')
? topicId?.split('@')?.[1]
: topicId;
const toast = useToast();
const [message, setMessage] = useState('');
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
const { data: tokenUsage, isLoading } = useQuery(
aiLimitOptions(),
queryClient,
);
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const { data: roadmapTreeMapping, isLoading: isRoadmapTreeMappingLoading } =
useQuery(
{
...roadmapTreeMappingOptions(resourceId),
select: (data) => {
const node = data.find(
(mapping) => mapping.nodeId === sanitizedTopicId,
);
return node;
},
},
queryClient,
);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
const handleChatSubmit = (overrideMessage?: string) => {
const trimmedMessage = (overrideMessage ?? message).trim();
if (
!trimmedMessage ||
status !== 'ready' ||
!isLoggedIn() ||
isLimitExceeded ||
isLoading
) {
return;
}
sendMessage(
{
text: trimmedMessage,
},
{
body: {
resourceId,
resourceType,
topicId: sanitizedTopicId,
},
},
);
setMessage('');
setTimeout(() => {
scrollToBottom();
textareaRef.current?.focus();
}, 0);
};
const { scrollToBottom, scrollableContainerRef, showScrollToBottomButton } =
useAIChatScroll({
messages,
});
useEffect(() => {
scrollToBottom();
}, []);
const isDataLoading =
isLoading || isBillingDetailsLoading || isRoadmapTreeMappingLoading;
const usagePercentage = getPercentage(
tokenUsage?.used || 0,
tokenUsage?.limit || 0,
);
const hasChatHistory = messages.length > 0;
const nodeTextParts = roadmapTreeMapping?.text?.split('>') || [];
const hasSubjects =
(roadmapTreeMapping?.subjects &&
roadmapTreeMapping?.subjects?.length > 0) ||
nodeTextParts.length > 1;
return (
<div className="ai-chat relative mt-4 flex grow flex-col overflow-hidden rounded-lg border border-gray-200">
{isDataLoading && (
<div className="absolute inset-0 z-20 flex items-center justify-center gap-2 bg-white text-black">
<Loader2Icon className="size-8 animate-spin stroke-3 text-gray-500" />
</div>
)}
{showAILimitsPopup && (
<AILimitsPopup
onClose={() => setShowAILimitsPopup(false)}
onUpgrade={() => {
setShowAILimitsPopup(false);
onUpgrade();
}}
/>
)}
{hasSubjects && (
<div className="border-b border-gray-200 p-3">
<h4 className="flex items-center gap-2 text-sm">
Complete the following AI Tutor courses
</h4>
<div className="mt-2.5 flex flex-wrap gap-1 text-sm">
{roadmapTreeMapping?.subjects?.map((subject) => {
return (
<a
key={subject}
target="_blank"
onClick={(e) => {
if (!isLoggedIn()) {
e.preventDefault();
onLogin();
return;
}
if (isLimitExceeded) {
e.preventDefault();
onUpgrade();
return;
}
}}
href={`/ai/course/search?term=${subject}&difficulty=beginner&src=topic`}
className="flex items-center gap-1 rounded-md border border-gray-300 bg-gray-100 px-2 py-1 hover:bg-gray-200 hover:text-black"
>
{subject}
</a>
);
})}
{roadmapTreeMapping?.subjects?.length === 0 && (
<a
target="_blank"
href={`/ai/course/search?term=${roadmapTreeMapping?.text}&difficulty=beginner&src=topic`}
className="flex items-center gap-1 rounded-md border border-gray-300 bg-gray-100 px-2 py-1 hover:bg-gray-200 hover:text-black [&>svg:last-child]:hidden"
>
{nodeTextParts.slice(-2).map((text, index) => {
return (
<>
<span key={text} className="flex items-center">
{text}
</span>
<ChevronRightIcon className="h-3 w-3 text-gray-400" />
</>
);
})}
</a>
)}
<button
onClick={onShowSubjectSearchModal}
className="flex items-center gap-1.5 rounded-md border border-dashed border-gray-300 bg-transparent px-2 py-1 text-gray-400 hover:border-solid hover:bg-gray-200 hover:text-black"
>
<WandSparkles className="h-3 w-3" />
Learn another topic
</button>
</div>
</div>
)}
<div
className={cn(
'flex min-h-[46px] items-center justify-between gap-2 border-gray-200 px-3 py-2 text-sm',
)}
>
{hasSubjects && (
<span className="flex items-center gap-2 text-sm">
<BotIcon
className="relative -top-[1px] size-4 shrink-0 text-black"
strokeWidth={2.5}
/>
<span className="hidden sm:block">Chat with AI</span>
<span className="block sm:hidden">AI Tutor</span>
</span>
)}
{!hasSubjects && (
<h4 className="flex items-center gap-2 text-base font-medium">
<BotIcon
className="relative -top-[1px] size-5 shrink-0 text-black"
strokeWidth={2.5}
/>
AI Tutor
</h4>
)}
{!isDataLoading && (
<div className="flex gap-1.5">
{hasChatHistory && (
<button
className="rounded-md bg-white px-2 py-2 text-xs font-medium text-black hover:bg-gray-200"
onClick={() => {
setMessages([]);
}}
>
<Trash2 className="size-3.5" />
</button>
)}
{!isPaidUser && hasUpgradeButtons && (
<>
<button
className="hidden rounded-md bg-gray-200 px-2 py-1 text-sm hover:bg-gray-300 sm:block"
onClick={() => {
if (!isLoggedIn()) {
onLogin();
return;
}
setShowAILimitsPopup(true);
}}
>
<span className="font-medium">{usagePercentage}%</span>{' '}
credits used
</button>
<button
className="flex items-center gap-1 rounded-md bg-yellow-400 px-2 py-1 text-sm text-black hover:bg-yellow-500"
onClick={() => {
if (!isLoggedIn()) {
onLogin();
return;
}
onUpgrade();
}}
>
<Gift className="size-4" />
Upgrade
</button>
</>
)}
</div>
)}
</div>
<PredefinedActions
onSelect={(action) => {
if (!isLoggedIn()) {
onLogin();
return;
}
if (isLimitExceeded) {
onUpgrade();
return;
}
if (!action?.prompt) {
toast.error('Something went wrong');
return;
}
setMessage(action.prompt);
handleChatSubmit(action.prompt);
}}
/>
<div
className="scrollbar-thumb-gray-300 scrollbar-track-transparent scrollbar-thin relative grow overflow-y-auto"
ref={scrollableContainerRef}
>
<TopicChatMessages messages={messages} status={status} />
</div>
<form
ref={formRef}
className="relative flex items-start border-t border-gray-200 text-sm"
onSubmit={(e) => {
e.preventDefault();
handleChatSubmit();
}}
>
{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={onUpgrade}
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>
)}
{isDataLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
<Loader2Icon className="size-4 animate-spin" />
<p>Loading...</p>
</div>
)}
<TextareaAutosize
className={cn(
'h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-hidden',
)}
placeholder="Ask AI anything about the lesson..."
value={message}
onChange={(e) => setMessage(e.target.value)}
autoFocus={true}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleChatSubmit();
}
}}
ref={textareaRef}
/>
<button
type="submit"
disabled={status !== 'ready' || isLimitExceeded}
className="flex aspect-square size-[41px] items-center justify-center text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50"
>
<SendIcon className="size-4 stroke-[2.5]" />
</button>
</form>
</div>
);
}