mirror of
https://github.com/coleam00/bolt.new-any-llm
synced 2024-12-27 22:33:03 +00:00
Merge branch 'main' into docs
This commit is contained in:
commit
151fdb1a28
17
.husky/pre-commit
Normal file
17
.husky/pre-commit
Normal 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..."
|
@ -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
|
||||||
|
|
||||||
|
53
README.md
53
README.md
@ -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, 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!
|
||||||
|
@ -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>;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -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,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
32
app/components/chat/ExamplePrompts.tsx
Normal file
32
app/components/chat/ExamplePrompts.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
164
app/components/chat/ImportFolderButton.tsx
Normal file
164
app/components/chat/ImportFolderButton.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
13
app/components/chat/chatExportAndImport/ExportChatButton.tsx
Normal file
13
app/components/chat/chatExportAndImport/ExportChatButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
71
app/components/chat/chatExportAndImport/ImportButtons.tsx
Normal file
71
app/components/chat/chatExportAndImport/ImportButtons.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
73
app/components/ui/Tooltip.tsx
Normal file
73
app/components/ui/Tooltip.tsx
Normal 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;
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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) {
|
||||||
|
@ -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 [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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: {}
|
||||||
|
Loading…
Reference in New Issue
Block a user