Files
developer-roadmap/src/lib/render-chat-message.tsx
T
Arik ChakmaandKamran Ahmed 62f31a4964 feat: roadmap chat (#8666)
* wip: roadmap chat

* wip

* wip

* wip

* wip

* wip: message rendering

* wip: roadmap topics rendering

* Update UI for premium page

* Update UI for premium page

* wip: topic resources rendering

* Update premium page design

* Update navigation design

* Update design for play iconsg

* Update design for play iconsg

* wip: resource progress

* Update premium page

* Refactor premium page

* wip: resource progress

* feat: enhance the progress rendering

* feat: add abort functionality to chat and update UI elements

* feat: add roadmap recommendations feature and new query for built-in roadmaps

* Grid -> flex

* Update chat

* Handle error screen

* wip

* fix: empty content check

* feat: show loading status

* feat: ai chat limit

* Refactor content parsing

* Handle the wrapping of tag in codeblocks

* Refactor

* Refactor chat

* Fix hydration error

* feat: topic details

* style: adjust padding and icon size in RoadmapAIChat component

* feat: add tab navigation for chat and topic details

* fix: chat and details scrolling issue

* fix: invalidate progress query

* Fix upgrade modal and issue with close button

* Refactor topic detail AI

* Update progress UI

* Shorten the text

* Rewrite frontend roadmap content

* Minor update to content

* Update shrinking issue

* Changes to ai chat

* UI changes for chat card

* UI changes for chat card

* Refactor progress actions list

* Update progress marking UI

* UI Changes

* Responsiveness of sidebar

* Responsiveness of sidebar

* Mobile window for roadmap chat

* Responsiveness of AI chat

* Close button fix

* Make roadmap ai chat header responsive

* Escape key handling to close

* Update topic rendering node

* Make default active tab to AI in topic popup

* User progress component

* Clear chat only visible when there is chat

* wip: user persona

* fix: hide upgrade button

* fix: show chat input for guest users

* feat: persona update

* Roadmap recommendations

* Update persona form

* Update tell us more

* Fix persona form

* Fix scroll issue

* Lock body scroll when mobile is active

* Add intro message

* Refactor and update

* Update UI for AI chat

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2025-05-27 21:02:36 +01:00

193 lines
5.0 KiB
TypeScript

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;
isLoading?: boolean;
};
export type MessagePartRenderer = (
props: MessagePartRendererProps,
) => React.ReactNode | string;
export type MessagePartRendererOptions = {
isLoading?: boolean;
};
export async function parseMessageParts(
content: string,
renderer: Record<string, MessagePartRenderer>,
options: MessagePartRendererOptions = {
isLoading: false,
},
) {
const parts: MessagePart[] = [];
const tagNames = Object.keys(renderer);
// Remove codeblocks around custom tags
if (tagNames.length > 0) {
const tagPattern = tagNames.join('|');
// Remove opening codeblock before tags: ```lang\n<tag> -> <tag>
// It sometimes puts codeblocks around our tags (despite us asking it not to)
// so we manually remove them here
content = content.replace(
new RegExp(`\`\`\`\\w*?\\n+?<(${tagPattern})>`, 'g'),
'<$1>',
);
// Remove closing codeblock after tags: </tag>\n``` -> </tag>
content = content.replace(
new RegExp(`<\\/(${tagPattern})>\\n+?\`\`\``, 'g'),
'</$1>',
);
}
// If no renderers, just return the content as markdown
if (tagNames.length === 0) {
const html = await markdownToHtmlWithHighlighting(content);
parts.push({
id: nanoid(),
type: 'html',
content: html,
});
return parts;
}
const tagPattern = tagNames.join('|');
const regex = new RegExp(`<(${tagPattern})>(.*?)<\/\\1>`, 'gs');
let lastIndex = 0;
let match;
// we will match only tags that have renderers
// 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;
// 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,
isLoading: options.isLoading,
});
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
for (const tag of tagNames) {
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,
isLoading: options.isLoading,
});
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>,
options: MessagePartRendererOptions = {
isLoading: false,
},
) {
const parts = await parseMessageParts(content, renderer, options);
return (
<div className="max-w-[calc(100%-38px)] w-full">
{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>
);
}