feat: follow up message

feat/ai-courses
Arik Chakma 1 month ago
parent e21e3ab331
commit 2a6f02afcd
  1. 52
      src/components/GenerateCourse/AICourseFollowUp.tsx
  2. 287
      src/components/GenerateCourse/AICourseFollowUpPopover.tsx
  3. 22
      src/components/GenerateCourse/AICourseLimit.tsx
  4. 9
      src/components/GenerateCourse/AICourseModuleView.tsx
  5. 7
      src/queries/ai-course.ts

@ -0,0 +1,52 @@
import { ArrowRightIcon, BotIcon } from 'lucide-react';
import { useState } from 'react';
import {
AICourseFollowUpPopover,
type AIChatHistoryType,
} from './AICourseFollowUpPopover';
type AICourseFollowUpProps = {
courseSlug: string;
moduleTitle: string;
lessonTitle: string;
};
export function AICourseFollowUp(props: AICourseFollowUpProps) {
const { courseSlug, moduleTitle, lessonTitle } = props;
const [isOpen, setIsOpen] = useState(false);
const [courseAIChatHistory, setCourseAIChatHistory] = useState<
AIChatHistoryType[]
>([]);
return (
<div className="relative">
<button
className="mt-4 flex w-full items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-100 p-2"
onClick={() => setIsOpen(true)}
>
<BotIcon className="h-4 w-4" />
<span>You still have confusion about the lesson? Ask me anything.</span>
<ArrowRightIcon className="ml-auto h-4 w-4" />
</button>
{isOpen && (
<AICourseFollowUpPopover
courseSlug={courseSlug}
moduleTitle={moduleTitle}
lessonTitle={lessonTitle}
courseAIChatHistory={courseAIChatHistory}
setCourseAIChatHistory={setCourseAIChatHistory}
onOutsideClick={() => {
if (!isOpen) {
return;
}
setIsOpen(false);
}}
/>
)}
</div>
);
}

@ -0,0 +1,287 @@
import { useQuery } from '@tanstack/react-query';
import {
BookOpen,
Bot,
Code,
GitCompare,
HelpCircle,
LockIcon,
MessageCircle,
Send,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import { flushSync } from 'react-dom';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { readAICourseLessonStream } from '../../helper/read-stream';
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
import { useToast } from '../../hooks/use-toast';
import { markdownToHtml } from '../../lib/markdown';
import { cn } from '../../lib/classname';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
export type AllowedAIChatRole = 'user' | 'assistant';
export type AIChatHistoryType = {
role: AllowedAIChatRole;
content: string;
isDefault?: boolean;
};
type AICourseFollowUpPopoverProps = {
courseSlug: string;
moduleTitle: string;
lessonTitle: string;
courseAIChatHistory: AIChatHistoryType[];
setCourseAIChatHistory: (value: AIChatHistoryType[]) => void;
onOutsideClick?: () => void;
};
export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) {
const {
courseSlug,
moduleTitle,
lessonTitle,
onOutsideClick,
courseAIChatHistory,
setCourseAIChatHistory,
} = props;
const toast = useToast();
const containerRef = useRef<HTMLDivElement | null>(null);
const scrollareaRef = useRef<HTMLDivElement | null>(null);
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
const [message, setMessage] = useState('');
const [streamedMessage, setStreamedMessage] = useState('');
useOutsideClick(containerRef, onOutsideClick);
const { data: tokenUsage, isLoading } = useQuery(
getAiCourseLimitOptions(),
queryClient,
);
const isLimitExceeded =
(tokenUsage?.followUpLimit || 0) <= (tokenUsage?.followUpUsed || 0);
const handleChatSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const trimmedMessage = message.trim();
if (
!trimmedMessage ||
isStreamingMessage ||
!isLoggedIn() ||
isLimitExceeded ||
isLoading
) {
return;
}
const newMessages: AIChatHistoryType[] = [
...courseAIChatHistory,
{
role: 'user',
content: trimmedMessage,
},
];
flushSync(() => {
setCourseAIChatHistory(newMessages);
setMessage('');
});
scrollToBottom();
completeCourseAIChat(newMessages);
};
const scrollToBottom = () => {
scrollareaRef.current?.scrollTo({
top: scrollareaRef.current.scrollHeight,
behavior: 'smooth',
});
};
const completeCourseAIChat = async (messages: AIChatHistoryType[]) => {
setIsStreamingMessage(true);
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-follow-up-ai-course/${courseSlug}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
moduleTitle,
lessonTitle,
messages: messages.slice(-10),
}),
},
);
if (!response.ok) {
const data = await response.json();
toast.error(data?.message || 'Something went wrong');
setCourseAIChatHistory([...messages].slice(0, messages.length - 1));
setIsStreamingMessage(false);
if (data.status === 401) {
removeAuthToken();
window.location.reload();
}
}
const reader = response.body?.getReader();
if (!reader) {
setIsStreamingMessage(false);
toast.error('Something went wrong');
return;
}
await readAICourseLessonStream(reader, {
onStream: async (content) => {
flushSync(() => {
setStreamedMessage(content);
});
scrollToBottom();
},
onStreamEnd: async (content) => {
const newMessages: AIChatHistoryType[] = [
...messages,
{
role: 'assistant',
content,
},
];
flushSync(() => {
setStreamedMessage('');
setIsStreamingMessage(false);
setCourseAIChatHistory(newMessages);
});
queryClient.invalidateQueries(getAiCourseLimitOptions());
scrollToBottom();
},
});
setIsStreamingMessage(false);
};
useEffect(() => {
scrollToBottom();
}, []);
return (
<div
className="absolute bottom-0 left-0 z-10 flex h-[400px] w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow"
ref={containerRef}
>
<div className="flex items-center justify-between gap-2 border-b border-gray-200 px-4 py-2 text-sm">
<h4 className="text-base font-medium">Course AI</h4>
</div>
<div
className="scrollbar-thumb-gray-300 scrollbar-track-transparent scrollbar-thin relative grow overflow-y-auto"
ref={scrollareaRef}
>
<div className="absolute inset-0 flex flex-col">
<div className="flex grow flex-col justify-end">
<div className="flex flex-col justify-end gap-2 px-3 py-2">
{courseAIChatHistory.map((chat, index) => {
return (
<AIChatCard
key={index}
role={chat.role}
content={chat.content}
/>
);
})}
{isStreamingMessage && !streamedMessage && (
<AIChatCard role="assistant" content="Thinking..." />
)}
{streamedMessage && (
<AIChatCard role="assistant" content={streamedMessage} />
)}
</div>
</div>
</div>
</div>
<form
className="relative flex h-[41px] items-center border-t border-gray-200 text-sm"
onSubmit={handleChatSubmit}
>
{isLimitExceeded && (
<div className="absolute inset-0 flex items-center justify-center bg-black text-white">
<LockIcon className="size-4" strokeWidth={2.5} />
<p>You have reached the AI usage limit for today.</p>
</div>
)}
<input
className="h-full grow bg-transparent px-4 py-2 focus:outline-none"
placeholder="Ask AI anything about the lesson..."
value={message}
onChange={(e) => setMessage(e.target.value)}
autoFocus={true}
/>
<button
type="submit"
disabled={isStreamingMessage || isLimitExceeded}
className="flex aspect-square h-full items-center justify-center text-zinc-500 hover:text-black"
>
<Send className="size-4 stroke-[2.5]" />
</button>
</form>
</div>
);
}
type AIChatCardProps = {
role: AllowedAIChatRole;
content: string;
};
function AIChatCard(props: AIChatCardProps) {
const { role, content } = props;
const html = useMemo(() => {
return markdownToHtml(content);
}, [content]);
return (
<div
className={cn(
'flex flex-col rounded-lg',
role === 'user' ? 'bg-gray-300/30' : 'bg-yellow-500/30',
)}
>
<div className="flex items-start gap-2.5 p-3">
<div
className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-full',
role === 'user'
? 'bg-gray-200 text-black'
: 'bg-yellow-400 text-black',
)}
>
<Bot className="size-4 stroke-[2.5]" />
</div>
<div
className="course-content course-ai-content prose prose-sm mt-0.5 max-w-full grow overflow-hidden text-sm"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
</div>
);
}

@ -5,6 +5,7 @@ import {
MessageCircleQuestionIcon,
ChevronDownIcon,
ClockIcon,
BotIcon,
} from 'lucide-react';
import { useState, useRef } from 'react';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
@ -35,10 +36,13 @@ export function AICourseLimit() {
limit: courseLimit,
lessonUsed,
lessonLimit,
followUpUsed,
followUpLimit,
} = limits;
const coursePercentage = Math.round((courseUsed / courseLimit) * 100);
const lessonPercentage = Math.round((lessonUsed / lessonLimit) * 100);
const followUpPercentage = Math.round((followUpUsed / followUpLimit) * 100);
return (
<div className="relative" ref={containerRef}>
@ -54,6 +58,10 @@ export function AICourseLimit() {
<BookOpenIcon className="h-4 w-4" />
{lessonPercentage}%
</div>
<div className="mr-3 flex items-center gap-1.5">
<BotIcon className="h-4 w-4" />
{followUpPercentage}%
</div>
<span className="mr-1">of daily limits</span>
<ChevronDownIcon className="h-4 w-4" />
@ -89,6 +97,20 @@ export function AICourseLimit() {
}}
/>
</div>
<div className="relative overflow-hidden">
<div className="relative z-10 flex items-center gap-2 border-b border-b-gray-200 px-2 py-1">
<BotIcon className="size-3.5" />
{followUpUsed} of {followUpLimit} follow-ups used
</div>
<div
className="absolute inset-0 bg-gray-100"
style={{
width: `${followUpPercentage}%`,
}}
/>
</div>
</div>
<div className="mt-2 flex items-center justify-center gap-2 text-gray-500">

@ -15,6 +15,7 @@ import {
getAiCourseLimitOptions,
getAiCourseProgressOptions,
} from '../../queries/ai-course';
import { AICourseFollowUp } from './AICourseFollowUp';
type AICourseModuleViewProps = {
courseSlug: string;
@ -269,6 +270,14 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
</button>
</div>
</div>
{!isGenerating && (
<AICourseFollowUp
courseSlug={courseSlug}
moduleTitle={currentModuleTitle}
lessonTitle={currentLessonTitle}
/>
)}
</div>
);
}

@ -1,5 +1,6 @@
import { httpGet } from '../lib/query-http';
import { isLoggedIn } from '../lib/jwt';
import { queryOptions } from '@tanstack/react-query';
export interface AICourseProgressDocument {
_id: string;
@ -73,14 +74,16 @@ type GetAICourseLimitResponse = {
limit: number;
lessonUsed: number;
lessonLimit: number;
followUpUsed: number;
followUpLimit: number;
};
export function getAiCourseLimitOptions() {
return {
return queryOptions({
queryKey: ['ai-course-limit'],
queryFn: () => {
return httpGet<GetAICourseLimitResponse>(`/v1-get-ai-course-limit`);
},
enabled: !!isLoggedIn(),
};
});
}

Loading…
Cancel
Save