diff --git a/packages/bolt/app/components/chat/AssistantMessage.tsx b/packages/bolt/app/components/chat/AssistantMessage.tsx index c30d811..a5698e9 100644 --- a/packages/bolt/app/components/chat/AssistantMessage.tsx +++ b/packages/bolt/app/components/chat/AssistantMessage.tsx @@ -8,7 +8,7 @@ interface AssistantMessageProps { export const AssistantMessage = memo(({ content }: AssistantMessageProps) => { return (
- {content} + {content}
); }); diff --git a/packages/bolt/app/components/chat/Markdown.module.scss b/packages/bolt/app/components/chat/Markdown.module.scss index da02823..3da3861 100644 --- a/packages/bolt/app/components/chat/Markdown.module.scss +++ b/packages/bolt/app/components/chat/Markdown.module.scss @@ -58,9 +58,13 @@ $code-font-size: 13px; color: #6a737d; } - p:not(:last-of-type) { - margin-block-start: 0; - margin-block-end: 16px; + p { + white-space: pre-wrap; + + &:not(:last-of-type) { + margin-block-start: 0; + margin-block-end: 16px; + } } a { diff --git a/packages/bolt/app/components/chat/Markdown.tsx b/packages/bolt/app/components/chat/Markdown.tsx index 4a966e7..a91df43 100644 --- a/packages/bolt/app/components/chat/Markdown.tsx +++ b/packages/bolt/app/components/chat/Markdown.tsx @@ -2,7 +2,7 @@ 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 } from '~/utils/markdown'; +import { rehypePlugins, remarkPlugins, allowedHTMLElements } from '~/utils/markdown'; import { Artifact } from './Artifact'; import { CodeBlock } from './CodeBlock'; @@ -12,12 +12,14 @@ const logger = createScopedLogger('MarkdownComponent'); interface MarkdownProps { children: string; + html?: boolean; + limitedMarkdown?: boolean; } -export const Markdown = memo(({ children }: MarkdownProps) => { +export const Markdown = memo(({ children, html = false, limitedMarkdown = false }: MarkdownProps) => { logger.trace('Render'); - const components = useMemo(() => { + const components = useMemo(() => { return { div: ({ className, children, node, ...props }) => { if (className?.includes('__boltArtifact__')) { @@ -55,15 +57,16 @@ export const Markdown = memo(({ children }: MarkdownProps) => { return
{children}
; }, - }; + } satisfies Components; }, []); return ( {children} diff --git a/packages/bolt/app/components/chat/UserMessage.tsx b/packages/bolt/app/components/chat/UserMessage.tsx index 702a79a..2f4e1d5 100644 --- a/packages/bolt/app/components/chat/UserMessage.tsx +++ b/packages/bolt/app/components/chat/UserMessage.tsx @@ -8,7 +8,7 @@ interface UserMessageProps { export function UserMessage({ content }: UserMessageProps) { return (
- {sanitizeUserMessage(content)} + {sanitizeUserMessage(content)}
); } diff --git a/packages/bolt/app/lib/.server/llm/prompts.ts b/packages/bolt/app/lib/.server/llm/prompts.ts index d53c0cd..f78b418 100644 --- a/packages/bolt/app/lib/.server/llm/prompts.ts +++ b/packages/bolt/app/lib/.server/llm/prompts.ts @@ -1,4 +1,5 @@ import { MODIFICATIONS_TAG_NAME, WORK_DIR } from '~/utils/constants'; +import { allowedHTMLElements } from '~/utils/markdown'; import { stripIndents } from '~/utils/stripIndent'; export const getSystemPrompt = (cwd: string = WORK_DIR) => ` @@ -35,6 +36,10 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w Use 2 spaces for code indentation + + You can make the output pretty by using only the following available HTML elements: ${allowedHTMLElements.map((tagName) => `<${tagName}>`).join(', ')} + + For user-made file modifications, a \`<${MODIFICATIONS_TAG_NAME}>\` section will appear at the start of the user message. It will contain either \`\` or \`\` elements for each modified file: diff --git a/packages/bolt/app/lib/runtime/message-parser.ts b/packages/bolt/app/lib/runtime/message-parser.ts index c828815..317f81d 100644 --- a/packages/bolt/app/lib/runtime/message-parser.ts +++ b/packages/bolt/app/lib/runtime/message-parser.ts @@ -272,7 +272,7 @@ export class StreamingMessageParser { const createArtifactElement: ElementFactory = (props) => { const elementProps = [ 'class="__boltArtifact__"', - Object.entries(props).map(([key, value]) => { + ...Object.entries(props).map(([key, value]) => { return `data-${camelToDashCase(key)}=${JSON.stringify(value)}`; }), ]; diff --git a/packages/bolt/app/utils/markdown.ts b/packages/bolt/app/utils/markdown.ts index 7f13d27..4409b85 100644 --- a/packages/bolt/app/utils/markdown.ts +++ b/packages/bolt/app/utils/markdown.ts @@ -1,6 +1,116 @@ import rehypeRaw from 'rehype-raw'; import remarkGfm from 'remark-gfm'; -import type { PluggableList } from 'unified'; +import type { PluggableList, Plugin } from 'unified'; +import rehypeSanitize, { defaultSchema, type Options as RehypeSanitizeOptions } from 'rehype-sanitize'; +import { SKIP, visit } from 'unist-util-visit'; +import type { UnistNode, UnistParent } from 'node_modules/unist-util-visit/lib'; -export const remarkPlugins = [remarkGfm] satisfies PluggableList; -export const rehypePlugins = [rehypeRaw] satisfies PluggableList; +export const allowedHTMLElements = [ + 'a', + 'b', + 'blockquote', + 'br', + 'code', + 'dd', + 'del', + 'details', + 'div', + 'dl', + 'dt', + 'em', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'i', + 'ins', + 'kbd', + 'li', + 'ol', + 'p', + 'pre', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'source', + 'span', + 'strike', + 'strong', + 'sub', + 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'tfoot', + 'th', + 'thead', + 'tr', + 'ul', + 'var', +]; + +const rehypeSanitizeOptions: RehypeSanitizeOptions = { + ...defaultSchema, + tagNames: allowedHTMLElements, + attributes: { + ...defaultSchema.attributes, + div: [...(defaultSchema.attributes?.div ?? []), 'data*', ['className', '__boltArtifact__']], + }, + strip: [], +}; + +export function remarkPlugins(limitedMarkdown: boolean) { + const plugins: PluggableList = [remarkGfm]; + + if (limitedMarkdown) { + plugins.unshift(limitedMarkdownPlugin); + } + + return plugins; +} + +export function rehypePlugins(html: boolean) { + const plugins: PluggableList = []; + + if (html) { + plugins.push(rehypeRaw, [rehypeSanitize, rehypeSanitizeOptions]); + } + + return plugins; +} + +const limitedMarkdownPlugin: Plugin = () => { + return (tree, file) => { + const contents = file.toString(); + + visit(tree, (node: UnistNode, index, parent: UnistParent) => { + if ( + index == null || + ['paragraph', 'text', 'inlineCode', 'code', 'strong', 'emphasis'].includes(node.type) || + !node.position + ) { + return true; + } + + let value = contents.slice(node.position.start.offset, node.position.end.offset); + + if (node.type === 'heading') { + value = `\n${value}`; + } + + parent.children[index] = { + type: 'text', + value, + } as any; + + return [SKIP, index] as const; + }); + }; +}; diff --git a/packages/bolt/package.json b/packages/bolt/package.json index e685b8c..9a80d54 100644 --- a/packages/bolt/package.json +++ b/packages/bolt/package.json @@ -64,9 +64,11 @@ "react-resizable-panels": "^2.0.20", "react-toastify": "^10.0.5", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.0", "remix-utils": "^7.6.0", - "shiki": "^1.9.1" + "shiki": "^1.9.1", + "unist-util-visit": "^5.0.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20240620.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6481ed..0084701 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: rehype-raw: specifier: ^7.0.0 version: 7.0.0 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 remark-gfm: specifier: ^4.0.0 version: 4.0.0 @@ -188,6 +191,9 @@ importers: shiki: specifier: ^1.9.1 version: 1.9.1 + unist-util-visit: + specifier: ^5.0.0 + version: 5.0.0 devDependencies: '@cloudflare/workers-types': specifier: ^4.20240620.0 @@ -3214,6 +3220,9 @@ packages: hast-util-raw@9.0.4: resolution: {integrity: sha512-LHE65TD2YiNsHD3YuXcKPHXPLuYh/gjp12mOfU8jxSrm1f/yJpsb0F/KKljS6U9LJoP0Ux+tCe8iJ2AsPzTdgA==} + hast-util-sanitize@5.0.1: + resolution: {integrity: sha512-IGrgWLuip4O2nq5CugXy4GI2V8kx4sFVy5Hd4vF7AR2gxS0N9s7nEAVUyeMtZKZvzrxVsHt73XdTsno1tClIkQ==} + hast-util-to-estree@2.3.3: resolution: {integrity: sha512-ihhPIUPxN0v0w6M5+IiAZZrn0LH2uZomeWwhn7uP7avZC6TE7lIiEh2yBMPr5+zi1aUCXq6VoYRgs2Bw9xmycQ==} @@ -4586,6 +4595,9 @@ packages: rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + remark-frontmatter@4.0.1: resolution: {integrity: sha512-38fJrB0KnmD3E33a5jZC/5+gGAC2WKNiPw1/fdXJvijBlhA7RCsvJklrYJakS0HedninvaCYW8lQGf9C918GfA==} @@ -8935,6 +8947,12 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-sanitize@5.0.1: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.2.0 + unist-util-position: 5.0.0 + hast-util-to-estree@2.3.3: dependencies: '@types/estree': 1.0.5 @@ -10699,6 +10717,11 @@ snapshots: hast-util-raw: 9.0.4 vfile: 6.0.1 + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.1 + remark-frontmatter@4.0.1: dependencies: '@types/mdast': 3.0.15