mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-09 16:53:33 +02:00
wip: message rendering
This commit is contained in:
@@ -6,30 +6,50 @@ import {
|
||||
roadmapJSONOptions,
|
||||
} from '../../queries/roadmap';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { BotIcon, Loader2Icon, SendIcon } from 'lucide-react';
|
||||
import { ChatEditor } from '../ChatEditor/ChatEditor';
|
||||
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
||||
import {
|
||||
AIChatCard,
|
||||
type AIChatHistoryType,
|
||||
type AllowedAIChatRole,
|
||||
} from '../GenerateCourse/AICourseLessonChat';
|
||||
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
||||
import type { JSONContent } from '@tiptap/core';
|
||||
import type { JSONContent, Editor } 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';
|
||||
import { userResourceProgressOptions } from '../../queries/resource-progress';
|
||||
import { renderTopicProgress } from '../../lib/resource-progress';
|
||||
import { EditorRoadmapRenderer } from '../EditorRoadmap/EditorRoadmapRenderer';
|
||||
import { ChatRoadmapRenderer } from './ChatRoadmapRenderer';
|
||||
import {
|
||||
renderMessage,
|
||||
type MessagePartRenderer,
|
||||
} from '../../lib/render-chat-message';
|
||||
import { RoadmapAIChatCard } from './RoadmapAIChatCard';
|
||||
import { UserProgressList } from './UserProgressList';
|
||||
|
||||
export type RoamdapAIChatHistoryType = AIChatHistoryType & {
|
||||
|
||||
export type RoamdapAIChatHistoryType = {
|
||||
role: AllowedAIChatRole;
|
||||
isDefault?: boolean;
|
||||
|
||||
// these two will be used only into the backend
|
||||
// for transforming the raw message into the final message
|
||||
content?: string;
|
||||
json?: JSONContent;
|
||||
|
||||
// these two will be used only into the frontend
|
||||
// for rendering the message
|
||||
html?: string;
|
||||
jsx?: React.ReactNode;
|
||||
};
|
||||
|
||||
type RoadmapAIChatProps = {
|
||||
@@ -49,7 +69,8 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
RoamdapAIChatHistoryType[]
|
||||
>([]);
|
||||
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
|
||||
const [streamedMessage, setStreamedMessage] = useState('');
|
||||
const [streamedMessage, setStreamedMessage] =
|
||||
useState<React.ReactNode | null>(null);
|
||||
|
||||
const { data: roadmapDetailsData } = useQuery(
|
||||
roadmapDetailsOptions(roadmapId),
|
||||
@@ -93,12 +114,13 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const html = htmlFromTiptapJSON(json);
|
||||
const newMessages: RoamdapAIChatHistoryType[] = [
|
||||
...aiChatHistory,
|
||||
{
|
||||
role: 'user',
|
||||
content: '',
|
||||
json,
|
||||
html,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -118,7 +140,18 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
});
|
||||
}, [scrollareaRef]);
|
||||
|
||||
const completeAITutorChat = async (messages: AIChatHistoryType[]) => {
|
||||
const renderer: Record<string, MessagePartRenderer> = useMemo(() => {
|
||||
return {
|
||||
'user-progress': () => {
|
||||
return <UserProgressList roadmapId={roadmapId} />;
|
||||
},
|
||||
'update-progress': (options) => {
|
||||
return 'hello';
|
||||
},
|
||||
};
|
||||
}, [roadmapId]);
|
||||
|
||||
const completeAITutorChat = async (messages: RoamdapAIChatHistoryType[]) => {
|
||||
try {
|
||||
setIsStreamingMessage(true);
|
||||
|
||||
@@ -163,24 +196,27 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
|
||||
await readStream(reader, {
|
||||
onStream: async (content) => {
|
||||
const jsx = await renderMessage(content, renderer);
|
||||
|
||||
flushSync(() => {
|
||||
setStreamedMessage(content);
|
||||
setStreamedMessage(jsx);
|
||||
});
|
||||
|
||||
scrollToBottom();
|
||||
},
|
||||
onStreamEnd: async (content) => {
|
||||
const newMessages: AIChatHistoryType[] = [
|
||||
const jsx = await renderMessage(content, renderer);
|
||||
const newMessages: RoamdapAIChatHistoryType[] = [
|
||||
...messages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content,
|
||||
html: await markdownToHtmlWithHighlighting(content),
|
||||
jsx,
|
||||
},
|
||||
];
|
||||
|
||||
flushSync(() => {
|
||||
setStreamedMessage('');
|
||||
setStreamedMessage(null);
|
||||
setIsStreamingMessage(false);
|
||||
setAiChatHistory(newMessages);
|
||||
});
|
||||
@@ -241,27 +277,19 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
<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 || {})
|
||||
}
|
||||
/>
|
||||
<RoadmapAIChatCard {...chat} />
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{isStreamingMessage && !streamedMessage && (
|
||||
<AIChatCard role="assistant" content="Thinking..." />
|
||||
<RoadmapAIChatCard role="assistant" html="Thinking..." />
|
||||
)}
|
||||
|
||||
{streamedMessage && (
|
||||
<AIChatCard role="assistant" content={streamedMessage} />
|
||||
<RoadmapAIChatCard role="assistant" jsx={streamedMessage} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
44
src/components/RoadmapAIChat/RoadmapAIChatCard.tsx
Normal file
44
src/components/RoadmapAIChat/RoadmapAIChatCard.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { RoamdapAIChatHistoryType } from './RoadmapAIChat';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { BotIcon, User2Icon } from 'lucide-react';
|
||||
|
||||
type RoadmapAIChatCardProps = RoamdapAIChatHistoryType;
|
||||
|
||||
export function RoadmapAIChatCard(props: RoadmapAIChatCardProps) {
|
||||
const { role, html, jsx } = props;
|
||||
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{role === 'user' ? (
|
||||
<User2Icon className="size-4 stroke-[2.5]" />
|
||||
) : (
|
||||
<BotIcon className="size-4 stroke-[2.5]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!!jsx && jsx}
|
||||
|
||||
{html && (
|
||||
<div
|
||||
className="course-content course-ai-content prose prose-sm mt-0.5 max-w-full overflow-hidden text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
40
src/components/RoadmapAIChat/UserProgressList.tsx
Normal file
40
src/components/RoadmapAIChat/UserProgressList.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { roadmapDetailsOptions } from '../../queries/roadmap';
|
||||
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
||||
import { userResourceProgressOptions } from '../../queries/resource-progress';
|
||||
|
||||
type UserProgressListProps = {
|
||||
roadmapId: string;
|
||||
};
|
||||
|
||||
export function UserProgressList(props: UserProgressListProps) {
|
||||
const { roadmapId } = props;
|
||||
|
||||
const { data: roadmapTreeData } = useQuery(
|
||||
roadmapTreeMappingOptions(roadmapId),
|
||||
queryClient,
|
||||
);
|
||||
const { data: userResourceProgressData } = useQuery(
|
||||
userResourceProgressOptions('roadmap', roadmapId),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const doneCount = userResourceProgressData?.done?.length ?? 0;
|
||||
const learningCount = userResourceProgressData?.learning?.length ?? 0;
|
||||
const skippedCount = userResourceProgressData?.skipped?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="relative my-6 flex flex-col gap-2 rounded-lg border border-gray-200 bg-white p-2 first:mt-0 last:mb-0">
|
||||
<span>
|
||||
Done: <span className="font-bold">{doneCount}</span>
|
||||
</span>
|
||||
<span>
|
||||
Learning: <span className="font-bold">{learningCount}</span>
|
||||
</span>
|
||||
<span>
|
||||
Skipped: <span className="font-bold">{skippedCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -177,8 +177,8 @@ export async function readStream(
|
||||
onStream,
|
||||
onStreamEnd,
|
||||
}: {
|
||||
onStream?: (course: string) => void;
|
||||
onStreamEnd?: (course: string) => void;
|
||||
onStream?: (course: string) => Promise<void>;
|
||||
onStreamEnd?: (course: string) => Promise<void>;
|
||||
},
|
||||
) {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
@@ -196,7 +196,7 @@ export async function readStream(
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value[i] === NEW_LINE) {
|
||||
result += decoder.decode(value.slice(start, i + 1));
|
||||
onStream?.(result);
|
||||
await onStream?.(result);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
@@ -206,8 +206,8 @@ export async function readStream(
|
||||
}
|
||||
}
|
||||
|
||||
onStream?.(result);
|
||||
onStreamEnd?.(result);
|
||||
await onStream?.(result);
|
||||
await onStreamEnd?.(result);
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
|
146
src/lib/render-chat-message.tsx
Normal file
146
src/lib/render-chat-message.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { markdownToHtmlWithHighlighting } from './markdown';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
type MessagePart = {
|
||||
id: string;
|
||||
type: 'text' | 'html';
|
||||
content: string | React.ReactNode;
|
||||
};
|
||||
|
||||
type MessagePartRendererProps = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type MessagePartRenderer = (
|
||||
props: MessagePartRendererProps,
|
||||
) => React.ReactNode | string;
|
||||
|
||||
export async function parseMessageParts(
|
||||
content: string,
|
||||
renderer: Record<string, MessagePartRenderer>,
|
||||
): Promise<MessagePart[]> {
|
||||
const parts: MessagePart[] = [];
|
||||
const regex = /<([a-zA-Z0-9\-]+)>(.*?)<\/\1>/gs;
|
||||
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
// we will match all tags in the content
|
||||
// and then we will render each tag with the corresponding renderer
|
||||
// and then we will push the rendered content to the parts array
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const [_, tag, innerContent] = match;
|
||||
|
||||
// check if the tag has a renderer
|
||||
if (renderer[tag]) {
|
||||
// push the text before the tag
|
||||
// so that we can render it later
|
||||
if (match.index > lastIndex) {
|
||||
const rawBefore = content.slice(lastIndex, match.index);
|
||||
const html = await markdownToHtmlWithHighlighting(rawBefore);
|
||||
parts.push({
|
||||
id: nanoid(),
|
||||
type: 'html',
|
||||
content: html,
|
||||
});
|
||||
}
|
||||
|
||||
const output = renderer[tag]({ content: innerContent });
|
||||
parts.push({
|
||||
id: nanoid(),
|
||||
type: 'html',
|
||||
content: output,
|
||||
});
|
||||
|
||||
// update the last index
|
||||
// so that we can render the next tag
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// if there was an opening tag that never closed, check manually
|
||||
// search for any known tag that starts but wasn't matched
|
||||
// we have to do this way otherwise we might process html tags
|
||||
// that are not in the renderer
|
||||
for (const tag of Object.keys(renderer)) {
|
||||
const openingTag = `<${tag}>`;
|
||||
const openingIndex = content.indexOf(openingTag, lastIndex);
|
||||
const closingTag = `</${tag}>`;
|
||||
const closingIndex = content.indexOf(closingTag, lastIndex);
|
||||
|
||||
if (openingIndex !== -1 && closingIndex === -1) {
|
||||
if (openingIndex > lastIndex) {
|
||||
const rawBefore = content.slice(lastIndex, openingIndex);
|
||||
const html = await markdownToHtmlWithHighlighting(rawBefore);
|
||||
parts.push({
|
||||
id: nanoid(),
|
||||
type: 'html',
|
||||
content: html,
|
||||
});
|
||||
}
|
||||
|
||||
const innerContent = content.slice(openingIndex + openingTag.length);
|
||||
const output = renderer[tag]({ content: innerContent });
|
||||
parts.push({
|
||||
id: nanoid(),
|
||||
type: 'html',
|
||||
content: output,
|
||||
});
|
||||
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
// add the remaining content
|
||||
if (lastIndex < content.length) {
|
||||
const rawRemaining = content.slice(lastIndex);
|
||||
const html = await markdownToHtmlWithHighlighting(rawRemaining);
|
||||
|
||||
parts.push({
|
||||
id: nanoid(),
|
||||
type: 'html',
|
||||
content: html,
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
export async function renderMessage(
|
||||
content: string,
|
||||
renderer: Record<string, MessagePartRenderer>,
|
||||
) {
|
||||
const parts = await parseMessageParts(content, renderer);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{parts.map((item) => {
|
||||
if (
|
||||
(item.type === 'html' || item.type === 'text') &&
|
||||
typeof item.content === 'string'
|
||||
) {
|
||||
const trimmedContent = item.content.trim();
|
||||
if (!trimmedContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="course-content course-ai-content prose prose-sm mt-0.5 max-w-full overflow-hidden text-sm"
|
||||
key={item.id}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: trimmedContent,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'html' && typeof item.content === 'object') {
|
||||
return <Fragment key={item.id}>{item.content}</Fragment>;
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -22,5 +22,6 @@ export function roadmapTreeMappingOptions(roadmapId: string) {
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-roadmap-tree-mapping/${roadmapId}`,
|
||||
);
|
||||
},
|
||||
refetchOnMount: false,
|
||||
});
|
||||
}
|
||||
|
Reference in New Issue
Block a user