diff --git a/app/components/chat/Artifact.tsx b/app/components/chat/Artifact.tsx index cef5a39794..94987377d4 100644 --- a/app/components/chat/Artifact.tsx +++ b/app/components/chat/Artifact.tsx @@ -23,15 +23,16 @@ if (import.meta.hot) { interface ArtifactProps { messageId: string; + artifactId: string; } -export const Artifact = memo(({ messageId }: ArtifactProps) => { +export const Artifact = memo(({ artifactId }: ArtifactProps) => { const userToggledActions = useRef(false); const [showActions, setShowActions] = useState(false); const [allActionFinished, setAllActionFinished] = useState(false); const artifacts = useStore(workbenchStore.artifacts); - const artifact = artifacts[messageId]; + const artifact = artifacts[artifactId]; const actions = useStore( computed(artifact.runner.actions, (actions) => { diff --git a/app/components/chat/Markdown.tsx b/app/components/chat/Markdown.tsx index 3471c73380..fd0d770a23 100644 --- a/app/components/chat/Markdown.tsx +++ b/app/components/chat/Markdown.tsx @@ -34,12 +34,17 @@ export const Markdown = memo( if (className?.includes('__boltArtifact__')) { const messageId = node?.properties.dataMessageId as string; + const artifactId = node?.properties.dataArtifactId as string; if (!messageId) { logger.error(`Invalid message id ${messageId}`); } - return ; + if (!artifactId) { + logger.error(`Invalid artifact id ${artifactId}`); + } + + return ; } if (className?.includes('__boltSelectedElement__')) { diff --git a/app/lib/hooks/useMessageParser.ts b/app/lib/hooks/useMessageParser.ts index 92e3a9673a..ff65fe7991 100644 --- a/app/lib/hooks/useMessageParser.ts +++ b/app/lib/hooks/useMessageParser.ts @@ -22,7 +22,10 @@ const messageParser = new EnhancedStreamingMessageParser({ onActionOpen: (data) => { logger.trace('onActionOpen', data.action); - // we only add shell actions when when the close tag got parsed because only then we have the content + /* + * File actions are streamed, so we add them immediately to show progress + * Shell actions are complete when created by enhanced parser, so we wait for close + */ if (data.action.type === 'file') { workbenchStore.addAction(data); } @@ -30,6 +33,10 @@ const messageParser = new EnhancedStreamingMessageParser({ onActionClose: (data) => { logger.trace('onActionClose', data.action); + /* + * Add non-file actions (shell, build, start, etc.) when they close + * Enhanced parser creates complete shell actions, so they're ready to execute + */ if (data.action.type !== 'file') { workbenchStore.addAction(data); } diff --git a/app/lib/runtime/__snapshots__/message-parser.spec.ts.snap b/app/lib/runtime/__snapshots__/message-parser.spec.ts.snap index 4b60f10bd1..8e6ed9b0f1 100644 --- a/app/lib/runtime/__snapshots__/message-parser.spec.ts.snap +++ b/app/lib/runtime/__snapshots__/message-parser.spec.ts.snap @@ -7,7 +7,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl "type": "shell", }, "actionId": "0", - "artifactId": "artifact_1", + "artifactId": "message_1-0", "messageId": "message_1", } `; @@ -19,14 +19,15 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl "type": "shell", }, "actionId": "0", - "artifactId": "artifact_1", + "artifactId": "message_1-0", "messageId": "message_1", } `; exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (0) > onArtifactClose 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": undefined, @@ -35,7 +36,8 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (0) > onArtifactOpen 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": undefined, @@ -49,7 +51,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl "type": "shell", }, "actionId": "0", - "artifactId": "artifact_1", + "artifactId": "message_1-0", "messageId": "message_1", } `; @@ -63,7 +65,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl "type": "file", }, "actionId": "1", - "artifactId": "artifact_1", + "artifactId": "message_1-0", "messageId": "message_1", } `; @@ -75,7 +77,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl "type": "shell", }, "actionId": "0", - "artifactId": "artifact_1", + "artifactId": "message_1-0", "messageId": "message_1", } `; @@ -88,14 +90,15 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl "type": "file", }, "actionId": "1", - "artifactId": "artifact_1", + "artifactId": "message_1-0", "messageId": "message_1", } `; exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (1) > onArtifactClose 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": undefined, @@ -104,7 +107,8 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (1) > onArtifactOpen 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": undefined, @@ -113,7 +117,8 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (0) > onArtifactClose 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": undefined, @@ -122,7 +127,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (0) > onArtifactOpen 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": undefined, @@ -131,7 +137,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (1) > onArtifactClose 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": "bundled", @@ -140,7 +147,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (1) > onArtifactOpen 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": "bundled", @@ -149,7 +157,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (2) > onArtifactClose 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": undefined, @@ -158,7 +167,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (2) > onArtifactOpen 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": undefined, @@ -167,7 +177,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (3) > onArtifactClose 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": undefined, @@ -176,7 +187,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (3) > onArtifactOpen 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": undefined, @@ -185,7 +197,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (4) > onArtifactClose 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": undefined, @@ -194,7 +207,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (4) > onArtifactOpen 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": undefined, @@ -203,7 +217,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (5) > onArtifactClose 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": undefined, @@ -212,7 +227,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (5) > onArtifactOpen 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": undefined, @@ -221,7 +237,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (6) > onArtifactClose 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": undefined, @@ -230,7 +247,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (6) > onArtifactOpen 1`] = ` { - "id": "artifact_1", + "artifactId": "message_1-0", + "id": "message_1-0", "messageId": "message_1", "title": "Some title", "type": undefined, diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index 0c3e0f7e48..b14d3a89b0 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -140,7 +140,7 @@ export class ActionRunner { return this.#executeAction(actionId, isStreaming); }) .catch((error) => { - console.error('Action failed:', error); + logger.error('Action execution promise failed:', error); }); await this.#currentExecutionPromise; @@ -259,6 +259,14 @@ export class ActionRunner { unreachable('Shell terminal not found'); } + // Pre-validate command for common issues + const validationResult = await this.#validateShellCommand(action.content); + + if (validationResult.shouldModify && validationResult.modifiedCommand) { + logger.debug(`Modified command: ${action.content} -> ${validationResult.modifiedCommand}`); + action.content = validationResult.modifiedCommand; + } + const resp = await shell.executeCommand(this.runnerId.get(), action.content, () => { logger.debug(`[${action.type}]:Aborting Action\n\n`, action); action.abort(); @@ -266,7 +274,8 @@ export class ActionRunner { logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`); if (resp?.exitCode != 0) { - throw new ActionCommandError(`Failed To Execute Shell Command`, resp?.output || 'No Output Available'); + const enhancedError = this.#createEnhancedShellError(action.content, resp?.exitCode, resp?.output); + throw new ActionCommandError(enhancedError.title, enhancedError.details); } } @@ -549,4 +558,188 @@ export class ActionRunner { source: details?.source || 'netlify', }); } + + async #validateShellCommand(command: string): Promise<{ + shouldModify: boolean; + modifiedCommand?: string; + warning?: string; + }> { + const trimmedCommand = command.trim(); + + // Handle rm commands that might fail due to missing files + if (trimmedCommand.startsWith('rm ') && !trimmedCommand.includes(' -f')) { + const rmMatch = trimmedCommand.match(/^rm\s+(.+)$/); + + if (rmMatch) { + const filePaths = rmMatch[1].split(/\s+/); + + // Check if any of the files exist using WebContainer + try { + const webcontainer = await this.#webcontainer; + const existingFiles = []; + + for (const filePath of filePaths) { + if (filePath.startsWith('-')) { + continue; + } // Skip flags + + try { + await webcontainer.fs.readFile(filePath); + existingFiles.push(filePath); + } catch { + // File doesn't exist, skip it + } + } + + if (existingFiles.length === 0) { + // No files exist, modify command to use -f flag to avoid error + return { + shouldModify: true, + modifiedCommand: `rm -f ${filePaths.join(' ')}`, + warning: 'Added -f flag to rm command as target files do not exist', + }; + } else if (existingFiles.length < filePaths.length) { + // Some files don't exist, modify to only remove existing ones with -f for safety + return { + shouldModify: true, + modifiedCommand: `rm -f ${filePaths.join(' ')}`, + warning: 'Added -f flag to rm command as some target files do not exist', + }; + } + } catch (error) { + logger.debug('Could not validate rm command files:', error); + } + } + } + + // Handle cd commands to non-existent directories + if (trimmedCommand.startsWith('cd ')) { + const cdMatch = trimmedCommand.match(/^cd\s+(.+)$/); + + if (cdMatch) { + const targetDir = cdMatch[1].trim(); + + try { + const webcontainer = await this.#webcontainer; + await webcontainer.fs.readdir(targetDir); + } catch { + return { + shouldModify: true, + modifiedCommand: `mkdir -p ${targetDir} && cd ${targetDir}`, + warning: 'Directory does not exist, created it first', + }; + } + } + } + + // Handle cp/mv commands with missing source files + if (trimmedCommand.match(/^(cp|mv)\s+/)) { + const parts = trimmedCommand.split(/\s+/); + + if (parts.length >= 3) { + const sourceFile = parts[1]; + + try { + const webcontainer = await this.#webcontainer; + await webcontainer.fs.readFile(sourceFile); + } catch { + return { + shouldModify: false, + warning: `Source file '${sourceFile}' does not exist`, + }; + } + } + } + + return { shouldModify: false }; + } + + #createEnhancedShellError( + command: string, + exitCode: number | undefined, + output: string | undefined, + ): { + title: string; + details: string; + } { + const trimmedCommand = command.trim(); + const firstWord = trimmedCommand.split(/\s+/)[0]; + + // Common error patterns and their explanations + const errorPatterns = [ + { + pattern: /cannot remove.*No such file or directory/, + title: 'File Not Found', + getMessage: () => { + const fileMatch = output?.match(/'([^']+)'/); + const fileName = fileMatch ? fileMatch[1] : 'file'; + + return `The file '${fileName}' does not exist and cannot be removed.\n\nSuggestion: Use 'ls' to check what files exist, or use 'rm -f' to ignore missing files.`; + }, + }, + { + pattern: /No such file or directory/, + title: 'File or Directory Not Found', + getMessage: () => { + if (trimmedCommand.startsWith('cd ')) { + const dirMatch = trimmedCommand.match(/cd\s+(.+)/); + const dirName = dirMatch ? dirMatch[1] : 'directory'; + + return `The directory '${dirName}' does not exist.\n\nSuggestion: Use 'mkdir -p ${dirName}' to create it first, or check available directories with 'ls'.`; + } + + return `The specified file or directory does not exist.\n\nSuggestion: Check the path and use 'ls' to see available files.`; + }, + }, + { + pattern: /Permission denied/, + title: 'Permission Denied', + getMessage: () => + `Permission denied for '${firstWord}'.\n\nSuggestion: The file may not be executable. Try 'chmod +x filename' first.`, + }, + { + pattern: /command not found/, + title: 'Command Not Found', + getMessage: () => + `The command '${firstWord}' is not available in WebContainer.\n\nSuggestion: Check available commands or use a package manager to install it.`, + }, + { + pattern: /Is a directory/, + title: 'Target is a Directory', + getMessage: () => + `Cannot perform this operation - target is a directory.\n\nSuggestion: Use 'ls' to list directory contents or add appropriate flags.`, + }, + { + pattern: /File exists/, + title: 'File Already Exists', + getMessage: () => `File already exists.\n\nSuggestion: Use a different name or add '-f' flag to overwrite.`, + }, + ]; + + // Try to match known error patterns + for (const errorPattern of errorPatterns) { + if (output && errorPattern.pattern.test(output)) { + return { + title: errorPattern.title, + details: errorPattern.getMessage(), + }; + } + } + + // Generic error with suggestions based on command type + let suggestion = ''; + + if (trimmedCommand.startsWith('npm ')) { + suggestion = '\n\nSuggestion: Try running "npm install" first or check package.json.'; + } else if (trimmedCommand.startsWith('git ')) { + suggestion = "\n\nSuggestion: Check if you're in a git repository or if remote is configured."; + } else if (trimmedCommand.match(/^(ls|cat|rm|cp|mv)/)) { + suggestion = '\n\nSuggestion: Check file paths and use "ls" to see available files.'; + } + + return { + title: `Command Failed (exit code: ${exitCode})`, + details: `Command: ${trimmedCommand}\n\nOutput: ${output || 'No output available'}${suggestion}`, + }; + } } diff --git a/app/lib/runtime/enhanced-message-parser.ts b/app/lib/runtime/enhanced-message-parser.ts index 9820302df0..0c96313687 100644 --- a/app/lib/runtime/enhanced-message-parser.ts +++ b/app/lib/runtime/enhanced-message-parser.ts @@ -12,6 +12,22 @@ export class EnhancedStreamingMessageParser extends StreamingMessageParser { private _processedCodeBlocks = new Map>(); private _artifactCounter = 0; + // Optimized command pattern lookup + private _commandPatternMap = new Map([ + ['npm', /^(npm|yarn|pnpm)\s+(install|run|start|build|dev|test|init|create|add|remove)/], + ['git', /^(git)\s+(add|commit|push|pull|clone|status|checkout|branch|merge|rebase|init|remote|fetch|log)/], + ['docker', /^(docker|docker-compose)\s+/], + ['build', /^(make|cmake|gradle|mvn|cargo|go)\s+/], + ['network', /^(curl|wget|ping|ssh|scp|rsync)\s+/], + ['webcontainer', /^(cat|chmod|cp|echo|hostname|kill|ln|ls|mkdir|mv|ps|pwd|rm|rmdir|xxd)\s*/], + ['webcontainer-extended', /^(alias|cd|clear|env|false|getconf|head|sort|tail|touch|true|uptime|which)\s*/], + ['interpreters', /^(node|python|python3|java|go|rust|ruby|php|perl)\s+/], + ['text-processing', /^(grep|sed|awk|cut|tr|sort|uniq|wc|diff)\s+/], + ['archive', /^(tar|zip|unzip|gzip|gunzip)\s+/], + ['process', /^(ps|top|htop|kill|killall|jobs|nohup)\s*/], + ['system', /^(df|du|free|uname|whoami|id|groups|date|uptime)\s*/], + ]); + constructor(options: StreamingMessageParserOptions = {}) { super(options); } @@ -46,32 +62,49 @@ export class EnhancedStreamingMessageParser extends StreamingMessageParser { 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, + let enhanced = input; - // Pattern 2: Code blocks with filename comments - /```(\w*)\n(?:\/\/|#|