Merge branch 'main' into git-import-from-url

This commit is contained in:
Anirban Kar 2024-12-08 18:32:01 +05:30
commit 6c1ff871c4
8 changed files with 351 additions and 148 deletions

View File

@ -41,6 +41,7 @@ https://thinktank.ottomator.ai
- ✅ Mobile friendly (@qwikode) - ✅ Mobile friendly (@qwikode)
- ✅ Better prompt enhancing (@SujalXplores) - ✅ Better prompt enhancing (@SujalXplores)
- ✅ Attach images to prompts (@atrokhym) - ✅ 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** - 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** - 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 - ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call

View File

@ -1 +1 @@
{ "commit": "95e38e020cc8a4d865172187fc25c94b39806275" } { "commit": "67f63aaf31f406379daa97708d6a1a9f8ac41d43" }

View File

@ -2,6 +2,8 @@ import ignore from 'ignore';
import { useGit } from '~/lib/hooks/useGit'; import { useGit } from '~/lib/hooks/useGit';
import type { Message } from 'ai'; import type { Message } from 'ai';
import WithTooltip from '~/components/ui/Tooltip'; import WithTooltip from '~/components/ui/Tooltip';
import { detectProjectCommands, createCommandsMessage } from '~/utils/projectCommands';
import { generateId } from '~/utils/fileUtils';
const IGNORE_PATTERNS = [ const IGNORE_PATTERNS = [
'node_modules/**', 'node_modules/**',
@ -28,7 +30,6 @@ const IGNORE_PATTERNS = [
]; ];
const ig = ignore().add(IGNORE_PATTERNS); const ig = ignore().add(IGNORE_PATTERNS);
const generateId = () => Math.random().toString(36).substring(2, 15);
interface GitCloneButtonProps { interface GitCloneButtonProps {
className?: string; className?: string;
@ -52,36 +53,47 @@ export default function GitCloneButton({ importChat }: GitCloneButtonProps) {
console.log(filePaths); console.log(filePaths);
const textDecoder = new TextDecoder('utf-8'); 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', role: 'assistant',
content: `Cloning the repo ${repoUrl} into ${workdir} content: `Cloning the repo ${repoUrl} into ${workdir}
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled" > <boltArtifact id="imported-files" title="Git Cloned Files" type="bundled">
${filePaths ${fileContents
.map((filePath) => { .map(
const { data: content, encoding } = data[filePath]; (file) =>
`<boltAction type="file" filePath="${file.path}">
if (encoding === 'utf8') { ${file.content}
return `<boltAction type="file" filePath="${filePath}"> </boltAction>`,
${content} )
</boltAction>`; .join('\n')}
} else if (content instanceof Uint8Array) { </boltArtifact>`,
return `<boltAction type="file" filePath="${filePath}">
${textDecoder.decode(content)}
</boltAction>`;
} else {
return '';
}
})
.join('\n')}
</boltArtifact>`,
id: generateId(), id: generateId(),
createdAt: new Date(), 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);
} }
} }
}; };

View File

@ -1,102 +1,75 @@
import React from 'react'; import React, { useState } from 'react';
import type { Message } from 'ai'; import type { Message } from 'ai';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import ignore from 'ignore'; import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils';
import { createChatFromFolder } from '~/utils/folderImport';
interface ImportFolderButtonProps { interface ImportFolderButtonProps {
className?: string; className?: string;
importChat?: (description: string, messages: Message[]) => Promise<void>; importChat?: (description: string, messages: Message[]) => Promise<void>;
} }
// 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<boolean> => {
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<ImportFolderButtonProps> = ({ className, importChat }) => { export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ className, importChat }) => {
const shouldIncludeFile = (path: string): boolean => { const [isLoading, setIsLoading] = useState(false);
return !ig.ignores(path);
};
const createChatFromFolder = async (files: File[], binaryFiles: string[]) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const fileArtifacts = await Promise.all( const allFiles = Array.from(e.target.files || []);
files.map(async (file) => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => { if (allFiles.length > MAX_FILES) {
const content = reader.result as string; toast.error(
const relativePath = file.webkitRelativePath.split('/').slice(1).join('/'); `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.`,
resolve( );
`<boltAction type="file" filePath="${relativePath}"> return;
${content} }
</boltAction>`,
);
};
reader.onerror = reject;
reader.readAsText(file);
});
}),
);
const binaryFilesMessage = const folderName = allFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder';
binaryFiles.length > 0 setIsLoading(true);
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
: '';
const message: Message = { const loadingToast = toast.loading(`Importing ${folderName}...`);
role: 'assistant',
content: `I'll help you set up these files.${binaryFilesMessage}
<boltArtifact id="imported-files" title="Imported Files" type="bundled"> try {
${fileArtifacts.join('\n\n')} const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath));
</boltArtifact>`,
id: generateId(),
createdAt: new Date(),
};
const userMessage: Message = { if (filteredFiles.length === 0) {
role: 'user', toast.error('No files found in the selected folder');
id: generateId(), return;
content: 'Import my files', }
createdAt: new Date(),
};
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) { const textFiles = fileChecks.filter((f) => !f.isBinary).map((f) => f.file);
await importChat(description, [userMessage, message]); 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" className="hidden"
webkitdirectory="" webkitdirectory=""
directory="" directory=""
onChange={async (e) => { onChange={handleFileChange}
const allFiles = Array.from(e.target.files || []); {...({} as any)}
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
/> />
<button <button
onClick={() => { onClick={() => {
@ -155,9 +90,10 @@ ${fileArtifacts.join('\n\n')}
input?.click(); input?.click();
}} }}
className={className} className={className}
disabled={isLoading}
> >
<div className="i-ph:upload-simple" /> <div className="i-ph:upload-simple" />
Import Folder {isLoading ? 'Importing...' : 'Import Folder'}
</button> </button>
</> </>
); );

View File

@ -23,6 +23,7 @@ export const SendButton = ({ show, isStreaming, disabled, onClick }: SendButtonP
disabled={disabled} disabled={disabled}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
if (!disabled) { if (!disabled) {
onClick?.(event); onClick?.(event);
} }

105
app/utils/fileUtils.ts Normal file
View File

@ -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<boolean> => {
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<string, string> } | null> => {
const packageJsonFile = files.find((f) => f.webkitRelativePath.endsWith('package.json'));
if (!packageJsonFile) {
return null;
}
try {
const content = await new Promise<string>((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: '' };
};

68
app/utils/folderImport.ts Normal file
View File

@ -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<Message[]> => {
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}
<boltArtifact id="imported-files" title="Imported Files">
${fileArtifacts
.map(
(file) => `<boltAction type="file" filePath="${file.path}">
${file.content}
</boltAction>`,
)
.join('\n\n')}
</boltArtifact>`,
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;
};

View File

@ -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<ProjectCommands> {
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: `
<boltArtifact id="project-setup" title="Project Setup">
<boltAction type="shell">
${commands.setupCommand}
</boltAction>
</boltArtifact>${commands.followupMessage ? `\n\n${commands.followupMessage}` : ''}`,
id: generateId(),
createdAt: new Date(),
};
}