From 823f536a47f9c78b0e7c5fb9f0befeed5a864f6f Mon Sep 17 00:00:00 2001 From: eduardruzga Date: Sun, 8 Dec 2024 12:16:09 +0200 Subject: [PATCH 1/2] Reuse automatic setup commands for git import --- app/components/chat/GitCloneButton.tsx | 60 +++++++++------- app/components/chat/ImportFolderButton.tsx | 8 ++- app/components/chat/SendButton.client.tsx | 1 + app/utils/fileUtils.ts | 28 +++++--- app/utils/folderImport.ts | 57 ++++++++------- app/utils/projectCommands.ts | 80 ++++++++++++++++++++++ 6 files changed, 171 insertions(+), 63 deletions(-) create mode 100644 app/utils/projectCommands.ts diff --git a/app/components/chat/GitCloneButton.tsx b/app/components/chat/GitCloneButton.tsx index ddecdc80e..7b7c9f7f2 100644 --- a/app/components/chat/GitCloneButton.tsx +++ b/app/components/chat/GitCloneButton.tsx @@ -2,6 +2,8 @@ import ignore from 'ignore'; import { useGit } from '~/lib/hooks/useGit'; import type { Message } from 'ai'; import WithTooltip from '~/components/ui/Tooltip'; +import { detectProjectCommands, createCommandsMessage } from '~/utils/projectCommands'; +import { generateId } from '~/utils/fileUtils'; const IGNORE_PATTERNS = [ 'node_modules/**', @@ -28,7 +30,6 @@ const IGNORE_PATTERNS = [ ]; const ig = ignore().add(IGNORE_PATTERNS); -const generateId = () => Math.random().toString(36).substring(2, 15); interface GitCloneButtonProps { className?: string; @@ -52,36 +53,47 @@ export default function GitCloneButton({ importChat }: GitCloneButtonProps) { console.log(filePaths); const textDecoder = new TextDecoder('utf-8'); - const message: Message = { + + // Convert files to common format for command detection + const fileContents = filePaths + .map((filePath) => { + const { data: content, encoding } = data[filePath]; + return { + path: filePath, + content: encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '', + }; + }) + .filter((f) => f.content); + + // Detect and create commands message + const commands = await detectProjectCommands(fileContents); + const commandsMessage = createCommandsMessage(commands); + + // Create files message + const filesMessage: Message = { role: 'assistant', content: `Cloning the repo ${repoUrl} into ${workdir} - - ${filePaths - .map((filePath) => { - const { data: content, encoding } = data[filePath]; - - if (encoding === 'utf8') { - return ` -${content} -`; - } else if (content instanceof Uint8Array) { - return ` -${textDecoder.decode(content)} -`; - } else { - return ''; - } - }) - .join('\n')} - `, + +${fileContents + .map( + (file) => + ` +${file.content} +`, + ) + .join('\n')} +`, id: generateId(), createdAt: new Date(), }; - console.log(JSON.stringify(message)); - importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, [message]); + const messages = [filesMessage]; + + if (commandsMessage) { + messages.push(commandsMessage); + } - // console.log(files); + await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages); } } }; diff --git a/app/components/chat/ImportFolderButton.tsx b/app/components/chat/ImportFolderButton.tsx index e766a7167..6cbfcacfc 100644 --- a/app/components/chat/ImportFolderButton.tsx +++ b/app/components/chat/ImportFolderButton.tsx @@ -1,8 +1,8 @@ import React, { useState } from 'react'; import type { Message } from 'ai'; import { toast } from 'react-toastify'; -import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '../../utils/fileUtils'; -import { createChatFromFolder } from '../../utils/folderImport'; +import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils'; +import { createChatFromFolder } from '~/utils/folderImport'; interface ImportFolderButtonProps { className?: string; @@ -17,12 +17,14 @@ export const ImportFolderButton: React.FC = ({ classNam if (allFiles.length > MAX_FILES) { toast.error( - `This folder contains ${allFiles.length.toLocaleString()} files. This product is not yet optimized for very large projects. Please select a folder with fewer than ${MAX_FILES.toLocaleString()} files.` + `This folder contains ${allFiles.length.toLocaleString()} files. This product is not yet optimized for very large projects. Please select a folder with fewer than ${MAX_FILES.toLocaleString()} files.`, ); return; } + const folderName = allFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder'; setIsLoading(true); + const loadingToast = toast.loading(`Importing ${folderName}...`); try { diff --git a/app/components/chat/SendButton.client.tsx b/app/components/chat/SendButton.client.tsx index c5aa83049..389ca3bf7 100644 --- a/app/components/chat/SendButton.client.tsx +++ b/app/components/chat/SendButton.client.tsx @@ -23,6 +23,7 @@ export const SendButton = ({ show, isStreaming, disabled, onClick }: SendButtonP disabled={disabled} onClick={(event) => { event.preventDefault(); + if (!disabled) { onClick?.(event); } diff --git a/app/utils/fileUtils.ts b/app/utils/fileUtils.ts index f6a52d909..fcf2a017e 100644 --- a/app/utils/fileUtils.ts +++ b/app/utils/fileUtils.ts @@ -29,10 +29,12 @@ export const isBinaryFile = async (file: File): Promise => { for (let i = 0; i < buffer.length; i++) { const byte = buffer[i]; + if (byte === 0 || (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13)) { return true; } } + return false; }; @@ -41,8 +43,11 @@ export const shouldIncludeFile = (path: string): boolean => { }; const readPackageJson = async (files: File[]): Promise<{ scripts?: Record } | null> => { - const packageJsonFile = files.find(f => f.webkitRelativePath.endsWith('package.json')); - if (!packageJsonFile) return null; + const packageJsonFile = files.find((f) => f.webkitRelativePath.endsWith('package.json')); + + if (!packageJsonFile) { + return null; + } try { const content = await new Promise((resolve, reject) => { @@ -59,29 +64,32 @@ const readPackageJson = async (files: File[]): Promise<{ scripts?: Record => { - const hasFile = (name: string) => files.some(f => f.webkitRelativePath.endsWith(name)); +export const detectProjectType = async ( + files: File[], +): Promise<{ type: string; setupCommand: string; followupMessage: string }> => { + const hasFile = (name: string) => files.some((f) => f.webkitRelativePath.endsWith(name)); if (hasFile('package.json')) { const packageJson = await readPackageJson(files); const scripts = packageJson?.scripts || {}; - + // Check for preferred commands in priority order const preferredCommands = ['dev', 'start', 'preview']; - const availableCommand = preferredCommands.find(cmd => scripts[cmd]); - + const availableCommand = preferredCommands.find((cmd) => scripts[cmd]); + if (availableCommand) { return { type: 'Node.js', setupCommand: `npm install && npm run ${availableCommand}`, - followupMessage: `Found "${availableCommand}" script in package.json. Running "npm run ${availableCommand}" after installation.` + followupMessage: `Found "${availableCommand}" script in package.json. Running "npm run ${availableCommand}" after installation.`, }; } return { type: 'Node.js', setupCommand: 'npm install', - followupMessage: 'Would you like me to inspect package.json to determine the available scripts for running this project?' + followupMessage: + 'Would you like me to inspect package.json to determine the available scripts for running this project?', }; } @@ -89,7 +97,7 @@ export const detectProjectType = async (files: File[]): Promise<{ type: string; return { type: 'Static', setupCommand: 'npx --yes serve', - followupMessage: '' + followupMessage: '', }; } diff --git a/app/utils/folderImport.ts b/app/utils/folderImport.ts index 57cbe318b..759df100b 100644 --- a/app/utils/folderImport.ts +++ b/app/utils/folderImport.ts @@ -1,23 +1,24 @@ import type { Message } from 'ai'; -import { generateId, detectProjectType } from './fileUtils'; +import { generateId } from './fileUtils'; +import { detectProjectCommands, createCommandsMessage } from './projectCommands'; export const createChatFromFolder = async ( files: File[], binaryFiles: string[], - folderName: string + folderName: string, ): Promise => { const fileArtifacts = await Promise.all( files.map(async (file) => { - return new Promise((resolve, reject) => { + return new Promise<{ content: string; path: string }>((resolve, reject) => { const reader = new FileReader(); + reader.onload = () => { const content = reader.result as string; const relativePath = file.webkitRelativePath.split('/').slice(1).join('/'); - resolve( - ` -${content} -`, - ); + resolve({ + content, + path: relativePath, + }); }; reader.onerror = reject; reader.readAsText(file); @@ -25,32 +26,30 @@ ${content} }), ); - const project = await detectProjectType(files); - const setupCommand = project.setupCommand ? `\n\n\n${project.setupCommand}\n` : ''; - const followupMessage = project.followupMessage ? `\n\n${project.followupMessage}` : ''; + const commands = await detectProjectCommands(fileArtifacts); + const commandsMessage = createCommandsMessage(commands); - const binaryFilesMessage = binaryFiles.length > 0 - ? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}` - : ''; + const binaryFilesMessage = + binaryFiles.length > 0 + ? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}` + : ''; - const assistantMessages: Message[] = [{ + const filesMessage: Message = { role: 'assistant', content: `I've imported the contents of the "${folderName}" folder.${binaryFilesMessage} -${fileArtifacts.join('\n\n')} +${fileArtifacts + .map( + (file) => ` +${file.content} +`, + ) + .join('\n\n')} `, id: generateId(), createdAt: new Date(), - },{ - role: 'assistant', - content: ` - -${setupCommand} -${followupMessage}`, - id: generateId(), - createdAt: new Date(), - }]; + }; const userMessage: Message = { role: 'user', @@ -59,5 +58,11 @@ ${setupCommand} createdAt: new Date(), }; - return [ userMessage, ...assistantMessages ]; + const messages = [userMessage, filesMessage]; + + if (commandsMessage) { + messages.push(commandsMessage); + } + + return messages; }; diff --git a/app/utils/projectCommands.ts b/app/utils/projectCommands.ts new file mode 100644 index 000000000..050663aed --- /dev/null +++ b/app/utils/projectCommands.ts @@ -0,0 +1,80 @@ +import type { Message } from 'ai'; +import { generateId } from './fileUtils'; + +export interface ProjectCommands { + type: string; + setupCommand: string; + followupMessage: string; +} + +interface FileContent { + content: string; + path: string; +} + +export async function detectProjectCommands(files: FileContent[]): Promise { + const hasFile = (name: string) => files.some((f) => f.path.endsWith(name)); + + if (hasFile('package.json')) { + const packageJsonFile = files.find((f) => f.path.endsWith('package.json')); + + if (!packageJsonFile) { + return { type: '', setupCommand: '', followupMessage: '' }; + } + + try { + const packageJson = JSON.parse(packageJsonFile.content); + const scripts = packageJson?.scripts || {}; + + // Check for preferred commands in priority order + const preferredCommands = ['dev', 'start', 'preview']; + const availableCommand = preferredCommands.find((cmd) => scripts[cmd]); + + if (availableCommand) { + return { + type: 'Node.js', + setupCommand: `npm install && npm run ${availableCommand}`, + followupMessage: `Found "${availableCommand}" script in package.json. Running "npm run ${availableCommand}" after installation.`, + }; + } + + return { + type: 'Node.js', + setupCommand: 'npm install', + followupMessage: + 'Would you like me to inspect package.json to determine the available scripts for running this project?', + }; + } catch (error) { + console.error('Error parsing package.json:', error); + return { type: '', setupCommand: '', followupMessage: '' }; + } + } + + if (hasFile('index.html')) { + return { + type: 'Static', + setupCommand: 'npx --yes serve', + followupMessage: '', + }; + } + + return { type: '', setupCommand: '', followupMessage: '' }; +} + +export function createCommandsMessage(commands: ProjectCommands): Message | null { + if (!commands.setupCommand) { + return null; + } + + return { + role: 'assistant', + content: ` + + +${commands.setupCommand} + +${commands.followupMessage ? `\n\n${commands.followupMessage}` : ''}`, + id: generateId(), + createdAt: new Date(), + }; +} From de37f6dab84a5dda0fac249bd3cefaec68f71203 Mon Sep 17 00:00:00 2001 From: eduardruzga Date: Sun, 8 Dec 2024 12:17:40 +0200 Subject: [PATCH 2/2] Update readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 33f861fa0..9ac258176 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ https://thinktank.ottomator.ai - ✅ Mobile friendly (@qwikode) - ✅ Better prompt enhancing (@SujalXplores) - ✅ Attach images to prompts (@atrokhym) +- ✅ Detect package.json and commands to auto install and run preview for folder and git import (@wonderwhy-er) - ⬜ **HIGH PRIORITY** - Prevent Bolt from rewriting files as often (file locking and diffs) - ⬜ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start) - ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call