From 39d0775b37f6a662b4f8ff453fd3a3112f9d26b0 Mon Sep 17 00:00:00 2001 From: Keoma Wright Date: Sun, 24 Aug 2025 10:50:15 +0000 Subject: [PATCH] fix: auto-detect and convert code blocks to artifacts when missing tags When AI models fail to use proper artifact tags, code blocks now get automatically detected and converted to file artifacts, preventing code from appearing in chat. The parser detects markdown code fences outside artifacts and wraps them with proper artifact/action tags. This fixes the issue where code would randomly appear in chat instead of being generated as files in the workspace. Fixes #1230 Co-Authored-By: Keoma Wright --- app/lib/runtime/message-parser.ts | 136 ++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/app/lib/runtime/message-parser.ts b/app/lib/runtime/message-parser.ts index cfe6526..429c48e 100644 --- a/app/lib/runtime/message-parser.ts +++ b/app/lib/runtime/message-parser.ts @@ -72,6 +72,7 @@ function cleanEscapedTags(content: string) { } export class StreamingMessageParser { #messages = new Map(); + #artifactCounter = 0; constructor(private _options: StreamingMessageParserOptions = {}) {} @@ -300,6 +301,69 @@ export class StreamingMessageParser { break; } } else { + // Check for code blocks outside of artifacts + if (!state.insideArtifact && input[i] === '`' && input[i + 1] === '`' && input[i + 2] === '`') { + // Find the end of the code block + const languageEnd = input.indexOf('\n', i + 3); + + if (languageEnd !== -1) { + const codeBlockEnd = input.indexOf('\n```', languageEnd + 1); + + if (codeBlockEnd !== -1) { + // Extract language and code content + const language = input.slice(i + 3, languageEnd).trim(); + const codeContent = input.slice(languageEnd + 1, codeBlockEnd); + + // Determine file extension based on language + const fileExtension = this.#getFileExtension(language); + const fileName = `code_${++this.#artifactCounter}${fileExtension}`; + + // Auto-generate artifact and action tags + const artifactId = `artifact_${Date.now()}_${this.#artifactCounter}`; + const autoArtifact = { + id: artifactId, + title: fileName, + type: 'code', + }; + + // Emit artifact open callback + this._options.callbacks?.onArtifactOpen?.({ messageId, ...autoArtifact }); + + // Add artifact element to output + const artifactFactory = this._options.artifactElement ?? createArtifactElement; + output += artifactFactory({ messageId }); + + // Emit action for file creation + const fileAction = { + type: 'file' as const, + filePath: fileName, + content: codeContent + '\n', + }; + + this._options.callbacks?.onActionOpen?.({ + artifactId, + messageId, + actionId: String(state.actionId++), + action: fileAction, + }); + + this._options.callbacks?.onActionClose?.({ + artifactId, + messageId, + actionId: String(state.actionId - 1), + action: fileAction, + }); + + // Emit artifact close callback + this._options.callbacks?.onArtifactClose?.({ messageId, ...autoArtifact }); + + // Move position past the code block + i = codeBlockEnd + 4; // +4 for \n``` + continue; + } + } + } + output += input[i]; i++; } @@ -367,6 +431,78 @@ export class StreamingMessageParser { const match = tag.match(new RegExp(`${attributeName}="([^"]*)"`, 'i')); return match ? match[1] : undefined; } + + #getFileExtension(language: string): string { + const languageMap: Record = { + javascript: '.js', + js: '.js', + typescript: '.ts', + ts: '.ts', + jsx: '.jsx', + tsx: '.tsx', + python: '.py', + py: '.py', + java: '.java', + c: '.c', + cpp: '.cpp', + 'c++': '.cpp', + csharp: '.cs', + 'c#': '.cs', + php: '.php', + ruby: '.rb', + rb: '.rb', + go: '.go', + rust: '.rs', + rs: '.rs', + kotlin: '.kt', + kt: '.kt', + swift: '.swift', + html: '.html', + css: '.css', + scss: '.scss', + sass: '.sass', + less: '.less', + xml: '.xml', + json: '.json', + yaml: '.yaml', + yml: '.yml', + toml: '.toml', + markdown: '.md', + md: '.md', + sql: '.sql', + sh: '.sh', + bash: '.sh', + zsh: '.sh', + fish: '.fish', + powershell: '.ps1', + ps1: '.ps1', + dockerfile: '.dockerfile', + docker: '.dockerfile', + makefile: '.makefile', + make: '.makefile', + vim: '.vim', + lua: '.lua', + perl: '.pl', + r: '.r', + matlab: '.m', + julia: '.jl', + scala: '.scala', + clojure: '.clj', + haskell: '.hs', + erlang: '.erl', + elixir: '.ex', + nim: '.nim', + crystal: '.cr', + dart: '.dart', + vue: '.vue', + svelte: '.svelte', + astro: '.astro', + }; + + const normalized = language.toLowerCase(); + + return languageMap[normalized] || '.txt'; + } } const createArtifactElement: ElementFactory = (props) => {