bolt.diy/app/components/chat/Markdown.tsx
KevIsDev cd37599f3b feat(design): add design scheme support and UI improvements
- Implement design scheme system with palette, typography, and feature customization
- Add color scheme dialog for user customization
- Update chat UI components to use design scheme values
- Improve header actions with consolidated deploy and export buttons
- Adjust layout spacing and styling across multiple components (chat, workbench etc...)
- Add model and provider info to chat messages
- Refactor workbench and sidebar components for better responsiveness
2025-05-28 23:49:51 +01:00

202 lines
7.5 KiB
TypeScript

import { memo, useMemo } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import type { BundledLanguage } from 'shiki';
import { createScopedLogger } from '~/utils/logger';
import { rehypePlugins, remarkPlugins, allowedHTMLElements } from '~/utils/markdown';
import { Artifact, openArtifactInWorkbench } from './Artifact';
import { CodeBlock } from './CodeBlock';
import type { Message } from 'ai';
import styles from './Markdown.module.scss';
import ThoughtBox from './ThoughtBox';
import type { ProviderInfo } from '~/types/model';
const logger = createScopedLogger('MarkdownComponent');
interface MarkdownProps {
children: string;
html?: boolean;
limitedMarkdown?: boolean;
append?: (message: Message) => void;
chatMode?: 'discuss' | 'build';
setChatMode?: (mode: 'discuss' | 'build') => void;
model?: string;
provider?: ProviderInfo;
}
export const Markdown = memo(
({ children, html = false, limitedMarkdown = false, append, setChatMode, model, provider }: MarkdownProps) => {
logger.trace('Render');
const components = useMemo(() => {
return {
div: ({ className, children, node, ...props }) => {
const dataProps = node?.properties as Record<string, unknown>;
if (className?.includes('__boltArtifact__')) {
const messageId = node?.properties.dataMessageId as string;
if (!messageId) {
logger.error(`Invalid message id ${messageId}`);
}
return <Artifact messageId={messageId} />;
}
if (className?.includes('__boltThought__')) {
return <ThoughtBox title="Thought process">{children}</ThoughtBox>;
}
if (className?.includes('__boltQuickAction__') || dataProps?.dataBoltQuickAction) {
return <div className="flex items-center gap-2 flex-wrap mt-3.5">{children}</div>;
}
return (
<div className={className} {...props}>
{children}
</div>
);
},
pre: (props) => {
const { children, node, ...rest } = props;
const [firstChild] = node?.children ?? [];
if (
firstChild &&
firstChild.type === 'element' &&
firstChild.tagName === 'code' &&
firstChild.children[0].type === 'text'
) {
const { className, ...rest } = firstChild.properties;
const [, language = 'plaintext'] = /language-(\w+)/.exec(String(className) || '') ?? [];
return <CodeBlock code={firstChild.children[0].value} language={language as BundledLanguage} {...rest} />;
}
return <pre {...rest}>{children}</pre>;
},
button: ({ node, children, ...props }) => {
const dataProps = node?.properties as Record<string, unknown>;
if (
dataProps?.class?.toString().includes('__boltQuickAction__') ||
dataProps?.dataBoltQuickAction === 'true'
) {
const type = dataProps['data-type'] || dataProps.dataType;
const message = dataProps['data-message'] || dataProps.dataMessage;
const path = dataProps['data-path'] || dataProps.dataPath;
const href = dataProps['data-href'] || dataProps.dataHref;
const iconClassMap: Record<string, string> = {
file: 'i-ph:file',
message: 'i-ph:chats',
implement: 'i-ph:code',
link: 'i-ph:link',
};
const safeType = typeof type === 'string' ? type : '';
const iconClass = iconClassMap[safeType] ?? 'i-ph:question';
return (
<button
className="rounded-md justify-center px-3 py-1.5 text-xs bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent opacity-90 hover:opacity-100 flex items-center gap-2 cursor-pointer"
data-type={type}
data-message={message}
data-path={path}
data-href={href}
onClick={() => {
if (type === 'file') {
openArtifactInWorkbench(path);
} else if (type === 'message' && append) {
append({
id: `quick-action-message-${Date.now()}`,
content: `[Model: ${model}]\n\n[Provider: ${provider?.name}]\n\n${message}`,
role: 'user',
});
console.log('Message appended:', message);
} else if (type === 'implement' && append && setChatMode) {
setChatMode('build');
append({
id: `quick-action-implement-${Date.now()}`,
content: `[Model: ${model}]\n\n[Provider: ${provider?.name}]\n\n${message}`,
role: 'user',
});
} else if (type === 'link' && typeof href === 'string') {
try {
const url = new URL(href, window.location.origin);
window.open(url.toString(), '_blank', 'noopener,noreferrer');
} catch (error) {
console.error('Invalid URL:', href, error);
}
}
}}
>
<div className={`text-lg ${iconClass}`} />
{children}
</button>
);
}
return <button {...props}>{children}</button>;
},
} satisfies Components;
}, []);
return (
<ReactMarkdown
allowedElements={allowedHTMLElements}
className={styles.MarkdownContent}
components={components}
remarkPlugins={remarkPlugins(limitedMarkdown)}
rehypePlugins={rehypePlugins(html)}
>
{stripCodeFenceFromArtifact(children)}
</ReactMarkdown>
);
},
);
/**
* Removes code fence markers (```) surrounding an artifact element while preserving the artifact content.
* This is necessary because artifacts should not be wrapped in code blocks when rendered for rendering action list.
*
* @param content - The markdown content to process
* @returns The processed content with code fence markers removed around artifacts
*
* @example
* // Removes code fences around artifact
* const input = "```xml\n<div class='__boltArtifact__'></div>\n```";
* stripCodeFenceFromArtifact(input);
* // Returns: "\n<div class='__boltArtifact__'></div>\n"
*
* @remarks
* - Only removes code fences that directly wrap an artifact (marked with __boltArtifact__ class)
* - Handles code fences with optional language specifications (e.g. ```xml, ```typescript)
* - Preserves original content if no artifact is found
* - Safely handles edge cases like empty input or artifacts at start/end of content
*/
export const stripCodeFenceFromArtifact = (content: string) => {
if (!content || !content.includes('__boltArtifact__')) {
return content;
}
const lines = content.split('\n');
const artifactLineIndex = lines.findIndex((line) => line.includes('__boltArtifact__'));
// Return original content if artifact line not found
if (artifactLineIndex === -1) {
return content;
}
// Check previous line for code fence
if (artifactLineIndex > 0 && lines[artifactLineIndex - 1]?.trim().match(/^```\w*$/)) {
lines[artifactLineIndex - 1] = '';
}
if (artifactLineIndex < lines.length - 1 && lines[artifactLineIndex + 1]?.trim().match(/^```$/)) {
lines[artifactLineIndex + 1] = '';
}
return lines.join('\n');
};