Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add command detection to git import flow #589

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(),
};
}
Loading