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/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 e766a71..6cbfcac 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 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
index f6a52d9..fcf2a01 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 57cbe31..759df10 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 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(),
+ };
+}