Skip to content

Commit 03241d3

Browse files
authored
Merge pull request #1927 from embire2/fix/code-outputs-to-chat
fix: resolve code output to chat instead of files
2 parents a5725bc + 1d26dea commit 03241d3

File tree

2 files changed

+302
-2
lines changed

2 files changed

+302
-2
lines changed

app/lib/hooks/useMessageParser.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import type { Message } from 'ai';
22
import { useCallback, useState } from 'react';
3-
import { StreamingMessageParser } from '~/lib/runtime/message-parser';
3+
import { EnhancedStreamingMessageParser } from '~/lib/runtime/enhanced-message-parser';
44
import { workbenchStore } from '~/lib/stores/workbench';
55
import { createScopedLogger } from '~/utils/logger';
66

77
const logger = createScopedLogger('useMessageParser');
88

9-
const messageParser = new StreamingMessageParser({
9+
const messageParser = new EnhancedStreamingMessageParser({
1010
callbacks: {
1111
onArtifactOpen: (data) => {
1212
logger.trace('onArtifactOpen', data);
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import { createScopedLogger } from '~/utils/logger';
2+
import { StreamingMessageParser, type StreamingMessageParserOptions } from './message-parser';
3+
4+
const logger = createScopedLogger('EnhancedMessageParser');
5+
6+
/**
7+
* Enhanced message parser that detects code blocks and file patterns
8+
* even when AI models don't wrap them in proper artifact tags.
9+
* Fixes issue #1797 where code outputs to chat instead of files.
10+
*/
11+
export class EnhancedStreamingMessageParser extends StreamingMessageParser {
12+
private _processedCodeBlocks = new Map<string, Set<string>>();
13+
private _artifactCounter = 0;
14+
15+
constructor(options: StreamingMessageParserOptions = {}) {
16+
super(options);
17+
}
18+
19+
parse(messageId: string, input: string): string {
20+
// First try the normal parsing
21+
let output = super.parse(messageId, input);
22+
23+
// If no artifacts were detected, check for code blocks that should be files
24+
if (!this._hasDetectedArtifacts(input)) {
25+
const enhancedInput = this._detectAndWrapCodeBlocks(messageId, input);
26+
27+
if (enhancedInput !== input) {
28+
// Reset and reparse with enhanced input
29+
this.reset();
30+
output = super.parse(messageId, enhancedInput);
31+
}
32+
}
33+
34+
return output;
35+
}
36+
37+
private _hasDetectedArtifacts(input: string): boolean {
38+
return input.includes('<boltArtifact') || input.includes('</boltArtifact>');
39+
}
40+
41+
private _detectAndWrapCodeBlocks(messageId: string, input: string): string {
42+
// Initialize processed blocks for this message if not exists
43+
if (!this._processedCodeBlocks.has(messageId)) {
44+
this._processedCodeBlocks.set(messageId, new Set());
45+
}
46+
47+
const processed = this._processedCodeBlocks.get(messageId)!;
48+
49+
// Regex patterns for detecting code blocks with file indicators
50+
const patterns = [
51+
// Pattern 1: Explicit file creation/modification mentions
52+
/(?:create|update|modify|edit|write|add|generate|here'?s?|file:?)\s+(?:a\s+)?(?:new\s+)?(?:file\s+)?(?:called\s+)?[`'"]*([\/\w\-\.]+\.\w+)[`'"]*:?\s*\n+```(\w*)\n([\s\S]*?)```/gi,
53+
54+
// Pattern 2: Code blocks with filename comments
55+
/```(\w*)\n(?:\/\/|#|<!--)\s*(?:file:?|filename:?)\s*([\/\w\-\.]+\.\w+).*?\n([\s\S]*?)```/gi,
56+
57+
// Pattern 3: File path followed by code block
58+
/(?:^|\n)([\/\w\-\.]+\.\w+):?\s*\n+```(\w*)\n([\s\S]*?)```/gim,
59+
60+
// Pattern 4: Code block with "in <filename>" context
61+
/(?:in|for|update)\s+[`'"]*([\/\w\-\.]+\.\w+)[`'"]*:?\s*\n+```(\w*)\n([\s\S]*?)```/gi,
62+
63+
// Pattern 5: HTML/Component files with clear structure
64+
/```(?:jsx?|tsx?|html?|vue|svelte)\n(<[\w\-]+[^>]*>[\s\S]*?<\/[\w\-]+>[\s\S]*?)```/gi,
65+
66+
// Pattern 6: Package.json or config files
67+
/```(?:json)?\n(\{[\s\S]*?"(?:name|version|scripts|dependencies|devDependencies)"[\s\S]*?\})```/gi,
68+
];
69+
70+
let enhanced = input;
71+
72+
// Process each pattern
73+
for (const pattern of patterns) {
74+
enhanced = enhanced.replace(pattern, (match, ...args) => {
75+
// Skip if already processed
76+
const blockHash = this._hashBlock(match);
77+
78+
if (processed.has(blockHash)) {
79+
return match;
80+
}
81+
82+
let filePath: string;
83+
let language: string;
84+
let content: string;
85+
86+
// Extract based on pattern
87+
if (pattern.source.includes('file:?|filename:?')) {
88+
// Pattern 2: filename in comment
89+
[language, filePath, content] = args;
90+
} else if (pattern.source.includes('<[\w\-]+[^>]*>')) {
91+
// Pattern 5: HTML/Component detection
92+
content = args[0];
93+
language = 'jsx';
94+
filePath = this._inferFileNameFromContent(content, language);
95+
} else if (pattern.source.includes('"name"|"version"')) {
96+
// Pattern 6: package.json detection
97+
content = args[0];
98+
language = 'json';
99+
filePath = 'package.json';
100+
} else {
101+
// Other patterns
102+
[filePath, language, content] = args;
103+
}
104+
105+
// Clean up the file path
106+
filePath = this._normalizeFilePath(filePath);
107+
108+
// Validate file path
109+
if (!this._isValidFilePath(filePath)) {
110+
return match; // Return original if invalid
111+
}
112+
113+
// Mark as processed
114+
processed.add(blockHash);
115+
116+
// Generate artifact wrapper
117+
const artifactId = `artifact-${messageId}-${this._artifactCounter++}`;
118+
const wrapped = this._wrapInArtifact(artifactId, filePath, content);
119+
120+
logger.debug(`Auto-wrapped code block as file: ${filePath}`);
121+
122+
return wrapped;
123+
});
124+
}
125+
126+
// Also detect standalone file operations without code blocks
127+
const fileOperationPattern =
128+
/(?:create|write|save|generate)\s+(?:a\s+)?(?:new\s+)?file\s+(?:at\s+)?[`'"]*([\/\w\-\.]+\.\w+)[`'"]*\s+with\s+(?:the\s+)?(?:following\s+)?content:?\s*\n([\s\S]+?)(?=\n\n|\n(?:create|write|save|generate|now|next|then|finally)|$)/gi;
129+
130+
enhanced = enhanced.replace(fileOperationPattern, (match, filePath, content) => {
131+
const blockHash = this._hashBlock(match);
132+
133+
if (processed.has(blockHash)) {
134+
return match;
135+
}
136+
137+
filePath = this._normalizeFilePath(filePath);
138+
139+
if (!this._isValidFilePath(filePath)) {
140+
return match;
141+
}
142+
143+
processed.add(blockHash);
144+
145+
const artifactId = `artifact-${messageId}-${this._artifactCounter++}`;
146+
147+
// Clean content - remove leading/trailing whitespace but preserve indentation
148+
content = content.trim();
149+
150+
const wrapped = this._wrapInArtifact(artifactId, filePath, content);
151+
logger.debug(`Auto-wrapped file operation: ${filePath}`);
152+
153+
return wrapped;
154+
});
155+
156+
return enhanced;
157+
}
158+
159+
private _wrapInArtifact(artifactId: string, filePath: string, content: string): string {
160+
const title = filePath.split('/').pop() || 'File';
161+
162+
return `<boltArtifact id="${artifactId}" title="${title}" type="bundled">
163+
<boltAction type="file" filePath="${filePath}">
164+
${content}
165+
</boltAction>
166+
</boltArtifact>`;
167+
}
168+
169+
private _normalizeFilePath(filePath: string): string {
170+
// Remove quotes, backticks, and clean up
171+
filePath = filePath.replace(/[`'"]/g, '').trim();
172+
173+
// Ensure forward slashes
174+
filePath = filePath.replace(/\\/g, '/');
175+
176+
// Remove leading ./ if present
177+
if (filePath.startsWith('./')) {
178+
filePath = filePath.substring(2);
179+
}
180+
181+
// Add leading slash if missing and not a relative path
182+
if (!filePath.startsWith('/') && !filePath.startsWith('.')) {
183+
filePath = '/' + filePath;
184+
}
185+
186+
return filePath;
187+
}
188+
189+
private _isValidFilePath(filePath: string): boolean {
190+
// Check for valid file extension
191+
const hasExtension = /\.\w+$/.test(filePath);
192+
193+
if (!hasExtension) {
194+
return false;
195+
}
196+
197+
// Check for valid characters
198+
const isValid = /^[\/\w\-\.]+$/.test(filePath);
199+
200+
if (!isValid) {
201+
return false;
202+
}
203+
204+
// Exclude certain patterns that are likely not real files
205+
const excludePatterns = [/^\/?(tmp|temp|test|example)\//i, /\.(tmp|temp|bak|backup|old|orig)$/i];
206+
207+
for (const pattern of excludePatterns) {
208+
if (pattern.test(filePath)) {
209+
return false;
210+
}
211+
}
212+
213+
return true;
214+
}
215+
216+
private _detectLanguageFromPath(filePath: string): string {
217+
const ext = filePath.split('.').pop()?.toLowerCase();
218+
219+
const languageMap: Record<string, string> = {
220+
js: 'javascript',
221+
jsx: 'jsx',
222+
ts: 'typescript',
223+
tsx: 'tsx',
224+
py: 'python',
225+
rb: 'ruby',
226+
go: 'go',
227+
rs: 'rust',
228+
java: 'java',
229+
cpp: 'cpp',
230+
c: 'c',
231+
cs: 'csharp',
232+
php: 'php',
233+
swift: 'swift',
234+
kt: 'kotlin',
235+
html: 'html',
236+
css: 'css',
237+
scss: 'scss',
238+
sass: 'sass',
239+
less: 'less',
240+
json: 'json',
241+
xml: 'xml',
242+
yaml: 'yaml',
243+
yml: 'yaml',
244+
md: 'markdown',
245+
sh: 'bash',
246+
bash: 'bash',
247+
zsh: 'bash',
248+
fish: 'bash',
249+
ps1: 'powershell',
250+
sql: 'sql',
251+
graphql: 'graphql',
252+
gql: 'graphql',
253+
vue: 'vue',
254+
svelte: 'svelte',
255+
};
256+
257+
return languageMap[ext || ''] || 'text';
258+
}
259+
260+
private _inferFileNameFromContent(content: string, language: string): string {
261+
// Try to infer component name from content
262+
const componentMatch = content.match(
263+
/(?:function|class|const|export\s+default\s+function|export\s+function)\s+(\w+)/,
264+
);
265+
266+
if (componentMatch) {
267+
const name = componentMatch[1];
268+
const ext = language === 'jsx' ? '.jsx' : language === 'tsx' ? '.tsx' : '.js';
269+
270+
return `/components/${name}${ext}`;
271+
}
272+
273+
// Check for App component
274+
if (content.includes('function App') || content.includes('const App')) {
275+
return '/App.jsx';
276+
}
277+
278+
// Default to a generic name
279+
return `/component-${Date.now()}.jsx`;
280+
}
281+
282+
private _hashBlock(content: string): string {
283+
// Simple hash for identifying processed blocks
284+
let hash = 0;
285+
286+
for (let i = 0; i < content.length; i++) {
287+
const char = content.charCodeAt(i);
288+
hash = (hash << 5) - hash + char;
289+
hash = hash & hash; // Convert to 32-bit integer
290+
}
291+
292+
return hash.toString(36);
293+
}
294+
295+
reset() {
296+
super.reset();
297+
this._processedCodeBlocks.clear();
298+
this._artifactCounter = 0;
299+
}
300+
}

0 commit comments

Comments
 (0)