wip: course ai

feat/course
Arik Chakma 2 weeks ago
parent 611322bcfe
commit 0e44c85377
  1. 23
      src/components/CourseAI/CourseAI.tsx
  2. 144
      src/components/CourseAI/CourseAIPopover.tsx
  3. 30
      src/lib/course.ts
  4. 13
      src/stores/course.ts

@ -3,6 +3,13 @@ import type { ChapterFileType } from '../../lib/course';
import { useState } from 'react';
import { CourseAIPopover } from './CourseAIPopover';
export type AllowedAIChatRole = 'user' | 'assistant';
export type AIChatHistoryType = {
role: AllowedAIChatRole;
content: string;
isDefault?: boolean;
};
type CourseAIProps = {
courseId: string;
currentChapterId: string;
@ -13,6 +20,15 @@ type CourseAIProps = {
export function CourseAI(props: CourseAIProps) {
const [isOpen, setIsOpen] = useState(false);
const [courseAIChatHistory, setCourseAIChatHistory] = useState<
AIChatHistoryType[]
>([
{
role: 'assistant',
content: 'Hey, how can I help you today? 🤖',
isDefault: true,
},
]);
return (
<div className="relative">
@ -25,7 +41,12 @@ export function CourseAI(props: CourseAIProps) {
</button>
{isOpen && (
<CourseAIPopover {...props} onOutsideClick={() => setIsOpen(false)} />
<CourseAIPopover
{...props}
onOutsideClick={() => setIsOpen(false)}
courseAIChatHistory={courseAIChatHistory}
setCourseAIChatHistory={setCourseAIChatHistory}
/>
)}
</div>
);

@ -1,16 +1,17 @@
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import type { ChapterFileType } from '../../lib/course';
import {
readCourseAIContentStream,
type ChapterFileType,
} from '../../lib/course';
import { Bot, Send } from 'lucide-react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { cn } from '../../lib/classname';
import { markdownToHtml } from '../../lib/markdown';
import { sanitizeHtml } from '../../lib/sanitize-html';
import {
roadmapAIChatHistory,
type AllowedAIChatType,
} from '../../stores/course';
import { useStore } from '@nanostores/react';
import { flushSync } from 'react-dom';
import type { AIChatHistoryType, AllowedAIChatRole } from './CourseAI';
import { useToast } from '../../hooks/use-toast';
import { removeAuthToken } from '../../lib/jwt';
type CourseAIPopoverProps = {
courseId: string;
@ -19,6 +20,9 @@ type CourseAIPopoverProps = {
chapters: ChapterFileType[];
courseAIChatHistory: AIChatHistoryType[];
setCourseAIChatHistory: (value: AIChatHistoryType[]) => void;
onOutsideClick?: () => void;
};
@ -29,34 +33,43 @@ export function CourseAIPopover(props: CourseAIPopoverProps) {
currentChapterId,
currentLessonId,
onOutsideClick,
courseAIChatHistory,
setCourseAIChatHistory,
} = props;
const toast = useToast();
const containerRef = useRef<HTMLDivElement | null>(null);
const scrollareaRef = useRef<HTMLDivElement | null>(null);
const [message, setMessage] = useState('');
const $roadmapAIChatHistory = useStore(roadmapAIChatHistory);
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState('');
const [streamedMessage, setStreamedMessage] = useState('');
useOutsideClick(containerRef, onOutsideClick);
const handleChatSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!message) {
const trimmedMessage = message.trim();
if (!trimmedMessage || isLoading) {
return;
}
const newMessages: AIChatHistoryType[] = [
...courseAIChatHistory,
{
role: 'user',
content: trimmedMessage,
},
];
flushSync(() => {
roadmapAIChatHistory.set([
...$roadmapAIChatHistory,
{
type: 'user',
message,
},
]);
setCourseAIChatHistory(newMessages);
setMessage('');
});
scrollToBottom();
completeCourseAIChat(newMessages);
};
const scrollToBottom = () => {
@ -66,6 +79,78 @@ export function CourseAIPopover(props: CourseAIPopoverProps) {
});
};
const completeCourseAIChat = async (messages: AIChatHistoryType[]) => {
setIsLoading(true);
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-course-ai/${courseId}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
chapterId: currentChapterId,
lessonId: currentLessonId,
messages,
}),
},
);
if (!response.ok) {
const data = await response.json();
toast.error(data?.message || 'Something went wrong');
setCourseAIChatHistory([...messages].slice(0, messages.length - 1));
setIsLoading(false);
// Logout user if token is invalid
if (data.status === 401) {
removeAuthToken();
window.location.reload();
}
}
const reader = response.body?.getReader();
if (!reader) {
setIsLoading(false);
toast.error('Something went wrong');
return;
}
await readCourseAIContentStream(reader, {
onStream: async (content) => {
flushSync(() => {
setStreamedMessage(content);
});
scrollToBottom();
},
onStreamEnd: async (content) => {
const newMessages: AIChatHistoryType[] = [
...messages,
{
role: 'assistant',
content,
},
];
flushSync(() => {
setStreamedMessage('');
setIsLoading(false);
setCourseAIChatHistory(newMessages);
});
scrollToBottom();
},
});
setIsLoading(false);
};
useEffect(() => {
scrollToBottom();
}, []);
@ -86,15 +171,19 @@ export function CourseAIPopover(props: CourseAIPopoverProps) {
<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 p-2">
{$roadmapAIChatHistory.map((chat, index) => {
{courseAIChatHistory.map((chat, index) => {
return (
<AIChatCard
key={index}
type={chat.type}
message={chat.message}
role={chat.role}
content={chat.content}
/>
);
})}
{streamedMessage && (
<AIChatCard role="assistant" content={streamedMessage} />
)}
</div>
</div>
</div>
@ -112,6 +201,7 @@ export function CourseAIPopover(props: CourseAIPopoverProps) {
/>
<button
type="submit"
disabled={isLoading}
className="flex aspect-square h-full items-center justify-center text-zinc-500 hover:text-zinc-50"
>
<Send className="size-4 stroke-[2.5]" />
@ -122,28 +212,28 @@ export function CourseAIPopover(props: CourseAIPopoverProps) {
}
type AIChatCardProps = {
type: AllowedAIChatType;
message: string;
role: AllowedAIChatRole;
content: string;
};
function AIChatCard(props: AIChatCardProps) {
const { type, message } = props;
const { role, content } = props;
const html = useMemo(() => {
return sanitizeHtml(markdownToHtml(message, false));
}, [message]);
return sanitizeHtml(markdownToHtml(content, false));
}, [content]);
return (
<div
className={cn(
'flex items-start gap-2.5 rounded-xl p-3',
type === 'user' ? 'bg-zinc-500/30' : 'bg-yellow-500/30',
role === 'user' ? 'bg-zinc-500/30' : 'bg-yellow-500/30',
)}
>
<div
className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-full',
type === 'user'
role === 'user'
? 'bg-zinc-500 text-zinc-50'
: 'bg-yellow-500 text-zinc-950',
)}

@ -176,3 +176,33 @@ export async function getLessonsByCourseId(
}))
.sort((a, b) => a.frontmatter.order - b.frontmatter.order);
}
export async function readCourseAIContentStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
{
onStream,
onStreamEnd,
}: {
onStream?: (content: string) => void;
onStreamEnd?: (content: string) => void;
},
) {
const decoder = new TextDecoder('utf-8');
let result = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
if (value) {
result += decoder.decode(value);
onStream?.(result);
}
}
onStream?.(result);
onStreamEnd?.(result);
reader.releaseLock();
}

@ -11,16 +11,3 @@ export type CurrentLessonType = {
};
export const currentLesson = atom<CurrentLessonType | null>(null);
export type AllowedAIChatType = 'user' | 'system';
export type AIChatHistoryType = {
type: AllowedAIChatType;
message: string;
};
export const roadmapAIChatHistory = atom<AIChatHistoryType[]>([
{
type: 'system',
message: 'Hey, how can I help you today? 🤖',
},
]);

Loading…
Cancel
Save