feat: implement ai tutor

feat/topic-chat
Arik Chakma 4 weeks ago
parent 09ed8c4692
commit 90d17cd2ed
  1. 29
      src/components/TopicDetail/TopicDetail.tsx
  2. 226
      src/components/TopicDetail/TopicDetailAI.tsx
  3. 47
      src/components/TopicDetail/TopicProgressButton.tsx
  4. 110
      src/data/roadmaps/frontend/tree.json
  5. 4
      src/hooks/use-keydown.ts
  6. 2
      src/pages/[roadmapId]/index.astro
  7. 34
      src/pages/[roadmapId]/tree.json.ts
  8. 3
      src/pages/best-practices/[bestPracticeId]/index.astro

@ -49,6 +49,7 @@ import {
import { TopicDetailAI } from './TopicDetailAI.tsx';
import { cn } from '../../lib/classname.ts';
import type { AIChatHistoryType } from '../GenerateCourse/AICourseLessonChat.tsx';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal.tsx';
type TopicDetailProps = {
resourceId?: string;
@ -121,6 +122,8 @@ export function TopicDetail(props: TopicDetailProps) {
useState<AllowedTopicDetailsTabs>('content');
const [aiChatHistory, setAiChatHistory] =
useState<AIChatHistoryType[]>(defaultChatHistory);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [isCustomResource, setIsCustomResource] = useState(false);
const toast = useToast();
@ -139,6 +142,7 @@ export function TopicDetail(props: TopicDetailProps) {
const handleClose = () => {
setIsActive(false);
setShowUpgradeModal(false);
setAiChatHistory(defaultChatHistory);
setActiveTab('content');
};
@ -209,6 +213,7 @@ export function TopicDetail(props: TopicDetailProps) {
setTopicId(topicId);
setResourceType(resourceType);
setResourceId(resourceId);
setIsCustomResource(isCustomResource);
const topicPartial = topicId.replaceAll(':', '/');
let topicUrl =
@ -369,6 +374,8 @@ export function TopicDetail(props: TopicDetailProps) {
(resource) => resource?.url?.toLowerCase().indexOf('scrimba') !== -1,
);
const shouldShowAiTab = !isCustomResource && resourceType === 'roadmap';
return (
<div className={'relative z-92'}>
<div
@ -376,6 +383,10 @@ export function TopicDetail(props: TopicDetailProps) {
tabIndex={0}
className="fixed top-0 right-0 z-40 flex h-screen w-full flex-col overflow-y-auto bg-white p-4 focus:outline-0 sm:max-w-[600px] sm:p-6"
>
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
{isLoading && (
<div className="flex h-full w-full items-center justify-center">
<Spinner
@ -394,7 +405,7 @@ export function TopicDetail(props: TopicDetailProps) {
'flex flex-col': activeTab === 'ai',
})}
>
<div className="mb-6">
<div className={cn('mb-6', !shouldShowAiTab && 'mb-2')}>
{!isEmbed && (
<TopicProgressButton
topicId={
@ -418,15 +429,21 @@ export function TopicDetail(props: TopicDetailProps) {
</button>
</div>
<TopicDetailsTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
{shouldShowAiTab && (
<TopicDetailsTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
)}
{activeTab === 'ai' && (
{activeTab === 'ai' && shouldShowAiTab && (
<TopicDetailAI
resourceId={resourceId}
resourceType={resourceType}
topicId={topicId}
aiChatHistory={aiChatHistory}
setAiChatHistory={setAiChatHistory}
onUpgrade={() => setShowUpgradeModal(true)}
/>
)}

@ -11,7 +11,7 @@ import { billingDetailsOptions } from '../../queries/billing';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
import { BotIcon, LockIcon, SendIcon } from 'lucide-react';
import { BotIcon, Loader2Icon, LockIcon, SendIcon } from 'lucide-react';
import { showLoginPopup } from '../../lib/popup';
import { cn } from '../../lib/classname';
import TextareaAutosize from 'react-textarea-autosize';
@ -23,15 +23,31 @@ import {
import { useToast } from '../../hooks/use-toast';
import { readStream } from '../../lib/ai';
import { markdownToHtmlWithHighlighting } from '../../lib/markdown';
import type { ResourceType } from '../../lib/resource-progress';
import { getPercentage } from '../../lib/number';
type TopicDetailAIProps = {
resourceId: string;
resourceType: ResourceType;
topicId: string;
aiChatHistory: AIChatHistoryType[];
setAiChatHistory: (history: AIChatHistoryType[]) => void;
onUpgrade: () => void;
};
export function TopicDetailAI(props: TopicDetailAIProps) {
const { aiChatHistory, setAiChatHistory } = props;
const {
aiChatHistory,
setAiChatHistory,
resourceId,
resourceType,
topicId,
onUpgrade,
} = props;
const textareaRef = useRef<HTMLTextAreaElement>(null);
const scrollareaRef = useRef<HTMLDivElement>(null);
const toast = useToast();
@ -78,7 +94,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
});
scrollToBottom();
// completeCourseAIChat(newMessages);
completeAITutorChat(newMessages);
};
const scrollToBottom = useCallback(() => {
@ -88,86 +104,103 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
});
}, [scrollareaRef]);
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),
// }),
// },
// );
const response = new Response();
if (!response.ok) {
const data = await response.json();
toast.error(data?.message || 'Something went wrong');
setAiChatHistory([...messages].slice(0, messages.length - 1));
setIsStreamingMessage(false);
const completeAITutorChat = async (messages: AIChatHistoryType[]) => {
try {
setIsStreamingMessage(true);
if (data.status === 401) {
removeAuthToken();
window.location.reload();
}
}
const reader = response.body?.getReader();
const sanitizedTopicId = topicId?.includes('@')
? topicId?.split('@')?.[1]
: topicId;
if (!reader) {
setIsStreamingMessage(false);
toast.error('Something went wrong');
return;
}
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-topic-detail-chat`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
resourceId,
resourceType,
topicId: sanitizedTopicId,
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();
}
await readStream(reader, {
onStream: async (content) => {
flushSync(() => {
setStreamedMessage(content);
});
queryClient.invalidateQueries(getAiCourseLimitOptions());
return;
}
scrollToBottom();
},
onStreamEnd: async (content) => {
const newMessages: AIChatHistoryType[] = [
...messages,
{
role: 'assistant',
content,
html: await markdownToHtmlWithHighlighting(content),
},
];
const reader = response.body?.getReader();
flushSync(() => {
setStreamedMessage('');
setIsStreamingMessage(false);
setAiChatHistory(newMessages);
});
if (!reader) {
setIsStreamingMessage(false);
toast.error('Something went wrong');
return;
}
queryClient.invalidateQueries(getAiCourseLimitOptions());
scrollToBottom();
},
});
await readStream(reader, {
onStream: async (content) => {
flushSync(() => {
setStreamedMessage(content);
});
scrollToBottom();
},
onStreamEnd: async (content) => {
const newMessages: AIChatHistoryType[] = [
...messages,
{
role: 'assistant',
content,
html: await markdownToHtmlWithHighlighting(content),
},
];
flushSync(() => {
setStreamedMessage('');
setIsStreamingMessage(false);
setAiChatHistory(newMessages);
});
queryClient.invalidateQueries(getAiCourseLimitOptions());
scrollToBottom();
},
});
setIsStreamingMessage(false);
setIsStreamingMessage(false);
} catch (error) {
toast.error('Something went wrong');
setIsStreamingMessage(false);
}
};
useEffect(() => {
scrollToBottom();
}, []);
const isDataLoading = isLoading || isBillingDetailsLoading;
const usagePercentage = getPercentage(
tokenUsage?.used || 0,
tokenUsage?.limit || 0,
);
return (
<div className="mt-4 flex grow flex-col rounded-lg border">
<div className="mt-4 flex grow flex-col overflow-hidden rounded-lg border">
<div className="flex items-center justify-between gap-2 border-b border-gray-200 px-4 py-2 text-sm">
<h4 className="flex items-center gap-2 text-base font-medium">
<BotIcon
@ -176,6 +209,12 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
/>
AI Tutor
</h4>
{!isDataLoading && !isPaidUser && (
<p className="text-sm text-gray-500">
<span className="font-medium">{usagePercentage}%</span> used
</p>
)}
</div>
<div
@ -193,31 +232,6 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
content={chat.content}
html={chat.html}
/>
{/* {chat.isDefault && defaultQuestions?.length > 1 && (
<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 lesson.
</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={() => {
flushSync(() => {
setMessage(question);
});
textareaRef.current?.focus();
}}
>
{question}
</button>
))}
</div>
</div>
)} */}
</Fragment>
);
})}
@ -247,9 +261,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
</p>
{!isPaidUser && (
<button
onClick={() => {
// onUpgradeClick();
}}
onClick={onUpgrade}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
>
Upgrade for more
@ -271,6 +283,14 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
</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',
@ -281,15 +301,15 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
onChange={(e) => setMessage(e.target.value)}
autoFocus={true}
onKeyDown={(e) => {
// if (e.key === 'Enter' && !e.shiftKey) {
// handleChatSubmit(e as unknown as FormEvent<HTMLFormElement>);
// }
if (e.key === 'Enter' && !e.shiftKey) {
handleChatSubmit(e as unknown as FormEvent<HTMLFormElement>);
}
}}
// ref={textareaRef}
ref={textareaRef}
/>
<button
type="submit"
// disabled={isDisabled || isStreamingMessage || isLimitExceeded}
disabled={isStreamingMessage || 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]" />

@ -66,7 +66,15 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
// Mark as done
useKeydown(
'd',
() => {
(e: KeyboardEvent) => {
if (
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
if (progress === 'done') {
onClose();
return;
@ -80,7 +88,15 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
// Mark as learning
useKeydown(
'l',
() => {
(e: KeyboardEvent) => {
if (
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
if (progress === 'learning') {
return;
}
@ -93,7 +109,15 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
// Mark as learning
useKeydown(
's',
() => {
(e: KeyboardEvent) => {
if (
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
if (progress === 'skipped') {
onClose();
return;
@ -107,9 +131,16 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
// Mark as pending
useKeydown(
'r',
() => {
(e: KeyboardEvent) => {
if (
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
if (progress === 'pending') {
onClose();
return;
}
@ -175,7 +206,7 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
return (
<div className="relative inline-flex rounded-md border border-gray-300">
<span className="inline-flex cursor-default items-center p-1 px-2 text-sm text-black">
<span className="inline-flex cursor-default items-center p-1 px-2 text-sm text-black">
<span className="flex h-2 w-2">
<span
className={`relative inline-flex h-2 w-2 rounded-full ${statusColors[progress]}`}
@ -187,7 +218,7 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
</span>
<button
className="inline-flex cursor-pointer items-center rounded-br-md rounded-tr-md border-l border-l-gray-300 bg-gray-100 p-1 px-2 text-sm text-black hover:bg-gray-200"
className="inline-flex cursor-pointer items-center rounded-tr-md rounded-br-md border-l border-l-gray-300 bg-gray-100 p-1 px-2 text-sm text-black hover:bg-gray-200"
onClick={() => setShowChangeStatus(true)}
>
<span className="mr-0.5">Update Status</span>
@ -196,7 +227,7 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
{showChangeStatus && (
<div
className="absolute right-0 top-full mt-1 flex min-w-[160px] flex-col divide-y rounded-md border border-gray-200 bg-white shadow-md [&>button:first-child]:rounded-t-md [&>button:last-child]:rounded-b-md"
className="absolute top-full right-0 mt-1 flex min-w-[160px] flex-col divide-y rounded-md border border-gray-200 bg-white shadow-md [&>button:first-child]:rounded-t-md [&>button:last-child]:rounded-b-md"
ref={changeStatusRef!}
>
{allowMarkingDone && (

@ -0,0 +1,110 @@
[
{
"id": "VlNNwIEDWqQXtqkHWJYzC",
"text": "Front-end > Internet"
},
{
"id": "yCnn-NfSxIybUQ2iTuUGq",
"text": "Front-end > Internet > How does the internet work?"
},
{
"id": "R12sArWVpbIs_PHxBqVaR",
"text": "Front-end > Internet > What is HTTP?"
},
{
"id": "ZhSuu2VArnzPDp6dPQQSC",
"text": "Front-end > Internet > What is Domain Name?"
},
{
"id": "aqMaEY8gkKMikiqleV5EP",
"text": "Front-end > Internet > What is hosting?"
},
{
"id": "hkxw9jPGYphmjhTjw8766",
"text": "Front-end > Internet > DNS and how it works?"
},
{
"id": "P82WFaTPgQEPNp5IIuZ1Y",
"text": "Front-end > Internet > Browsers and how they work?"
},
{
"id": "yWG2VUkaF5IJVVut6AiSy",
"text": "Front-end > HTML"
},
{
"id": "mH_qff8R7R6eLQ1tPHLgG",
"text": "Front-end > HTML > SEO Basics"
},
{
"id": "iJIqi7ngpGHWAqtgdjgxB",
"text": "Front-end > HTML > Accessibility"
},
{
"id": "V5zucKEHnIPPjwHqsMPHF",
"text": "Front-end > HTML > Forms and Validations"
},
{
"id": "z8-556o-PaHXjlytrawaF",
"text": "Front-end > HTML > Writing Semantic HTML"
},
{
"id": "PCirR2QiFYO89Fm-Ev3o1",
"text": "Front-end > HTML > Learn the basics"
},
{
"id": "ZhJhf1M2OphYbEmduFq-9",
"text": "Front-end > CSS"
},
{
"id": "YFjzPKWDwzrgk2HUX952L",
"text": "Front-end > CSS > Learn the basics"
},
{
"id": "dXeYVMXv-3MRQ1ovOUuJW",
"text": "Front-end > CSS > Making Layouts"
},
{
"id": "TKtWmArHn7elXRJdG6lDQ",
"text": "Front-end > CSS > Responsive Design"
},
{
"id": "ODcfFEorkfJNupoQygM53",
"text": "Front-end > JavaScript"
},
{
"id": "wQSjQqwKHfn5RGPk34BWI",
"text": "Front-end > JavaScript > Learn the Basics"
},
{
"id": "0MAogsAID9R04R5TTO2Qa",
"text": "Front-end > JavaScript > Learn DOM Manipulation"
},
{
"id": "A4brX0efjZ0FFPTB4r6U0",
"text": "Front-end > JavaScript > Fetch API / Ajax (XHR)"
},
{
"id": "NIY7c4TQEEHx0hATu-k5C",
"text": "Front-end > Version Control Systems"
},
{
"id": "R_I4SGYqLk5zze5I1zS_E",
"text": "Front-end > Version Control Systems > Git"
},
{
"id": "MXnFhZlNB1zTsBFDyni9H",
"text": "Front-end > VCS Hosting"
},
{
"id": "DILBiQp7WWgSZ5hhtDW6A",
"text": "Front-end > VCS Hosting > Bitbucket"
},
{
"id": "zIoSJMX3cuzCgDYHjgbEh",
"text": "Front-end > VCS Hosting > GitLab"
},
{
"id": "qmTVMJDsEhNIkiwE_UTYu",
"text": "Front-end > VCS Hosting > GitHub"
}
]

@ -7,14 +7,14 @@ export function useKeydown(keyName: string, callback: any, deps: any[] = []) {
!keyName.startsWith('mod_') &&
event.key.toLowerCase() === keyName.toLowerCase()
) {
callback();
callback(event);
} else if (
keyName.startsWith('mod_') &&
event.metaKey &&
event.key.toLowerCase() === keyName.replace('mod_', '').toLowerCase()
) {
event.preventDefault();
callback();
callback(event);
}
};

@ -18,6 +18,7 @@ import RoadmapNote from '../../components/RoadmapNote.astro';
import { RoadmapTitleQuestion } from '../../components/RoadmapTitleQuestion';
import ResourceProgressStats from '../../components/ResourceProgressStats.astro';
import { getProjectsByRoadmapId } from '../../lib/project';
import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification';
export const prerender = true;
@ -183,5 +184,6 @@ const courses = roadmapData.courses || [];
<RelatedRoadmaps roadmap={roadmapData} />
</div>
<CheckSubscriptionVerification client:load />
<div slot='changelog-banner'></div>
</BaseLayout>

@ -0,0 +1,34 @@
import type { APIRoute } from 'astro';
export const prerender = true;
export async function getStaticPaths() {
const roadmapJsons = import.meta.glob('/src/data/roadmaps/**/tree.json', {
eager: true,
});
return Object.keys(roadmapJsons).map((filePath) => {
const filePathParts = filePath.split('/');
const roadmapId = filePathParts?.[filePathParts.length - 2];
const treeJSON = roadmapJsons[filePath] as Record<string, any>;
return {
params: {
roadmapId,
},
props: {
treeJSON: treeJSON?.default || {},
},
};
});
}
export const GET: APIRoute = async function ({ params, request, props }) {
return new Response(JSON.stringify(props.treeJSON), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
};

@ -14,6 +14,7 @@ import {
type BestPracticeFrontmatter,
getAllBestPractices,
} from '../../../lib/best-practice';
import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification';
export const prerender = true;
@ -136,6 +137,6 @@ const ogImageUrl = getOpenGraphImageUrl({
/>
{bestPracticeData.isUpcoming && <UpcomingForm />}
<CheckSubscriptionVerification client:load />
<div slot='changelog-banner'></div>
</BaseLayout>

Loading…
Cancel
Save