* 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>
193 lines
5.0 KiB
TypeScript
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>
|
|
);
|
|
}
|