Merge branch 'main' into code-streaming

This commit is contained in:
Anirban Kar 2024-11-12 23:20:30 +05:30 committed by GitHub
commit 2d270e749b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 6269 additions and 491 deletions

View File

@ -43,6 +43,12 @@ OPENAI_LIKE_API_KEY=
# You only need this environment variable set if you want to use Mistral models # You only need this environment variable set if you want to use Mistral models
MISTRAL_API_KEY= MISTRAL_API_KEY=
# Get LMStudio Base URL from LM Studio Developer Console
# Make sure to enable CORS
# Example: http://localhost:1234
LMSTUDIO_API_BASE_URL=
# Get your xAI API key # Get your xAI API key
# https://x.ai/api # https://x.ai/api
# You only need this environment variable set if you want to use xAI models # You only need this environment variable set if you want to use xAI models

View File

@ -1,39 +0,0 @@
name: Build and Push Container
on:
push:
branches:
- main
# paths:
# - 'Dockerfile'
workflow_dispatch:
jobs:
build-and-push:
runs-on: [ubuntu-latest]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push Containers
uses: docker/build-push-action@v2
with:
context: .
file: Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}

View File

@ -1,32 +0,0 @@
name: Semantic Pull Request
on:
pull_request_target:
types: [opened, reopened, edited, synchronize]
permissions:
pull-requests: read
jobs:
main:
name: Validate PR Title
runs-on: ubuntu-latest
steps:
# https://github.com/amannn/action-semantic-pull-request/releases/tag/v5.5.3
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
subjectPattern: ^(?![A-Z]).+$
subjectPatternError: |
The subject "{subject}" found in the pull request title "{title}"
didn't match the configured pattern. Please ensure that the subject
doesn't start with an uppercase character.
types: |
fix
feat
chore
build
ci
perf
docs
refactor
revert
test

1
.gitignore vendored
View File

@ -31,3 +31,4 @@ dist-ssr
_worker.bundle _worker.bundle
Modelfile Modelfile
modelfiles

View File

@ -18,7 +18,9 @@ This fork of Bolt.new allows you to choose the LLM that you use for each prompt!
- ✅ Ability to sync files (one way sync) to local folder (@muzafferkadir) - ✅ Ability to sync files (one way sync) to local folder (@muzafferkadir)
- ✅ Containerize the application with Docker for easy installation (@aaronbolton) - ✅ Containerize the application with Docker for easy installation (@aaronbolton)
- ✅ Publish projects directly to GitHub (@goncaloalves) - ✅ Publish projects directly to GitHub (@goncaloalves)
- ⬜ Prevent Bolt from rewriting files as often (Done but need to review PR still) - ✅ Ability to enter API keys in the UI (@ali00209)
- ✅ xAI Grok Beta Integration (@milutinke)
- ⬜ **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
- ⬜ **HIGH PRIORITY** - Attach images to prompts - ⬜ **HIGH PRIORITY** - Attach images to prompts
@ -34,7 +36,6 @@ This fork of Bolt.new allows you to choose the LLM that you use for each prompt!
- ⬜ Ability to revert code to earlier version - ⬜ Ability to revert code to earlier version
- ⬜ Prompt caching - ⬜ Prompt caching
- ⬜ Better prompt enhancing - ⬜ Better prompt enhancing
- ⬜ Ability to enter API keys in the UI
- ⬜ Have LLM plan the project in a MD file for better results/transparency - ⬜ Have LLM plan the project in a MD file for better results/transparency
- ⬜ VSCode Integration with git-like confirmations - ⬜ VSCode Integration with git-like confirmations
- ⬜ Upload documents for knowledge - UI design templates, a code base to reference coding style, etc. - ⬜ Upload documents for knowledge - UI design templates, a code base to reference coding style, etc.
@ -85,7 +86,7 @@ If you see usr/local/bin in the output then you're good to go.
git clone https://github.com/coleam00/bolt.new-any-llm.git git clone https://github.com/coleam00/bolt.new-any-llm.git
``` ```
3. Rename .env.example to .env and add your LLM API keys. You will find this file on a Mac at "[your name]/bold.new-any-llm/.env.example". For Windows and Linux the path will be similar. 3. Rename .env.example to .env.local and add your LLM API keys. You will find this file on a Mac at "[your name]/bold.new-any-llm/.env.example". For Windows and Linux the path will be similar.
![image](https://github.com/user-attachments/assets/7e6a532c-2268-401f-8310-e8d20c731328) ![image](https://github.com/user-attachments/assets/7e6a532c-2268-401f-8310-e8d20c731328)
@ -115,7 +116,7 @@ Optionally, you can set the debug level:
VITE_LOG_LEVEL=debug VITE_LOG_LEVEL=debug
``` ```
**Important**: Never commit your `.env` file to version control. It's already included in .gitignore. **Important**: Never commit your `.env.local` file to version control. It's already included in .gitignore.
## Run with Docker ## Run with Docker

View File

@ -0,0 +1,49 @@
import React, { useState } from 'react';
import { IconButton } from '~/components/ui/IconButton';
interface APIKeyManagerProps {
provider: string;
apiKey: string;
setApiKey: (key: string) => void;
}
export const APIKeyManager: React.FC<APIKeyManagerProps> = ({ provider, apiKey, setApiKey }) => {
const [isEditing, setIsEditing] = useState(false);
const [tempKey, setTempKey] = useState(apiKey);
const handleSave = () => {
setApiKey(tempKey);
setIsEditing(false);
};
return (
<div className="flex items-center gap-2 mt-2 mb-2">
<span className="text-sm text-bolt-elements-textSecondary">{provider} API Key:</span>
{isEditing ? (
<>
<input
type="password"
value={tempKey}
onChange={(e) => setTempKey(e.target.value)}
className="flex-1 p-1 text-sm rounded border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus"
/>
<IconButton onClick={handleSave} title="Save API Key">
<div className="i-ph:check" />
</IconButton>
<IconButton onClick={() => setIsEditing(false)} title="Cancel">
<div className="i-ph:x" />
</IconButton>
</>
) : (
<>
<span className="flex-1 text-sm text-bolt-elements-textPrimary">
{apiKey ? '••••••••' : 'Not set (will still work if set in .env file)'}
</span>
<IconButton onClick={() => setIsEditing(true)} title="Edit API Key">
<div className="i-ph:pencil-simple" />
</IconButton>
</>
)}
</div>
);
};

View File

@ -151,7 +151,13 @@ const ActionList = memo(({ actions }: ActionListProps) => {
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<div className={classNames('text-lg', getIconColor(action.status))}> <div className={classNames('text-lg', getIconColor(action.status))}>
{status === 'running' ? ( {status === 'running' ? (
<>
{type !== 'start' ? (
<div className="i-svg-spinners:90-ring-with-bg"></div> <div className="i-svg-spinners:90-ring-with-bg"></div>
) : (
<div className="i-ph:terminal-window-duotone"></div>
)}
</>
) : status === 'pending' ? ( ) : status === 'pending' ? (
<div className="i-ph:circle-duotone"></div> <div className="i-ph:circle-duotone"></div>
) : status === 'complete' ? ( ) : status === 'complete' ? (
@ -171,9 +177,19 @@ const ActionList = memo(({ actions }: ActionListProps) => {
<div className="flex items-center w-full min-h-[28px]"> <div className="flex items-center w-full min-h-[28px]">
<span className="flex-1">Run command</span> <span className="flex-1">Run command</span>
</div> </div>
) : type === 'start' ? (
<a
onClick={(e) => {
e.preventDefault();
workbenchStore.currentView.set('preview');
}}
className="flex items-center w-full min-h-[28px]"
>
<span className="flex-1">Start Application</span>
</a>
) : null} ) : null}
</div> </div>
{type === 'shell' && ( {(type === 'shell' || type === 'start') && (
<ShellCodeBlock <ShellCodeBlock
classsName={classNames('mt-1', { classsName={classNames('mt-1', {
'mb-3.5': !isLast, 'mb-3.5': !isLast,

View File

@ -1,7 +1,7 @@
// @ts-nocheck // @ts-nocheck
// 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 } from 'react'; import React, { type RefCallback, useEffect } 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';
@ -11,6 +11,8 @@ import { MODEL_LIST, DEFAULT_PROVIDER } 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 { useState } from 'react';
import { APIKeyManager } from './APIKeyManager';
import Cookies from 'js-cookie';
import styles from './BaseChat.module.scss'; import styles from './BaseChat.module.scss';
@ -24,10 +26,9 @@ const EXAMPLE_PROMPTS = [
const providerList = [...new Set(MODEL_LIST.map((model) => model.provider))] const providerList = [...new Set(MODEL_LIST.map((model) => model.provider))]
const ModelSelector = ({ model, setModel, modelList, providerList }) => { const ModelSelector = ({ model, setModel, provider, setProvider, modelList, providerList }) => {
const [provider, setProvider] = useState(DEFAULT_PROVIDER);
return ( return (
<div className="mb-2"> <div className="mb-2 flex gap-2">
<select <select
value={provider} value={provider}
onChange={(e) => { onChange={(e) => {
@ -35,7 +36,7 @@ const ModelSelector = ({ model, setModel, modelList, providerList }) => {
const firstModel = [...modelList].find(m => m.provider == e.target.value); const firstModel = [...modelList].find(m => m.provider == e.target.value);
setModel(firstModel ? firstModel.name : ''); setModel(firstModel ? firstModel.name : '');
}} }}
className="w-full p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none" className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all"
> >
{providerList.map((provider) => ( {providerList.map((provider) => (
<option key={provider} value={provider}> <option key={provider} value={provider}>
@ -48,11 +49,14 @@ const ModelSelector = ({ model, setModel, modelList, providerList }) => {
<option key="OpenAILike" value="OpenAILike"> <option key="OpenAILike" value="OpenAILike">
OpenAILike OpenAILike
</option> </option>
<option key="LMStudio" value="LMStudio">
LMStudio
</option>
</select> </select>
<select <select
value={model} value={model}
onChange={(e) => setModel(e.target.value)} onChange={(e) => setModel(e.target.value)}
className="w-full p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none" className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all"
> >
{[...modelList].filter(e => e.provider == provider && e.name).map((modelOption) => ( {[...modelList].filter(e => e.provider == provider && e.name).map((modelOption) => (
<option key={modelOption.name} value={modelOption.name}> <option key={modelOption.name} value={modelOption.name}>
@ -79,6 +83,8 @@ interface BaseChatProps {
input?: string; input?: string;
model: string; model: string;
setModel: (model: string) => void; setModel: (model: string) => void;
provider: string;
setProvider: (provider: string) => void;
handleStop?: () => void; handleStop?: () => void;
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;
@ -100,6 +106,8 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
input = '', input = '',
model, model,
setModel, setModel,
provider,
setProvider,
sendMessage, sendMessage,
handleInputChange, handleInputChange,
enhancePrompt, enhancePrompt,
@ -108,6 +116,40 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
ref, ref,
) => { ) => {
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
useEffect(() => {
// Load API keys from cookies on component mount
try {
const storedApiKeys = Cookies.get('apiKeys');
if (storedApiKeys) {
const parsedKeys = JSON.parse(storedApiKeys);
if (typeof parsedKeys === 'object' && parsedKeys !== null) {
setApiKeys(parsedKeys);
}
}
} catch (error) {
console.error('Error loading API keys from cookies:', error);
// Clear invalid cookie data
Cookies.remove('apiKeys');
}
}, []);
const updateApiKey = (provider: string, key: string) => {
try {
const updatedApiKeys = { ...apiKeys, [provider]: key };
setApiKeys(updatedApiKeys);
// Save updated API keys to cookies with 30 day expiry and secure settings
Cookies.set('apiKeys', JSON.stringify(updatedApiKeys), {
expires: 30, // 30 days
secure: true, // Only send over HTTPS
sameSite: 'strict', // Protect against CSRF
path: '/' // Accessible across the site
});
} catch (error) {
console.error('Error saving API keys to cookies:', error);
}
};
return ( return (
<div <div
@ -122,11 +164,11 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
<div ref={scrollRef} className="flex overflow-y-auto w-full h-full"> <div ref={scrollRef} className="flex overflow-y-auto w-full h-full">
<div className={classNames(styles.Chat, 'flex flex-col flex-grow min-w-[var(--chat-min-width)] h-full')}> <div className={classNames(styles.Chat, 'flex flex-col flex-grow min-w-[var(--chat-min-width)] h-full')}>
{!chatStarted && ( {!chatStarted && (
<div id="intro" className="mt-[26vh] max-w-chat mx-auto"> <div id="intro" className="mt-[26vh] max-w-chat mx-auto text-center">
<h1 className="text-5xl text-center font-bold text-bolt-elements-textPrimary mb-2"> <h1 className="text-6xl font-bold text-bolt-elements-textPrimary mb-4 animate-fade-in">
Where ideas begin Where ideas begin
</h1> </h1>
<p className="mb-4 text-center text-bolt-elements-textSecondary"> <p className="text-xl mb-8 text-bolt-elements-textSecondary animate-fade-in animation-delay-200">
Bring ideas to life in seconds or get help on existing projects. Bring ideas to life in seconds or get help on existing projects.
</p> </p>
</div> </div>
@ -157,16 +199,23 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
model={model} model={model}
setModel={setModel} setModel={setModel}
modelList={MODEL_LIST} modelList={MODEL_LIST}
provider={provider}
setProvider={setProvider}
providerList={providerList} providerList={providerList}
/> />
<APIKeyManager
provider={provider}
apiKey={apiKeys[provider] || ''}
setApiKey={(key) => updateApiKey(provider, key)}
/>
<div <div
className={classNames( className={classNames(
'shadow-sm border border-bolt-elements-borderColor bg-bolt-elements-prompt-background backdrop-filter backdrop-blur-[8px] rounded-lg overflow-hidden', 'shadow-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background backdrop-filter backdrop-blur-[8px] rounded-lg overflow-hidden transition-all',
)} )}
> >
<textarea <textarea
ref={textareaRef} ref={textareaRef}
className={`w-full pl-4 pt-4 pr-16 focus:outline-none resize-none text-md text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent`} className={`w-full pl-4 pt-4 pr-16 focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus resize-none text-md text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent transition-all`}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
if (event.shiftKey) { if (event.shiftKey) {
@ -205,12 +254,12 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
/> />
)} )}
</ClientOnly> </ClientOnly>
<div className="flex justify-between text-sm p-4 pt-2"> <div className="flex justify-between items-center text-sm p-4 pt-2">
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
<IconButton <IconButton
title="Enhance prompt" title="Enhance prompt"
disabled={input.length === 0 || enhancingPrompt} disabled={input.length === 0 || enhancingPrompt}
className={classNames({ className={classNames('transition-all', {
'opacity-100!': enhancingPrompt, 'opacity-100!': enhancingPrompt,
'text-bolt-elements-item-contentAccent! pr-1.5 enabled:hover:bg-bolt-elements-item-backgroundAccent!': 'text-bolt-elements-item-contentAccent! pr-1.5 enabled:hover:bg-bolt-elements-item-backgroundAccent!':
promptEnhanced, promptEnhanced,
@ -219,7 +268,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
> >
{enhancingPrompt ? ( {enhancingPrompt ? (
<> <>
<div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl"></div> <div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl animate-spin"></div>
<div className="ml-1.5">Enhancing prompt...</div> <div className="ml-1.5">Enhancing prompt...</div>
</> </>
) : ( ) : (
@ -232,7 +281,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</div> </div>
{input.length > 3 ? ( {input.length > 3 ? (
<div className="text-xs text-bolt-elements-textTertiary"> <div className="text-xs text-bolt-elements-textTertiary">
Use <kbd className="kdb">Shift</kbd> + <kbd className="kdb">Return</kbd> for a new line Use <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Shift</kbd> + <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd> for a new line
</div> </div>
) : null} ) : null}
</div> </div>

View File

@ -11,10 +11,11 @@ import { 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';
import { DEFAULT_MODEL } from '~/utils/constants'; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from '~/utils/constants';
import { cubicEasingFn } from '~/utils/easings'; import { cubicEasingFn } from '~/utils/easings';
import { createScopedLogger, renderLogger } from '~/utils/logger'; import { createScopedLogger, renderLogger } from '~/utils/logger';
import { BaseChat } from './BaseChat'; import { BaseChat } from './BaseChat';
import Cookies from 'js-cookie';
const toastAnimation = cssTransition({ const toastAnimation = cssTransition({
enter: 'animated fadeInRight', enter: 'animated fadeInRight',
@ -73,14 +74,26 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
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(DEFAULT_MODEL); const [model, setModel] = useState(() => {
const savedModel = Cookies.get('selectedModel');
return savedModel || DEFAULT_MODEL;
});
const [provider, setProvider] = useState(() => {
const savedProvider = Cookies.get('selectedProvider');
return 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 { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({ const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({
api: '/api/chat', api: '/api/chat',
body: {
apiKeys
},
onError: (error) => { onError: (error) => {
logger.error('Request failed\n\n', error); logger.error('Request failed\n\n', error);
toast.error('There was an error processing your request'); toast.error('There was an error processing your request');
@ -182,7 +195,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
* manually reset the input and we'd have to manually pass in file attachments. However, those * manually reset the input and we'd have to manually pass in file attachments. However, those
* aren't relevant here. * aren't relevant here.
*/ */
append({ role: 'user', content: `[Model: ${model}]\n\n${diff}\n\n${_input}` }); append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider}]\n\n${diff}\n\n${_input}` });
/** /**
* After sending a new message we reset all modifications since the model * After sending a new message we reset all modifications since the model
@ -190,7 +203,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
*/ */
workbenchStore.resetAllFileModifications(); workbenchStore.resetAllFileModifications();
} else { } else {
append({ role: 'user', content: `[Model: ${model}]\n\n${_input}` }); append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider}]\n\n${_input}` });
} }
setInput(''); setInput('');
@ -202,6 +215,23 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
const [messageRef, scrollRef] = useSnapScroll(); 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: string) => {
setProvider(newProvider);
Cookies.set('selectedProvider', newProvider, { expires: 30 });
};
return ( return (
<BaseChat <BaseChat
ref={animationScope} ref={animationScope}
@ -214,7 +244,9 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
promptEnhanced={promptEnhanced} promptEnhanced={promptEnhanced}
sendMessage={sendMessage} sendMessage={sendMessage}
model={model} model={model}
setModel={setModel} setModel={handleModelChange}
provider={provider}
setProvider={handleProviderChange}
messageRef={messageRef} messageRef={messageRef}
scrollRef={scrollRef} scrollRef={scrollRef}
handleInputChange={handleInputChange} handleInputChange={handleInputChange}
@ -230,10 +262,16 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
}; };
})} })}
enhancePrompt={() => { enhancePrompt={() => {
enhancePrompt(input, (input) => { enhancePrompt(
input,
(input) => {
setInput(input); setInput(input);
scrollTextArea(); scrollTextArea();
}); },
model,
provider,
apiKeys
);
}} }}
/> />
); );

View File

@ -1,7 +1,7 @@
// @ts-nocheck // @ts-nocheck
// 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 { modificationsRegex } from '~/utils/diff'; import { modificationsRegex } from '~/utils/diff';
import { MODEL_REGEX } from '~/utils/constants'; import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
interface UserMessageProps { interface UserMessageProps {
@ -17,5 +17,5 @@ export function UserMessage({ content }: UserMessageProps) {
} }
function sanitizeUserMessage(content: string) { function sanitizeUserMessage(content: string) {
return content.replace(modificationsRegex, '').replace(MODEL_REGEX, '').trim(); return content.replace(modificationsRegex, '').replace(MODEL_REGEX, 'Using: $1').replace(PROVIDER_REGEX, ' ($1)\n\n').trim();
} }

View File

@ -18,7 +18,7 @@ import { themeStore } from '~/lib/stores/theme';
import { workbenchStore } from '~/lib/stores/workbench'; import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
import { WORK_DIR } from '~/utils/constants'; import { WORK_DIR } from '~/utils/constants';
import { renderLogger } from '~/utils/logger'; import { logger, renderLogger } from '~/utils/logger';
import { isMobile } from '~/utils/mobile'; import { isMobile } from '~/utils/mobile';
import { FileBreadcrumb } from './FileBreadcrumb'; import { FileBreadcrumb } from './FileBreadcrumb';
import { FileTree } from './FileTree'; import { FileTree } from './FileTree';
@ -199,10 +199,30 @@ export const EditorPanel = memo(
<div className="h-full"> <div className="h-full">
<div className="bg-bolt-elements-terminals-background h-full flex flex-col"> <div className="bg-bolt-elements-terminals-background h-full flex flex-col">
<div className="flex items-center bg-bolt-elements-background-depth-2 border-y border-bolt-elements-borderColor gap-1.5 min-h-[34px] p-2"> <div className="flex items-center bg-bolt-elements-background-depth-2 border-y border-bolt-elements-borderColor gap-1.5 min-h-[34px] p-2">
{Array.from({ length: terminalCount }, (_, index) => { {Array.from({ length: terminalCount + 1 }, (_, index) => {
const isActive = activeTerminal === index; const isActive = activeTerminal === index;
return ( return (
<>
{index == 0 ? (
<button
key={index}
className={classNames(
'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
{
'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary':
isActive,
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
!isActive,
},
)}
onClick={() => setActiveTerminal(index)}
>
<div className="i-ph:terminal-window-duotone text-lg" />
Bolt Terminal
</button>
) : (
<>
<button <button
key={index} key={index}
className={classNames( className={classNames(
@ -216,8 +236,11 @@ export const EditorPanel = memo(
onClick={() => setActiveTerminal(index)} onClick={() => setActiveTerminal(index)}
> >
<div className="i-ph:terminal-window-duotone text-lg" /> <div className="i-ph:terminal-window-duotone text-lg" />
Terminal {terminalCount > 1 && index + 1} Terminal {terminalCount > 1 && index}
</button> </button>
</>
)}
</>
); );
})} })}
{terminalCount < MAX_TERMINALS && <IconButton icon="i-ph:plus" size="md" onClick={addTerminal} />} {terminalCount < MAX_TERMINALS && <IconButton icon="i-ph:plus" size="md" onClick={addTerminal} />}
@ -229,9 +252,26 @@ export const EditorPanel = memo(
onClick={() => workbenchStore.toggleTerminal(false)} onClick={() => workbenchStore.toggleTerminal(false)}
/> />
</div> </div>
{Array.from({ length: terminalCount }, (_, index) => { {Array.from({ length: terminalCount + 1 }, (_, index) => {
const isActive = activeTerminal === index; const isActive = activeTerminal === index;
if (index == 0) {
logger.info('Starting bolt terminal');
return (
<Terminal
key={index}
className={classNames('h-full overflow-hidden', {
hidden: !isActive,
})}
ref={(ref) => {
terminalRefs.current.push(ref);
}}
onTerminalReady={(terminal) => workbenchStore.attachBoltTerminal(terminal)}
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
theme={theme}
/>
);
}
return ( return (
<Terminal <Terminal
key={index} key={index}

View File

@ -2,12 +2,18 @@
// 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 { env } from 'node:process'; import { env } from 'node:process';
export function getAPIKey(cloudflareEnv: Env, provider: string) { export function getAPIKey(cloudflareEnv: Env, provider: string, userApiKeys?: Record<string, string>) {
/** /**
* The `cloudflareEnv` is only used when deployed or when previewing locally. * The `cloudflareEnv` is only used when deployed or when previewing locally.
* In development the environment variables are available through `env`. * In development the environment variables are available through `env`.
*/ */
// First check user-provided API keys
if (userApiKeys?.[provider]) {
return userApiKeys[provider];
}
// Fall back to environment variables
switch (provider) { switch (provider) {
case 'Anthropic': case 'Anthropic':
return env.ANTHROPIC_API_KEY || cloudflareEnv.ANTHROPIC_API_KEY; return env.ANTHROPIC_API_KEY || cloudflareEnv.ANTHROPIC_API_KEY;
@ -36,6 +42,8 @@ export function getBaseURL(cloudflareEnv: Env, provider: string) {
switch (provider) { switch (provider) {
case 'OpenAILike': case 'OpenAILike':
return env.OPENAI_LIKE_API_BASE_URL || cloudflareEnv.OPENAI_LIKE_API_BASE_URL; return env.OPENAI_LIKE_API_BASE_URL || cloudflareEnv.OPENAI_LIKE_API_BASE_URL;
case 'LMStudio':
return env.LMSTUDIO_API_BASE_URL || cloudflareEnv.LMSTUDIO_API_BASE_URL || "http://localhost:1234";
case 'Ollama': case 'Ollama':
let baseUrl = env.OLLAMA_API_BASE_URL || cloudflareEnv.OLLAMA_API_BASE_URL || "http://localhost:11434"; let baseUrl = env.OLLAMA_API_BASE_URL || cloudflareEnv.OLLAMA_API_BASE_URL || "http://localhost:11434";
if (env.RUNNING_IN_DOCKER === 'true') { if (env.RUNNING_IN_DOCKER === 'true') {

View File

@ -83,6 +83,15 @@ export function getOpenRouterModel(apiKey: string, model: string) {
return openRouter.chat(model); return openRouter.chat(model);
} }
export function getLMStudioModel(baseURL: string, model: string) {
const lmstudio = createOpenAI({
baseUrl: `${baseURL}/v1`,
apiKey: "",
});
return lmstudio(model);
}
export function getXAIModel(apiKey: string, model: string) { export function getXAIModel(apiKey: string, model: string) {
const openai = createOpenAI({ const openai = createOpenAI({
baseURL: 'https://api.x.ai/v1', baseURL: 'https://api.x.ai/v1',
@ -91,9 +100,8 @@ export function getXAIModel(apiKey: string, model: string) {
return openai(model); return openai(model);
} }
export function getModel(provider: string, model: string, env: Env, apiKeys?: Record<string, string>) {
export function getModel(provider: string, model: string, env: Env) { const apiKey = getAPIKey(env, provider, apiKeys);
const apiKey = getAPIKey(env, provider);
const baseURL = getBaseURL(env, provider); const baseURL = getBaseURL(env, provider);
switch (provider) { switch (provider) {
@ -106,13 +114,15 @@ export function getModel(provider: string, model: string, env: Env) {
case 'OpenRouter': case 'OpenRouter':
return getOpenRouterModel(apiKey, model); return getOpenRouterModel(apiKey, model);
case 'Google': case 'Google':
return getGoogleModel(apiKey, model) return getGoogleModel(apiKey, model);
case 'OpenAILike': case 'OpenAILike':
return getOpenAILikeModel(baseURL,apiKey, model); return getOpenAILikeModel(baseURL,apiKey, model);
case 'Deepseek': case 'Deepseek':
return getDeepseekModel(apiKey, model) return getDeepseekModel(apiKey, model);
case 'Mistral': case 'Mistral':
return getMistralModel(apiKey, model); return getMistralModel(apiKey, model);
case 'LMStudio':
return getLMStudioModel(baseURL, model);
case 'xAI': case 'xAI':
return getXAIModel(apiKey, model); return getXAIModel(apiKey, model);
default: default:

View File

@ -174,10 +174,16 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w
- When Using \`npx\`, ALWAYS provide the \`--yes\` flag. - When Using \`npx\`, ALWAYS provide the \`--yes\` flag.
- When running multiple shell commands, use \`&&\` to run them sequentially. - When running multiple shell commands, use \`&&\` to run them sequentially.
- ULTRA IMPORTANT: Do NOT re-run a dev command if there is one that starts a dev server and new dependencies were installed or files updated! If a dev server has started already, assume that installing dependencies will be executed in a different process and will be picked up by the dev server. - ULTRA IMPORTANT: Do NOT re-run a dev command with shell action use dev action to run dev commands
- file: For writing new files or updating existing files. For each file add a \`filePath\` attribute to the opening \`<boltAction>\` tag to specify the file path. The content of the file artifact is the file contents. All file paths MUST BE relative to the current working directory. - file: For writing new files or updating existing files. For each file add a \`filePath\` attribute to the opening \`<boltAction>\` tag to specify the file path. The content of the file artifact is the file contents. All file paths MUST BE relative to the current working directory.
- start: For starting development server.
- Use to start application if not already started or NEW dependencies added
- Only use this action when you need to run a dev server or start the application
- ULTRA IMORTANT: do NOT re-run a dev server if files updated, existing dev server can autometically detect changes and executes the file changes
9. The order of the actions is VERY IMPORTANT. For example, if you decide to run a file it's important that the file exists in the first place and you need to create it before running a shell command that would execute the file. 9. The order of the actions is VERY IMPORTANT. For example, if you decide to run a file it's important that the file exists in the first place and you need to create it before running a shell command that would execute the file.
10. ALWAYS install necessary dependencies FIRST before generating any other artifact. If that requires a \`package.json\` then you should create that first! 10. ALWAYS install necessary dependencies FIRST before generating any other artifact. If that requires a \`package.json\` then you should create that first!
@ -265,7 +271,7 @@ Here are some examples of correct usage of artifacts:
... ...
</boltAction> </boltAction>
<boltAction type="shell"> <boltAction type="start">
npm run dev npm run dev
</boltAction> </boltAction>
</boltArtifact> </boltArtifact>
@ -322,7 +328,7 @@ Here are some examples of correct usage of artifacts:
... ...
</boltAction> </boltAction>
<boltAction type="shell"> <boltAction type="start">
npm run dev npm run dev
</boltAction> </boltAction>
</boltArtifact> </boltArtifact>

View File

@ -4,7 +4,7 @@ import { streamText as _streamText, convertToCoreMessages } 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 } from '~/utils/constants'; import { MODEL_LIST, DEFAULT_MODEL, DEFAULT_PROVIDER, 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;
@ -24,42 +24,53 @@ export type Messages = Message[];
export type StreamingOptions = Omit<Parameters<typeof _streamText>[0], 'model'>; export type StreamingOptions = Omit<Parameters<typeof _streamText>[0], 'model'>;
function extractModelFromMessage(message: Message): { model: string; content: string } { function extractPropertiesFromMessage(message: Message): { model: string; provider: string; content: string } {
const modelRegex = /^\[Model: (.*?)\]\n\n/; // Extract model
const match = message.content.match(modelRegex); const modelMatch = message.content.match(MODEL_REGEX);
const model = modelMatch ? modelMatch[1] : DEFAULT_MODEL;
if (match) { // Extract provider
const model = match[1]; const providerMatch = message.content.match(PROVIDER_REGEX);
const content = message.content.replace(modelRegex, ''); const provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER;
return { model, content };
// Remove model and provider lines from content
const cleanedContent = message.content
.replace(MODEL_REGEX, '')
.replace(PROVIDER_REGEX, '')
.trim();
return { model, provider, content: cleanedContent };
} }
// Default model if not specified export function streamText(
return { model: DEFAULT_MODEL, content: message.content }; messages: Messages,
} env: Env,
options?: StreamingOptions,
export function streamText(messages: Messages, env: Env, options?: StreamingOptions) { apiKeys?: Record<string, string>
) {
let currentModel = DEFAULT_MODEL; let currentModel = DEFAULT_MODEL;
let currentProvider = DEFAULT_PROVIDER;
const processedMessages = messages.map((message) => { const processedMessages = messages.map((message) => {
if (message.role === 'user') { if (message.role === 'user') {
const { model, content } = extractModelFromMessage(message); const { model, provider, content } = extractPropertiesFromMessage(message);
if (model && MODEL_LIST.find((m) => m.name === model)) {
currentModel = model; // Update the current model if (MODEL_LIST.find((m) => m.name === model)) {
currentModel = model;
} }
currentProvider = provider;
return { ...message, content }; return { ...message, content };
} }
return message;
return message; // No changes for non-user messages
}); });
const provider = MODEL_LIST.find((model) => model.name === currentModel)?.provider || DEFAULT_PROVIDER;
return _streamText({ return _streamText({
model: getModel(provider, currentModel, env), model: getModel(currentProvider, currentModel, env, apiKeys),
system: getSystemPrompt(), system: getSystemPrompt(),
maxTokens: MAX_TOKENS, maxTokens: MAX_TOKENS,
// headers: {
// 'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15',
// },
messages: convertToCoreMessages(processedMessages), messages: convertToCoreMessages(processedMessages),
...options, ...options,
}); });

View File

@ -12,15 +12,29 @@ export function usePromptEnhancer() {
setPromptEnhanced(false); setPromptEnhanced(false);
}; };
const enhancePrompt = async (input: string, setInput: (value: string) => void) => { const enhancePrompt = async (
input: string,
setInput: (value: string) => void,
model: string,
provider: string,
apiKeys?: Record<string, string>
) => {
setEnhancingPrompt(true); setEnhancingPrompt(true);
setPromptEnhanced(false); setPromptEnhanced(false);
const requestBody: any = {
message: input,
model,
provider,
};
if (apiKeys) {
requestBody.apiKeys = apiKeys;
}
const response = await fetch('/api/enhancer', { const response = await fetch('/api/enhancer', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify(requestBody),
message: input,
}),
}); });
const reader = response.body?.getReader(); const reader = response.body?.getReader();

View File

@ -1,10 +1,12 @@
import { WebContainer } from '@webcontainer/api'; import { WebContainer, type WebContainerProcess } from '@webcontainer/api';
import { map, type MapStore } from 'nanostores'; import { atom, map, type MapStore } from 'nanostores';
import * as nodePath from 'node:path'; import * as nodePath from 'node:path';
import type { BoltAction } from '~/types/actions'; import type { BoltAction } from '~/types/actions';
import { createScopedLogger } from '~/utils/logger'; import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable'; import { unreachable } from '~/utils/unreachable';
import type { ActionCallbackData } from './message-parser'; import type { ActionCallbackData } from './message-parser';
import type { ITerminal } from '~/types/terminal';
import type { BoltShell } from '~/utils/shell';
const logger = createScopedLogger('ActionRunner'); const logger = createScopedLogger('ActionRunner');
@ -36,11 +38,14 @@ type ActionsMap = MapStore<Record<string, ActionState>>;
export class ActionRunner { export class ActionRunner {
#webcontainer: Promise<WebContainer>; #webcontainer: Promise<WebContainer>;
#currentExecutionPromise: Promise<void> = Promise.resolve(); #currentExecutionPromise: Promise<void> = Promise.resolve();
#shellTerminal: () => BoltShell;
runnerId = atom<string>(`${Date.now()}`);
actions: ActionsMap = map({}); actions: ActionsMap = map({});
constructor(webcontainerPromise: Promise<WebContainer>) { constructor(webcontainerPromise: Promise<WebContainer>, getShellTerminal: () => BoltShell) {
this.#webcontainer = webcontainerPromise; this.#webcontainer = webcontainerPromise;
this.#shellTerminal = getShellTerminal;
} }
addAction(data: ActionCallbackData) { addAction(data: ActionCallbackData) {
@ -113,11 +118,16 @@ export class ActionRunner {
await this.#runFileAction(action); await this.#runFileAction(action);
break; break;
} }
case 'start': {
await this.#runStartAction(action)
break;
}
} }
this.#updateAction(actionId, { status: isStreaming ? 'running' : action.abortSignal.aborted ? 'aborted' : 'complete' }); this.#updateAction(actionId, { status: isStreaming ? 'running' : action.abortSignal.aborted ? 'aborted' : 'complete' });
} catch (error) { } catch (error) {
this.#updateAction(actionId, { status: 'failed', error: 'Action failed' }); this.#updateAction(actionId, { status: 'failed', error: 'Action failed' });
logger.error(`[${action.type}]:Action failed\n\n`, error);
// re-throw the error to be caught in the promise chain // re-throw the error to be caught in the promise chain
throw error; throw error;
@ -128,28 +138,38 @@ export class ActionRunner {
if (action.type !== 'shell') { if (action.type !== 'shell') {
unreachable('Expected shell action'); unreachable('Expected shell action');
} }
const shell = this.#shellTerminal()
await shell.ready()
if (!shell || !shell.terminal || !shell.process) {
unreachable('Shell terminal not found');
}
const resp = await shell.executeCommand(this.runnerId.get(), action.content)
logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`)
if (resp?.exitCode != 0) {
throw new Error("Failed To Execute Shell Command");
const webcontainer = await this.#webcontainer; }
}
const process = await webcontainer.spawn('jsh', ['-c', action.content], { async #runStartAction(action: ActionState) {
env: { npm_config_yes: true }, if (action.type !== 'start') {
}); unreachable('Expected shell action');
}
if (!this.#shellTerminal) {
unreachable('Shell terminal not found');
}
const shell = this.#shellTerminal()
await shell.ready()
if (!shell || !shell.terminal || !shell.process) {
unreachable('Shell terminal not found');
}
const resp = await shell.executeCommand(this.runnerId.get(), action.content)
logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`)
action.abortSignal.addEventListener('abort', () => { if (resp?.exitCode != 0) {
process.kill(); throw new Error("Failed To Start Application");
}); }
return resp
process.output.pipeTo(
new WritableStream({
write(data) {
console.log(data);
},
}),
);
const exitCode = await process.exit;
logger.debug(`Process terminated with code ${exitCode}`);
} }
async #runFileAction(action: ActionState) { async #runFileAction(action: ActionState) {
@ -180,7 +200,6 @@ export class ActionRunner {
logger.error('Failed to write file\n\n', error); logger.error('Failed to write file\n\n', error);
} }
} }
#updateAction(id: string, newState: ActionStateUpdate) { #updateAction(id: string, newState: ActionStateUpdate) {
const actions = this.actions.get(); const actions = this.actions.get();

View File

@ -272,7 +272,7 @@ export class StreamingMessageParser {
} }
(actionAttributes as FileAction).filePath = filePath; (actionAttributes as FileAction).filePath = filePath;
} else if (actionType !== 'shell') { } else if (!(['shell', 'start'].includes(actionType))) {
logger.warn(`Unknown action type '${actionType}'`); logger.warn(`Unknown action type '${actionType}'`);
} }

View File

@ -1,14 +1,15 @@
import type { WebContainer, WebContainerProcess } from '@webcontainer/api'; import type { WebContainer, WebContainerProcess } from '@webcontainer/api';
import { atom, type WritableAtom } from 'nanostores'; import { atom, type WritableAtom } from 'nanostores';
import type { ITerminal } from '~/types/terminal'; import type { ITerminal } from '~/types/terminal';
import { newShellProcess } from '~/utils/shell'; import { newBoltShellProcess, newShellProcess } from '~/utils/shell';
import { coloredText } from '~/utils/terminal'; import { coloredText } from '~/utils/terminal';
export class TerminalStore { export class TerminalStore {
#webcontainer: Promise<WebContainer>; #webcontainer: Promise<WebContainer>;
#terminals: Array<{ terminal: ITerminal; process: WebContainerProcess }> = []; #terminals: Array<{ terminal: ITerminal; process: WebContainerProcess }> = [];
#boltTerminal = newBoltShellProcess()
showTerminal: WritableAtom<boolean> = import.meta.hot?.data.showTerminal ?? atom(false); showTerminal: WritableAtom<boolean> = import.meta.hot?.data.showTerminal ?? atom(true);
constructor(webcontainerPromise: Promise<WebContainer>) { constructor(webcontainerPromise: Promise<WebContainer>) {
this.#webcontainer = webcontainerPromise; this.#webcontainer = webcontainerPromise;
@ -17,10 +18,22 @@ export class TerminalStore {
import.meta.hot.data.showTerminal = this.showTerminal; import.meta.hot.data.showTerminal = this.showTerminal;
} }
} }
get boltTerminal() {
return this.#boltTerminal;
}
toggleTerminal(value?: boolean) { toggleTerminal(value?: boolean) {
this.showTerminal.set(value !== undefined ? value : !this.showTerminal.get()); this.showTerminal.set(value !== undefined ? value : !this.showTerminal.get());
} }
async attachBoltTerminal(terminal: ITerminal) {
try {
let wc = await this.#webcontainer
await this.#boltTerminal.init(wc, terminal)
} catch (error: any) {
terminal.write(coloredText.red('Failed to spawn bolt shell\n\n') + error.message);
return;
}
}
async attachTerminal(terminal: ITerminal) { async attachTerminal(terminal: ITerminal) {
try { try {

View File

@ -13,6 +13,7 @@ import JSZip from 'jszip';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import { Octokit } from "@octokit/rest"; import { Octokit } from "@octokit/rest";
import * as nodePath from 'node:path'; import * as nodePath from 'node:path';
import type { WebContainerProcess } from '@webcontainer/api';
export interface ArtifactState { export interface ArtifactState {
id: string; id: string;
@ -40,6 +41,7 @@ export class WorkbenchStore {
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>()); unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
modifiedFiles = new Set<string>(); modifiedFiles = new Set<string>();
artifactIdList: string[] = []; artifactIdList: string[] = [];
#boltTerminal: { terminal: ITerminal; process: WebContainerProcess } | undefined;
constructor() { constructor() {
if (import.meta.hot) { if (import.meta.hot) {
@ -77,6 +79,9 @@ export class WorkbenchStore {
get showTerminal() { get showTerminal() {
return this.#terminalStore.showTerminal; return this.#terminalStore.showTerminal;
} }
get boltTerminal() {
return this.#terminalStore.boltTerminal;
}
toggleTerminal(value?: boolean) { toggleTerminal(value?: boolean) {
this.#terminalStore.toggleTerminal(value); this.#terminalStore.toggleTerminal(value);
@ -85,6 +90,10 @@ export class WorkbenchStore {
attachTerminal(terminal: ITerminal) { attachTerminal(terminal: ITerminal) {
this.#terminalStore.attachTerminal(terminal); this.#terminalStore.attachTerminal(terminal);
} }
attachBoltTerminal(terminal: ITerminal) {
this.#terminalStore.attachBoltTerminal(terminal);
}
onTerminalResize(cols: number, rows: number) { onTerminalResize(cols: number, rows: number) {
this.#terminalStore.onTerminalResize(cols, rows); this.#terminalStore.onTerminalResize(cols, rows);
@ -233,7 +242,7 @@ export class WorkbenchStore {
id, id,
title, title,
closed: false, closed: false,
runner: new ActionRunner(webcontainer), runner: new ActionRunner(webcontainer, () => this.boltTerminal),
}); });
} }

View File

@ -11,13 +11,17 @@ export async function action(args: ActionFunctionArgs) {
} }
async function chatAction({ context, request }: ActionFunctionArgs) { async function chatAction({ context, request }: ActionFunctionArgs) {
const { messages } = await request.json<{ messages: Messages }>(); const { messages, apiKeys } = await request.json<{
messages: Messages,
apiKeys: Record<string, string>
}>();
const stream = new SwitchableStream(); const stream = new SwitchableStream();
try { try {
const options: StreamingOptions = { const options: StreamingOptions = {
toolChoice: 'none', toolChoice: 'none',
apiKeys,
onFinish: async ({ text: content, finishReason }) => { onFinish: async ({ text: content, finishReason }) => {
if (finishReason !== 'length') { if (finishReason !== 'length') {
return stream.close(); return stream.close();
@ -40,7 +44,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
}, },
}; };
const result = await streamText(messages, context.cloudflare.env, options); const result = await streamText(messages, context.cloudflare.env, options, apiKeys);
stream.switchSource(result.toAIStream()); stream.switchSource(result.toAIStream());
@ -53,6 +57,13 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
} catch (error) { } catch (error) {
console.log(error); console.log(error);
if (error.message?.includes('API key')) {
throw new Response('Invalid or missing API key', {
status: 401,
statusText: 'Unauthorized'
});
}
throw new Response(null, { throw new Response(null, {
status: 500, status: 500,
statusText: 'Internal Server Error', statusText: 'Internal Server Error',

View File

@ -2,6 +2,7 @@ import { type ActionFunctionArgs } from '@remix-run/cloudflare';
import { StreamingTextResponse, parseStreamPart } from 'ai'; import { StreamingTextResponse, parseStreamPart } from 'ai';
import { streamText } from '~/lib/.server/llm/stream-text'; import { streamText } from '~/lib/.server/llm/stream-text';
import { stripIndents } from '~/utils/stripIndent'; import { stripIndents } from '~/utils/stripIndent';
import type { StreamingOptions } from '~/lib/.server/llm/stream-text';
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
@ -11,14 +12,34 @@ export async function action(args: ActionFunctionArgs) {
} }
async function enhancerAction({ context, request }: ActionFunctionArgs) { async function enhancerAction({ context, request }: ActionFunctionArgs) {
const { message } = await request.json<{ message: string }>(); const { message, model, provider, apiKeys } = await request.json<{
message: string;
model: string;
provider: string;
apiKeys?: Record<string, string>;
}>();
// Validate 'model' and 'provider' fields
if (!model || typeof model !== 'string') {
throw new Response('Invalid or missing model', {
status: 400,
statusText: 'Bad Request'
});
}
if (!provider || typeof provider !== 'string') {
throw new Response('Invalid or missing provider', {
status: 400,
statusText: 'Bad Request'
});
}
try { try {
const result = await streamText( const result = await streamText(
[ [
{ {
role: 'user', role: 'user',
content: stripIndents` content: `[Model: ${model}]\n\n[Provider: ${provider}]\n\n` + stripIndents`
I want you to improve the user prompt that is wrapped in \`<original_prompt>\` tags. I want you to improve the user prompt that is wrapped in \`<original_prompt>\` tags.
IMPORTANT: Only respond with the improved prompt and nothing else! IMPORTANT: Only respond with the improved prompt and nothing else!
@ -30,28 +51,42 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
}, },
], ],
context.cloudflare.env, context.cloudflare.env,
undefined,
apiKeys
); );
const transformStream = new TransformStream({ const transformStream = new TransformStream({
transform(chunk, controller) { transform(chunk, controller) {
const processedChunk = decoder const text = decoder.decode(chunk);
.decode(chunk) const lines = text.split('\n').filter(line => line.trim() !== '');
.split('\n')
.filter((line) => line !== '')
.map(parseStreamPart)
.map((part) => part.value)
.join('');
controller.enqueue(encoder.encode(processedChunk)); for (const line of lines) {
try {
const parsed = parseStreamPart(line);
if (parsed.type === 'text') {
controller.enqueue(encoder.encode(parsed.value));
}
} catch (e) {
// Skip invalid JSON lines
console.warn('Failed to parse stream part:', line);
}
}
}, },
}); });
const transformedStream = result.toAIStream().pipeThrough(transformStream); const transformedStream = result.toAIStream().pipeThrough(transformStream);
return new StreamingTextResponse(transformedStream); return new StreamingTextResponse(transformedStream);
} catch (error) { } catch (error: unknown) {
console.log(error); console.log(error);
if (error instanceof Error && error.message?.includes('API key')) {
throw new Response('Invalid or missing API key', {
status: 401,
statusText: 'Unauthorized'
});
}
throw new Response(null, { throw new Response(null, {
status: 500, status: 500,
statusText: 'Internal Server Error', statusText: 'Internal Server Error',

View File

@ -13,6 +13,10 @@ export interface ShellAction extends BaseAction {
type: 'shell'; type: 'shell';
} }
export type BoltAction = FileAction | ShellAction; export interface StartAction extends BaseAction {
type: 'start';
}
export type BoltAction = FileAction | ShellAction | StartAction;
export type BoltActionData = BoltAction | BaseAction; export type BoltActionData = BoltAction | BaseAction;

View File

@ -5,4 +5,5 @@ export interface ITerminal {
reset: () => void; reset: () => void;
write: (data: string) => void; write: (data: string) => void;
onData: (cb: (data: string) => void) => void; onData: (cb: (data: string) => void) => void;
input: (data: string) => void;
} }

View File

@ -4,11 +4,11 @@ export const WORK_DIR_NAME = 'project';
export const WORK_DIR = `/home/${WORK_DIR_NAME}`; export const WORK_DIR = `/home/${WORK_DIR_NAME}`;
export const MODIFICATIONS_TAG_NAME = 'bolt_file_modifications'; export const MODIFICATIONS_TAG_NAME = 'bolt_file_modifications';
export const MODEL_REGEX = /^\[Model: (.*?)\]\n\n/; export const MODEL_REGEX = /^\[Model: (.*?)\]\n\n/;
export const DEFAULT_MODEL = 'claude-3-5-sonnet-20240620'; export const PROVIDER_REGEX = /\[Provider: (.*?)\]\n\n/;
export const DEFAULT_MODEL = 'claude-3-5-sonnet-latest';
export const DEFAULT_PROVIDER = 'Anthropic'; export const DEFAULT_PROVIDER = 'Anthropic';
const staticModels: ModelInfo[] = [ const staticModels: ModelInfo[] = [
{ name: 'claude-3-5-sonnet-20240620', label: 'Claude 3.5 Sonnet', provider: 'Anthropic' },
{ name: 'gpt-4o', label: 'GPT-4o', provider: 'OpenAI' }, { name: 'gpt-4o', label: 'GPT-4o', provider: 'OpenAI' },
{ name: 'anthropic/claude-3.5-sonnet', label: 'Anthropic: Claude 3.5 Sonnet (OpenRouter)', provider: 'OpenRouter' }, { name: 'anthropic/claude-3.5-sonnet', label: 'Anthropic: Claude 3.5 Sonnet (OpenRouter)', provider: 'OpenRouter' },
{ name: 'anthropic/claude-3-haiku', label: 'Anthropic: Claude 3 Haiku (OpenRouter)', provider: 'OpenRouter' }, { name: 'anthropic/claude-3-haiku', label: 'Anthropic: Claude 3 Haiku (OpenRouter)', provider: 'OpenRouter' },
@ -26,7 +26,10 @@ const staticModels: ModelInfo[] = [
{ name: 'llama-3.2-11b-vision-preview', label: 'Llama 3.2 11b (Groq)', provider: 'Groq' }, { name: 'llama-3.2-11b-vision-preview', label: 'Llama 3.2 11b (Groq)', provider: 'Groq' },
{ name: 'llama-3.2-3b-preview', label: 'Llama 3.2 3b (Groq)', provider: 'Groq' }, { name: 'llama-3.2-3b-preview', label: 'Llama 3.2 3b (Groq)', provider: 'Groq' },
{ name: 'llama-3.2-1b-preview', label: 'Llama 3.2 1b (Groq)', provider: 'Groq' }, { name: 'llama-3.2-1b-preview', label: 'Llama 3.2 1b (Groq)', provider: 'Groq' },
{ name: 'claude-3-opus-20240229', label: 'Claude 3 Opus', provider: 'Anthropic' }, { name: 'claude-3-5-sonnet-latest', label: 'Claude 3.5 Sonnet (new)', provider: 'Anthropic' },
{ name: 'claude-3-5-sonnet-20240620', label: 'Claude 3.5 Sonnet (old)', provider: 'Anthropic' },
{ name: 'claude-3-5-haiku-latest', label: 'Claude 3.5 Haiku (new)', provider: 'Anthropic' },
{ name: 'claude-3-opus-latest', label: 'Claude 3 Opus', provider: 'Anthropic' },
{ name: 'claude-3-sonnet-20240229', label: 'Claude 3 Sonnet', provider: 'Anthropic' }, { name: 'claude-3-sonnet-20240229', label: 'Claude 3 Sonnet', provider: 'Anthropic' },
{ name: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku', provider: 'Anthropic' }, { name: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku', provider: 'Anthropic' },
{ name: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'OpenAI' }, { name: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'OpenAI' },
@ -104,10 +107,28 @@ async function getOpenAILikeModels(): Promise<ModelInfo[]> {
} }
} }
async function getLMStudioModels(): Promise<ModelInfo[]> {
try {
const base_url = import.meta.env.LMSTUDIO_API_BASE_URL || "http://localhost:1234";
const response = await fetch(`${base_url}/v1/models`);
const data = await response.json() as any;
return data.data.map((model: any) => ({
name: model.id,
label: model.id,
provider: 'LMStudio',
}));
} catch (e) {
return [];
}
}
async function initializeModelList(): Promise<void> { async function initializeModelList(): Promise<void> {
const ollamaModels = await getOllamaModels(); const ollamaModels = await getOllamaModels();
const openAiLikeModels = await getOpenAILikeModels(); const openAiLikeModels = await getOpenAILikeModels();
MODEL_LIST = [...ollamaModels,...openAiLikeModels, ...staticModels]; const lmstudioModels = await getLMStudioModels();
MODEL_LIST = [...ollamaModels,...openAiLikeModels, ...staticModels,...lmstudioModels,];
} }
initializeModelList().then(); initializeModelList().then();
export { getOllamaModels, getOpenAILikeModels, initializeModelList }; export { getOllamaModels,getOpenAILikeModels,getLMStudioModels,initializeModelList };

View File

@ -1,6 +1,7 @@
import type { WebContainer } from '@webcontainer/api'; import type { WebContainer, WebContainerProcess } from '@webcontainer/api';
import type { ITerminal } from '~/types/terminal'; import type { ITerminal } from '~/types/terminal';
import { withResolvers } from './promises'; import { withResolvers } from './promises';
import { atom } from 'nanostores';
export async function newShellProcess(webcontainer: WebContainer, terminal: ITerminal) { export async function newShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
const args: string[] = []; const args: string[] = [];
@ -19,7 +20,6 @@ export async function newShellProcess(webcontainer: WebContainer, terminal: ITer
const jshReady = withResolvers<void>(); const jshReady = withResolvers<void>();
let isInteractive = false; let isInteractive = false;
output.pipeTo( output.pipeTo(
new WritableStream({ new WritableStream({
write(data) { write(data) {
@ -40,6 +40,8 @@ export async function newShellProcess(webcontainer: WebContainer, terminal: ITer
); );
terminal.onData((data) => { terminal.onData((data) => {
// console.log('terminal onData', { data, isInteractive });
if (isInteractive) { if (isInteractive) {
input.write(data); input.write(data);
} }
@ -49,3 +51,145 @@ export async function newShellProcess(webcontainer: WebContainer, terminal: ITer
return process; return process;
} }
export class BoltShell {
#initialized: (() => void) | undefined
#readyPromise: Promise<void>
#webcontainer: WebContainer | undefined
#terminal: ITerminal | undefined
#process: WebContainerProcess | undefined
executionState = atom<{ sessionId: string, active: boolean, executionPrms?: Promise<any> } | undefined>()
#outputStream: ReadableStreamDefaultReader<string> | undefined
#shellInputStream: WritableStreamDefaultWriter<string> | undefined
constructor() {
this.#readyPromise = new Promise((resolve) => {
this.#initialized = resolve
})
}
ready() {
return this.#readyPromise;
}
async init(webcontainer: WebContainer, terminal: ITerminal) {
this.#webcontainer = webcontainer
this.#terminal = terminal
let callback = (data: string) => {
console.log(data)
}
let { process, output } = await this.newBoltShellProcess(webcontainer, terminal)
this.#process = process
this.#outputStream = output.getReader()
await this.waitTillOscCode('interactive')
this.#initialized?.()
}
get terminal() {
return this.#terminal
}
get process() {
return this.#process
}
async executeCommand(sessionId: string, command: string) {
if (!this.process || !this.terminal) {
return
}
let state = this.executionState.get()
//interrupt the current execution
// this.#shellInputStream?.write('\x03');
this.terminal.input('\x03');
if (state && state.executionPrms) {
await state.executionPrms
}
//start a new execution
this.terminal.input(command.trim() + '\n');
//wait for the execution to finish
let executionPrms = this.getCurrentExecutionResult()
this.executionState.set({ sessionId, active: true, executionPrms })
let resp = await executionPrms
this.executionState.set({ sessionId, active: false })
return resp
}
async newBoltShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
const args: string[] = [];
// we spawn a JSH process with a fallback cols and rows in case the process is not attached yet to a visible terminal
const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], {
terminal: {
cols: terminal.cols ?? 80,
rows: terminal.rows ?? 15,
},
});
const input = process.input.getWriter();
this.#shellInputStream = input;
const [internalOutput, terminalOutput] = process.output.tee();
const jshReady = withResolvers<void>();
let isInteractive = false;
terminalOutput.pipeTo(
new WritableStream({
write(data) {
if (!isInteractive) {
const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || [];
if (osc === 'interactive') {
// wait until we see the interactive OSC
isInteractive = true;
jshReady.resolve();
}
}
terminal.write(data);
},
}),
);
terminal.onData((data) => {
// console.log('terminal onData', { data, isInteractive });
if (isInteractive) {
input.write(data);
}
});
await jshReady.promise;
return { process, output: internalOutput };
}
async getCurrentExecutionResult() {
let { output, exitCode } = await this.waitTillOscCode('exit')
return { output, exitCode };
}
async waitTillOscCode(waitCode: string) {
let fullOutput = '';
let exitCode: number = 0;
if (!this.#outputStream) return { output: fullOutput, exitCode };
let tappedStream = this.#outputStream
while (true) {
const { value, done } = await tappedStream.read();
if (done) break;
const text = value || '';
fullOutput += text;
// Check if command completion signal with exit code
const [, osc, , pid, code] = text.match(/\x1b\]654;([^\x07=]+)=?((-?\d+):(\d+))?\x07/) || [];
if (osc === 'exit') {
exitCode = parseInt(code, 10);
}
if (osc === waitCode) {
break;
}
}
return { output: fullOutput, exitCode };
}
}
export function newBoltShellProcess() {
return new BoltShell();
}

View File

@ -16,7 +16,7 @@
"start": "bindings=$(./bindings.sh) && wrangler pages dev ./build/client $bindings", "start": "bindings=$(./bindings.sh) && wrangler pages dev ./build/client $bindings",
"dockerstart": "bindings=$(./bindings.sh) && wrangler pages dev ./build/client $bindings --ip 0.0.0.0 --port 5173 --no-show-interactive-dev-session", "dockerstart": "bindings=$(./bindings.sh) && wrangler pages dev ./build/client $bindings --ip 0.0.0.0 --port 5173 --no-show-interactive-dev-session",
"dockerrun": "docker run -it -d --name bolt-ai-live -p 5173:5173 --env-file .env.local bolt-ai", "dockerrun": "docker run -it -d --name bolt-ai-live -p 5173:5173 --env-file .env.local bolt-ai",
"dockerbuild:prod": "docker build -t bolt-ai:production bolt-ai:latest --target bolt-ai-production .", "dockerbuild:prod": "docker build -t bolt-ai:production -t bolt-ai:latest --target bolt-ai-production .",
"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",
@ -28,8 +28,8 @@
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^0.0.39", "@ai-sdk/anthropic": "^0.0.39",
"@ai-sdk/google": "^0.0.52", "@ai-sdk/google": "^0.0.52",
"@ai-sdk/openai": "^0.0.66",
"@ai-sdk/mistral": "^0.0.43", "@ai-sdk/mistral": "^0.0.43",
"@ai-sdk/openai": "^0.0.66",
"@codemirror/autocomplete": "^6.17.0", "@codemirror/autocomplete": "^6.17.0",
"@codemirror/commands": "^6.6.0", "@codemirror/commands": "^6.6.0",
"@codemirror/lang-cpp": "^6.0.2", "@codemirror/lang-cpp": "^6.0.2",
@ -71,6 +71,7 @@
"isbot": "^4.1.0", "isbot": "^4.1.0",
"istextorbinary": "^9.5.0", "istextorbinary": "^9.5.0",
"jose": "^5.6.3", "jose": "^5.6.3",
"js-cookie": "^3.0.5",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"nanostores": "^0.10.3", "nanostores": "^0.10.3",
"ollama-ai-provider": "^0.15.2", "ollama-ai-provider": "^0.15.2",
@ -94,6 +95,7 @@
"@remix-run/dev": "^2.10.0", "@remix-run/dev": "^2.10.0",
"@types/diff": "^5.2.1", "@types/diff": "^5.2.1",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6",
"@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",

File diff suppressed because it is too large Load Diff

View File

@ -27,7 +27,7 @@ export default defineConfig((config) => {
chrome129IssuePlugin(), chrome129IssuePlugin(),
config.mode === 'production' && optimizeCssModules({ apply: 'build' }), config.mode === 'production' && optimizeCssModules({ apply: 'build' }),
], ],
envPrefix:["VITE_","OPENAI_LIKE_API_","OLLAMA_API_BASE_URL"], envPrefix:["VITE_","OPENAI_LIKE_API_","OLLAMA_API_BASE_URL","LMSTUDIO_API_BASE_URL"],
css: { css: {
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {

View File

@ -7,4 +7,5 @@ interface Env {
OPENAI_LIKE_API_KEY: string; OPENAI_LIKE_API_KEY: string;
OPENAI_LIKE_API_BASE_URL: string; OPENAI_LIKE_API_BASE_URL: string;
DEEPSEEK_API_KEY: string; DEEPSEEK_API_KEY: string;
LMSTUDIO_API_BASE_URL: string;
} }