mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-09 16:53:33 +02:00
wip
This commit is contained in:
@@ -1,12 +1,21 @@
|
||||
import './ChatEditor.css';
|
||||
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
useEditor,
|
||||
type JSONContent,
|
||||
} from '@tiptap/react';
|
||||
import DocumentExtension from '@tiptap/extension-document';
|
||||
import ParagraphExtension from '@tiptap/extension-paragraph';
|
||||
import TextExtension from '@tiptap/extension-text';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { VariableExtension } from './VariableExtension/VariableExtension';
|
||||
import { variableSuggestion } from './VariableExtension/VariableSuggestion';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, type RefObject } from 'react';
|
||||
|
||||
const extensions = [
|
||||
DocumentExtension,
|
||||
@@ -22,7 +31,20 @@ const extensions = [
|
||||
|
||||
const content = '<p></p>';
|
||||
|
||||
export function ChatEditor() {
|
||||
type ChatEditorProps = {
|
||||
editorRef: RefObject<Editor | null>;
|
||||
roadmapId: string;
|
||||
onSubmit: (content: JSONContent) => void;
|
||||
};
|
||||
|
||||
export function ChatEditor(props: ChatEditorProps) {
|
||||
const { roadmapId, onSubmit, editorRef } = props;
|
||||
|
||||
const { data: roadmapTreeData } = useQuery(
|
||||
roadmapTreeMappingOptions(roadmapId),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
content,
|
||||
@@ -36,21 +58,53 @@ export function ChatEditor() {
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
// check if the variable suggestion list is focused
|
||||
// if it is, return false so the default behavior is not triggered
|
||||
const variableSuggestionList = document.getElementById(
|
||||
'variable-suggestion-list',
|
||||
);
|
||||
if (variableSuggestionList) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
onSubmit(editor.getJSON());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
editor.commands.insertContent('<p></p>');
|
||||
editor.commands.insertContent([
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'paragraph' },
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
editorRef.current = editor;
|
||||
},
|
||||
onDestroy: () => {
|
||||
editorRef.current = null;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !roadmapTreeData) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.storage.variable.variables = roadmapTreeData.map((mapping) => {
|
||||
return {
|
||||
id: mapping._id,
|
||||
label: mapping.text,
|
||||
};
|
||||
});
|
||||
}, [editor, roadmapTreeData]);
|
||||
|
||||
return (
|
||||
<div className="chat-editor w-full">
|
||||
<EditorContent editor={editor} />
|
||||
|
@@ -25,34 +25,24 @@ export const VariableList = forwardRef((props: VariableListProps, ref) => {
|
||||
command(item);
|
||||
};
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||
};
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||
};
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex);
|
||||
};
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [items]);
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
upHandler();
|
||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
downHandler();
|
||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
enterHandler();
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -61,12 +51,15 @@ export const VariableList = forwardRef((props: VariableListProps, ref) => {
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 overflow-auto rounded-lg border border-gray-200 bg-white p-1 shadow-sm">
|
||||
<div
|
||||
id="variable-suggestion-list"
|
||||
className="flex max-w-[260px] flex-col gap-0.5 overflow-auto rounded-lg border border-gray-200 bg-white p-1 shadow-sm"
|
||||
>
|
||||
{items.length ? (
|
||||
items.map((item, index) => (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-md p-1 px-1.5 text-left text-sm hover:bg-gray-100',
|
||||
'truncate rounded-md p-1 px-1.5 text-left text-sm hover:bg-gray-100',
|
||||
index === selectedIndex && 'bg-gray-100',
|
||||
)}
|
||||
key={index}
|
||||
@@ -91,7 +84,7 @@ export function variableSuggestion(): Omit<SuggestionOptions, 'editor'> {
|
||||
|
||||
return storage.variables
|
||||
.filter((variable) =>
|
||||
variable.label.toLowerCase().startsWith(query.toLowerCase()),
|
||||
variable?.label?.toLowerCase().includes(query.toLowerCase()),
|
||||
)
|
||||
.slice(0, 5);
|
||||
},
|
||||
|
141
src/components/RoadmapAIChat/RoadmapAIChat.css
Normal file
141
src/components/RoadmapAIChat/RoadmapAIChat.css
Normal file
@@ -0,0 +1,141 @@
|
||||
.prose ul li > code,
|
||||
.prose ol li > code,
|
||||
p code,
|
||||
a > code,
|
||||
strong > code,
|
||||
em > code,
|
||||
h1 > code,
|
||||
h2 > code,
|
||||
h3 > code {
|
||||
background: #ebebeb !important;
|
||||
color: currentColor !important;
|
||||
font-size: 14px;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
.course-ai-content.course-content.prose ul li > code,
|
||||
.course-ai-content.course-content.prose ol li > code,
|
||||
.course-ai-content.course-content.prose p code,
|
||||
.course-ai-content.course-content.prose a > code,
|
||||
.course-ai-content.course-content.prose strong > code,
|
||||
.course-ai-content.course-content.prose em > code,
|
||||
.course-ai-content.course-content.prose h1 > code,
|
||||
.course-ai-content.course-content.prose h2 > code,
|
||||
.course-ai-content.course-content.prose h3 > code,
|
||||
.course-notes-content.prose ul li > code,
|
||||
.course-notes-content.prose ol li > code,
|
||||
.course-notes-content.prose p code,
|
||||
.course-notes-content.prose a > code,
|
||||
.course-notes-content.prose strong > code,
|
||||
.course-notes-content.prose em > code,
|
||||
.course-notes-content.prose h1 > code,
|
||||
.course-notes-content.prose h2 > code,
|
||||
.course-notes-content.prose h3 > code {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.course-ai-content pre {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.course-ai-content pre::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.course-ai-content pre,
|
||||
.course-notes-content pre {
|
||||
overflow: scroll;
|
||||
font-size: 15px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.prose ul li > code:before,
|
||||
p > code:before,
|
||||
.prose ul li > code:after,
|
||||
.prose ol li > code:before,
|
||||
p > code:before,
|
||||
.prose ol li > code:after,
|
||||
.course-content h1 > code:after,
|
||||
.course-content h1 > code:before,
|
||||
.course-content h2 > code:after,
|
||||
.course-content h2 > code:before,
|
||||
.course-content h3 > code:after,
|
||||
.course-content h3 > code:before,
|
||||
.course-content h4 > code:after,
|
||||
.course-content h4 > code:before,
|
||||
p > code:after,
|
||||
a > code:after,
|
||||
a > code:before {
|
||||
content: '' !important;
|
||||
}
|
||||
|
||||
.course-content.prose ul li > code,
|
||||
.course-content.prose ol li > code,
|
||||
.course-content p code,
|
||||
.course-content a > code,
|
||||
.course-content strong > code,
|
||||
.course-content em > code,
|
||||
.course-content h1 > code,
|
||||
.course-content h2 > code,
|
||||
.course-content h3 > code,
|
||||
.course-content table code {
|
||||
background: #f4f4f5 !important;
|
||||
border: 1px solid #282a36 !important;
|
||||
color: #282a36 !important;
|
||||
padding: 2px 4px;
|
||||
border-radius: 5px;
|
||||
font-size: 16px !important;
|
||||
white-space: pre;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.course-content blockquote {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.course-content.prose blockquote h1,
|
||||
.course-content.prose blockquote h2,
|
||||
.course-content.prose blockquote h3,
|
||||
.course-content.prose blockquote h4 {
|
||||
font-style: normal;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.course-content.prose ul li > code:before,
|
||||
.course-content p > code:before,
|
||||
.course-content.prose ul li > code:after,
|
||||
.course-content p > code:after,
|
||||
.course-content h2 > code:after,
|
||||
.course-content h2 > code:before,
|
||||
.course-content table code:before,
|
||||
.course-content table code:after,
|
||||
.course-content a > code:after,
|
||||
.course-content a > code:before,
|
||||
.course-content h2 code:after,
|
||||
.course-content h2 code:before,
|
||||
.course-content h2 code:after,
|
||||
.course-content h2 code:before {
|
||||
content: '' !important;
|
||||
}
|
||||
|
||||
.course-content table {
|
||||
border-collapse: collapse;
|
||||
border: 1px solid black;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.course-content table td,
|
||||
.course-content table th {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.course-ai-content .chat-variable {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
padding: 2px 4px;
|
||||
border-radius: 8px;
|
||||
background-color: #f0f5ff;
|
||||
color: #2c5df1;
|
||||
}
|
@@ -1,10 +1,27 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { roadmapJSONOptions } from '../../queries/roadmap';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { BotIcon, SendIcon } from 'lucide-react';
|
||||
import { BotIcon, Loader2Icon, SendIcon } from 'lucide-react';
|
||||
import { ChatEditor } from '../ChatEditor/ChatEditor';
|
||||
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
||||
import {
|
||||
AIChatCard,
|
||||
type AIChatHistoryType,
|
||||
} from '../GenerateCourse/AICourseLessonChat';
|
||||
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
||||
import type { JSONContent } from '@tiptap/core';
|
||||
import { flushSync } from 'react-dom';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { markdownToHtmlWithHighlighting } from '../../lib/markdown';
|
||||
import { readStream } from '../../lib/ai';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export type RoamdapAIChatHistoryType = AIChatHistoryType & {
|
||||
json?: JSONContent;
|
||||
};
|
||||
|
||||
type RoadmapAIChatProps = {
|
||||
roadmapId: string;
|
||||
@@ -13,54 +30,234 @@ type RoadmapAIChatProps = {
|
||||
export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
const { roadmapId } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const scrollareaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { data } = useQuery(roadmapJSONOptions(roadmapId), queryClient);
|
||||
|
||||
const [aiChatHistory, setAiChatHistory] = useState<
|
||||
RoamdapAIChatHistoryType[]
|
||||
>([]);
|
||||
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
|
||||
const [streamedMessage, setStreamedMessage] = useState('');
|
||||
|
||||
const { data: roadmapJSONData } = useQuery(
|
||||
roadmapJSONOptions(roadmapId),
|
||||
queryClient,
|
||||
);
|
||||
const { data: roadmapTreeData } = useQuery(
|
||||
roadmapTreeMappingOptions(roadmapId),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const roadmapContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !roadmapContainerRef.current) {
|
||||
if (!roadmapJSONData || !roadmapContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
roadmapContainerRef.current.replaceChildren(roadmapJSONData.svg);
|
||||
}, [roadmapJSONData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roadmapTreeData || !roadmapJSONData) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
roadmapContainerRef.current.replaceChildren(data.svg);
|
||||
}, [data]);
|
||||
}, [roadmapTreeData, roadmapJSONData]);
|
||||
|
||||
const handleChatSubmit = (json: JSONContent) => {
|
||||
if (!json || isStreamingMessage || !isLoggedIn() || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newMessages: RoamdapAIChatHistoryType[] = [
|
||||
...aiChatHistory,
|
||||
{
|
||||
role: 'user',
|
||||
content: '',
|
||||
json,
|
||||
},
|
||||
];
|
||||
|
||||
flushSync(() => {
|
||||
setAiChatHistory(newMessages);
|
||||
editorRef.current?.commands.setContent('<p></p>');
|
||||
});
|
||||
|
||||
scrollToBottom();
|
||||
completeAITutorChat(newMessages);
|
||||
};
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
scrollareaRef.current?.scrollTo({
|
||||
top: scrollareaRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [scrollareaRef]);
|
||||
|
||||
const completeAITutorChat = async (messages: AIChatHistoryType[]) => {
|
||||
try {
|
||||
setIsStreamingMessage(true);
|
||||
|
||||
const response = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-chat-roadmap`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
roadmapId,
|
||||
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();
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
setIsStreamingMessage(false);
|
||||
toast.error('Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
} catch (error) {
|
||||
toast.error('Something went wrong');
|
||||
setIsStreamingMessage(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="grid grow grid-cols-3">
|
||||
<div className="relative col-span-2 h-full overflow-y-scroll">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
|
||||
<Spinner
|
||||
className="h-6 w-6 animate-spin sm:h-12 sm:w-12"
|
||||
isDualRing={false}
|
||||
/>
|
||||
<Loader2Icon className="size-6 animate-spin stroke-[2.5]" />
|
||||
</div>
|
||||
)}
|
||||
<div ref={roadmapContainerRef} />
|
||||
<div ref={roadmapContainerRef} hidden={isLoading} className="p-4" />
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-col border-l border-gray-200 bg-white">
|
||||
<div className="flex min-h-[46px] items-center justify-between gap-2 border-gray-200 px-3 py-2 text-sm">
|
||||
<div className="flex min-h-[46px] items-center justify-between gap-2 border-b border-gray-200 px-3 py-2 text-sm">
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<BotIcon className="size-4 shrink-0 text-black" />
|
||||
<span>AI Chat</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative grow overflow-y-auto">
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<div className="h-[1000px] w-full bg-red-100" />
|
||||
</div>
|
||||
<div className="relative grow overflow-y-auto" ref={scrollareaRef}>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-1.5 px-3 text-sm text-gray-500">
|
||||
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
|
||||
<span>Loading Roadmap</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<div className="relative flex grow flex-col justify-end">
|
||||
<div className="flex flex-col justify-end gap-2 px-3 py-2">
|
||||
{aiChatHistory.map((chat, index) => {
|
||||
let content = chat.content;
|
||||
|
||||
return (
|
||||
<Fragment key={`chat-${index}`}>
|
||||
<AIChatCard
|
||||
role={chat.role}
|
||||
content={content}
|
||||
html={
|
||||
chat.html || htmlFromTiptapJSON(chat.json || {})
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{isStreamingMessage && !streamedMessage && (
|
||||
<AIChatCard role="assistant" content="Thinking..." />
|
||||
)}
|
||||
|
||||
{streamedMessage && (
|
||||
<AIChatCard role="assistant" content={streamedMessage} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="relative flex items-start border-t border-gray-200 text-sm"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleChatSubmit(editorRef.current?.getJSON() || {});
|
||||
}}
|
||||
>
|
||||
<ChatEditor />
|
||||
<ChatEditor
|
||||
editorRef={editorRef}
|
||||
roadmapId={roadmapId}
|
||||
onSubmit={(content) => {
|
||||
handleChatSubmit(content);
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
@@ -73,3 +270,30 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function htmlFromTiptapJSON(json: JSONContent) {
|
||||
const content = json.content;
|
||||
|
||||
let text = '';
|
||||
for (const child of content || []) {
|
||||
switch (child.type) {
|
||||
case 'text':
|
||||
text += child.text;
|
||||
break;
|
||||
case 'paragraph':
|
||||
// Add a new line before each paragraph
|
||||
// This is to ensure that the text is formatted correctly
|
||||
text += '\n';
|
||||
text += `<p>${htmlFromTiptapJSON(child)}</p>`;
|
||||
break;
|
||||
case 'variable':
|
||||
const label = child?.attrs?.label || '';
|
||||
text += `<span class="chat-variable">${label}</span>`;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
Reference in New Issue
Block a user