Skip to content

Commit

Permalink
Merge pull request #589 from wonderwhy-er/Add-command-detection-to-gi…
Browse files Browse the repository at this point in the history
…t-import-flow

Add command detection to git import flow
  • Loading branch information
wonderwhy-er authored Dec 8, 2024
2 parents 6e61a4f + de37f6d commit 67f63aa
Show file tree
Hide file tree
Showing 7 changed files with 172 additions and 63 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 36 additions & 24 deletions app/components/chat/GitCloneButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/**',
Expand All @@ -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;
Expand All @@ -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}
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled" >
${filePaths
.map((filePath) => {
const { data: content, encoding } = data[filePath];
if (encoding === 'utf8') {
return `<boltAction type="file" filePath="${filePath}">
${content}
</boltAction>`;
} else if (content instanceof Uint8Array) {
return `<boltAction type="file" filePath="${filePath}">
${textDecoder.decode(content)}
</boltAction>`;
} else {
return '';
}
})
.join('\n')}
</boltArtifact>`,
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled">
${fileContents
.map(
(file) =>
`<boltAction type="file" filePath="${file.path}">
${file.content}
</boltAction>`,
)
.join('\n')}
</boltArtifact>`,
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);
}
}
};
Expand Down
8 changes: 5 additions & 3 deletions app/components/chat/ImportFolderButton.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,12 +17,14 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ 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 {
Expand Down
1 change: 1 addition & 0 deletions app/components/chat/SendButton.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const SendButton = ({ show, isStreaming, disabled, onClick }: SendButtonP
disabled={disabled}
onClick={(event) => {
event.preventDefault();

if (!disabled) {
onClick?.(event);
}
Expand Down
28 changes: 18 additions & 10 deletions app/utils/fileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ export const isBinaryFile = async (file: File): Promise<boolean> => {

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;
};

Expand All @@ -41,8 +43,11 @@ export const shouldIncludeFile = (path: string): boolean => {
};

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;
const packageJsonFile = files.find((f) => f.webkitRelativePath.endsWith('package.json'));

if (!packageJsonFile) {
return null;
}

try {
const content = await new Promise<string>((resolve, reject) => {
Expand All @@ -59,37 +64,40 @@ const readPackageJson = async (files: File[]): Promise<{ scripts?: Record<string
}
};

export const detectProjectType = async (files: File[]): Promise<{ type: string; setupCommand: string; followupMessage: string }> => {
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?',
};
}

if (hasFile('index.html')) {
return {
type: 'Static',
setupCommand: 'npx --yes serve',
followupMessage: ''
followupMessage: '',
};
}

Expand Down
57 changes: 31 additions & 26 deletions app/utils/folderImport.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,55 @@
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<Message[]> => {
const fileArtifacts = await Promise.all(
files.map(async (file) => {
return new Promise<string>((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(
`<boltAction type="file" filePath="${relativePath}">
${content}
</boltAction>`,
);
resolve({
content,
path: relativePath,
});
};
reader.onerror = reject;
reader.readAsText(file);
});
}),
);

const project = await detectProjectType(files);
const setupCommand = project.setupCommand ? `\n\n<boltAction type="shell">\n${project.setupCommand}\n</boltAction>` : '';
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}
<boltArtifact id="imported-files" title="Imported Files">
${fileArtifacts.join('\n\n')}
${fileArtifacts
.map(
(file) => `<boltAction type="file" filePath="${file.path}">
${file.content}
</boltAction>`,
)
.join('\n\n')}
</boltArtifact>`,
id: generateId(),
createdAt: new Date(),
},{
role: 'assistant',
content: `
<boltArtifact id="imported-files" title="Imported Files">
${setupCommand}
</boltArtifact>${followupMessage}`,
id: generateId(),
createdAt: new Date(),
}];
};

const userMessage: Message = {
role: 'user',
Expand All @@ -59,5 +58,11 @@ ${setupCommand}
createdAt: new Date(),
};

return [ userMessage, ...assistantMessages ];
const messages = [userMessage, filesMessage];

if (commandsMessage) {
messages.push(commandsMessage);
}

return messages;
};
80 changes: 80 additions & 0 deletions app/utils/projectCommands.ts
Original file line number Diff line number Diff line change
@@ -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(),
};
}

0 comments on commit 67f63aa

Please sign in to comment.