1
0
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:
Arik Chakma
2025-05-21 04:05:07 +06:00
parent 525f36e473
commit d44a4aae71
6 changed files with 290 additions and 31 deletions

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View File

@@ -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();
}

View 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>
);
}

View File

@@ -22,5 +22,6 @@ export function roadmapTreeMappingOptions(roadmapId: string) {
`${import.meta.env.PUBLIC_API_URL}/v1-roadmap-tree-mapping/${roadmapId}`,
);
},
refetchOnMount: false,
});
}