feat: chat UI

feat/course
Arik Chakma 3 weeks ago
parent 47a0537480
commit 927c5c6ff0
  1. 2
      package.json
  2. 52
      pnpm-lock.yaml
  3. 126
      src/components/CourseAI/CourseAIPopover.tsx
  4. 6
      src/components/CourseNotes/CourseNoteCard.tsx
  5. 45
      src/lib/sanitize-html.ts
  6. 13
      src/stores/course.ts

@ -79,6 +79,7 @@
"rehype-external-links": "^3.0.0",
"remark-parse": "^11.0.0",
"roadmap-renderer": "^1.0.6",
"sanitize-html": "^2.13.1",
"satori": "^0.10.14",
"satori-html": "^0.3.2",
"sharp": "^0.33.4",
@ -101,6 +102,7 @@
"@types/prismjs": "^1.26.4",
"@types/react-calendar-heatmap": "^1.6.7",
"@types/react-slick": "^0.23.13",
"@types/sanitize-html": "^2.13.0",
"@types/sql.js": "^1.4.9",
"@types/turndown": "^5.0.5",
"csv-parser": "^3.0.0",

@ -158,6 +158,9 @@ importers:
roadmap-renderer:
specifier: ^1.0.6
version: 1.0.6
sanitize-html:
specifier: ^2.13.1
version: 2.13.1
satori:
specifier: ^0.10.14
version: 0.10.14
@ -219,6 +222,9 @@ importers:
'@types/react-slick':
specifier: ^0.23.13
version: 0.23.13
'@types/sanitize-html':
specifier: ^2.13.0
version: 2.13.0
'@types/sql.js':
specifier: ^1.4.9
version: 1.4.9
@ -1554,6 +1560,9 @@ packages:
'@types/react@18.3.8':
resolution: {integrity: sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==}
'@types/sanitize-html@2.13.0':
resolution: {integrity: sha512-X31WxbvW9TjIhZZNyNBZ/p5ax4ti7qsNDBDEnH4zAgmEh35YnFD1UiS6z9Cd34kKm0LslFW0KPmTQzu/oGtsqQ==}
'@types/sax@1.2.7':
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
@ -1928,6 +1937,10 @@ packages:
decode-named-character-reference@1.0.2:
resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==}
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@ -2279,6 +2292,9 @@ packages:
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
http-cache-semantics@4.1.1:
resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==}
@ -2357,6 +2373,10 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-unicode-supported@1.3.0:
resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==}
engines: {node: '>=12'}
@ -2820,6 +2840,9 @@ packages:
parse-latin@7.0.0:
resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==}
parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
parse5@7.1.2:
resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
@ -3223,6 +3246,9 @@ packages:
s.color@0.0.15:
resolution: {integrity: sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA==}
sanitize-html@2.13.1:
resolution: {integrity: sha512-ZXtKq89oue4RP7abL9wp/9URJcqQNABB5GGJ2acW1sdO8JTVl92f4ygD7Yc9Ze09VAZhnt2zegeU0tbNsdcLYg==}
sass-formatter@0.7.9:
resolution: {integrity: sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw==}
@ -4955,6 +4981,10 @@ snapshots:
'@types/prop-types': 15.7.13
csstype: 3.1.3
'@types/sanitize-html@2.13.0':
dependencies:
htmlparser2: 8.0.2
'@types/sax@1.2.7':
dependencies:
'@types/node': 17.0.45
@ -5373,6 +5403,8 @@ snapshots:
dependencies:
character-entities: 2.0.2
deepmerge@4.3.1: {}
delayed-stream@1.0.0: {}
depd@2.0.0: {}
@ -5784,6 +5816,13 @@ snapshots:
html-void-elements@3.0.0: {}
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.1.0
entities: 4.5.0
http-cache-semantics@4.1.1: {}
http-errors@2.0.0:
@ -5845,6 +5884,8 @@ snapshots:
is-plain-obj@4.1.0: {}
is-plain-object@5.0.0: {}
is-unicode-supported@1.3.0: {}
is-unicode-supported@2.1.0: {}
@ -6462,6 +6503,8 @@ snapshots:
unist-util-visit-children: 3.0.0
vfile: 6.0.3
parse-srcset@1.0.2: {}
parse5@7.1.2:
dependencies:
entities: 4.5.0
@ -6914,6 +6957,15 @@ snapshots:
s.color@0.0.15: {}
sanitize-html@2.13.1:
dependencies:
deepmerge: 4.3.1
escape-string-regexp: 4.0.0
htmlparser2: 8.0.2
is-plain-object: 5.0.0
parse-srcset: 1.0.2
postcss: 8.4.47
sass-formatter@0.7.9:
dependencies:
suf-log: 2.5.3

@ -1,10 +1,16 @@
import { useMemo, useRef, useState } from 'react';
import { useListCourseNote } from '../../hooks/use-course-note';
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import type { ChapterFileType } from '../../lib/course';
import { Bot, Loader2 } from 'lucide-react';
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';
type CourseAIPopoverProps = {
courseId: string;
@ -26,9 +32,44 @@ export function CourseAIPopover(props: CourseAIPopoverProps) {
} = props;
const containerRef = useRef<HTMLDivElement | null>(null);
const scrollareaRef = useRef<HTMLDivElement | null>(null);
const [message, setMessage] = useState('');
const $roadmapAIChatHistory = useStore(roadmapAIChatHistory);
useOutsideClick(containerRef, onOutsideClick);
const handleChatSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!message) {
return;
}
flushSync(() => {
roadmapAIChatHistory.set([
...$roadmapAIChatHistory,
{
type: 'user',
message,
},
]);
setMessage('');
});
scrollToBottom();
};
const scrollToBottom = () => {
scrollareaRef.current?.scrollTo({
top: scrollareaRef.current.scrollHeight,
behavior: 'smooth',
});
};
useEffect(() => {
scrollToBottom();
}, []);
return (
<div
className="absolute bottom-full left-0 z-10 flex h-[65dvh] w-[420px] -translate-y-2 flex-col overflow-hidden rounded-xl border border-zinc-700 bg-zinc-800 text-white"
@ -38,67 +79,50 @@ export function CourseAIPopover(props: CourseAIPopoverProps) {
<h4 className="text-base font-medium">Roadmap AI</h4>
</div>
<div className="relative grow overflow-y-auto [scrollbar-color:#3f3f46_#27272a;]">
<div
className="relative grow overflow-y-auto [scrollbar-color:#3f3f46_#27272a;]"
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 p-2">
{$roadmapAIChatHistory.map((chat, index) => {
return (
<AIChatCard
type="system"
message="Hey, how can I help you today? 🤖"
/>
<AIChatCard
type="user"
message={`What's wrong with this query?
\`\`\`sql
SELECT *
FROM users
WHERE id = 1
\`\`\``}
/>
<AIChatCard
type="system"
message={`Looks like you're missing a semicolon at the end of the query. Try this:
\`\`\`sql
SELECT *
FROM users
WHERE id = 1;
\`\`\``}
/>
<AIChatCard type="user" message={`Got it! Thanks! 🙏`} />
<AIChatCard
type="system"
message={`You're welcome! If you have any other questions, feel free to ask. 🤖`}
/>
<AIChatCard
type="system"
message={`Looks like you're missing a semicolon at the end of the query. Try this:
\`\`\`sql
SELECT *
FROM users
WHERE id = 1;
\`\`\``}
/>
<AIChatCard type="user" message={`Got it! Thanks! 🙏`} />
<AIChatCard
type="system"
message={`You're welcome! If you have any other questions, feel free to ask. 🤖`}
key={index}
type={chat.type}
message={chat.message}
/>
);
})}
</div>
</div>
</div>
</div>
<form
className="flex h-[41px] items-center border-t border-zinc-700 bg-zinc-800 text-sm text-white"
onSubmit={handleChatSubmit}
>
<input
className="h-[41px] w-full border-t border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-white focus:outline-none"
className="h-full grow bg-transparent px-4 py-2 focus:outline-none"
placeholder="Ask AI anything about the course..."
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button
type="submit"
className="flex aspect-square h-full items-center justify-center text-zinc-500 hover:text-zinc-50"
>
<Send className="size-4 stroke-[2.5]" />
</button>
</form>
</div>
);
}
type AIChatCardProps = {
type: 'user' | 'system';
type: AllowedAIChatType;
message: string;
};
@ -106,9 +130,7 @@ function AIChatCard(props: AIChatCardProps) {
const { type, message } = props;
const html = useMemo(() => {
const html = markdownToHtml(message, false);
// FIXME: Sanitize HTML
return html;
return sanitizeHtml(markdownToHtml(message, false));
}, [message]);
return (
@ -129,7 +151,7 @@ function AIChatCard(props: AIChatCardProps) {
<Bot className="size-4 stroke-[2.5]" />
</div>
<div
className="course-content prose prose-sm prose-invert w-full text-sm text-white"
className="course-content prose prose-sm prose-invert mt-0.5 w-full text-sm text-white"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>

@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { markdownToHtml } from '../../lib/markdown';
import { ArrowRight } from 'lucide-react';
import { sanitizeHtml } from '../../lib/sanitize-html';
type CourseNoteCardProps = {
courseId: string;
@ -27,10 +28,7 @@ export function CourseNoteCard(props: CourseNoteCardProps) {
} = props;
const markdownHTML = useMemo(() => {
const html = markdownToHtml(content, false);
// FIXME: Sanitize html before returning
return html;
return sanitizeHtml(markdownToHtml(content, false));
}, [content]);
return (

@ -0,0 +1,45 @@
import _sanitizeHtml from 'sanitize-html';
export function sanitizeHtml(html: string) {
return _sanitizeHtml(html, {
allowedTags: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'p',
'a',
'ul',
'ol',
'nl',
'li',
'b',
'i',
'strong',
'em',
'strike',
'code',
'hr',
'br',
'div',
'table',
'thead',
'caption',
'tbody',
'tr',
'th',
'td',
'pre',
'img',
],
allowedAttributes: {
a: ['href', 'name', 'target'],
img: ['src'],
'*': ['class'],
},
allowedSchemes: ['http', 'https', 'ftp', 'mailto'],
});
}

@ -11,3 +11,16 @@ 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