Merge branch 'main' into docs

This commit is contained in:
Anirban Kar 2024-11-28 18:21:35 +05:30
commit 151fdb1a28
20 changed files with 851 additions and 406 deletions

17
.husky/pre-commit Normal file
View File

@ -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..."

View File

@ -1,9 +1,6 @@
# Contributing to Bolt.new Fork # Contributing to oTToDev
## DEFAULT_NUM_CTX
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 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.
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.
## 📋 Table of Contents ## 📋 Table of Contents
- [Code of Conduct](#code-of-conduct) - [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 - Comment complex logic
- Keep functions focused and small - Keep functions focused and small
- Use meaningful variable names - 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 ## Development Setup

View File

@ -29,6 +29,8 @@ https://thinktank.ottomator.ai
- ✅ Bolt terminal to see the output of LLM run commands (@thecodacus) - ✅ Bolt terminal to see the output of LLM run commands (@thecodacus)
- ✅ Streaming of code output (@thecodacus) - ✅ Streaming of code output (@thecodacus)
- ✅ Ability to revert code to earlier version (@wonderwhy-er) - ✅ 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** - 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** - Better prompting for smaller LLMs (code window sometimes doesn't start)
- ⬜ **HIGH PRIORITY** - Load local projects into the app - ⬜ **HIGH PRIORITY** - Load local projects into the app
@ -39,8 +41,6 @@ https://thinktank.ottomator.ai
- ⬜ Azure Open AI API Integration - ⬜ Azure Open AI API Integration
- ⬜ Perplexity Integration - ⬜ Perplexity Integration
- ⬜ Vertex AI Integration - ⬜ Vertex AI Integration
- ✅ Cohere Integration (@hasanraiyan)
- ✅ Dynamic model max token length (@hasanraiyan)
- ⬜ Deploy directly to Vercel/Netlify/other similar platforms - ⬜ Deploy directly to Vercel/Netlify/other similar platforms
- ⬜ Prompt caching - ⬜ Prompt caching
- ⬜ Better prompt enhancing - ⬜ 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. 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. - **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. - **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, youre 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 didnt 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 dont 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!

View File

@ -3,7 +3,7 @@
* Preventing TS checks with files presented in the video for a better presentation. * Preventing TS checks with files presented in the video for a better presentation.
*/ */
import type { Message } from 'ai'; 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 { ClientOnly } from 'remix-utils/client-only';
import { Menu } from '~/components/sidebar/Menu.client'; import { Menu } from '~/components/sidebar/Menu.client';
import { IconButton } from '~/components/ui/IconButton'; 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 { MODEL_LIST, PROVIDER_LIST, initializeModelList } from '~/utils/constants';
import { Messages } from './Messages.client'; import { Messages } from './Messages.client';
import { SendButton } from './SendButton.client'; import { SendButton } from './SendButton.client';
import { useState } from 'react';
import { APIKeyManager } from './APIKeyManager'; import { APIKeyManager } from './APIKeyManager';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import * as Tooltip from '@radix-ui/react-tooltip';
import styles from './BaseChat.module.scss'; import styles from './BaseChat.module.scss';
import type { ProviderInfo } from '~/utils/types'; import type { ProviderInfo } from '~/utils/types';
import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
const EXAMPLE_PROMPTS = [ import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
{ text: 'Build a todo app in React using Tailwind' }, import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
{ 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;
// @ts-ignore TODO: Introduce proper types // @ts-ignore TODO: Introduce proper types
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -79,6 +71,7 @@ interface BaseChatProps {
chatStarted?: boolean; chatStarted?: boolean;
isStreaming?: boolean; isStreaming?: boolean;
messages?: Message[]; messages?: Message[];
description?: string;
enhancingPrompt?: boolean; enhancingPrompt?: boolean;
promptEnhanced?: boolean; promptEnhanced?: boolean;
input?: string; input?: string;
@ -90,6 +83,8 @@ interface BaseChatProps {
sendMessage?: (event: React.UIEvent, messageInput?: string) => void; sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void; handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
enhancePrompt?: () => void; enhancePrompt?: () => void;
importChat?: (description: string, messages: Message[]) => Promise<void>;
exportChat?: () => void;
} }
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>( export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
@ -113,6 +108,8 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
handleInputChange, handleInputChange,
enhancePrompt, enhancePrompt,
handleStop, handleStop,
importChat,
exportChat,
}, },
ref, ref,
) => { ) => {
@ -161,7 +158,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
} }
}; };
return ( const baseChat = (
<div <div
ref={ref} ref={ref}
className={classNames( className={classNames(
@ -297,6 +294,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</> </>
)} )}
</IconButton> </IconButton>
{chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
</div> </div>
{input.length > 3 ? ( {input.length > 3 ? (
<div className="text-xs text-bolt-elements-textTertiary"> <div className="text-xs text-bolt-elements-textTertiary">
@ -309,30 +307,14 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</div> </div>
</div> </div>
</div> </div>
{!chatStarted && ( {!chatStarted && ImportButtons(importChat)}
<div id="examples" className="relative w-full max-w-xl mx-auto mt-8 flex justify-center"> {!chatStarted && ExamplePrompts(sendMessage)}
<div className="flex flex-col space-y-2 [mask-image:linear-gradient(to_bottom,black_0%,transparent_180%)] hover:[mask-image:none]">
{EXAMPLE_PROMPTS.map((examplePrompt, index) => {
return (
<button
key={index}
onClick={(event) => {
sendMessage?.(event, examplePrompt.text);
}}
className="group flex items-center w-full gap-2 justify-center bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-theme"
>
{examplePrompt.text}
<div className="i-ph:arrow-bend-down-left" />
</button>
);
})}
</div>
</div>
)}
</div> </div>
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly> <ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
</div> </div>
</div> </div>
); );
return <Tooltip.Provider delayDuration={200}>{baseChat}</Tooltip.Provider>;
}, },
); );

View File

@ -9,7 +9,7 @@ import { useAnimate } from 'framer-motion';
import { memo, useEffect, useRef, useState } from 'react'; import { memo, useEffect, useRef, useState } from 'react';
import { cssTransition, toast, ToastContainer } from 'react-toastify'; import { cssTransition, toast, ToastContainer } from 'react-toastify';
import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks'; 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 { chatStore } from '~/lib/stores/chat';
import { workbenchStore } from '~/lib/stores/workbench'; import { workbenchStore } from '~/lib/stores/workbench';
import { fileModificationsToHTML } from '~/utils/diff'; import { fileModificationsToHTML } from '~/utils/diff';
@ -30,11 +30,20 @@ const logger = createScopedLogger('Chat');
export function Chat() { export function Chat() {
renderLogger.trace('Chat'); renderLogger.trace('Chat');
const { ready, initialMessages, storeMessageHistory } = useChatHistory(); const { ready, initialMessages, storeMessageHistory, importChat, exportChat } = useChatHistory();
const title = useStore(description);
return ( return (
<> <>
{ready && <ChatImpl initialMessages={initialMessages} storeMessageHistory={storeMessageHistory} />} {ready && (
<ChatImpl
description={title}
initialMessages={initialMessages}
exportChat={exportChat}
storeMessageHistory={storeMessageHistory}
importChat={importChat}
/>
)}
<ToastContainer <ToastContainer
closeButton={({ closeToast }) => { closeButton={({ closeToast }) => {
return ( return (
@ -69,216 +78,224 @@ export function Chat() {
interface ChatProps { interface ChatProps {
initialMessages: Message[]; initialMessages: Message[];
storeMessageHistory: (messages: Message[]) => Promise<void>; storeMessageHistory: (messages: Message[]) => Promise<void>;
importChat: (description: string, messages: Message[]) => Promise<void>;
exportChat: () => void;
description?: string;
} }
export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProps) => { export const ChatImpl = memo(
useShortcuts(); ({ description, initialMessages, storeMessageHistory, importChat, exportChat }: ChatProps) => {
useShortcuts();
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
const [model, setModel] = useState(() => { const [model, setModel] = useState(() => {
const savedModel = Cookies.get('selectedModel'); const savedModel = Cookies.get('selectedModel');
return savedModel || DEFAULT_MODEL; return savedModel || DEFAULT_MODEL;
}); });
const [provider, setProvider] = useState(() => { const [provider, setProvider] = useState(() => {
const savedProvider = Cookies.get('selectedProvider'); const savedProvider = Cookies.get('selectedProvider');
return PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER; 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<Record<string, string>>({}); const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({ const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({
api: '/api/chat', api: '/api/chat',
body: { body: {
apiKeys, apiKeys,
}, },
onError: (error) => { onError: (error) => {
logger.error('Request failed\n\n', error); logger.error('Request failed\n\n', error);
toast.error( toast.error(
'There was an error processing your request: ' + (error.message ? error.message : 'No details were returned'), '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 (
<BaseChat
ref={animationScope}
textareaRef={textareaRef}
input={input}
showChat={showChat}
chatStarted={chatStarted}
isStreaming={isLoading}
enhancingPrompt={enhancingPrompt}
promptEnhanced={promptEnhanced}
sendMessage={sendMessage}
model={model}
setModel={handleModelChange}
provider={provider}
setProvider={handleProviderChange}
messageRef={messageRef}
scrollRef={scrollRef}
handleInputChange={handleInputChange}
handleStop={abort}
messages={messages.map((message, i) => {
if (message.role === 'user') {
return message;
}
return {
...message,
content: parsedMessages[i] || '',
};
})}
enhancePrompt={() => {
enhancePrompt(
input,
(input) => {
setInput(input);
scrollTextArea();
},
model,
provider,
apiKeys,
); );
}} },
/> 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 (
<BaseChat
ref={animationScope}
textareaRef={textareaRef}
input={input}
showChat={showChat}
chatStarted={chatStarted}
isStreaming={isLoading}
enhancingPrompt={enhancingPrompt}
promptEnhanced={promptEnhanced}
sendMessage={sendMessage}
model={model}
setModel={handleModelChange}
provider={provider}
setProvider={handleProviderChange}
messageRef={messageRef}
scrollRef={scrollRef}
handleInputChange={handleInputChange}
handleStop={abort}
description={description}
importChat={importChat}
exportChat={exportChat}
messages={messages.map((message, i) => {
if (message.role === 'user') {
return message;
}
return {
...message,
content: parsedMessages[i] || '',
};
})}
enhancePrompt={() => {
enhancePrompt(
input,
(input) => {
setInput(input);
scrollTextArea();
},
model,
provider,
apiKeys,
);
}}
/>
);
},
);

View File

@ -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 (
<div id="examples" className="relative w-full max-w-xl mx-auto mt-8 flex justify-center">
<div className="flex flex-col space-y-2 [mask-image:linear-gradient(to_bottom,black_0%,transparent_180%)] hover:[mask-image:none]">
{EXAMPLE_PROMPTS.map((examplePrompt, index: number) => {
return (
<button
key={index}
onClick={(event) => {
sendMessage?.(event, examplePrompt.text);
}}
className="group flex items-center w-full gap-2 justify-center bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-theme"
>
{examplePrompt.text}
<div className="i-ph:arrow-bend-down-left" />
</button>
);
})}
</div>
</div>
);
}

View File

@ -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<void>;
}
// 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<boolean> => {
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<ImportFolderButtonProps> = ({ 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<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>`,
);
};
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}
<boltArtifact id="imported-files" title="Imported Files">
${fileArtifacts.join('\n\n')}
</boltArtifact>`,
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 (
<>
<input
type="file"
id="folder-import"
className="hidden"
webkitdirectory=""
directory=""
onChange={async (e) => {
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
/>
<button
onClick={() => {
const input = document.getElementById('folder-import');
input?.click();
}}
className={className}
>
<div className="i-ph:folder-simple-upload" />
Import Folder
</button>
</>
);
};

View File

@ -3,11 +3,11 @@ import React from 'react';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
import { AssistantMessage } from './AssistantMessage'; import { AssistantMessage } from './AssistantMessage';
import { UserMessage } from './UserMessage'; import { UserMessage } from './UserMessage';
import * as Tooltip from '@radix-ui/react-tooltip';
import { useLocation } from '@remix-run/react'; import { useLocation } from '@remix-run/react';
import { db, chatId } from '~/lib/persistence/useChatHistory'; import { db, chatId } from '~/lib/persistence/useChatHistory';
import { forkChat } from '~/lib/persistence/db'; import { forkChat } from '~/lib/persistence/db';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import WithTooltip from '~/components/ui/Tooltip';
interface MessagesProps { interface MessagesProps {
id?: string; id?: string;
@ -41,92 +41,66 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
}; };
return ( return (
<Tooltip.Provider delayDuration={200}> <div id={id} ref={ref} className={props.className}>
<div id={id} ref={ref} className={props.className}> {messages.length > 0
{messages.length > 0 ? messages.map((message, index) => {
? messages.map((message, index) => { const { role, content, id: messageId } = message;
const { role, content, id: messageId } = message; const isUserMessage = role === 'user';
const isUserMessage = role === 'user'; const isFirst = index === 0;
const isFirst = index === 0; const isLast = index === messages.length - 1;
const isLast = index === messages.length - 1;
return ( return (
<div <div
key={index} key={index}
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', { className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast), 'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent': 'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
isStreaming && isLast, isStreaming && isLast,
'mt-4': !isFirst, 'mt-4': !isFirst,
})} })}
> >
{isUserMessage && ( {isUserMessage && (
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start"> <div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
<div className="i-ph:user-fill text-xl"></div> <div className="i-ph:user-fill text-xl"></div>
</div>
)}
<div className="grid grid-col-1 w-full">
{isUserMessage ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
</div> </div>
{!isUserMessage && ( )}
<div className="flex gap-2 flex-col lg:flex-row"> <div className="grid grid-col-1 w-full">
<Tooltip.Root> {isUserMessage ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
<Tooltip.Trigger asChild>
{messageId && (
<button
onClick={() => handleRewind(messageId)}
key="i-ph:arrow-u-up-left"
className={classNames(
'i-ph:arrow-u-up-left',
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
)}
/>
)}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="bg-bolt-elements-tooltip-background text-bolt-elements-textPrimary px-3 py-2 rounded-lg text-sm shadow-lg"
sideOffset={5}
style={{ zIndex: 1000 }}
>
Revert to this message
<Tooltip.Arrow className="fill-bolt-elements-tooltip-background" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
onClick={() => handleFork(messageId)}
key="i-ph:git-fork"
className={classNames(
'i-ph:git-fork',
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
)}
/>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="bg-bolt-elements-tooltip-background text-bolt-elements-textPrimary px-3 py-2 rounded-lg text-sm shadow-lg"
sideOffset={5}
style={{ zIndex: 1000 }}
>
Fork chat from this message
<Tooltip.Arrow className="fill-bolt-elements-tooltip-background" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</div>
)}
</div> </div>
); {!isUserMessage && (
}) <div className="flex gap-2 flex-col lg:flex-row">
: null} <WithTooltip tooltip="Revert to this message">
{isStreaming && ( {messageId && (
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div> <button
)} onClick={() => handleRewind(messageId)}
</div> key="i-ph:arrow-u-up-left"
</Tooltip.Provider> className={classNames(
'i-ph:arrow-u-up-left',
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
)}
/>
)}
</WithTooltip>
<WithTooltip tooltip="Fork chat from this message">
<button
onClick={() => handleFork(messageId)}
key="i-ph:git-fork"
className={classNames(
'i-ph:git-fork',
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
)}
/>
</WithTooltip>
</div>
)}
</div>
);
})
: null}
{isStreaming && (
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
)}
</div>
); );
}); });

View File

@ -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 (
<WithTooltip tooltip="Export Chat">
<IconButton title="Export Chat" onClick={() => exportChat?.()}>
<div className="i-ph:download-simple text-xl"></div>
</IconButton>
</WithTooltip>
);
};

View File

@ -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<void>) | undefined) {
return (
<div className="flex flex-col items-center justify-center flex-1 p-4">
<input
type="file"
id="chat-import"
className="hidden"
accept=".json"
onChange={async (e) => {
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');
}
}}
/>
<div className="flex flex-col items-center gap-4 max-w-2xl text-center">
<div className="flex gap-2">
<button
onClick={() => {
const input = document.getElementById('chat-import');
input?.click();
}}
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
>
<div className="i-ph:upload-simple" />
Import Chat
</button>
<ImportFolderButton
importChat={importChat}
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
/>
</div>
</div>
</div>
);
}

View File

@ -1,70 +1,55 @@
import * as Dialog from '@radix-ui/react-dialog'; import * as Dialog from '@radix-ui/react-dialog';
import { useEffect, useRef, useState } from 'react';
import { type ChatHistoryItem } from '~/lib/persistence'; import { type ChatHistoryItem } from '~/lib/persistence';
import WithTooltip from '~/components/ui/Tooltip';
interface HistoryItemProps { interface HistoryItemProps {
item: ChatHistoryItem; item: ChatHistoryItem;
onDelete?: (event: React.UIEvent) => void; onDelete?: (event: React.UIEvent) => void;
onDuplicate?: (id: string) => void; onDuplicate?: (id: string) => void;
exportChat: (id?: string) => void;
} }
export function HistoryItem({ item, onDelete, onDuplicate }: HistoryItemProps) { export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) {
const [hovering, setHovering] = useState(false);
const hoverRef = useRef<HTMLDivElement>(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);
};
}, []);
return ( return (
<div <div className="group rounded-md text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 overflow-hidden flex justify-between items-center px-2 py-1">
ref={hoverRef}
className="group rounded-md text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 overflow-hidden flex justify-between items-center px-2 py-1"
>
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block"> <a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
{item.description} {item.description}
<div className="absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 to-transparent w-10 flex justify-end group-hover:w-15 group-hover:from-45%"> <div className="absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 box-content pl-3 to-transparent w-10 flex justify-end group-hover:w-15 group-hover:from-99%">
{hovering && ( <div className="flex items-center p-1 text-bolt-elements-textSecondary opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center p-1 text-bolt-elements-textSecondary"> <WithTooltip tooltip="Export chat">
{onDuplicate && ( <button
type="button"
className="i-ph:download-simple scale-110 mr-2 hover:text-bolt-elements-item-contentAccent"
onClick={(event) => {
event.preventDefault();
exportChat(item.id);
}}
title="Export chat"
/>
</WithTooltip>
{onDuplicate && (
<WithTooltip tooltip="Duplicate chat">
<button <button
className="i-ph:copy scale-110 mr-2" type="button"
className="i-ph:copy scale-110 mr-2 hover:text-bolt-elements-item-contentAccent"
onClick={() => onDuplicate?.(item.id)} onClick={() => onDuplicate?.(item.id)}
title="Duplicate chat" title="Duplicate chat"
/> />
)} </WithTooltip>
<Dialog.Trigger asChild> )}
<Dialog.Trigger asChild>
<WithTooltip tooltip="Delete chat">
<button <button
className="i-ph:trash scale-110" type="button"
className="i-ph:trash scale-110 hover:text-bolt-elements-button-danger-text"
onClick={(event) => { onClick={(event) => {
// we prevent the default so we don't trigger the anchor above
event.preventDefault(); event.preventDefault();
onDelete?.(event); onDelete?.(event);
}} }}
/> />
</Dialog.Trigger> </WithTooltip>
</div> </Dialog.Trigger>
)} </div>
</div> </div>
</a> </a>
</div> </div>

View File

@ -33,7 +33,7 @@ const menuVariants = {
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null; type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
export function Menu() { export function Menu() {
const { duplicateCurrentChat } = useChatHistory(); const { duplicateCurrentChat, exportChat } = useChatHistory();
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const [list, setList] = useState<ChatHistoryItem[]>([]); const [list, setList] = useState<ChatHistoryItem[]>([]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -101,7 +101,6 @@ export function Menu() {
const handleDeleteClick = (event: React.UIEvent, item: ChatHistoryItem) => { const handleDeleteClick = (event: React.UIEvent, item: ChatHistoryItem) => {
event.preventDefault(); event.preventDefault();
setDialogContent({ type: 'delete', item }); setDialogContent({ type: 'delete', item });
}; };
@ -130,7 +129,7 @@ export function Menu() {
</a> </a>
</div> </div>
<div className="text-bolt-elements-textPrimary font-medium pl-6 pr-5 my-2">Your Chats</div> <div className="text-bolt-elements-textPrimary font-medium pl-6 pr-5 my-2">Your Chats</div>
<div className="flex-1 overflow-scroll pl-4 pr-5 pb-5"> <div className="flex-1 overflow-auto pl-4 pr-5 pb-5">
{list.length === 0 && <div className="pl-2 text-bolt-elements-textTertiary">No previous conversations</div>} {list.length === 0 && <div className="pl-2 text-bolt-elements-textTertiary">No previous conversations</div>}
<DialogRoot open={dialogContent !== null}> <DialogRoot open={dialogContent !== null}>
{binDates(list).map(({ category, items }) => ( {binDates(list).map(({ category, items }) => (
@ -142,6 +141,7 @@ export function Menu() {
<HistoryItem <HistoryItem
key={item.id} key={item.id}
item={item} item={item}
exportChat={exportChat}
onDelete={(event) => handleDeleteClick(event, item)} onDelete={(event) => handleDeleteClick(event, item)}
onDuplicate={() => handleDuplicate(item.id)} onDuplicate={() => handleDuplicate(item.id)}
/> />

View File

@ -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 (
<Tooltip.Root delayDuration={delay}>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side={position}
className={`
z-[2000]
px-2.5
py-1.5
max-h-[300px]
select-none
rounded-md
bg-bolt-elements-background-depth-3
text-bolt-elements-textPrimary
text-sm
leading-tight
shadow-lg
animate-in
fade-in-0
zoom-in-95
data-[state=closed]:animate-out
data-[state=closed]:fade-out-0
data-[state=closed]:zoom-out-95
${className}
`}
sideOffset={sideOffset}
style={{
maxWidth,
...tooltipStyle,
}}
>
<div className="break-words">{tooltip}</div>
<Tooltip.Arrow
className={`
fill-bolt-elements-background-depth-3
${arrowClassName}
`}
width={12}
height={6}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
);
};
export default WithTooltip;

View File

@ -1,11 +1,11 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck TODO: Provider proper types // @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 { getModel } from '~/lib/.server/llm/model';
import { MAX_TOKENS } from './constants'; import { MAX_TOKENS } from './constants';
import { getSystemPrompt } from './prompts'; 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<Name extends string, Args, Result> { interface ToolResult<Name extends string, Args, Result> {
toolCallId: string; toolCallId: string;

View File

@ -176,14 +176,7 @@ export async function forkChat(db: IDBDatabase, chatId: string, messageId: strin
// Get messages up to and including the selected message // Get messages up to and including the selected message
const messages = chat.messages.slice(0, messageIndex + 1); const messages = chat.messages.slice(0, messageIndex + 1);
// Generate new IDs return createChatFromMessages(db, chat.description ? `${chat.description} (fork)` : 'Forked chat', messages);
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;
} }
export async function duplicateChat(db: IDBDatabase, id: string): Promise<string> { export async function duplicateChat(db: IDBDatabase, id: string): Promise<string> {
@ -193,15 +186,23 @@ export async function duplicateChat(db: IDBDatabase, id: string): Promise<string
throw new Error('Chat not found'); throw new Error('Chat not found');
} }
return createChatFromMessages(db, `${chat.description || 'Chat'} (copy)`, chat.messages);
}
export async function createChatFromMessages(
db: IDBDatabase,
description: string,
messages: Message[],
): Promise<string> {
const newId = await getNextId(db); const newId = await getNextId(db);
const newUrlId = await getUrlId(db, newId); // Get a new urlId for the duplicated chat const newUrlId = await getUrlId(db, newId); // Get a new urlId for the duplicated chat
await setMessages( await setMessages(
db, db,
newId, newId,
chat.messages, messages,
newUrlId, // Use the new urlId newUrlId, // Use the new urlId
`${chat.description || 'Chat'} (copy)`, description,
); );
return newUrlId; // Return the urlId instead of id for navigation return newUrlId; // Return the urlId instead of id for navigation

View File

@ -4,7 +4,15 @@ import { atom } from 'nanostores';
import type { Message } from 'ai'; import type { Message } from 'ai';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { workbenchStore } from '~/lib/stores/workbench'; 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 { export interface ChatHistoryItem {
id: string; id: string;
@ -113,6 +121,45 @@ export function useChatHistory() {
console.log(error); 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);
},
}; };
} }

View File

@ -93,14 +93,13 @@ export class ActionRunner {
this.#updateAction(actionId, { ...action, ...data.action, executed: !isStreaming }); this.#updateAction(actionId, { ...action, ...data.action, executed: !isStreaming });
// eslint-disable-next-line consistent-return this.#currentExecutionPromise = this.#currentExecutionPromise
return (this.#currentExecutionPromise = this.#currentExecutionPromise
.then(() => { .then(() => {
this.#executeAction(actionId, isStreaming); this.#executeAction(actionId, isStreaming);
}) })
.catch((error) => { .catch((error) => {
console.error('Action failed:', error); console.error('Action failed:', error);
})); });
} }
async #executeAction(actionId: string, isStreaming: boolean = false) { async #executeAction(actionId: string, isStreaming: boolean = false) {

View File

@ -283,6 +283,10 @@ const getOllamaBaseUrl = () => {
}; };
async function getOllamaModels(): Promise<ModelInfo[]> { async function getOllamaModels(): Promise<ModelInfo[]> {
if (typeof window === 'undefined') {
return [];
}
try { try {
const baseUrl = getOllamaBaseUrl(); const baseUrl = getOllamaBaseUrl();
const response = await fetch(`${baseUrl}/api/tags`); const response = await fetch(`${baseUrl}/api/tags`);
@ -294,8 +298,8 @@ async function getOllamaModels(): Promise<ModelInfo[]> {
provider: 'Ollama', provider: 'Ollama',
maxTokenAllowed: 8000, maxTokenAllowed: 8000,
})); }));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) { } catch (e) {
console.error('Error getting Ollama models:', e);
return []; return [];
} }
} }
@ -321,8 +325,8 @@ async function getOpenAILikeModels(): Promise<ModelInfo[]> {
label: model.id, label: model.id,
provider: 'OpenAILike', provider: 'OpenAILike',
})); }));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) { } catch (e) {
console.error('Error getting OpenAILike models:', e);
return []; return [];
} }
} }
@ -361,6 +365,10 @@ async function getOpenRouterModels(): Promise<ModelInfo[]> {
} }
async function getLMStudioModels(): Promise<ModelInfo[]> { async function getLMStudioModels(): Promise<ModelInfo[]> {
if (typeof window === 'undefined') {
return [];
}
try { try {
const baseUrl = import.meta.env.LMSTUDIO_API_BASE_URL || 'http://localhost:1234'; const baseUrl = import.meta.env.LMSTUDIO_API_BASE_URL || 'http://localhost:1234';
const response = await fetch(`${baseUrl}/v1/models`); const response = await fetch(`${baseUrl}/v1/models`);
@ -371,8 +379,8 @@ async function getLMStudioModels(): Promise<ModelInfo[]> {
label: model.id, label: model.id,
provider: 'LMStudio', provider: 'LMStudio',
})); }));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) { } catch (e) {
console.error('Error getting LMStudio models:', e);
return []; return [];
} }
} }

View File

@ -20,7 +20,8 @@
"dockerbuild": "docker build -t bolt-ai:development -t bolt-ai:latest --target bolt-ai-development .", "dockerbuild": "docker build -t bolt-ai:development -t bolt-ai:latest --target bolt-ai-development .",
"typecheck": "tsc", "typecheck": "tsc",
"typegen": "wrangler types", "typegen": "wrangler types",
"preview": "pnpm run build && pnpm run start" "preview": "pnpm run build && pnpm run start",
"prepare": "husky"
}, },
"engines": { "engines": {
"node": ">=18.18.0" "node": ">=18.18.0"
@ -70,6 +71,7 @@
"diff": "^5.2.0", "diff": "^5.2.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"framer-motion": "^11.2.12", "framer-motion": "^11.2.12",
"ignore": "^6.0.2",
"isbot": "^4.1.0", "isbot": "^4.1.0",
"istextorbinary": "^9.5.0", "istextorbinary": "^9.5.0",
"jose": "^5.6.3", "jose": "^5.6.3",
@ -101,6 +103,7 @@
"@types/react": "^18.2.20", "@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"husky": "9.1.7",
"is-ci": "^3.0.1", "is-ci": "^3.0.1",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"prettier": "^3.3.2", "prettier": "^3.3.2",

View File

@ -143,6 +143,9 @@ importers:
framer-motion: framer-motion:
specifier: ^11.2.12 specifier: ^11.2.12
version: 11.2.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) 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: isbot:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.4.0 version: 4.4.0
@ -231,6 +234,9 @@ importers:
fast-glob: fast-glob:
specifier: ^3.3.2 specifier: ^3.3.2
version: 3.3.2 version: 3.3.2
husky:
specifier: 9.1.7
version: 9.1.7
is-ci: is-ci:
specifier: ^3.0.1 specifier: ^3.0.1
version: 3.0.1 version: 3.0.1
@ -2482,7 +2488,7 @@ packages:
resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==}
bytes@3.0.0: bytes@3.0.0:
resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} resolution: {integrity: sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
bytes@3.1.2: bytes@3.1.2:
@ -3382,6 +3388,11 @@ packages:
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
engines: {node: '>=16.17.0'} 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: iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -3399,6 +3410,10 @@ packages:
resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
ignore@6.0.2:
resolution: {integrity: sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==}
engines: {node: '>= 4'}
immediate@3.0.6: immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
@ -9278,6 +9293,8 @@ snapshots:
human-signals@5.0.0: {} human-signals@5.0.0: {}
husky@9.1.7: {}
iconv-lite@0.4.24: iconv-lite@0.4.24:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@ -9290,6 +9307,8 @@ snapshots:
ignore@5.3.1: {} ignore@5.3.1: {}
ignore@6.0.2: {}
immediate@3.0.6: {} immediate@3.0.6: {}
immutable@4.3.7: {} immutable@4.3.7: {}