diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..966a4ad --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,17 @@ +#!/bin/sh + +echo "🔍 Running pre-commit hook to check the code looks good... 🔍" + +if ! pnpm typecheck; then + echo "❌ Type checking failed! Please review TypeScript types." + echo "Once you're done, don't forget to add your changes to the commit! 🚀" + exit 1 +fi + +if ! pnpm lint; then + echo "❌ Linting failed! 'pnpm lint:check' will help you fix the easy ones." + echo "Once you're done, don't forget to add your beautification to the commit! 🤩" + exit 1 +fi + +echo "👍 All good! Committing changes..." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f7744a..68215a2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,6 @@ -# Contributing to Bolt.new Fork -## DEFAULT_NUM_CTX +# Contributing to oTToDev -The `DEFAULT_NUM_CTX` environment variable can be used to limit the maximum number of context values used by the qwen2.5-coder model. For example, to limit the context to 24576 values (which uses 32GB of VRAM), set `DEFAULT_NUM_CTX=24576` in your `.env.local` file. - -First off, thank you for considering contributing to Bolt.new! This fork aims to expand the capabilities of the original project by integrating multiple LLM providers and enhancing functionality. Every contribution helps make Bolt.new a better tool for developers worldwide. +First off, thank you for considering contributing to oTToDev! This fork aims to expand the capabilities of the original project by integrating multiple LLM providers and enhancing functionality. Every contribution helps make oTToDev a better tool for developers worldwide. ## 📋 Table of Contents - [Code of Conduct](#code-of-conduct) @@ -56,6 +53,8 @@ We're looking for dedicated contributors to help maintain and grow this project. - Comment complex logic - Keep functions focused and small - Use meaningful variable names +- Lint your code. This repo contains a pre-commit-hook that will verify your code is linted properly, +so set up your IDE to do that for you! ## Development Setup diff --git a/README.md b/README.md index f952e0a..0127dd0 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ https://thinktank.ottomator.ai - ✅ Bolt terminal to see the output of LLM run commands (@thecodacus) - ✅ Streaming of code output (@thecodacus) - ✅ Ability to revert code to earlier version (@wonderwhy-er) +- ✅ Cohere Integration (@hasanraiyan) +- ✅ Dynamic model max token length (@hasanraiyan) - ⬜ **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** - Load local projects into the app @@ -39,8 +41,6 @@ https://thinktank.ottomator.ai - ⬜ Azure Open AI API Integration - ⬜ Perplexity Integration - ⬜ Vertex AI Integration -- ✅ Cohere Integration (@hasanraiyan) -- ✅ Dynamic model max token length (@hasanraiyan) - ⬜ Deploy directly to Vercel/Netlify/other similar platforms - ⬜ Prompt caching - ⬜ Better prompt enhancing @@ -246,14 +246,55 @@ pnpm run dev This will start the Remix Vite development server. You will need Google Chrome Canary to run this locally if you use Chrome! It's an easy install and a good browser for web development anyway. -## Tips and Tricks +## FAQ -Here are some tips to get the most out of Bolt.new: +### How do I get the best results with oTToDev? - **Be specific about your stack**: If you want to use specific frameworks or libraries (like Astro, Tailwind, ShadCN, or any other popular JavaScript framework), mention them in your initial prompt to ensure Bolt scaffolds the project accordingly. - **Use the enhance prompt icon**: Before sending your prompt, try clicking the 'enhance' icon to have the AI model help you refine your prompt, then edit the results before submitting. -- **Scaffold the basics first, then add features**: Make sure the basic structure of your application is in place before diving into more advanced functionality. This helps Bolt understand the foundation of your project and ensure everything is wired up right before building out more advanced functionality. +- **Scaffold the basics first, then add features**: Make sure the basic structure of your application is in place before diving into more advanced functionality. This helps oTToDev understand the foundation of your project and ensure everything is wired up right before building out more advanced functionality. -- **Batch simple instructions**: Save time by combining simple instructions into one message. For example, you can ask Bolt to change the color scheme, add mobile responsiveness, and restart the dev server, all in one go saving you time and reducing API credit consumption significantly. +- **Batch simple instructions**: Save time by combining simple instructions into one message. For example, you can ask oTToDev to change the color scheme, add mobile responsiveness, and restart the dev server, all in one go saving you time and reducing API credit consumption significantly. + +### How do I contribute to oTToDev? + +[Please check out our dedicated page for contributing to oTToDev here!](CONTRIBUTING.md) + +### Do you plan on merging oTToDev back into the official Bolt.new repo? + +More news coming on this coming early next month - stay tuned! + +### What are the future plans for oTToDev? + +[Check out our Roadmap here!](https://roadmap.sh/r/ottodev-roadmap-2ovzo) + +Lot more updates to this roadmap coming soon! + +### Why are there so many open issues/pull requests? + +oTToDev was started simply to showcase how to edit an open source project and to do something cool with local LLMs on my (@ColeMedin) YouTube channel! However, it quickly +grew into a massive community project that I am working hard to keep up with the demand of by forming a team of maintainers and getting as many people involved as I can. +That effort is going well and all of our maintainers are ABSOLUTE rockstars, but it still takes time to organize everything so we can efficiently get through all +the issues and PRs. But rest assured, we are working hard and even working on some partnerships behind the scenes to really help this project take off! + +### How do local LLMs fair compared to larger models like Claude 3.5 Sonnet for oTToDev/Bolt.new? + +As much as the gap is quickly closing between open source and massive close source models, you’re still going to get the best results with the very large models like GPT-4o, Claude 3.5 Sonnet, and DeepSeek Coder V2 236b. This is one of the big tasks we have at hand - figuring out how to prompt better, use agents, and improve the platform as a whole to make it work better for even the smaller local LLMs! + +### I'm getting the error: "There was an error processing this request" + +If you see this error within oTToDev, that is just the application telling you there is a problem at a high level, and this could mean a number of different things. To find the actual error, please check BOTH the terminal where you started the application (with Docker or pnpm) and the developer console in the browser. For most browsers, you can access the developer console by pressing F12 or right clicking anywhere in the browser and selecting “Inspect”. Then go to the “console” tab in the top right. + +### I'm getting the error: "x-api-key header missing" + +We have seen this error a couple times and for some reason just restarting the Docker container has fixed it. This seems to be Ollama specific. Another thing to try is try to run oTToDev with Docker or pnpm, whichever you didn’t run first. We are still on the hunt for why this happens once and a while! + +### I'm getting a blank preview when oTToDev runs my app! + +We promise you that we are constantly testing new PRs coming into oTToDev and the preview is core functionality, so the application is not broken! When you get a blank preview or don’t get a preview, this is generally because the LLM hallucinated bad code or incorrect commands. We are working on making this more transparent so it is obvious. Sometimes the error will appear in developer console too so check that as well. + +### Everything works but the results are bad + +This goes to the point above about how local LLMs are getting very powerful but you still are going to see better (sometimes much better) results with the largest LLMs like GPT-4o, Claude 3.5 Sonnet, and DeepSeek Coder V2 236b. If you are using smaller LLMs like Qwen-2.5-Coder, consider it more experimental and educational at this point. It can build smaller applications really well, which is super impressive for a local LLM, but for larger scale applications you want to use the larger LLMs still! diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index b7792bf..dc6aecc 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -3,7 +3,7 @@ * Preventing TS checks with files presented in the video for a better presentation. */ import type { Message } from 'ai'; -import React, { type RefCallback, useEffect } from 'react'; +import React, { type RefCallback, useEffect, useState } from 'react'; import { ClientOnly } from 'remix-utils/client-only'; import { Menu } from '~/components/sidebar/Menu.client'; import { IconButton } from '~/components/ui/IconButton'; @@ -12,23 +12,15 @@ import { classNames } from '~/utils/classNames'; import { MODEL_LIST, PROVIDER_LIST, initializeModelList } from '~/utils/constants'; import { Messages } from './Messages.client'; import { SendButton } from './SendButton.client'; -import { useState } from 'react'; import { APIKeyManager } from './APIKeyManager'; import Cookies from 'js-cookie'; +import * as Tooltip from '@radix-ui/react-tooltip'; import styles from './BaseChat.module.scss'; import type { ProviderInfo } from '~/utils/types'; - -const EXAMPLE_PROMPTS = [ - { text: 'Build a todo app in React using Tailwind' }, - { text: 'Build a simple blog using Astro' }, - { text: 'Create a cookie consent form using Material UI' }, - { text: 'Make a space invaders game' }, - { text: 'How do I center a div?' }, -]; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const providerList = PROVIDER_LIST; +import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton'; +import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons'; +import { ExamplePrompts } from '~/components/chat/ExamplePrompts'; // @ts-ignore TODO: Introduce proper types // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -79,6 +71,7 @@ interface BaseChatProps { chatStarted?: boolean; isStreaming?: boolean; messages?: Message[]; + description?: string; enhancingPrompt?: boolean; promptEnhanced?: boolean; input?: string; @@ -90,6 +83,8 @@ interface BaseChatProps { sendMessage?: (event: React.UIEvent, messageInput?: string) => void; handleInputChange?: (event: React.ChangeEvent) => void; enhancePrompt?: () => void; + importChat?: (description: string, messages: Message[]) => Promise; + exportChat?: () => void; } export const BaseChat = React.forwardRef( @@ -113,6 +108,8 @@ export const BaseChat = React.forwardRef( handleInputChange, enhancePrompt, handleStop, + importChat, + exportChat, }, ref, ) => { @@ -161,7 +158,7 @@ export const BaseChat = React.forwardRef( } }; - return ( + const baseChat = (
( )} + {chatStarted && {() => }}
{input.length > 3 ? (
@@ -309,30 +307,14 @@ export const BaseChat = React.forwardRef(
- {!chatStarted && ( -
-
- {EXAMPLE_PROMPTS.map((examplePrompt, index) => { - return ( - - ); - })} -
-
- )} + {!chatStarted && ImportButtons(importChat)} + {!chatStarted && ExamplePrompts(sendMessage)} {() => } ); + + return {baseChat}; }, ); diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index 47515dd..9841820 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -9,7 +9,7 @@ import { useAnimate } from 'framer-motion'; import { memo, useEffect, useRef, useState } from 'react'; import { cssTransition, toast, ToastContainer } from 'react-toastify'; import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks'; -import { useChatHistory } from '~/lib/persistence'; +import { description, useChatHistory } from '~/lib/persistence'; import { chatStore } from '~/lib/stores/chat'; import { workbenchStore } from '~/lib/stores/workbench'; import { fileModificationsToHTML } from '~/utils/diff'; @@ -30,11 +30,20 @@ const logger = createScopedLogger('Chat'); export function Chat() { renderLogger.trace('Chat'); - const { ready, initialMessages, storeMessageHistory } = useChatHistory(); + const { ready, initialMessages, storeMessageHistory, importChat, exportChat } = useChatHistory(); + const title = useStore(description); return ( <> - {ready && } + {ready && ( + + )} { return ( @@ -69,216 +78,224 @@ export function Chat() { interface ChatProps { initialMessages: Message[]; storeMessageHistory: (messages: Message[]) => Promise; + importChat: (description: string, messages: Message[]) => Promise; + exportChat: () => void; + description?: string; } -export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProps) => { - useShortcuts(); +export const ChatImpl = memo( + ({ description, initialMessages, storeMessageHistory, importChat, exportChat }: ChatProps) => { + useShortcuts(); - const textareaRef = useRef(null); + const textareaRef = useRef(null); - const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); - const [model, setModel] = useState(() => { - const savedModel = Cookies.get('selectedModel'); - return savedModel || DEFAULT_MODEL; - }); - const [provider, setProvider] = useState(() => { - const savedProvider = Cookies.get('selectedProvider'); - return PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER; - }); + const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); + const [model, setModel] = useState(() => { + const savedModel = Cookies.get('selectedModel'); + return savedModel || DEFAULT_MODEL; + }); + const [provider, setProvider] = useState(() => { + const savedProvider = Cookies.get('selectedProvider'); + return PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER; + }); - const { showChat } = useStore(chatStore); + const { showChat } = useStore(chatStore); - const [animationScope, animate] = useAnimate(); + const [animationScope, animate] = useAnimate(); - const [apiKeys, setApiKeys] = useState>({}); + const [apiKeys, setApiKeys] = useState>({}); - const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({ - api: '/api/chat', - body: { - apiKeys, - }, - onError: (error) => { - logger.error('Request failed\n\n', error); - toast.error( - 'There was an error processing your request: ' + (error.message ? error.message : 'No details were returned'), - ); - }, - onFinish: () => { - logger.debug('Finished streaming'); - }, - initialMessages, - }); - - const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer(); - const { parsedMessages, parseMessages } = useMessageParser(); - - const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; - - useEffect(() => { - chatStore.setKey('started', initialMessages.length > 0); - }, []); - - useEffect(() => { - parseMessages(messages, isLoading); - - if (messages.length > initialMessages.length) { - storeMessageHistory(messages).catch((error) => toast.error(error.message)); - } - }, [messages, isLoading, parseMessages]); - - const scrollTextArea = () => { - const textarea = textareaRef.current; - - if (textarea) { - textarea.scrollTop = textarea.scrollHeight; - } - }; - - const abort = () => { - stop(); - chatStore.setKey('aborted', true); - workbenchStore.abortAllActions(); - }; - - useEffect(() => { - const textarea = textareaRef.current; - - if (textarea) { - textarea.style.height = 'auto'; - - const scrollHeight = textarea.scrollHeight; - - textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`; - textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden'; - } - }, [input, textareaRef]); - - const runAnimation = async () => { - if (chatStarted) { - return; - } - - await Promise.all([ - animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }), - animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }), - ]); - - chatStore.setKey('started', true); - - setChatStarted(true); - }; - - const sendMessage = async (_event: React.UIEvent, messageInput?: string) => { - const _input = messageInput || input; - - if (_input.length === 0 || isLoading) { - return; - } - - /** - * @note (delm) Usually saving files shouldn't take long but it may take longer if there - * many unsaved files. In that case we need to block user input and show an indicator - * of some kind so the user is aware that something is happening. But I consider the - * happy case to be no unsaved files and I would expect users to save their changes - * before they send another message. - */ - await workbenchStore.saveAllFiles(); - - const fileModifications = workbenchStore.getFileModifcations(); - - chatStore.setKey('aborted', false); - - runAnimation(); - - if (fileModifications !== undefined) { - const diff = fileModificationsToHTML(fileModifications); - - /** - * If we have file modifications we append a new user message manually since we have to prefix - * the user input with the file modifications and we don't want the new user input to appear - * in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to - * manually reset the input and we'd have to manually pass in file attachments. However, those - * aren't relevant here. - */ - append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${diff}\n\n${_input}` }); - - /** - * After sending a new message we reset all modifications since the model - * should now be aware of all the changes. - */ - workbenchStore.resetAllFileModifications(); - } else { - append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}` }); - } - - setInput(''); - - resetEnhancer(); - - textareaRef.current?.blur(); - }; - - const [messageRef, scrollRef] = useSnapScroll(); - - useEffect(() => { - const storedApiKeys = Cookies.get('apiKeys'); - - if (storedApiKeys) { - setApiKeys(JSON.parse(storedApiKeys)); - } - }, []); - - const handleModelChange = (newModel: string) => { - setModel(newModel); - Cookies.set('selectedModel', newModel, { expires: 30 }); - }; - - const handleProviderChange = (newProvider: ProviderInfo) => { - setProvider(newProvider); - Cookies.set('selectedProvider', newProvider.name, { expires: 30 }); - }; - - return ( - { - if (message.role === 'user') { - return message; - } - - return { - ...message, - content: parsedMessages[i] || '', - }; - })} - enhancePrompt={() => { - enhancePrompt( - input, - (input) => { - setInput(input); - scrollTextArea(); - }, - model, - provider, - apiKeys, + const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({ + api: '/api/chat', + body: { + apiKeys, + }, + onError: (error) => { + logger.error('Request failed\n\n', error); + toast.error( + 'There was an error processing your request: ' + (error.message ? error.message : 'No details were returned'), ); - }} - /> - ); -}); + }, + onFinish: () => { + logger.debug('Finished streaming'); + }, + initialMessages, + }); + + const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer(); + const { parsedMessages, parseMessages } = useMessageParser(); + + const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; + + useEffect(() => { + chatStore.setKey('started', initialMessages.length > 0); + }, []); + + useEffect(() => { + parseMessages(messages, isLoading); + + if (messages.length > initialMessages.length) { + storeMessageHistory(messages).catch((error) => toast.error(error.message)); + } + }, [messages, isLoading, parseMessages]); + + const scrollTextArea = () => { + const textarea = textareaRef.current; + + if (textarea) { + textarea.scrollTop = textarea.scrollHeight; + } + }; + + const abort = () => { + stop(); + chatStore.setKey('aborted', true); + workbenchStore.abortAllActions(); + }; + + useEffect(() => { + const textarea = textareaRef.current; + + if (textarea) { + textarea.style.height = 'auto'; + + const scrollHeight = textarea.scrollHeight; + + textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`; + textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden'; + } + }, [input, textareaRef]); + + const runAnimation = async () => { + if (chatStarted) { + return; + } + + await Promise.all([ + animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }), + animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }), + ]); + + chatStore.setKey('started', true); + + setChatStarted(true); + }; + + const sendMessage = async (_event: React.UIEvent, messageInput?: string) => { + const _input = messageInput || input; + + if (_input.length === 0 || isLoading) { + return; + } + + /** + * @note (delm) Usually saving files shouldn't take long but it may take longer if there + * many unsaved files. In that case we need to block user input and show an indicator + * of some kind so the user is aware that something is happening. But I consider the + * happy case to be no unsaved files and I would expect users to save their changes + * before they send another message. + */ + await workbenchStore.saveAllFiles(); + + const fileModifications = workbenchStore.getFileModifcations(); + + chatStore.setKey('aborted', false); + + runAnimation(); + + if (fileModifications !== undefined) { + const diff = fileModificationsToHTML(fileModifications); + + /** + * If we have file modifications we append a new user message manually since we have to prefix + * the user input with the file modifications and we don't want the new user input to appear + * in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to + * manually reset the input and we'd have to manually pass in file attachments. However, those + * aren't relevant here. + */ + append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${diff}\n\n${_input}` }); + + /** + * After sending a new message we reset all modifications since the model + * should now be aware of all the changes. + */ + workbenchStore.resetAllFileModifications(); + } else { + append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}` }); + } + + setInput(''); + + resetEnhancer(); + + textareaRef.current?.blur(); + }; + + const [messageRef, scrollRef] = useSnapScroll(); + + useEffect(() => { + const storedApiKeys = Cookies.get('apiKeys'); + + if (storedApiKeys) { + setApiKeys(JSON.parse(storedApiKeys)); + } + }, []); + + const handleModelChange = (newModel: string) => { + setModel(newModel); + Cookies.set('selectedModel', newModel, { expires: 30 }); + }; + + const handleProviderChange = (newProvider: ProviderInfo) => { + setProvider(newProvider); + Cookies.set('selectedProvider', newProvider.name, { expires: 30 }); + }; + + return ( + { + if (message.role === 'user') { + return message; + } + + return { + ...message, + content: parsedMessages[i] || '', + }; + })} + enhancePrompt={() => { + enhancePrompt( + input, + (input) => { + setInput(input); + scrollTextArea(); + }, + model, + provider, + apiKeys, + ); + }} + /> + ); + }, +); diff --git a/app/components/chat/ExamplePrompts.tsx b/app/components/chat/ExamplePrompts.tsx new file mode 100644 index 0000000..60b7643 --- /dev/null +++ b/app/components/chat/ExamplePrompts.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +const EXAMPLE_PROMPTS = [ + { text: 'Build a todo app in React using Tailwind' }, + { text: 'Build a simple blog using Astro' }, + { text: 'Create a cookie consent form using Material UI' }, + { text: 'Make a space invaders game' }, + { text: 'How do I center a div?' }, +]; + +export function ExamplePrompts(sendMessage?: { (event: React.UIEvent, messageInput?: string): void | undefined }) { + return ( +
+
+ {EXAMPLE_PROMPTS.map((examplePrompt, index: number) => { + return ( + + ); + })} +
+
+ ); +} 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/Messages.client.tsx b/app/components/chat/Messages.client.tsx index a67104c..4a2ac6a 100644 --- a/app/components/chat/Messages.client.tsx +++ b/app/components/chat/Messages.client.tsx @@ -3,11 +3,11 @@ import React from 'react'; import { classNames } from '~/utils/classNames'; import { AssistantMessage } from './AssistantMessage'; import { UserMessage } from './UserMessage'; -import * as Tooltip from '@radix-ui/react-tooltip'; import { useLocation } from '@remix-run/react'; import { db, chatId } from '~/lib/persistence/useChatHistory'; import { forkChat } from '~/lib/persistence/db'; import { toast } from 'react-toastify'; +import WithTooltip from '~/components/ui/Tooltip'; interface MessagesProps { id?: string; @@ -41,92 +41,66 @@ export const Messages = React.forwardRef((props: }; return ( - -
- {messages.length > 0 - ? messages.map((message, index) => { - const { role, content, id: messageId } = message; - const isUserMessage = role === 'user'; - const isFirst = index === 0; - const isLast = index === messages.length - 1; +
+ {messages.length > 0 + ? messages.map((message, index) => { + const { role, content, id: messageId } = message; + const isUserMessage = role === 'user'; + const isFirst = index === 0; + const isLast = index === messages.length - 1; - return ( -
- {isUserMessage && ( -
-
-
- )} -
- {isUserMessage ? : } + return ( +
+ {isUserMessage && ( +
+
- {!isUserMessage && ( -
- - - {messageId && ( -
- )} + )} +
+ {isUserMessage ? : }
- ); - }) - : null} - {isStreaming && ( -
- )} -
- + {!isUserMessage && ( +
+ + {messageId && ( +
+ )} +
+ ); + }) + : null} + {isStreaming && ( +
+ )} +
); }); diff --git a/app/components/chat/chatExportAndImport/ExportChatButton.tsx b/app/components/chat/chatExportAndImport/ExportChatButton.tsx new file mode 100644 index 0000000..6ab294b --- /dev/null +++ b/app/components/chat/chatExportAndImport/ExportChatButton.tsx @@ -0,0 +1,13 @@ +import WithTooltip from '~/components/ui/Tooltip'; +import { IconButton } from '~/components/ui/IconButton'; +import React from 'react'; + +export const ExportChatButton = ({ exportChat }: { exportChat?: () => void }) => { + return ( + + exportChat?.()}> +
+
+
+ ); +}; diff --git a/app/components/chat/chatExportAndImport/ImportButtons.tsx b/app/components/chat/chatExportAndImport/ImportButtons.tsx new file mode 100644 index 0000000..2b59574 --- /dev/null +++ b/app/components/chat/chatExportAndImport/ImportButtons.tsx @@ -0,0 +1,71 @@ +import type { Message } from 'ai'; +import { toast } from 'react-toastify'; +import React from 'react'; +import { ImportFolderButton } from '~/components/chat/ImportFolderButton'; + +export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise) | undefined) { + return ( +
+ { + const file = e.target.files?.[0]; + + if (file && importChat) { + try { + const reader = new FileReader(); + + reader.onload = async (e) => { + try { + const content = e.target?.result as string; + const data = JSON.parse(content); + + if (!Array.isArray(data.messages)) { + toast.error('Invalid chat file format'); + } + + await importChat(data.description, data.messages); + toast.success('Chat imported successfully'); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error('Failed to parse chat file: ' + error.message); + } else { + toast.error('Failed to parse chat file'); + } + } + }; + reader.onerror = () => toast.error('Failed to read chat file'); + reader.readAsText(file); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to import chat'); + } + e.target.value = ''; // Reset file input + } else { + toast.error('Something went wrong'); + } + }} + /> +
+
+ + +
+
+
+ ); +} diff --git a/app/components/sidebar/HistoryItem.tsx b/app/components/sidebar/HistoryItem.tsx index df270c8..4c28435 100644 --- a/app/components/sidebar/HistoryItem.tsx +++ b/app/components/sidebar/HistoryItem.tsx @@ -1,70 +1,55 @@ import * as Dialog from '@radix-ui/react-dialog'; -import { useEffect, useRef, useState } from 'react'; import { type ChatHistoryItem } from '~/lib/persistence'; +import WithTooltip from '~/components/ui/Tooltip'; interface HistoryItemProps { item: ChatHistoryItem; onDelete?: (event: React.UIEvent) => void; onDuplicate?: (id: string) => void; + exportChat: (id?: string) => void; } -export function HistoryItem({ item, onDelete, onDuplicate }: HistoryItemProps) { - const [hovering, setHovering] = useState(false); - const hoverRef = useRef(null); - - useEffect(() => { - let timeout: NodeJS.Timeout | undefined; - - function mouseEnter() { - setHovering(true); - - if (timeout) { - clearTimeout(timeout); - } - } - - function mouseLeave() { - setHovering(false); - } - - hoverRef.current?.addEventListener('mouseenter', mouseEnter); - hoverRef.current?.addEventListener('mouseleave', mouseLeave); - - return () => { - hoverRef.current?.removeEventListener('mouseenter', mouseEnter); - hoverRef.current?.removeEventListener('mouseleave', mouseLeave); - }; - }, []); - +export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) { return ( -
+
{item.description} - diff --git a/app/components/sidebar/Menu.client.tsx b/app/components/sidebar/Menu.client.tsx index 2cb3b77..d753d24 100644 --- a/app/components/sidebar/Menu.client.tsx +++ b/app/components/sidebar/Menu.client.tsx @@ -33,7 +33,7 @@ const menuVariants = { type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null; export function Menu() { - const { duplicateCurrentChat } = useChatHistory(); + const { duplicateCurrentChat, exportChat } = useChatHistory(); const menuRef = useRef(null); const [list, setList] = useState([]); const [open, setOpen] = useState(false); @@ -101,7 +101,6 @@ export function Menu() { const handleDeleteClick = (event: React.UIEvent, item: ChatHistoryItem) => { event.preventDefault(); - setDialogContent({ type: 'delete', item }); }; @@ -130,7 +129,7 @@ export function Menu() {
Your Chats
-
+
{list.length === 0 &&
No previous conversations
} {binDates(list).map(({ category, items }) => ( @@ -142,6 +141,7 @@ export function Menu() { handleDeleteClick(event, item)} onDuplicate={() => handleDuplicate(item.id)} /> diff --git a/app/components/ui/Tooltip.tsx b/app/components/ui/Tooltip.tsx new file mode 100644 index 0000000..4e22f54 --- /dev/null +++ b/app/components/ui/Tooltip.tsx @@ -0,0 +1,73 @@ +import * as Tooltip from '@radix-ui/react-tooltip'; + +interface TooltipProps { + tooltip: React.ReactNode; + children: React.ReactNode; + sideOffset?: number; + className?: string; + arrowClassName?: string; + tooltipStyle?: React.CSSProperties; + position?: 'top' | 'bottom' | 'left' | 'right'; + maxWidth?: number; + delay?: number; +} + +const WithTooltip = ({ + tooltip, + children, + sideOffset = 5, + className = '', + arrowClassName = '', + tooltipStyle = {}, + position = 'top', + maxWidth = 250, + delay = 0, +}: TooltipProps) => { + return ( + + {children} + + +
{tooltip}
+ +
+
+
+ ); +}; + +export default WithTooltip; diff --git a/app/lib/.server/llm/stream-text.ts b/app/lib/.server/llm/stream-text.ts index 86e285c..3ef8792 100644 --- a/app/lib/.server/llm/stream-text.ts +++ b/app/lib/.server/llm/stream-text.ts @@ -1,11 +1,11 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck – TODO: Provider proper types -import { streamText as _streamText, convertToCoreMessages } from 'ai'; +import { convertToCoreMessages, streamText as _streamText } from 'ai'; import { getModel } from '~/lib/.server/llm/model'; import { MAX_TOKENS } from './constants'; import { getSystemPrompt } from './prompts'; -import { MODEL_LIST, DEFAULT_MODEL, DEFAULT_PROVIDER, MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants'; +import { DEFAULT_MODEL, DEFAULT_PROVIDER, MODEL_LIST, MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants'; interface ToolResult { toolCallId: string; diff --git a/app/lib/persistence/db.ts b/app/lib/persistence/db.ts index b21ace0..6ce604d 100644 --- a/app/lib/persistence/db.ts +++ b/app/lib/persistence/db.ts @@ -176,14 +176,7 @@ export async function forkChat(db: IDBDatabase, chatId: string, messageId: strin // Get messages up to and including the selected message const messages = chat.messages.slice(0, messageIndex + 1); - // Generate new IDs - const newId = await getNextId(db); - const urlId = await getUrlId(db, newId); - - // Create the forked chat - await setMessages(db, newId, messages, urlId, chat.description ? `${chat.description} (fork)` : 'Forked chat'); - - return urlId; + return createChatFromMessages(db, chat.description ? `${chat.description} (fork)` : 'Forked chat', messages); } export async function duplicateChat(db: IDBDatabase, id: string): Promise { @@ -193,15 +186,23 @@ export async function duplicateChat(db: IDBDatabase, id: string): Promise { const newId = await getNextId(db); const newUrlId = await getUrlId(db, newId); // Get a new urlId for the duplicated chat await setMessages( db, newId, - chat.messages, + messages, newUrlId, // Use the new urlId - `${chat.description || 'Chat'} (copy)`, + description, ); return newUrlId; // Return the urlId instead of id for navigation diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts index 62bd53a..9daa61f 100644 --- a/app/lib/persistence/useChatHistory.ts +++ b/app/lib/persistence/useChatHistory.ts @@ -4,7 +4,15 @@ import { atom } from 'nanostores'; import type { Message } from 'ai'; import { toast } from 'react-toastify'; import { workbenchStore } from '~/lib/stores/workbench'; -import { getMessages, getNextId, getUrlId, openDatabase, setMessages, duplicateChat } from './db'; +import { + getMessages, + getNextId, + getUrlId, + openDatabase, + setMessages, + duplicateChat, + createChatFromMessages, +} from './db'; export interface ChatHistoryItem { id: string; @@ -113,6 +121,45 @@ export function useChatHistory() { console.log(error); } }, + importChat: async (description: string, messages: Message[]) => { + if (!db) { + return; + } + + try { + const newId = await createChatFromMessages(db, description, messages); + window.location.href = `/chat/${newId}`; + toast.success('Chat imported successfully'); + } catch (error) { + if (error instanceof Error) { + toast.error('Failed to import chat: ' + error.message); + } else { + toast.error('Failed to import chat'); + } + } + }, + exportChat: async (id = urlId) => { + if (!db || !id) { + return; + } + + const chat = await getMessages(db, id); + const chatData = { + messages: chat.messages, + description: chat.description, + exportDate: new Date().toISOString(), + }; + + const blob = new Blob([JSON.stringify(chatData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `chat-${new Date().toISOString()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, }; } diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index ca78a74..13b17ef 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -93,14 +93,13 @@ export class ActionRunner { this.#updateAction(actionId, { ...action, ...data.action, executed: !isStreaming }); - // eslint-disable-next-line consistent-return - return (this.#currentExecutionPromise = this.#currentExecutionPromise + this.#currentExecutionPromise = this.#currentExecutionPromise .then(() => { this.#executeAction(actionId, isStreaming); }) .catch((error) => { console.error('Action failed:', error); - })); + }); } async #executeAction(actionId: string, isStreaming: boolean = false) { diff --git a/app/utils/constants.ts b/app/utils/constants.ts index de68a82..17fe9d8 100644 --- a/app/utils/constants.ts +++ b/app/utils/constants.ts @@ -283,6 +283,10 @@ const getOllamaBaseUrl = () => { }; async function getOllamaModels(): Promise { + if (typeof window === 'undefined') { + return []; + } + try { const baseUrl = getOllamaBaseUrl(); const response = await fetch(`${baseUrl}/api/tags`); @@ -294,8 +298,8 @@ async function getOllamaModels(): Promise { provider: 'Ollama', maxTokenAllowed: 8000, })); - // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { + console.error('Error getting Ollama models:', e); return []; } } @@ -321,8 +325,8 @@ async function getOpenAILikeModels(): Promise { label: model.id, provider: 'OpenAILike', })); - // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { + console.error('Error getting OpenAILike models:', e); return []; } } @@ -361,6 +365,10 @@ async function getOpenRouterModels(): Promise { } async function getLMStudioModels(): Promise { + if (typeof window === 'undefined') { + return []; + } + try { const baseUrl = import.meta.env.LMSTUDIO_API_BASE_URL || 'http://localhost:1234'; const response = await fetch(`${baseUrl}/v1/models`); @@ -371,8 +379,8 @@ async function getLMStudioModels(): Promise { label: model.id, provider: 'LMStudio', })); - // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { + console.error('Error getting LMStudio models:', e); return []; } } diff --git a/package.json b/package.json index 16c59f7..c211182 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "dockerbuild": "docker build -t bolt-ai:development -t bolt-ai:latest --target bolt-ai-development .", "typecheck": "tsc", "typegen": "wrangler types", - "preview": "pnpm run build && pnpm run start" + "preview": "pnpm run build && pnpm run start", + "prepare": "husky" }, "engines": { "node": ">=18.18.0" @@ -70,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", @@ -101,6 +103,7 @@ "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", "fast-glob": "^3.3.2", + "husky": "9.1.7", "is-ci": "^3.0.1", "node-fetch": "^3.3.2", "prettier": "^3.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 951d1a4..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 @@ -231,6 +234,9 @@ importers: fast-glob: specifier: ^3.3.2 version: 3.3.2 + husky: + specifier: 9.1.7 + version: 9.1.7 is-ci: specifier: ^3.0.1 version: 3.0.1 @@ -2482,7 +2488,7 @@ packages: resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} bytes@3.0.0: - resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + resolution: {integrity: sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=} engines: {node: '>= 0.8'} bytes@3.1.2: @@ -3382,6 +3388,11 @@ packages: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -3399,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==} @@ -9278,6 +9293,8 @@ snapshots: human-signals@5.0.0: {} + husky@9.1.7: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -9290,6 +9307,8 @@ snapshots: ignore@5.3.1: {} + ignore@6.0.2: {} + immediate@3.0.6: {} immutable@4.3.7: {}