Merge pull request #1927 from embire2/fix/code-outputs-to-chat
fix: resolve code output to chat instead of files
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import type { Message } from 'ai';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { StreamingMessageParser } from '~/lib/runtime/message-parser';
|
||||
import { EnhancedStreamingMessageParser } from '~/lib/runtime/enhanced-message-parser';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('useMessageParser');
|
||||
|
||||
const messageParser = new StreamingMessageParser({
|
||||
const messageParser = new EnhancedStreamingMessageParser({
|
||||
callbacks: {
|
||||
onArtifactOpen: (data) => {
|
||||
logger.trace('onArtifactOpen', data);
|
||||
|
||||
300
app/lib/runtime/enhanced-message-parser.ts
Normal file
300
app/lib/runtime/enhanced-message-parser.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { StreamingMessageParser, type StreamingMessageParserOptions } from './message-parser';
|
||||
|
||||
const logger = createScopedLogger('EnhancedMessageParser');
|
||||
|
||||
/**
|
||||
* Enhanced message parser that detects code blocks and file patterns
|
||||
* even when AI models don't wrap them in proper artifact tags.
|
||||
* Fixes issue #1797 where code outputs to chat instead of files.
|
||||
*/
|
||||
export class EnhancedStreamingMessageParser extends StreamingMessageParser {
|
||||
private _processedCodeBlocks = new Map<string, Set<string>>();
|
||||
private _artifactCounter = 0;
|
||||
|
||||
constructor(options: StreamingMessageParserOptions = {}) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
parse(messageId: string, input: string): string {
|
||||
// First try the normal parsing
|
||||
let output = super.parse(messageId, input);
|
||||
|
||||
// If no artifacts were detected, check for code blocks that should be files
|
||||
if (!this._hasDetectedArtifacts(input)) {
|
||||
const enhancedInput = this._detectAndWrapCodeBlocks(messageId, input);
|
||||
|
||||
if (enhancedInput !== input) {
|
||||
// Reset and reparse with enhanced input
|
||||
this.reset();
|
||||
output = super.parse(messageId, enhancedInput);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private _hasDetectedArtifacts(input: string): boolean {
|
||||
return input.includes('<boltArtifact') || input.includes('</boltArtifact>');
|
||||
}
|
||||
|
||||
private _detectAndWrapCodeBlocks(messageId: string, input: string): string {
|
||||
// Initialize processed blocks for this message if not exists
|
||||
if (!this._processedCodeBlocks.has(messageId)) {
|
||||
this._processedCodeBlocks.set(messageId, new Set());
|
||||
}
|
||||
|
||||
const processed = this._processedCodeBlocks.get(messageId)!;
|
||||
|
||||
// Regex patterns for detecting code blocks with file indicators
|
||||
const patterns = [
|
||||
// Pattern 1: Explicit file creation/modification mentions
|
||||
/(?: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,
|
||||
|
||||
// Pattern 2: Code blocks with filename comments
|
||||
/```(\w*)\n(?:\/\/|#|<!--)\s*(?:file:?|filename:?)\s*([\/\w\-\.]+\.\w+).*?\n([\s\S]*?)```/gi,
|
||||
|
||||
// Pattern 3: File path followed by code block
|
||||
/(?:^|\n)([\/\w\-\.]+\.\w+):?\s*\n+```(\w*)\n([\s\S]*?)```/gim,
|
||||
|
||||
// Pattern 4: Code block with "in <filename>" context
|
||||
/(?:in|for|update)\s+[`'"]*([\/\w\-\.]+\.\w+)[`'"]*:?\s*\n+```(\w*)\n([\s\S]*?)```/gi,
|
||||
|
||||
// Pattern 5: HTML/Component files with clear structure
|
||||
/```(?:jsx?|tsx?|html?|vue|svelte)\n(<[\w\-]+[^>]*>[\s\S]*?<\/[\w\-]+>[\s\S]*?)```/gi,
|
||||
|
||||
// Pattern 6: Package.json or config files
|
||||
/```(?:json)?\n(\{[\s\S]*?"(?:name|version|scripts|dependencies|devDependencies)"[\s\S]*?\})```/gi,
|
||||
];
|
||||
|
||||
let enhanced = input;
|
||||
|
||||
// Process each pattern
|
||||
for (const pattern of patterns) {
|
||||
enhanced = enhanced.replace(pattern, (match, ...args) => {
|
||||
// Skip if already processed
|
||||
const blockHash = this._hashBlock(match);
|
||||
|
||||
if (processed.has(blockHash)) {
|
||||
return match;
|
||||
}
|
||||
|
||||
let filePath: string;
|
||||
let language: string;
|
||||
let content: string;
|
||||
|
||||
// Extract based on pattern
|
||||
if (pattern.source.includes('file:?|filename:?')) {
|
||||
// Pattern 2: filename in comment
|
||||
[language, filePath, content] = args;
|
||||
} else if (pattern.source.includes('<[\w\-]+[^>]*>')) {
|
||||
// Pattern 5: HTML/Component detection
|
||||
content = args[0];
|
||||
language = 'jsx';
|
||||
filePath = this._inferFileNameFromContent(content, language);
|
||||
} else if (pattern.source.includes('"name"|"version"')) {
|
||||
// Pattern 6: package.json detection
|
||||
content = args[0];
|
||||
language = 'json';
|
||||
filePath = 'package.json';
|
||||
} else {
|
||||
// Other patterns
|
||||
[filePath, language, content] = args;
|
||||
}
|
||||
|
||||
// Clean up the file path
|
||||
filePath = this._normalizeFilePath(filePath);
|
||||
|
||||
// Validate file path
|
||||
if (!this._isValidFilePath(filePath)) {
|
||||
return match; // Return original if invalid
|
||||
}
|
||||
|
||||
// Mark as processed
|
||||
processed.add(blockHash);
|
||||
|
||||
// Generate artifact wrapper
|
||||
const artifactId = `artifact-${messageId}-${this._artifactCounter++}`;
|
||||
const wrapped = this._wrapInArtifact(artifactId, filePath, content);
|
||||
|
||||
logger.debug(`Auto-wrapped code block as file: ${filePath}`);
|
||||
|
||||
return wrapped;
|
||||
});
|
||||
}
|
||||
|
||||
// Also detect standalone file operations without code blocks
|
||||
const fileOperationPattern =
|
||||
/(?: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;
|
||||
|
||||
enhanced = enhanced.replace(fileOperationPattern, (match, filePath, content) => {
|
||||
const blockHash = this._hashBlock(match);
|
||||
|
||||
if (processed.has(blockHash)) {
|
||||
return match;
|
||||
}
|
||||
|
||||
filePath = this._normalizeFilePath(filePath);
|
||||
|
||||
if (!this._isValidFilePath(filePath)) {
|
||||
return match;
|
||||
}
|
||||
|
||||
processed.add(blockHash);
|
||||
|
||||
const artifactId = `artifact-${messageId}-${this._artifactCounter++}`;
|
||||
|
||||
// Clean content - remove leading/trailing whitespace but preserve indentation
|
||||
content = content.trim();
|
||||
|
||||
const wrapped = this._wrapInArtifact(artifactId, filePath, content);
|
||||
logger.debug(`Auto-wrapped file operation: ${filePath}`);
|
||||
|
||||
return wrapped;
|
||||
});
|
||||
|
||||
return enhanced;
|
||||
}
|
||||
|
||||
private _wrapInArtifact(artifactId: string, filePath: string, content: string): string {
|
||||
const title = filePath.split('/').pop() || 'File';
|
||||
|
||||
return `<boltArtifact id="${artifactId}" title="${title}" type="bundled">
|
||||
<boltAction type="file" filePath="${filePath}">
|
||||
${content}
|
||||
</boltAction>
|
||||
</boltArtifact>`;
|
||||
}
|
||||
|
||||
private _normalizeFilePath(filePath: string): string {
|
||||
// Remove quotes, backticks, and clean up
|
||||
filePath = filePath.replace(/[`'"]/g, '').trim();
|
||||
|
||||
// Ensure forward slashes
|
||||
filePath = filePath.replace(/\\/g, '/');
|
||||
|
||||
// Remove leading ./ if present
|
||||
if (filePath.startsWith('./')) {
|
||||
filePath = filePath.substring(2);
|
||||
}
|
||||
|
||||
// Add leading slash if missing and not a relative path
|
||||
if (!filePath.startsWith('/') && !filePath.startsWith('.')) {
|
||||
filePath = '/' + filePath;
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private _isValidFilePath(filePath: string): boolean {
|
||||
// Check for valid file extension
|
||||
const hasExtension = /\.\w+$/.test(filePath);
|
||||
|
||||
if (!hasExtension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for valid characters
|
||||
const isValid = /^[\/\w\-\.]+$/.test(filePath);
|
||||
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude certain patterns that are likely not real files
|
||||
const excludePatterns = [/^\/?(tmp|temp|test|example)\//i, /\.(tmp|temp|bak|backup|old|orig)$/i];
|
||||
|
||||
for (const pattern of excludePatterns) {
|
||||
if (pattern.test(filePath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _detectLanguageFromPath(filePath: string): string {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
|
||||
const languageMap: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
py: 'python',
|
||||
rb: 'ruby',
|
||||
go: 'go',
|
||||
rs: 'rust',
|
||||
java: 'java',
|
||||
cpp: 'cpp',
|
||||
c: 'c',
|
||||
cs: 'csharp',
|
||||
php: 'php',
|
||||
swift: 'swift',
|
||||
kt: 'kotlin',
|
||||
html: 'html',
|
||||
css: 'css',
|
||||
scss: 'scss',
|
||||
sass: 'sass',
|
||||
less: 'less',
|
||||
json: 'json',
|
||||
xml: 'xml',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
md: 'markdown',
|
||||
sh: 'bash',
|
||||
bash: 'bash',
|
||||
zsh: 'bash',
|
||||
fish: 'bash',
|
||||
ps1: 'powershell',
|
||||
sql: 'sql',
|
||||
graphql: 'graphql',
|
||||
gql: 'graphql',
|
||||
vue: 'vue',
|
||||
svelte: 'svelte',
|
||||
};
|
||||
|
||||
return languageMap[ext || ''] || 'text';
|
||||
}
|
||||
|
||||
private _inferFileNameFromContent(content: string, language: string): string {
|
||||
// Try to infer component name from content
|
||||
const componentMatch = content.match(
|
||||
/(?:function|class|const|export\s+default\s+function|export\s+function)\s+(\w+)/,
|
||||
);
|
||||
|
||||
if (componentMatch) {
|
||||
const name = componentMatch[1];
|
||||
const ext = language === 'jsx' ? '.jsx' : language === 'tsx' ? '.tsx' : '.js';
|
||||
|
||||
return `/components/${name}${ext}`;
|
||||
}
|
||||
|
||||
// Check for App component
|
||||
if (content.includes('function App') || content.includes('const App')) {
|
||||
return '/App.jsx';
|
||||
}
|
||||
|
||||
// Default to a generic name
|
||||
return `/component-${Date.now()}.jsx`;
|
||||
}
|
||||
|
||||
private _hashBlock(content: string): string {
|
||||
// Simple hash for identifying processed blocks
|
||||
let hash = 0;
|
||||
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const char = content.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
reset() {
|
||||
super.reset();
|
||||
this._processedCodeBlocks.clear();
|
||||
this._artifactCounter = 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user