diff --git a/README.md b/README.md index 33f861f..9ac2581 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 diff --git a/app/commit.json b/app/commit.json index a756318..64fcbce 100644 --- a/app/commit.json +++ b/app/commit.json @@ -1 +1 @@ -{ "commit": "95e38e020cc8a4d865172187fc25c94b39806275" } +{ "commit": "67f63aaf31f406379daa97708d6a1a9f8ac41d43" } diff --git a/app/components/chat/GitCloneButton.tsx b/app/components/chat/GitCloneButton.tsx index ddecdc8..7b7c9f7 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]; - // console.log(files); + if (commandsMessage) { + messages.push(commandsMessage); + } + + 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 3da78c1..6cbfcac 100644 --- a/app/components/chat/ImportFolderButton.tsx +++ b/app/components/chat/ImportFolderButton.tsx @@ -1,102 +1,75 @@ -import React from 'react'; +import React, { useState } from 'react'; import type { Message } from 'ai'; import { toast } from 'react-toastify'; -import ignore from 'ignore'; +import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils'; +import { createChatFromFolder } from '~/utils/folderImport'; interface ImportFolderButtonProps { className?: string; importChat?: (description: string, messages: Message[]) => Promise; } -// Common patterns to ignore, similar to .gitignore -const IGNORE_PATTERNS = [ - 'node_modules/**', - '.git/**', - 'dist/**', - 'build/**', - '.next/**', - 'coverage/**', - '.cache/**', - '.vscode/**', - '.idea/**', - '**/*.log', - '**/.DS_Store', - '**/npm-debug.log*', - '**/yarn-debug.log*', - '**/yarn-error.log*', -]; - -const ig = ignore().add(IGNORE_PATTERNS); -const generateId = () => Math.random().toString(36).substring(2, 15); - -const isBinaryFile = async (file: File): Promise => { - const chunkSize = 1024; // Read the first 1 KB of the file - const buffer = new Uint8Array(await file.slice(0, chunkSize).arrayBuffer()); - - 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; // Found a binary character - } - } - - return false; -}; - export const ImportFolderButton: React.FC = ({ className, importChat }) => { - const shouldIncludeFile = (path: string): boolean => { - return !ig.ignores(path); - }; + const [isLoading, setIsLoading] = useState(false); - const createChatFromFolder = async (files: File[], binaryFiles: string[]) => { - const fileArtifacts = await Promise.all( - files.map(async (file) => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); + const handleFileChange = async (e: React.ChangeEvent) => { + const allFiles = Array.from(e.target.files || []); - reader.onload = () => { - const content = reader.result as string; - const relativePath = file.webkitRelativePath.split('/').slice(1).join('/'); - resolve( - ` -${content} -`, - ); - }; - reader.onerror = reject; - reader.readAsText(file); - }); - }), - ); + 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.`, + ); + return; + } - const binaryFilesMessage = - binaryFiles.length > 0 - ? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}` - : ''; + const folderName = allFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder'; + setIsLoading(true); - const message: Message = { - role: 'assistant', - content: `I'll help you set up these files.${binaryFilesMessage} + const loadingToast = toast.loading(`Importing ${folderName}...`); - -${fileArtifacts.join('\n\n')} -`, - id: generateId(), - createdAt: new Date(), - }; + try { + const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath)); - const userMessage: Message = { - role: 'user', - id: generateId(), - content: 'Import my files', - createdAt: new Date(), - }; + if (filteredFiles.length === 0) { + toast.error('No files found in the selected folder'); + return; + } - const description = `Folder Import: ${files[0].webkitRelativePath.split('/')[0]}`; + const fileChecks = await Promise.all( + filteredFiles.map(async (file) => ({ + file, + isBinary: await isBinaryFile(file), + })), + ); - if (importChat) { - await importChat(description, [userMessage, message]); + const textFiles = fileChecks.filter((f) => !f.isBinary).map((f) => f.file); + const binaryFilePaths = fileChecks + .filter((f) => f.isBinary) + .map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/')); + + if (textFiles.length === 0) { + toast.error('No text files found in the selected folder'); + return; + } + + if (binaryFilePaths.length > 0) { + toast.info(`Skipping ${binaryFilePaths.length} binary files`); + } + + const messages = await createChatFromFolder(textFiles, binaryFilePaths, folderName); + + if (importChat) { + await importChat(folderName, [...messages]); + } + + toast.success('Folder imported successfully'); + } catch (error) { + console.error('Failed to import folder:', error); + toast.error('Failed to import folder'); + } finally { + setIsLoading(false); + toast.dismiss(loadingToast); + e.target.value = ''; // Reset file input } }; @@ -108,46 +81,8 @@ ${fileArtifacts.join('\n\n')} className="hidden" webkitdirectory="" directory="" - onChange={async (e) => { - const allFiles = Array.from(e.target.files || []); - const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath)); - - if (filteredFiles.length === 0) { - toast.error('No files found in the selected folder'); - return; - } - - try { - const fileChecks = await Promise.all( - filteredFiles.map(async (file) => ({ - file, - isBinary: await isBinaryFile(file), - })), - ); - - const textFiles = fileChecks.filter((f) => !f.isBinary).map((f) => f.file); - const binaryFilePaths = fileChecks - .filter((f) => f.isBinary) - .map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/')); - - if (textFiles.length === 0) { - toast.error('No text files found in the selected folder'); - return; - } - - if (binaryFilePaths.length > 0) { - toast.info(`Skipping ${binaryFilePaths.length} binary files`); - } - - await createChatFromFolder(textFiles, binaryFilePaths); - } catch (error) { - console.error('Failed to import folder:', error); - toast.error('Failed to import folder'); - } - - e.target.value = ''; // Reset file input - }} - {...({} as any)} // if removed webkitdirectory will throw errors as unknow attribute + onChange={handleFileChange} + {...({} as any)} /> ); diff --git a/app/components/chat/SendButton.client.tsx b/app/components/chat/SendButton.client.tsx index c5aa830..389ca3b 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 new file mode 100644 index 0000000..fcf2a01 --- /dev/null +++ b/app/utils/fileUtils.ts @@ -0,0 +1,105 @@ +import ignore from 'ignore'; + +// Common patterns to ignore, similar to .gitignore +export const IGNORE_PATTERNS = [ + 'node_modules/**', + '.git/**', + 'dist/**', + 'build/**', + '.next/**', + 'coverage/**', + '.cache/**', + '.vscode/**', + '.idea/**', + '**/*.log', + '**/.DS_Store', + '**/npm-debug.log*', + '**/yarn-debug.log*', + '**/yarn-error.log*', +]; + +export const MAX_FILES = 1000; +export const ig = ignore().add(IGNORE_PATTERNS); + +export const generateId = () => Math.random().toString(36).substring(2, 15); + +export const isBinaryFile = async (file: File): Promise => { + const chunkSize = 1024; + const buffer = new Uint8Array(await file.slice(0, chunkSize).arrayBuffer()); + + 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; +}; + +export const shouldIncludeFile = (path: string): boolean => { + return !ig.ignores(path); +}; + +const readPackageJson = async (files: File[]): Promise<{ scripts?: Record } | null> => { + const packageJsonFile = files.find((f) => f.webkitRelativePath.endsWith('package.json')); + + if (!packageJsonFile) { + return null; + } + + try { + const content = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsText(packageJsonFile); + }); + + return JSON.parse(content); + } catch (error) { + console.error('Error reading package.json:', error); + return null; + } +}; + +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]); + + 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?', + }; + } + + if (hasFile('index.html')) { + return { + type: 'Static', + setupCommand: 'npx --yes serve', + followupMessage: '', + }; + } + + return { type: '', setupCommand: '', followupMessage: '' }; +}; diff --git a/app/utils/folderImport.ts b/app/utils/folderImport.ts new file mode 100644 index 0000000..759df10 --- /dev/null +++ b/app/utils/folderImport.ts @@ -0,0 +1,68 @@ +import type { Message } from 'ai'; +import { generateId } from './fileUtils'; +import { detectProjectCommands, createCommandsMessage } from './projectCommands'; + +export const createChatFromFolder = async ( + files: File[], + binaryFiles: string[], + folderName: string, +): Promise => { + const fileArtifacts = await Promise.all( + files.map(async (file) => { + 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, + path: relativePath, + }); + }; + reader.onerror = reject; + reader.readAsText(file); + }); + }), + ); + + 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 filesMessage: Message = { + role: 'assistant', + content: `I've imported the contents of the "${folderName}" folder.${binaryFilesMessage} + + +${fileArtifacts + .map( + (file) => ` +${file.content} +`, + ) + .join('\n\n')} +`, + id: generateId(), + createdAt: new Date(), + }; + + const userMessage: Message = { + role: 'user', + id: generateId(), + content: `Import the "${folderName}" folder`, + createdAt: new Date(), + }; + + 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 0000000..050663a --- /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(), + }; +}