diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index a645cc4..dc6aecc 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -19,7 +19,7 @@ import * as Tooltip from '@radix-ui/react-tooltip'; import styles from './BaseChat.module.scss'; import type { ProviderInfo } from '~/utils/types'; import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton'; -import { ImportButton } from '~/components/chat/chatExportAndImport/ImportButton'; +import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons'; import { ExamplePrompts } from '~/components/chat/ExamplePrompts'; // @ts-ignore TODO: Introduce proper types @@ -307,7 +307,7 @@ export const BaseChat = React.forwardRef( - {!chatStarted && ImportButton(importChat)} + {!chatStarted && ImportButtons(importChat)} {!chatStarted && ExamplePrompts(sendMessage)} {() => } diff --git a/app/components/chat/ImportFolderButton.tsx b/app/components/chat/ImportFolderButton.tsx new file mode 100644 index 0000000..5f822ee --- /dev/null +++ b/app/components/chat/ImportFolderButton.tsx @@ -0,0 +1,164 @@ +import React from 'react'; +import type { Message } from 'ai'; +import { toast } from 'react-toastify'; +import ignore from 'ignore'; + +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 createChatFromFolder = async (files: File[], binaryFiles: string[]) => { + const fileArtifacts = await Promise.all( + files.map(async (file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + const content = reader.result as string; + const relativePath = file.webkitRelativePath.split('/').slice(1).join('/'); + resolve( + ` +${content} +`, + ); + }; + reader.onerror = reject; + reader.readAsText(file); + }); + }), + ); + + const binaryFilesMessage = + binaryFiles.length > 0 + ? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}` + : ''; + + const message: Message = { + role: 'assistant', + content: `I'll help you set up these files.${binaryFilesMessage} + + +${fileArtifacts.join('\n\n')} +`, + id: generateId(), + createdAt: new Date(), + }; + + const userMessage: Message = { + role: 'user', + id: generateId(), + content: 'Import my files', + createdAt: new Date(), + }; + + const description = `Folder Import: ${files[0].webkitRelativePath.split('/')[0]}`; + + if (importChat) { + await importChat(description, [userMessage, message]); + } + }; + + return ( + <> + { + 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 + /> + + + ); +}; diff --git a/app/components/chat/chatExportAndImport/ImportButton.tsx b/app/components/chat/chatExportAndImport/ImportButtons.tsx similarity index 82% rename from app/components/chat/chatExportAndImport/ImportButton.tsx rename to app/components/chat/chatExportAndImport/ImportButtons.tsx index 91c699e..2b59574 100644 --- a/app/components/chat/chatExportAndImport/ImportButton.tsx +++ b/app/components/chat/chatExportAndImport/ImportButtons.tsx @@ -1,8 +1,9 @@ import type { Message } from 'ai'; import { toast } from 'react-toastify'; import React from 'react'; +import { ImportFolderButton } from '~/components/chat/ImportFolderButton'; -export function ImportButton(importChat: ((description: string, messages: Message[]) => Promise) | undefined) { +export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise) | undefined) { return (
Import Chat +
diff --git a/package.json b/package.json index 71627c9..c211182 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "diff": "^5.2.0", "file-saver": "^2.0.5", "framer-motion": "^11.2.12", + "ignore": "^6.0.2", "isbot": "^4.1.0", "istextorbinary": "^9.5.0", "jose": "^5.6.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f483b57..cd2355c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,9 @@ importers: framer-motion: specifier: ^11.2.12 version: 11.2.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + ignore: + specifier: ^6.0.2 + version: 6.0.2 isbot: specifier: ^4.1.0 version: 4.4.0 @@ -3407,6 +3410,10 @@ packages: resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} engines: {node: '>= 4'} + ignore@6.0.2: + resolution: {integrity: sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==} + engines: {node: '>= 4'} + immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} @@ -9300,6 +9307,8 @@ snapshots: ignore@5.3.1: {} + ignore@6.0.2: {} + immediate@3.0.6: {} immutable@4.3.7: {}